diff --git a/.editorconfig b/.editorconfig index bea4e492df..7edf6f2a1d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,73 @@ # top-most EditorConfig file root = true -[*.cs] +[*.{cs,vb}] + indent_style = space -indent_size = 4 \ No newline at end of file +indent_size = 4 + +# Organize usings +dotnet_sort_system_directives_first = true + +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = never:silent +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +[*.cs] + +# var preferences +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none +csharp_style_expression_bodied_properties = false:none +csharp_style_expression_bodied_indexers = false:none +csharp_style_expression_bodied_accessors = false:none + +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Expression-level preferences +csharp_prefer_braces = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Spacing options +csharp_space_after_keywords_in_control_flow_statements = true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..280630475b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,9 @@ +--- +name: Bug report +about: Create a report to help us improve the GitHub Extension for Visual Studio +labels: + +--- + +Please report GitHub for Visual Studio bugs here: +https://support.github.com/contact/bug-report?subject=Re:%20GitHub%20for%20Visual%20Studio diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000000..f6858ea3ee --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,83 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +env: + config: Release + githubvsSolution: GitHubVS.sln + vsixContainer: ${{ github.workspace }}\GitHub.VisualStudio.vsix + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: windows-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v1 + with: + submodules: true + + - name: Nerdbank.GitVersioning + uses: aarnott/nbgv@v0.3 + id: nbgv + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v1.0.0 + + - uses: nuget/setup-nuget@v1 + with: + nuget-version: '5.x' + + - name: Add VSTest to PATH + uses: darenm/Setup-VSTest@v1 + + - name: NuGet restore ${{ env.githubvsSolution }} + run: nuget restore ${{ env.githubvsSolution }} + + - name: MSBuild ${{ env.githubvsSolution }} + run: | + msbuild ${{ env.githubvsSolution }} /p:Configuration=${{ env.config }} /p:TargetVsixContainer=${{ env.vsixContainer }} /p:DeployExtension=False /verbosity:minimal + env: + GitHubVS_ClientId: ${{ secrets.GitHubVS_ClientId }} + GitHubVS_ClientSecret: ${{ secrets.GitHubVS_ClientSecret }} + + - name: Sign the VSIX + if: github.ref == 'refs/heads/master' + run: tools/vsixsigntool/vsixsigntool.exe sign /f certificate.pfx /p '${{ secrets.CERTIFICATE_PASSWORD }}' /sha1 9c5a6d389e1454f2ed9ee9419cdf743689709f9c /fd sha256 /tr http://timestamp.digicert.com /td sha256 ${{ env.vsixContainer }} + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v1 + with: + name: GitHubVS-${{ steps.nbgv.outputs.SemVer2 }} + path: ${{ env.vsixContainer }} + + # We need to run '**\bin\**\*Tests.dll' + - name: Run unit tests + shell: bash + run: | + vstest.console /TestAdapterPath:test /Settings:test/test.runsettings \ + test/GitHub.Api.UnitTests/bin/${{ env.config }}/net46/GitHub.Api.UnitTests.dll \ + test/GitHub.App.UnitTests/bin/${{ env.config }}/net46/GitHub.App.UnitTests.dll \ + test/GitHub.Exports.Reactive.UnitTests/bin/${{ env.config }}/net46/GitHub.Exports.Reactive.UnitTests.dll \ + test/GitHub.Exports.UnitTests/bin/${{ env.config }}/net46/GitHub.Exports.UnitTests.dll \ + test/GitHub.Extensions.UnitTests/bin/${{ env.config }}/net46/GitHub.Extensions.UnitTests.dll \ + test/GitHub.InlineReviews.UnitTests/bin/${{ env.config }}/net46/GitHub.InlineReviews.UnitTests.dll \ + test/GitHub.Services.Vssdk.UnitTests/bin/${{ env.config }}/net461/GitHub.Services.Vssdk.UnitTests.dll \ + test/GitHub.StartPage.UnitTests/bin/${{ env.config }}/net46/GitHub.StartPage.UnitTests.dll \ + test/GitHub.TeamFoundation.UnitTests/bin/${{ env.config }}/net46/GitHub.TeamFoundation.UnitTests.dll \ + test/GitHub.UI.UnitTests/bin/${{ env.config }}/net46/GitHub.UI.UnitTests.dll \ + test/GitHub.VisualStudio.UnitTests/bin/${{ env.config }}/net46/GitHub.VisualStudio.UnitTests.dll \ + test/MetricsTests/MetricsTests/bin/${{ env.config }}/MetricsTests.dll diff --git a/.gitignore b/.gitignore index 00cd02d504..e7cf555bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,10 @@ build/ [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* -#NUNIT +# NUnit *.VisualState.xml TestResult.xml +nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ @@ -232,9 +233,20 @@ $RECYCLE.BIN/ *.msm *.msp +#OpenCover output +coverage.xml + +#Msbuild binary log output +output.binlog + +# KDiff3 +*_BACKUP_* +*_BASE_* +*_LOCAL_* +*_REMOTE_* +*.orig + AkavacheSqliteLinkerOverride.cs NuGetBuild WiX.Toolset.DummyFile.txt -nunit-UnitTests.xml -nunit-TrackingCollectionTests.xml GitHubVS.sln.DotSettings diff --git a/.gitmodules b/.gitmodules index ce49941370..f7c8b36bf5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "submodules/rothko"] - path = submodules/rothko - url = https://github.com/editor-tools/Rothko.git [submodule "submodules/reactiveui"] path = submodules/reactiveui url = https://github.com/editor-tools/ReactiveUI @@ -13,9 +10,6 @@ [submodule "submodules/akavache"] path = submodules/akavache url = https://github.com/editor-tools/Akavache -[submodule "script"] - path = script - url = git@github.com:github/VisualStudioBuildScripts -[submodule "submodules/GitHubVSAutomationIDs"] - path = submodules/GitHubVSAutomationIDs - url = https://github.com/editor-tools/GitHubVSAutomationIDs.git +[submodule "submodules/octokit.graphql.net"] + path = submodules/octokit.graphql.net + url = https://github.com/octokit/octokit.graphql.net diff --git a/.ncrunch/Akavache.Sqlite3.v3.ncrunchproject b/.ncrunch/Akavache.Sqlite3.v3.ncrunchproject index 6800b4a3fe..161aafaa0b 100644 --- a/.ncrunch/Akavache.Sqlite3.v3.ncrunchproject +++ b/.ncrunch/Akavache.Sqlite3.v3.ncrunchproject @@ -1,5 +1,6 @@  + False True \ No newline at end of file diff --git a/.ncrunch/Akavache_Net45.v3.ncrunchproject b/.ncrunch/Akavache_Net45.v3.ncrunchproject index 6800b4a3fe..161aafaa0b 100644 --- a/.ncrunch/Akavache_Net45.v3.ncrunchproject +++ b/.ncrunch/Akavache_Net45.v3.ncrunchproject @@ -1,5 +1,6 @@  + False True \ No newline at end of file diff --git a/.ncrunch/Akavache_Portable.v3.ncrunchproject b/.ncrunch/Akavache_Portable.v3.ncrunchproject index 6800b4a3fe..161aafaa0b 100644 --- a/.ncrunch/Akavache_Portable.v3.ncrunchproject +++ b/.ncrunch/Akavache_Portable.v3.ncrunchproject @@ -1,5 +1,6 @@  + False True \ No newline at end of file diff --git a/.ncrunch/GitHub.App.UnitTests.v3.ncrunchproject b/.ncrunch/GitHub.App.UnitTests.v3.ncrunchproject new file mode 100644 index 0000000000..7b5b2139ff --- /dev/null +++ b/.ncrunch/GitHub.App.UnitTests.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/.ncrunch/GitHub.App.v3.ncrunchproject b/.ncrunch/GitHub.App.v3.ncrunchproject index 68266fc56f..586daf0054 100644 --- a/.ncrunch/GitHub.App.v3.ncrunchproject +++ b/.ncrunch/GitHub.App.v3.ncrunchproject @@ -1,6 +1,6 @@  - True + False True \ No newline at end of file diff --git a/.ncrunch/GitHub.Exports.UnitTests.v3.ncrunchproject b/.ncrunch/GitHub.Exports.UnitTests.v3.ncrunchproject new file mode 100644 index 0000000000..7b5b2139ff --- /dev/null +++ b/.ncrunch/GitHub.Exports.UnitTests.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/.ncrunch/GitHub.InlineReviews.UnitTests.v3.ncrunchproject b/.ncrunch/GitHub.InlineReviews.UnitTests.v3.ncrunchproject new file mode 100644 index 0000000000..0cef203ae5 --- /dev/null +++ b/.ncrunch/GitHub.InlineReviews.UnitTests.v3.ncrunchproject @@ -0,0 +1,9 @@ + + + True + + CopyReferencedAssembliesToWorkspaceIsOn + + True + + \ No newline at end of file diff --git a/.ncrunch/GitHub.InlineReviews.v3.ncrunchproject b/.ncrunch/GitHub.InlineReviews.v3.ncrunchproject new file mode 100644 index 0000000000..6800b4a3fe --- /dev/null +++ b/.ncrunch/GitHub.InlineReviews.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/.ncrunch/GitHub.Logging.v3.ncrunchproject b/.ncrunch/GitHub.Logging.v3.ncrunchproject new file mode 100644 index 0000000000..6800b4a3fe --- /dev/null +++ b/.ncrunch/GitHub.Logging.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/.ncrunch/GitHub.UI.UnitTests.v3.ncrunchproject b/.ncrunch/GitHub.UI.UnitTests.v3.ncrunchproject new file mode 100644 index 0000000000..0cef203ae5 --- /dev/null +++ b/.ncrunch/GitHub.UI.UnitTests.v3.ncrunchproject @@ -0,0 +1,9 @@ + + + True + + CopyReferencedAssembliesToWorkspaceIsOn + + True + + \ No newline at end of file diff --git a/.ncrunch/GitHub.VisualStudio.v3.ncrunchproject b/.ncrunch/GitHub.VisualStudio.v3.ncrunchproject index 6800b4a3fe..f7c97e191d 100644 --- a/.ncrunch/GitHub.VisualStudio.v3.ncrunchproject +++ b/.ncrunch/GitHub.VisualStudio.v3.ncrunchproject @@ -1,5 +1,8 @@  + + xlf\**.* + True \ No newline at end of file diff --git a/.ncrunch/Markdig.Wpf.v3.ncrunchproject b/.ncrunch/Markdig.Wpf.v3.ncrunchproject new file mode 100644 index 0000000000..6800b4a3fe --- /dev/null +++ b/.ncrunch/Markdig.Wpf.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/.ncrunch/UnitTests.v3.ncrunchproject b/.ncrunch/UnitTests.v3.ncrunchproject index 22a8abadce..8400a07ba0 100644 --- a/.ncrunch/UnitTests.v3.ncrunchproject +++ b/.ncrunch/UnitTests.v3.ncrunchproject @@ -1,8 +1,10 @@  + True 2000 AbnormalReferenceResolution + CopyReferencedAssembliesToWorkspaceIsOn @@ -15,7 +17,6 @@ UIProviderTests - True True \ No newline at end of file diff --git a/Build-Solution.cmd b/Build-Solution.cmd deleted file mode 100644 index 822884e5cc..0000000000 --- a/Build-Solution.cmd +++ /dev/null @@ -1 +0,0 @@ -Powershell -ExecutionPolicy Unrestricted %~dp0Build-Solution.ps1 \ No newline at end of file diff --git a/Build-Solution.ps1 b/Build-Solution.ps1 deleted file mode 100644 index 5e78326dce..0000000000 --- a/Build-Solution.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -param( - [ValidateSet('Full', 'Tests', 'Build', 'Clean')] - [string] - $build = "Build" - , - [ValidateSet('Debug', 'Release')] - [string] - $config = "Release" - , - [ValidateSet('Any CPU', 'x86', 'x64')] - [string] - $platform = "Any CPU" - , - [string] - $verbosity = "minimal" -) - -$rootDirectory = Split-Path $MyInvocation.MyCommand.Path -$projFile = join-path $rootDirectory GitHubVS.msbuild -$msbuild = "C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe" - -function Die([string]$message, [object[]]$output) { - if ($output) { - Write-Output $output - $message += ". See output above." - } - Throw (New-Object -TypeName ScriptException -ArgumentList $message) -} - -function Run-Command([scriptblock]$Command, [switch]$Fatal, [switch]$Quiet) { - $output = "" - if ($Quiet) { - $output = & $Command 2>&1 - } else { - & $Command - } - - if (!$Fatal) { - return - } - - $exitCode = 0 - if (!$? -and $LastExitCode -ne 0) { - $exitCode = $LastExitCode - } elseif (!$?) { - $exitCode = 1 - } else { - return - } - - Die "``$Command`` failed" $output -} - -function Run-XUnit([string]$project, [int]$timeoutDuration, [string]$configuration) { - $dll = "src\$project\bin\$configuration\$project.dll" - - $xunitDirectory = Join-Path $rootDirectory packages\xunit.runner.console.2.1.0\tools - $consoleRunner = Join-Path $xunitDirectory xunit.console.x86.exe - $xml = Join-Path $rootDirectory "nunit-$project.xml" - $outputPath = [System.IO.Path]::GetTempFileName() - - $args = $dll, "-noshadow", "-xml", $xml, "-parallel", "all" - [object[]] $output = "$consoleRunner " + ($args -join " ") - - $process = Start-Process -PassThru -NoNewWindow -RedirectStandardOutput $outputPath $consoleRunner ($args | %{ "`"$_`"" }) - Wait-Process -InputObject $process -Timeout $timeoutDuration -ErrorAction SilentlyContinue - if ($process.HasExited) { - $output += Get-Content $outputPath - $exitCode = $process.ExitCode - } else { - $output += "Tests timed out. Backtrace:" - $output += Get-DotNetStack $process.Id - $exitCode = 9999 - } - Stop-Process -InputObject $process - Remove-Item $outputPath - - $result = New-Object System.Object - $result | Add-Member -Type NoteProperty -Name Output -Value $output - $result | Add-Member -Type NoteProperty -Name ExitCode -Value $exitCode - $result -} - -function Run-NUnit([string]$project, [int]$timeoutDuration, [string]$configuration) { - $dll = "src\$project\bin\$configuration\$project.dll" - - $nunitDirectory = Join-Path $rootDirectory packages\NUnit.Runners.2.6.4\tools - $consoleRunner = Join-Path $nunitDirectory nunit-console-x86.exe - $xml = Join-Path $rootDirectory "nunit-$project.xml" - $outputPath = [System.IO.Path]::GetTempFileName() - - $args = "-noshadow", "-xml:$xml", "-framework:net-4.5", "-exclude:Timings", $dll - [object[]] $output = "$consoleRunner " + ($args -join " ") - - $process = Start-Process -PassThru -NoNewWindow -RedirectStandardOutput $outputPath $consoleRunner ($args | %{ "`"$_`"" }) - Wait-Process -InputObject $process -Timeout $timeoutDuration -ErrorAction SilentlyContinue - if ($process.HasExited) { - $output += Get-Content $outputPath - $exitCode = $process.ExitCode - } else { - $output += "Tests timed out. Backtrace:" - $output += Get-DotNetStack $process.Id - $exitCode = 9999 - } - - Stop-Process -InputObject $process - Remove-Item $outputPath - - $result = New-Object System.Object - $result | Add-Member -Type NoteProperty -Name Output -Value $output - $result | Add-Member -Type NoteProperty -Name ExitCode -Value $exitCode - $result -} - -function Build-Solution([string]$solution) { - Run-Command -Fatal { & $msbuild $solution /t:Build /property:Configuration=$config /verbosity:$verbosity /p:VisualStudioVersion=14.0 /p:DeployExtension=false } -} - -Write-Output "Building GitHub for Visual Studio..." -Write-Output "" - -Build-Solution GitHubVs.sln - -$exitCode = 0 - -Write-Output "Running Unit Tests..." -$result = Run-XUnit UnitTests 180 $config -if ($result.ExitCode -eq 0) { - # Print out the test result summary. - Write-Output $result.Output[-1] -} else { - $exitCode = $result.ExitCode - Write-Output $result.Output -} - -Write-Output "Running TrackingCollection Tests..." -$result = Run-NUnit TrackingCollectionTests 180 $config -if ($result.ExitCode -eq 0) { - # Print out the test result summary. - Write-Output $result.Output[-3] -} else { - $exitCode = $result.ExitCode - Write-Output $result.Output -} -Write-Output "" - -exit $exitCode \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..563f476d67 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at support@github.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1f4d22121..e6da65ffc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,20 +7,22 @@ Hi there! We're thrilled that you'd like to contribute to the __GitHub Extension for Visual Studio__. Your help is essential for keeping it great. -This project adheres to the [Open Code of Conduct][code-of-conduct]. By participating, you are expected to uphold this code. +Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). + +Please note that this project is released with a [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. ## Submitting a pull request -0. [Fork][] and clone the repository (see Build Instructions in the [README][readme]) -0. Create a new branch: `git checkout -b my-branch-name` -0. Make your change, add tests, and make sure the tests still pass -0. Push to your fork and [submit a pull request][pr] -0. Pat your self on the back and wait for your pull request to be reviewed and merged. +1. [Fork][] and clone the repository (see Build Instructions in the [README][readme]) +2. Create a new branch: `git checkout -b my-branch-name` +3. Make your change, add tests, and make sure the tests still pass +4. Push to your fork and [submit a pull request][pr] +5. Pat your self on the back and wait for your pull request to be reviewed and merged. Here are a few things you can do that will increase the likelihood of your pull request being accepted: -- Follow the existing code's style. -- Write tests. +- Follow the style/format of the existing code. +- Write tests for your changes. - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). @@ -36,9 +38,10 @@ There are certain areas of the extension that are restricted in what they can do ### Bug Reporting Here are a few helpful tips when reporting a bug: -- Verify the bug resides in the GitHub for Visual Studio extension +- Verify that the bug resides in the GitHub for Visual Studio extension - A lot of functionality provided by this extension resides in the Team Explorer pane, alongside other non-GitHub tools to manage and collaborate on source code, including Visual Studio's Git support, which is owned by Microsoft. - If this bug not is related to the GitHub extension, visit the [Visual Studio support page](https://www.visualstudio.com/support/support-overview-vs) for help +- To report a bug use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md). - Screenshots are very helpful in diagnosing bugs and understanding the state of the extension when it's experiencing problems. Please include them whenever possible. - A log file is helpful in diagnosing bug issues. To include log files in your issue: @@ -54,6 +57,7 @@ Here are a few helpful tips when reporting a bug: ### Feature Requests If you have a feature that you think would be a great addition to the extension, we might already have thought about it too, so be sure to check if your suggestion matches our [roadmap](#roadmap-and-future-feature-ideas) before making a request. Also take a peek at our [pull requests](https://github.com/github/VisualStudio/pulls) to see what we're currently working on. Additionally, someone might have already thought of your idea, so check out Issues labeled as [features](https://github.com/github/VisualStudio/issues?q=is%3Aopen+is%3Aissue+label%3Afeature) to see if it already exists. +To suggest a feature use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md). ## Things to improve in the current version diff --git a/Directory.Build.Props b/Directory.Build.Props new file mode 100644 index 0000000000..820c5c34da --- /dev/null +++ b/Directory.Build.Props @@ -0,0 +1,14 @@ + + + GitHub Extension for Visual Studio + 2.10.8.0 + Copyright © GitHub, Inc. 2014-2018 + 7.3 + + + + 3.0.26 + all + + + \ No newline at end of file diff --git a/GitHubVS.sln b/GitHubVS.sln index 1e6a6ea7b9..03f0b02a4c 100644 --- a/GitHubVS.sln +++ b/GitHubVS.sln @@ -1,591 +1,530 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.28603.18 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.VisualStudio.Vsix", "src\GitHub.VisualStudio.Vsix\GitHub.VisualStudio.Vsix.csproj", "{D26B4B40-0C94-48AD-8019-0B9BE46E0071}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.VisualStudio", "src\GitHub.VisualStudio\GitHub.VisualStudio.csproj", "{11569514-5AE5-4B5B-92A2-F10B0967DE5F}" + ProjectSection(ProjectDependencies) = postProject + {7F5ED78B-74A3-4406-A299-70CFB5885B8B} = {7F5ED78B-74A3-4406-A299-70CFB5885B8B} + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Meta", "Meta", "{72036B62-2FA6-4A22-8B33-69F698A18CF1}" ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .github\workflows\main.yml = .github\workflows\main.yml README.md = README.md + version.json = version.json EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "src\UnitTests\UnitTests.csproj", "{596595A6-2A3C-469E-9386-9E3767D863A5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.UI", "src\GitHub.UI\GitHub.UI.csproj", "{346384DD-2445-4A28-AF22-B45F3957BD89}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.UI", "src\GitHub.UI\GitHub.UI.csproj", "{346384DD-2445-4A28-AF22-B45F3957BD89}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.UI.Reactive", "src\GitHub.UI.Reactive\GitHub.UI.Reactive.csproj", "{158B05E8-FDBC-4D71-B871-C96E28D5ADF5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.UI.Reactive", "src\GitHub.UI.Reactive\GitHub.UI.Reactive.csproj", "{158B05E8-FDBC-4D71-B871-C96E28D5ADF5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.Extensions", "src\GitHub.Extensions\GitHub.Extensions.csproj", "{6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Extensions", "src\GitHub.Extensions\GitHub.Extensions.csproj", "{6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.Extensions.Reactive", "src\GitHub.Extensions.Reactive\GitHub.Extensions.Reactive.csproj", "{6559E128-8B40-49A5-85A8-05565ED0C7E3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Extensions.Reactive", "src\GitHub.Extensions.Reactive\GitHub.Extensions.Reactive.csproj", "{6559E128-8B40-49A5-85A8-05565ED0C7E3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.App", "src\GitHub.App\GitHub.App.csproj", "{1A1DA411-8D1F-4578-80A6-04576BEA2DC5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.App", "src\GitHub.App\GitHub.App.csproj", "{1A1DA411-8D1F-4578-80A6-04576BEA2DC5}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Submodules", "Submodules", "{1E7F7253-A6AF-43C4-A955-37BEDDA01AB8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rothko", "submodules\Rothko\src\Rothko.csproj", "{4A84E568-CA86-4510-8CD0-90D3EF9B65F9}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{8E1F1B4E-AEA2-4AB1-8F73-423A903550A1}" ProjectSection(SolutionItems) = preProject - script\Modules\BuildUtils.psm1 = script\Modules\BuildUtils.psm1 - script\Modules\Debugging.psm1 = script\Modules\Debugging.psm1 - script\Modules\Vsix.psm1 = script\Modules\Vsix.psm1 - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Script", "Script", "{7B6C5F8D-14B3-443D-B044-0E209AE12BDF}" - ProjectSection(SolutionItems) = preProject - .gitattributes = .gitattributes - .gitignore = .gitignore - script\Announce.ps1 = script\Announce.ps1 - script\Bootstrap.ps1 = script\Bootstrap.ps1 - build.cmd = build.cmd - script\Bump-Version.ps1 = script\Bump-Version.ps1 - script\cibuild.ps1 = script\cibuild.ps1 - script\common.ps1 = script\common.ps1 - script\Deploy.ps1 = script\Deploy.ps1 - script\Get-CheckedOutBranch.ps1 = script\Get-CheckedOutBranch.ps1 - script\Get-HeadSha1.ps1 = script\Get-HeadSha1.ps1 - script\HubotTell-NativeRoom.ps1 = script\HubotTell-NativeRoom.ps1 - nuget.config = nuget.config - script\Require-CleanWorkTree.ps1 = script\Require-CleanWorkTree.ps1 - script\Run-NUnit.ps1 = script\Run-NUnit.ps1 - script\Run-XUnit.ps1 = script\Run-XUnit.ps1 - script\Upload-DirectoryToS3.ps1 = script\Upload-DirectoryToS3.ps1 + test\UnitTests\Args.cs = test\UnitTests\Args.cs EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{8A7DA2E7-262B-4581-807A-1C45CE79CDFD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.Exports", "src\GitHub.Exports\GitHub.Exports.csproj", "{9AEA02DB-02B5-409C-B0CA-115D05331A6B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Exports", "src\GitHub.Exports\GitHub.Exports.csproj", "{9AEA02DB-02B5-409C-B0CA-115D05331A6B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.Api", "src\GitHub.Api\GitHub.Api.csproj", "{B389ADAF-62CC-486E-85B4-2D8B078DF763}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Api", "src\GitHub.Api\GitHub.Api.csproj", "{B389ADAF-62CC-486E-85B4-2D8B078DF763}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.Exports.Reactive", "src\GitHub.Exports.Reactive\GitHub.Exports.Reactive.csproj", "{E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DesignTimeStyleHelper", "src\DesignTimeStyleHelper\DesignTimeStyleHelper.csproj", "{B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Exports.Reactive", "src\GitHub.Exports.Reactive\GitHub.Exports.Reactive.csproj", "{E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Octokit", "Octokit", "{1E7F7253-A6AF-43C4-A955-37BEDDA01AC0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Octokit", "submodules\octokit.net\Octokit\Octokit.csproj", "{08DD4305-7787-4823-A53F-4D0F725A07F3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Octokit", "submodules\octokit.net\Octokit\Octokit.csproj", "{08DD4305-7787-4823-A53F-4D0F725A07F3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Octokit.Reactive", "submodules\octokit.net\Octokit.Reactive\Octokit.Reactive.csproj", "{674B69B8-0780-4D54-AE2B-C15821FA51CB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Octokit.Reactive", "submodules\octokit.net\Octokit.Reactive\Octokit.Reactive.csproj", "{674B69B8-0780-4D54-AE2B-C15821FA51CB}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ReactiveUI", "ReactiveUI", "{1E7F7253-A6AF-43C4-A955-37BEDDA01AB9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI_Net45", "submodules\reactiveui\ReactiveUI\ReactiveUI_Net45.csproj", "{1CE2D235-8072-4649-BA5A-CFB1AF8776E0}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Akavache", "Akavache", "{1E7F7253-A6AF-43C4-A955-37BEDDA01AC9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI.Events_Net45", "submodules\reactiveui\ReactiveUI.Events\ReactiveUI.Events_Net45.csproj", "{600998C4-54DD-4755-BFA8-6F44544D8E2E}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Splat", "Splat", "{1E7F7253-A6AF-43C4-A955-37BEDDA01AF9}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Akavache", "Akavache", "{1E7F7253-A6AF-43C4-A955-37BEDDA01AC9}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CredentialManagement", "src\CredentialManagement\CredentialManagement.csproj", "{41A47C5B-C606-45B4-B83C-22B9239E4DA0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akavache_Net45", "submodules\akavache\Akavache\Akavache_Net45.csproj", "{B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.TeamFoundation.14", "src\GitHub.TeamFoundation.14\GitHub.TeamFoundation.14.csproj", "{161DBF01-1DBF-4B00-8551-C5C00F26720D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akavache.Sqlite3", "submodules\akavache\Akavache.Sqlite3\Akavache.Sqlite3.csproj", "{241C47DF-CA8E-4296-AA03-2C48BB646ABD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.TeamFoundation.15", "src\GitHub.TeamFoundation.15\GitHub.TeamFoundation.15.csproj", "{161DBF01-1DBF-4B00-8551-C5C00F26720E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akavache_Portable", "submodules\akavache\Akavache\Akavache_Portable.csproj", "{EB73ADDD-2FE9-44C0-A1AB-20709B979B64}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.VisualStudio.UI", "src\GitHub.VisualStudio.UI\GitHub.VisualStudio.UI.csproj", "{D1DFBB0C-B570-4302-8F1E-2E3A19C41961}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Splat", "Splat", "{1E7F7253-A6AF-43C4-A955-37BEDDA01AF9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.StartPage", "src\GitHub.StartPage\GitHub.StartPage.csproj", "{50E277B8-8580-487A-8F8E-5C3B9FBF0F77}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Splat-Net45", "submodules\splat\Splat\Splat-Net45.csproj", "{252CE1C2-027A-4445-A3C2-E4D6C80A935A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.UI.UnitTests", "test\GitHub.UI.UnitTests\GitHub.UI.UnitTests.csproj", "{110B206F-8554-4B51-BF86-94DAA32F5E26}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Splat-Portable", "submodules\splat\Splat\Splat-Portable.csproj", "{0EC8DBA1-D745-4EE5-993A-6026440EC3BF}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.InlineReviews", "src\GitHub.InlineReviews\GitHub.InlineReviews.csproj", "{7F5ED78B-74A3-4406-A299-70CFB5885B8B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CredentialManagement", "src\CredentialManagement\CredentialManagement.csproj", "{41A47C5B-C606-45B4-B83C-22B9239E4DA0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.InlineReviews.UnitTests", "test\GitHub.InlineReviews.UnitTests\GitHub.InlineReviews.UnitTests.csproj", "{17EB676B-BB91-48B5-AA59-C67695C647C2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReactiveUI.Testing_Net45", "submodules\reactiveui\ReactiveUI.Testing\ReactiveUI.Testing_Net45.csproj", "{DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Logging", "src\GitHub.Logging\GitHub.Logging.csproj", "{8D73575A-A89F-47CC-B153-B47DD06837F0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrackingCollectionTests", "src\TrackingCollectionTests\TrackingCollectionTests.csproj", "{7B835A7D-CF94-45E8-B191-96F5A4FE26A8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Services.Vssdk", "src\GitHub.Services.Vssdk\GitHub.Services.Vssdk.csproj", "{2D3D2834-33BE-45CA-B3CC-12F853557D7B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.TeamFoundation.14", "src\GitHub.TeamFoundation.14\GitHub.TeamFoundation.14.csproj", "{161DBF01-1DBF-4B00-8551-C5C00F26720D}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Metrics", "Metrics", "{C2D88962-BD6B-4F11-B914-535B38377962}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.TeamFoundation.15", "src\GitHub.TeamFoundation.15\GitHub.TeamFoundation.15.csproj", "{161DBF01-1DBF-4B00-8551-C5C00F26720E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetricsServer", "test\MetricsTests\MetricsServer\MetricsServer.csproj", "{14FDEE91-7301-4247-846C-049647BF8E99}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.VisualStudio.UI", "src\GitHub.VisualStudio.UI\GitHub.VisualStudio.UI.csproj", "{D1DFBB0C-B570-4302-8F1E-2E3A19C41961}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetricsTests", "test\MetricsTests\MetricsTests\MetricsTests.csproj", "{09313E65-7ADB-48C1-AD3A-572020C5BDCB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.StartPage", "src\GitHub.StartPage\GitHub.StartPage.csproj", "{50E277B8-8580-487A-8F8E-5C3B9FBF0F77}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Api.UnitTests", "test\GitHub.Api.UnitTests\GitHub.Api.UnitTests.csproj", "{EFDE0798-ACDB-431D-B7F1-548A7231C853}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitHubVSAutomationIDs", "GitHubVSAutomationIDs", "{4405C20E-A72A-4807-9480-14E9BB558347}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.App.UnitTests", "test\GitHub.App.UnitTests\GitHub.App.UnitTests.csproj", "{3525D819-6AEC-4879-89FB-56B41F026571}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GithubVSTestAutomationIDs", "submodules\GitHubVSAutomationIDs\GithubVSAutomationIDs\GithubVSTestAutomationIDs.csproj", "{665078F4-5875-431D-A2CC-421849B43328}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Exports.UnitTests", "test\GitHub.Exports.UnitTests\GitHub.Exports.UnitTests.csproj", "{94509FCB-6C97-4ED6-AED6-6E74AB3CA336}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Exports.Reactive.UnitTests", "test\GitHub.Exports.Reactive.UnitTests\GitHub.Exports.Reactive.UnitTests.csproj", "{C59868FC-D8BC-4D47-B4F3-16908D2641C6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Extensions.UnitTests", "test\GitHub.Extensions.UnitTests\GitHub.Extensions.UnitTests.csproj", "{DE704BBB-6EC6-4173-B695-D9EBF5AEB092}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.TeamFoundation.UnitTests", "test\GitHub.TeamFoundation.UnitTests\GitHub.TeamFoundation.UnitTests.csproj", "{93778A89-3E58-4853-B772-948EBB3F17BE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.VisualStudio.UnitTests", "test\GitHub.VisualStudio.UnitTests\GitHub.VisualStudio.UnitTests.csproj", "{8B14F90B-0781-465D-AB94-19C8C56E3A94}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Resources", "src\GitHub.Resources\GitHub.Resources.csproj", "{54E8D71A-AABB-4698-95FE-7F11612B8E59}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akavache.Core", "submodules\akavache\src\Akavache.Core\Akavache.Core.csproj", "{9E17369D-DA49-48C1-9767-C5178A17BFB8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Akavache.Sqlite3", "submodules\akavache\src\Akavache.Sqlite3\Akavache.Sqlite3.csproj", "{A003B735-6F6C-4DF8-A663-78651A1B6CE0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveUI", "submodules\reactiveui\src\ReactiveUI\ReactiveUI.csproj", "{A4F579F3-77D3-450A-AACC-F2653EF11E69}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Splat", "submodules\splat\src\Splat\Splat.csproj", "{AD0306B7-F88E-44A4-AB36-1D04822E9234}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveUI.Events.WPF", "submodules\reactiveui\src\ReactiveUI.Events.WPF\ReactiveUI.Events.WPF.csproj", "{86C54B27-717F-478C-AC8C-01F1C68A56C5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveUI.Testing", "submodules\reactiveui\src\ReactiveUI.Testing\ReactiveUI.Testing.csproj", "{C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactiveUI.Wpf", "submodules\reactiveui\src\ReactiveUI.Wpf\ReactiveUI.Wpf.csproj", "{E899B03C-6E8E-4375-AB65-FC925D721D8B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InstallAndStart", "test\Launcher\InstallAndStart.csproj", "{79F32BE1-2764-4DBA-97F6-21053DE44270}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.TeamFoundation.16", "src\GitHub.TeamFoundation.16\GitHub.TeamFoundation.16.csproj", "{F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.Services.Vssdk.UnitTests", "test\GitHub.Services.Vssdk.UnitTests\GitHub.Services.Vssdk.UnitTests.csproj", "{65542DEE-D3BE-4810-B85A-08E970413A21}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Octokit.GraphQL.Core", "submodules\octokit.graphql.net\Octokit.GraphQL.Core\Octokit.GraphQL.Core.csproj", "{3321CE72-26ED-4D1E-A8F5-6901FB783007}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Octokit.GraphQL", "submodules\octokit.graphql.net\Octokit.GraphQL\Octokit.GraphQL.csproj", "{791B408C-0ABC-465B-9EB1-A2422D67F418}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitHub.StartPage.UnitTests", "test\GitHub.StartPage.UnitTests\GitHub.StartPage.UnitTests.csproj", "{B467682B-9F0E-42D8-8A20-1DE78F798793}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|x86 = Debug|x86 - Publish|Any CPU = Publish|Any CPU - Publish|x86 = Publish|x86 + DebugWithoutVsix|Any CPU = DebugWithoutVsix|Any CPU Release|Any CPU = Release|Any CPU - Release|x86 = Release|x86 - XamlDesign|Any CPU = XamlDesign|Any CPU - XamlDesign|x86 = XamlDesign|x86 + ReleaseWithoutVsix|Any CPU = ReleaseWithoutVsix|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.DebugWithoutVsix|Any CPU.ActiveCfg = DebugWithoutVsix|Any CPU + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.DebugWithoutVsix|Any CPU.Build.0 = DebugWithoutVsix|Any CPU + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.Release|Any CPU.Build.0 = Release|Any CPU + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.ReleaseWithoutVsix|Any CPU.ActiveCfg = ReleaseWithoutVsix|Any CPU + {D26B4B40-0C94-48AD-8019-0B9BE46E0071}.ReleaseWithoutVsix|Any CPU.Build.0 = ReleaseWithoutVsix|Any CPU {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Debug|x86.ActiveCfg = Debug|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Debug|x86.Build.0 = Debug|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Publish|x86.ActiveCfg = Release|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Publish|x86.Build.0 = Release|Any CPU + {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.DebugWithoutVsix|Any CPU.ActiveCfg = DebugWithoutVsix|Any CPU + {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.DebugWithoutVsix|Any CPU.Build.0 = DebugWithoutVsix|Any CPU {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Release|Any CPU.ActiveCfg = Release|Any CPU {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Release|Any CPU.Build.0 = Release|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Release|x86.ActiveCfg = Release|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.Release|x86.Build.0 = Release|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.XamlDesign|Any CPU.ActiveCfg = XamlDesign|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.XamlDesign|Any CPU.Build.0 = XamlDesign|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.XamlDesign|x86.ActiveCfg = XamlDesign|Any CPU - {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.XamlDesign|x86.Build.0 = XamlDesign|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Debug|x86.ActiveCfg = Debug|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Debug|x86.Build.0 = Debug|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Publish|x86.ActiveCfg = Release|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Publish|x86.Build.0 = Release|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Release|Any CPU.Build.0 = Release|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Release|x86.ActiveCfg = Release|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.Release|x86.Build.0 = Release|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {596595A6-2A3C-469E-9386-9E3767D863A5}.XamlDesign|x86.Build.0 = Debug|Any CPU + {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.ReleaseWithoutVsix|Any CPU.ActiveCfg = ReleaseWithoutVsix|Any CPU + {11569514-5AE5-4B5B-92A2-F10B0967DE5F}.ReleaseWithoutVsix|Any CPU.Build.0 = ReleaseWithoutVsix|Any CPU {346384DD-2445-4A28-AF22-B45F3957BD89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {346384DD-2445-4A28-AF22-B45F3957BD89}.Debug|Any CPU.Build.0 = Debug|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.Debug|x86.ActiveCfg = Debug|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.Debug|x86.Build.0 = Debug|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.Publish|x86.ActiveCfg = Release|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.Publish|x86.Build.0 = Release|Any CPU + {346384DD-2445-4A28-AF22-B45F3957BD89}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {346384DD-2445-4A28-AF22-B45F3957BD89}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {346384DD-2445-4A28-AF22-B45F3957BD89}.Release|Any CPU.ActiveCfg = Release|Any CPU {346384DD-2445-4A28-AF22-B45F3957BD89}.Release|Any CPU.Build.0 = Release|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.Release|x86.ActiveCfg = Release|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.Release|x86.Build.0 = Release|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {346384DD-2445-4A28-AF22-B45F3957BD89}.XamlDesign|x86.Build.0 = Debug|Any CPU + {346384DD-2445-4A28-AF22-B45F3957BD89}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {346384DD-2445-4A28-AF22-B45F3957BD89}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Debug|x86.ActiveCfg = Debug|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Debug|x86.Build.0 = Debug|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Publish|x86.ActiveCfg = Release|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Publish|x86.Build.0 = Release|Any CPU + {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Release|Any CPU.ActiveCfg = Release|Any CPU {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Release|Any CPU.Build.0 = Release|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Release|x86.ActiveCfg = Release|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.Release|x86.Build.0 = Release|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.XamlDesign|x86.Build.0 = Debug|Any CPU + {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {158B05E8-FDBC-4D71-B871-C96E28D5ADF5}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Debug|x86.ActiveCfg = Debug|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Debug|x86.Build.0 = Debug|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Publish|x86.ActiveCfg = Release|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Publish|x86.Build.0 = Release|Any CPU + {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Release|Any CPU.ActiveCfg = Release|Any CPU {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Release|Any CPU.Build.0 = Release|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Release|x86.ActiveCfg = Release|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.Release|x86.Build.0 = Release|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.XamlDesign|x86.Build.0 = Debug|Any CPU + {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Debug|x86.ActiveCfg = Debug|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Debug|x86.Build.0 = Debug|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Publish|x86.ActiveCfg = Release|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Publish|x86.Build.0 = Release|Any CPU + {6559E128-8B40-49A5-85A8-05565ED0C7E3}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {6559E128-8B40-49A5-85A8-05565ED0C7E3}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Release|Any CPU.ActiveCfg = Release|Any CPU {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Release|Any CPU.Build.0 = Release|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Release|x86.ActiveCfg = Release|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.Release|x86.Build.0 = Release|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3}.XamlDesign|x86.Build.0 = Debug|Any CPU + {6559E128-8B40-49A5-85A8-05565ED0C7E3}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {6559E128-8B40-49A5-85A8-05565ED0C7E3}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Debug|x86.ActiveCfg = Debug|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Debug|x86.Build.0 = Debug|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Publish|x86.ActiveCfg = Release|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Publish|x86.Build.0 = Release|Any CPU + {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Release|Any CPU.Build.0 = Release|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Release|x86.ActiveCfg = Release|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.Release|x86.Build.0 = Release|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.XamlDesign|x86.Build.0 = Debug|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Debug|Any CPU.Build.0 = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Debug|x86.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Debug|x86.Build.0 = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Publish|x86.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Publish|x86.Build.0 = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Release|Any CPU.Build.0 = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Release|x86.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.Release|x86.Build.0 = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9}.XamlDesign|x86.Build.0 = Release|Any CPU + {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {1A1DA411-8D1F-4578-80A6-04576BEA2DC5}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Debug|x86.ActiveCfg = Debug|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Debug|x86.Build.0 = Debug|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Publish|x86.ActiveCfg = Release|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Publish|x86.Build.0 = Release|Any CPU + {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Release|Any CPU.Build.0 = Release|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Release|x86.ActiveCfg = Release|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.Release|x86.Build.0 = Release|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.XamlDesign|x86.Build.0 = Debug|Any CPU + {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {9AEA02DB-02B5-409C-B0CA-115D05331A6B}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Debug|x86.ActiveCfg = Debug|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Debug|x86.Build.0 = Debug|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Publish|x86.ActiveCfg = Release|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Publish|x86.Build.0 = Release|Any CPU + {B389ADAF-62CC-486E-85B4-2D8B078DF763}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {B389ADAF-62CC-486E-85B4-2D8B078DF763}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Release|Any CPU.ActiveCfg = Release|Any CPU {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Release|Any CPU.Build.0 = Release|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Release|x86.ActiveCfg = Release|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.Release|x86.Build.0 = Release|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763}.XamlDesign|x86.Build.0 = Debug|Any CPU + {B389ADAF-62CC-486E-85B4-2D8B078DF763}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {B389ADAF-62CC-486E-85B4-2D8B078DF763}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Debug|x86.ActiveCfg = Debug|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Debug|x86.Build.0 = Debug|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Publish|x86.ActiveCfg = Release|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Publish|x86.Build.0 = Release|Any CPU + {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Release|Any CPU.ActiveCfg = Release|Any CPU {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Release|Any CPU.Build.0 = Release|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Release|x86.ActiveCfg = Release|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.Release|x86.Build.0 = Release|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.XamlDesign|x86.Build.0 = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Debug|x86.ActiveCfg = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Debug|x86.Build.0 = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Publish|x86.ActiveCfg = Release|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Publish|x86.Build.0 = Release|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Release|Any CPU.Build.0 = Release|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Release|x86.ActiveCfg = Release|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.Release|x86.Build.0 = Release|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0}.XamlDesign|x86.Build.0 = Debug|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Debug|Any CPU.Build.0 = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Debug|x86.ActiveCfg = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Debug|x86.Build.0 = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Publish|x86.ActiveCfg = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Publish|x86.Build.0 = Release|Any CPU + {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {08DD4305-7787-4823-A53F-4D0F725A07F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08DD4305-7787-4823-A53F-4D0F725A07F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08DD4305-7787-4823-A53F-4D0F725A07F3}.DebugWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {08DD4305-7787-4823-A53F-4D0F725A07F3}.DebugWithoutVsix|Any CPU.Build.0 = Release|Any CPU {08DD4305-7787-4823-A53F-4D0F725A07F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {08DD4305-7787-4823-A53F-4D0F725A07F3}.Release|Any CPU.Build.0 = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Release|x86.ActiveCfg = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.Release|x86.Build.0 = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {08DD4305-7787-4823-A53F-4D0F725A07F3}.XamlDesign|x86.Build.0 = Release|Any CPU + {08DD4305-7787-4823-A53F-4D0F725A07F3}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {08DD4305-7787-4823-A53F-4D0F725A07F3}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Debug|Any CPU.ActiveCfg = Release|Any CPU {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Debug|Any CPU.Build.0 = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Debug|x86.ActiveCfg = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Debug|x86.Build.0 = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Publish|x86.ActiveCfg = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Publish|x86.Build.0 = Release|Any CPU + {674B69B8-0780-4D54-AE2B-C15821FA51CB}.DebugWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {674B69B8-0780-4D54-AE2B-C15821FA51CB}.DebugWithoutVsix|Any CPU.Build.0 = Release|Any CPU {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Release|Any CPU.Build.0 = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Release|x86.ActiveCfg = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.Release|x86.Build.0 = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {674B69B8-0780-4D54-AE2B-C15821FA51CB}.XamlDesign|x86.Build.0 = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Debug|Any CPU.Build.0 = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Debug|x86.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Debug|x86.Build.0 = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Publish|x86.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Publish|x86.Build.0 = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Release|Any CPU.Build.0 = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Release|x86.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.Release|x86.Build.0 = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0}.XamlDesign|x86.Build.0 = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Debug|Any CPU.Build.0 = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Debug|x86.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Debug|x86.Build.0 = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Publish|x86.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Publish|x86.Build.0 = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Release|Any CPU.Build.0 = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Release|x86.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.Release|x86.Build.0 = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {600998C4-54DD-4755-BFA8-6F44544D8E2E}.XamlDesign|x86.Build.0 = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Debug|Any CPU.Build.0 = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Debug|x86.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Debug|x86.Build.0 = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Publish|x86.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Publish|x86.Build.0 = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Release|Any CPU.Build.0 = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Release|x86.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.Release|x86.Build.0 = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203}.XamlDesign|x86.Build.0 = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Debug|Any CPU.Build.0 = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Debug|x86.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Debug|x86.Build.0 = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Publish|x86.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Publish|x86.Build.0 = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Release|Any CPU.Build.0 = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Release|x86.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.Release|x86.Build.0 = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {241C47DF-CA8E-4296-AA03-2C48BB646ABD}.XamlDesign|x86.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Debug|Any CPU.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Debug|x86.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Debug|x86.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Publish|Any CPU.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Publish|x86.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Publish|x86.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Release|Any CPU.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Release|x86.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.Release|x86.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64}.XamlDesign|x86.Build.0 = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Debug|Any CPU.Build.0 = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Debug|x86.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Debug|x86.Build.0 = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Publish|x86.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Publish|x86.Build.0 = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Release|Any CPU.Build.0 = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Release|x86.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.Release|x86.Build.0 = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {252CE1C2-027A-4445-A3C2-E4D6C80A935A}.XamlDesign|x86.Build.0 = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Debug|Any CPU.Build.0 = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Debug|x86.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Debug|x86.Build.0 = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Publish|x86.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Publish|x86.Build.0 = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Release|Any CPU.Build.0 = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Release|x86.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.Release|x86.Build.0 = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF}.XamlDesign|x86.Build.0 = Release|Any CPU + {674B69B8-0780-4D54-AE2B-C15821FA51CB}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {674B69B8-0780-4D54-AE2B-C15821FA51CB}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Debug|x86.ActiveCfg = Debug|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Debug|x86.Build.0 = Debug|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Publish|Any CPU.Build.0 = Release|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Publish|x86.ActiveCfg = Release|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Publish|x86.Build.0 = Release|Any CPU + {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Release|Any CPU.ActiveCfg = Release|Any CPU {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Release|Any CPU.Build.0 = Release|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Release|x86.ActiveCfg = Release|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.Release|x86.Build.0 = Release|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.XamlDesign|x86.ActiveCfg = Debug|Any CPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.XamlDesign|x86.Build.0 = Debug|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Debug|Any CPU.Build.0 = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Debug|x86.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Debug|x86.Build.0 = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Publish|Any CPU.Build.0 = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Publish|x86.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Publish|x86.Build.0 = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Release|Any CPU.Build.0 = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Release|x86.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.Release|x86.Build.0 = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65}.XamlDesign|x86.Build.0 = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Debug|x86.ActiveCfg = Debug|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Debug|x86.Build.0 = Debug|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Publish|Any CPU.Build.0 = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Publish|x86.ActiveCfg = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Publish|x86.Build.0 = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Release|Any CPU.Build.0 = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Release|x86.ActiveCfg = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.Release|x86.Build.0 = Release|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8}.XamlDesign|x86.ActiveCfg = Debug|Any CPU + {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {41A47C5B-C606-45B4-B83C-22B9239E4DA0}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Debug|x86.ActiveCfg = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Debug|x86.Build.0 = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Publish|Any CPU.Build.0 = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Publish|x86.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Publish|x86.Build.0 = Release|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720D}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720D}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Release|Any CPU.ActiveCfg = Release|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Release|Any CPU.Build.0 = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Release|x86.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.Release|x86.Build.0 = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720D}.XamlDesign|x86.Build.0 = Release|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720D}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720D}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Debug|x86.ActiveCfg = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Debug|x86.Build.0 = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Publish|Any CPU.Build.0 = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Publish|x86.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Publish|x86.Build.0 = Release|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720E}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720E}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Release|Any CPU.ActiveCfg = Release|Any CPU {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Release|Any CPU.Build.0 = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Release|x86.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.Release|x86.Build.0 = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {161DBF01-1DBF-4B00-8551-C5C00F26720E}.XamlDesign|x86.Build.0 = Release|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720E}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {161DBF01-1DBF-4B00-8551-C5C00F26720E}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Debug|x86.ActiveCfg = Debug|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Debug|x86.Build.0 = Debug|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Publish|Any CPU.Build.0 = Release|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Publish|x86.ActiveCfg = Release|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Publish|x86.Build.0 = Release|Any CPU + {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Release|Any CPU.ActiveCfg = Release|Any CPU {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Release|Any CPU.Build.0 = Release|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Release|x86.ActiveCfg = Release|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.Release|x86.Build.0 = Release|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.XamlDesign|Any CPU.ActiveCfg = Debug|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.XamlDesign|Any CPU.Build.0 = Debug|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.XamlDesign|x86.Build.0 = Release|Any CPU + {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {D1DFBB0C-B570-4302-8F1E-2E3A19C41961}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Debug|Any CPU.Build.0 = Debug|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Debug|x86.ActiveCfg = Debug|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Debug|x86.Build.0 = Debug|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Publish|Any CPU.Build.0 = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Publish|x86.ActiveCfg = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Publish|x86.Build.0 = Release|Any CPU + {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Release|Any CPU.ActiveCfg = Release|Any CPU {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Release|Any CPU.Build.0 = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Release|x86.ActiveCfg = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.Release|x86.Build.0 = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.XamlDesign|x86.Build.0 = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Debug|Any CPU.Build.0 = Debug|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Debug|x86.ActiveCfg = Debug|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Debug|x86.Build.0 = Debug|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Publish|Any CPU.ActiveCfg = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Publish|Any CPU.Build.0 = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Publish|x86.ActiveCfg = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Publish|x86.Build.0 = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Release|Any CPU.ActiveCfg = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Release|Any CPU.Build.0 = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Release|x86.ActiveCfg = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.Release|x86.Build.0 = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.XamlDesign|Any CPU.ActiveCfg = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.XamlDesign|Any CPU.Build.0 = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.XamlDesign|x86.ActiveCfg = Release|Any CPU - {665078F4-5875-431D-A2CC-421849B43328}.XamlDesign|x86.Build.0 = Release|Any CPU + {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {50E277B8-8580-487A-8F8E-5C3B9FBF0F77}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.Release|Any CPU.Build.0 = Release|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {110B206F-8554-4B51-BF86-94DAA32F5E26}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.Release|Any CPU.Build.0 = Release|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {7F5ED78B-74A3-4406-A299-70CFB5885B8B}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.Release|Any CPU.Build.0 = Release|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {17EB676B-BB91-48B5-AA59-C67695C647C2}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.Release|Any CPU.Build.0 = Release|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {8D73575A-A89F-47CC-B153-B47DD06837F0}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.Release|Any CPU.Build.0 = Release|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {2D3D2834-33BE-45CA-B3CC-12F853557D7B}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.Release|Any CPU.Build.0 = Release|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {14FDEE91-7301-4247-846C-049647BF8E99}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.Release|Any CPU.Build.0 = Release|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {09313E65-7ADB-48C1-AD3A-572020C5BDCB}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.Release|Any CPU.Build.0 = Release|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {EFDE0798-ACDB-431D-B7F1-548A7231C853}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.Release|Any CPU.Build.0 = Release|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {3525D819-6AEC-4879-89FB-56B41F026571}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.Release|Any CPU.Build.0 = Release|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.Release|Any CPU.Build.0 = Release|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {C59868FC-D8BC-4D47-B4F3-16908D2641C6}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.Release|Any CPU.Build.0 = Release|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.Release|Any CPU.Build.0 = Release|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {93778A89-3E58-4853-B772-948EBB3F17BE}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.Release|Any CPU.Build.0 = Release|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {8B14F90B-0781-465D-AB94-19C8C56E3A94}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.Release|Any CPU.Build.0 = Release|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {54E8D71A-AABB-4698-95FE-7F11612B8E59}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.Release|Any CPU.Build.0 = Release|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {9E17369D-DA49-48C1-9767-C5178A17BFB8}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {A003B735-6F6C-4DF8-A663-78651A1B6CE0}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.Release|Any CPU.Build.0 = Release|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {A4F579F3-77D3-450A-AACC-F2653EF11E69}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.Release|Any CPU.Build.0 = Release|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {AD0306B7-F88E-44A4-AB36-1D04822E9234}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.Release|Any CPU.Build.0 = Release|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {86C54B27-717F-478C-AC8C-01F1C68A56C5}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.Release|Any CPU.Build.0 = Release|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.Release|Any CPU.Build.0 = Release|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {E899B03C-6E8E-4375-AB65-FC925D721D8B}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.Release|Any CPU.Build.0 = Release|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {79F32BE1-2764-4DBA-97F6-21053DE44270}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.DebugWithoutVsix|Any CPU.ActiveCfg = DebugCodeAnalysis|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.DebugWithoutVsix|Any CPU.Build.0 = DebugCodeAnalysis|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.Release|Any CPU.Build.0 = Release|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {F08BD4BC-B5DF-4193-9B01-6D0BBE101BD7}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.Release|Any CPU.Build.0 = Release|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {65542DEE-D3BE-4810-B85A-08E970413A21}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.Release|Any CPU.Build.0 = Release|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {3321CE72-26ED-4D1E-A8F5-6901FB783007}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.Debug|Any CPU.Build.0 = Debug|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.Release|Any CPU.ActiveCfg = Release|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.Release|Any CPU.Build.0 = Release|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {791B408C-0ABC-465B-9EB1-A2422D67F418}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.DebugWithoutVsix|Any CPU.ActiveCfg = Debug|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.DebugWithoutVsix|Any CPU.Build.0 = Debug|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.Release|Any CPU.Build.0 = Release|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.ReleaseWithoutVsix|Any CPU.ActiveCfg = Release|Any CPU + {B467682B-9F0E-42D8-8A20-1DE78F798793}.ReleaseWithoutVsix|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {596595A6-2A3C-469E-9386-9E3767D863A5} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB8} - {8E1F1B4E-AEA2-4AB1-8F73-423A903550A1} = {7B6C5F8D-14B3-443D-B044-0E209AE12BDF} {1E7F7253-A6AF-43C4-A955-37BEDDA01AC0} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB8} {08DD4305-7787-4823-A53F-4D0F725A07F3} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC0} {674B69B8-0780-4D54-AE2B-C15821FA51CB} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC0} {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB8} - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} - {600998C4-54DD-4755-BFA8-6F44544D8E2E} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} {1E7F7253-A6AF-43C4-A955-37BEDDA01AC9} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB8} - {B4E665E5-6CAF-4414-A6E2-8DE1C3BCF203} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC9} - {241C47DF-CA8E-4296-AA03-2C48BB646ABD} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC9} - {EB73ADDD-2FE9-44C0-A1AB-20709B979B64} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC9} {1E7F7253-A6AF-43C4-A955-37BEDDA01AF9} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB8} - {252CE1C2-027A-4445-A3C2-E4D6C80A935A} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AF9} - {0EC8DBA1-D745-4EE5-993A-6026440EC3BF} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AF9} - {DD99FD0F-82F6-4C30-930E-4A1D0DF01D65} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} - {7B835A7D-CF94-45E8-B191-96F5A4FE26A8} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} - {4405C20E-A72A-4807-9480-14E9BB558347} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB8} - {665078F4-5875-431D-A2CC-421849B43328} = {4405C20E-A72A-4807-9480-14E9BB558347} + {110B206F-8554-4B51-BF86-94DAA32F5E26} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {17EB676B-BB91-48B5-AA59-C67695C647C2} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {C2D88962-BD6B-4F11-B914-535B38377962} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {14FDEE91-7301-4247-846C-049647BF8E99} = {C2D88962-BD6B-4F11-B914-535B38377962} + {09313E65-7ADB-48C1-AD3A-572020C5BDCB} = {C2D88962-BD6B-4F11-B914-535B38377962} + {EFDE0798-ACDB-431D-B7F1-548A7231C853} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {3525D819-6AEC-4879-89FB-56B41F026571} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {94509FCB-6C97-4ED6-AED6-6E74AB3CA336} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {C59868FC-D8BC-4D47-B4F3-16908D2641C6} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {DE704BBB-6EC6-4173-B695-D9EBF5AEB092} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {93778A89-3E58-4853-B772-948EBB3F17BE} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {8B14F90B-0781-465D-AB94-19C8C56E3A94} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {9E17369D-DA49-48C1-9767-C5178A17BFB8} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC9} + {A003B735-6F6C-4DF8-A663-78651A1B6CE0} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC9} + {A4F579F3-77D3-450A-AACC-F2653EF11E69} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} + {AD0306B7-F88E-44A4-AB36-1D04822E9234} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AF9} + {86C54B27-717F-478C-AC8C-01F1C68A56C5} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} + {C6E8D1E1-FAAC-4E02-B6A1-6164EC5E704E} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} + {E899B03C-6E8E-4375-AB65-FC925D721D8B} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AB9} + {65542DEE-D3BE-4810-B85A-08E970413A21} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + {3321CE72-26ED-4D1E-A8F5-6901FB783007} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC0} + {791B408C-0ABC-465B-9EB1-A2422D67F418} = {1E7F7253-A6AF-43C4-A955-37BEDDA01AC0} + {B467682B-9F0E-42D8-8A20-1DE78F798793} = {8A7DA2E7-262B-4581-807A-1C45CE79CDFD} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {556014CF-5B35-4CE5-B3EF-6AB0007001AC} EndGlobalSection EndGlobal diff --git a/GitHubVS.v3.ncrunchsolution b/GitHubVS.v3.ncrunchsolution index 741f7898fe..531ec12867 100644 --- a/GitHubVS.v3.ncrunchsolution +++ b/GitHubVS.v3.ncrunchsolution @@ -1,9 +1,13 @@  + + packages\Fody.1.29.4\**.* + packages\EntryExitDecorator.Fody.0.3.0\**.* + lib\**.* - False + True .ncrunch True diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md deleted file mode 100644 index e014df8bd0..0000000000 --- a/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,6 +0,0 @@ -Hello! Please read the contributing guidelines before submitting an issue regarding the GitHub Extension for Visual Studio. - -- GitHub Extension for Visual Studio version: -- Visual Studio version: - -__What happened__ (with steps, logs and screenshots, if possible) diff --git a/Key.snk b/Key.snk new file mode 100644 index 0000000000..d24a9ef761 Binary files /dev/null and b/Key.snk differ diff --git a/README.md b/README.md index 67cccef089..3354b68c80 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,74 @@ # GitHub Extension for Visual Studio -**The location of the submodules has changed as of 31-01-2017.** If you have an existing clone, make sure to run `git submodule sync` to update your local clone with the new locations for the submodules. +## About -The GitHub Extension for Visual Studio provides GitHub integration in Visual Studio 2015. +The GitHub Extension for Visual Studio provides GitHub integration in Visual Studio 2015 and newer. Most of the extension UI lives in the Team Explorer pane, which is available from the View menu. -Official builds of this extension are available at [the official website](https://visualstudio.github.com). +Official builds of this extension are available at the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=GitHub.GitHubExtensionforVisualStudio). -[![Join the chat at freenode:github-vs](https://img.shields.io/badge/irc-freenode:%20%23github--vs-blue.svg)](http://webchat.freenode.net/?channels=%23github-vs) [![Join the chat at https://gitter.im/github/VisualStudio](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/github/VisualStudio?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +![CI](https://github.com/github/visualstudio/workflows/CI/badge.svg) -## Installing beta versions +[![Follow GitHub for Visual Studio](https://img.shields.io/twitter/follow/GitHubVS.svg?style=social "Follow GitHubVS")](https://twitter.com/githubvs?ref_src=twsrc%5Etfw) [![Join the chat at https://gitter.im/github/VisualStudio](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/github/VisualStudio?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -Older and pre-release/beta/untested versions are available at [the releases page](https://github.com/github/VisualStudio/releases), and also via a custom gallery feed for Visual Studio. - -You can configure the gallery by going to `Tools / Options / Extensions and Updates` and adding a new gallery with the url https://visualstudio.github.com/releases/feed.rss. The gallery will now be available from `Tools / Extensions and Updates`. - -Beta releases will have `(beta)` in their title in the gallery, following the version number. You can view the release notes in the gallery by hovering over the description, or by clicking the `Release Notes` link on the right side. +## Documentation +Visit the [documentation](https://github.com/github/VisualStudio/tree/master/docs) for details on how to use the features in the GitHub Extension for Visual Studio. ## Build requirements -* Visual Studio 2015 -* Visual Studio SDK +* Visual Studio 2019 + * `.NET desktop development` workload + * `.NET Core cross platform development` workload + * `Visual Studio extension development` workload + +The built VSIX will work with Visual Studio 2015 or newer ## Build -Clone the repository and its submodules in a git GUI client or via the command line: +Clone the repository and its submodules. -```txt -git clone https://github.com/github/VisualStudio -cd VisualStudio -git submodule init -git submodule deinit script -git submodule update -``` - -Open the `GitHubVS.sln` solution with Visual Studio 2015. To be able to use the GitHub API, you'll need to: -- [Register a new developer application](https://github.com/settings/developers) in your profile. -- Open [src/GitHub.App/Api/ApiClientConfiguration.cs](src/GitHub.App/Api/ApiClientConfiguration.cs) and fill out the clientId/clientSecret fields for your application. +- [Register a new developer application](https://github.com/settings/developers) in your profile +- Create an environment variable `GitHubVS_ClientID` with your `Client ID` +- Create an environment variable `GitHubVS_ClientSecret` with your `Client Secret` -Build using Visual Studio 2015 or: +Execute `build.cmd` -```txt -build.cmd -``` +## Visual Studio Build -Install in live (non-Experimental) instances of Visual Studio 2015 and 2017: +Build `GitHubVS.sln` using Visual Studio 2019. -```txt -install.cmd -``` +## Logs +Logs can be viewed at the following location: -Note, the script will only install in one instance of Visual Studio 2017 (Enterprise, Professional or Community). +`%LOCALAPPDATA%\GitHubVisualStudio\extension.log` -## Build Flavors +## Troubleshooting -By default, building will create a VSIX with `Experimental="true"` and `AllUsers="false"` in its `extension.vsixmanifest`. These settings are necessary in order to easily install a standalone VSIX file. There is no need to uninstall the version previously installed via Visual Studio setup / Extensions and Updates. +If you have issues building with failures similar to: -The following can be executed via `cmd.exe`. +> "The type or namespace name does not exist..." -To build and install a `Debug` configuration VSIX: -```txt -build.cmd -install.cmd -``` +or -To build and install a `Release` configuration VSIX: -```txt -set Configuration=Release -build.cmd -install.cmd -``` +> "Unable to find project... Check that the project reference is valid and that the project file exists."* + +Close Visual Studio and run the following command to update submodules and clean your environment. -To build a VSIX that can be installed via a gallery feed on Extensions and Updates: ```txt -set Configuration=Release -set IsExperimental=false -build.cmd +clean.cmd ``` -Note, attempting to install `IsExperimental=false` builds of the VSIX is not recommended. - ## More information - - Andreia Gaita's [presentation](https://www.youtube.com/watch?v=hz2hCO8e_8w) at Codemania 2016 about this extension. ## Contributing -Visit the [Contributor Guidelines](CONTRIBUTING.md) for details on how to contribute as well as the [Open Code of Conduct](http://todogroup.org/opencodeofconduct/#VisualStudio/opensource@github.com) for details on how to participate. +Visit the [Contributor Guidelines](CONTRIBUTING.md) for details on how to contribute as well as the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md) for details on how to participate. ## Copyright -Copyright 2015 - 2017 GitHub, Inc. +Copyright 2015 - 2019 GitHub, Inc. Licensed under the [MIT License](LICENSE.md) diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index db567afdef..0000000000 --- a/appveyor.yml +++ /dev/null @@ -1,28 +0,0 @@ -version: '{build}' -install: -- ps: >- - $full_build = Test-Path env:GHFVS_KEY - - git submodule init - - git submodule sync - - if ($full_build) { - $fileContent = "-----BEGIN RSA PRIVATE KEY-----`n" - $fileContent += $env:GHFVS_KEY.Replace(' ', "`n") - $fileContent += "`n-----END RSA PRIVATE KEY-----`n" - Set-Content c:\users\appveyor\.ssh\id_rsa $fileContent - } else { - git submodule deinit script - } - - git submodule update --recursive --force - - nuget restore GitHubVS.sln -build_script: -- cmd: msbuild "GitHubVS.sln" /logger:"C:\Program Files\AppVeyor\BuildAgent\Appveyor.MSBuildLogger.dll" /p:Configuration=Release /p:DeployExtension=false /verbosity:minimal /p:VisualStudioVersion=14.0 -test_script: -- ps: >- - scripts\Run-Nunit.ps1 TrackingCollectionTests 180 Release -AppVeyor - - scripts\Run-Xunit.ps1 UnitTests 180 Release -AppVeyor \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000000..a3fd3771a2 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,123 @@ +# .NET Desktop +# Build and run tests for .NET Desktop or Windows classic desktop solutions. +# Add steps that publish symbols, save build artifacts, and more: +# https://docs.microsoft.com/azure/devops/pipelines/apps/windows/dot-net + +pool: + vmImage: 'windows-latest' + +variables: +- group: GitHubVS +- name: githubvsSolution + value: 'GitHubVS.sln' +- name: essentialsSolution + value: 'src\GitHub.VisualStudio.16.sln' +- name: buildPlatform + value: 'Any CPU' +- name: buildConfiguration + value: 'Release' + +jobs: +- job: build_and_test + displayName: 'Build/test GitHub for VS and Essentials' + + strategy: + maxParallel: 2 + matrix: + VSMarketplace: + ArtifactDirectory: '$(Build.ArtifactStagingDirectory)' + IsProductComponent: false + VSInstaller: + ArtifactDirectory: '$(Build.ArtifactStagingDirectory)\VSInstaller' + IsProductComponent: true + + steps: + - checkout: self + submodules: true + + # Can't use the NuGet tasks because of https://github.com/Microsoft/azure-pipelines-tasks/issues/6790 + #- task: NuGetToolInstaller@0 + #- task: NuGetCommand@2 + # inputs: + # restoreSolution: '$(solution)' + # feedsToUse: 'config' + # nugetConfigPath: nuget.config + + # Instead run nuget manually. + + - task: DotNetCoreCLI@2 + inputs: + command: custom + custom: tool + arguments: install --tool-path . nbgv + displayName: Install NBGV tool + + - script: nbgv cloud + displayName: Set the cloud build number + + - script: tools\nuget\nuget.exe restore $(githubvsSolution) + displayName: NuGet restore $(githubvsSolution) + + - script: tools\nuget\nuget.exe restore $(essentialsSolution) + displayName: NuGet restore $(essentialsSolution) + + - pwsh: mkdir $(ArtifactDirectory) -Force + displayName: Create VSIX staging directory + + - task: MSBuild@1 + displayName: GitHub for Visual Studio + inputs: + solution: $(githubvsSolution) + platform: '$(buildPlatform)' + configuration: '$(buildConfiguration)' + msbuildVersion: '16.0' + msbuildArguments: > + /p:TargetVsixContainer=$(ArtifactDirectory)\GitHub.VisualStudio.vsix + /p:IsProductComponent=$(IsProductComponent) + /p:DeployExtension=False + /p:GitHubVS_ClientId=$(GitHubVS_ClientId) + /p:GitHubVS_ClientSecret=$(GitHubVS_ClientSecret) + + - task: MSBuild@1 + displayName: GitHub Essentials + inputs: + solution: $(essentialsSolution) + platform: '$(buildPlatform)' + configuration: '$(buildConfiguration)' + msbuildVersion: '16.0' + msbuildArguments: > + /p:TargetVsixContainer=$(ArtifactDirectory)\GitHub.VisualStudio.16.vsix + /p:IsProductComponent=$(IsProductComponent) + /p:DeployExtension=False + + - task: DownloadSecureFile@1 + name: vsixsigntool_exe + inputs: + secureFile: vsixsigntool.exe + + - task: DownloadSecureFile@1 + name: certificate_pfx + inputs: + secureFile: certificate.pfx + + - script: $(vsixsigntool_exe.secureFilePath) sign /f $(certificate_pfx.secureFilePath) /p "$(certificate_password)" /sha1 9c5a6d389e1454f2ed9ee9419cdf743689709f9c /fd sha256 /tr http://timestamp.digicert.com /td sha256 $(ArtifactDirectory)\GitHub.VisualStudio.vsix + condition: not(eq(variables['Build.Reason'], 'PullRequest')) + displayName: Sign the GitHub for Visual Studio VSIX + + - script: $(vsixsigntool_exe.secureFilePath) sign /f $(certificate_pfx.secureFilePath) /p "$(certificate_password)" /sha1 9c5a6d389e1454f2ed9ee9419cdf743689709f9c /fd sha256 /tr http://timestamp.digicert.com /td sha256 $(ArtifactDirectory)\GitHub.VisualStudio.16.vsix + condition: not(eq(variables['Build.Reason'], 'PullRequest')) + displayName: Sign the GitHub Essentials VSIX + + - task: PublishBuildArtifacts@1 + inputs: + pathToPublish: $(Build.ArtifactStagingDirectory) + artifactName: 'vsix' + + - task: VSTest@2 + inputs: + searchFolder: '$(Build.SourcesDirectory)\test' + testAssemblyVer2: '**\bin\**\*Tests.dll' + platform: '$(buildPlatform)' + configuration: '$(buildConfiguration)' + diagnosticsEnabled: true + runSettingsFile: '$(Build.SourcesDirectory)\test\test.runsettings' diff --git a/build.cmd b/build.cmd index 029ac6b9a4..c0b247efc5 100644 --- a/build.cmd +++ b/build.cmd @@ -1,8 +1,11 @@ -@if "%Configuration%" == "" set Configuration=Debug -@if "%IsExperimental%" == "" set IsExperimental=true -@if "%IsProductComponent%" == "" set IsProductComponent=false +@echo off -call "%ProgramFiles(x86)%\Microsoft Visual Studio 14.0\Common7\Tools\VsDevCmd.bat" +call vars.cmd -msbuild GitHubVS.sln /t:GitHub_VisualStudio /p:Configuration=%Configuration% /p:IsExperimental=%IsExperimental% /p:IsProductComponent=%IsProductComponent% -@echo Built GitHub.VisualStudio with Configuration=%Configuration% IsExperimental=%IsExperimental% IsProductComponent=%IsProductComponent% +rem Build GitHub for Visual Studio +NuGet restore .\GitHubVS.sln +msbuild .\GitHubVS.sln /p:DeployExtension=False + +rem Build GitHub Essentials +NuGet restore .\src\GitHub.VisualStudio.16.sln +msbuild .\src\GitHub.VisualStudio.16.sln /p:DeployExtension=False diff --git a/certificate.pfx b/certificate.pfx new file mode 100644 index 0000000000..e236e59130 Binary files /dev/null and b/certificate.pfx differ diff --git a/clean.cmd b/clean.cmd new file mode 100644 index 0000000000..58de3b189c --- /dev/null +++ b/clean.cmd @@ -0,0 +1,3 @@ +git submodule update --init +git clean -xdff +git submodule foreach git clean -xdff diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..9e1ee94add --- /dev/null +++ b/codecov.yml @@ -0,0 +1,35 @@ +codecov: + notify: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "70...100" + + status: + project: yes + patch: yes + changes: no + +parsers: + gcov: + branch_detection: + conditional: yes + loop: yes + method: no + macro: no + +comment: + layout: "header, diff, files" + behavior: once + require_changes: no + +fixes: + - "/C/projects/visualstudio/::" + +ignore: + - "*.xaml" + - "*.xaml.cs" + - "**/SampleData/*" + - "src/GitHub.App/sqlite-net/*" \ No newline at end of file diff --git a/deploy-local.cmd b/deploy-local.cmd deleted file mode 100644 index 40c410ec98..0000000000 --- a/deploy-local.cmd +++ /dev/null @@ -1 +0,0 @@ -powershell.exe .\script\deploy.ps1 production None -Force -NoChat -NoPush -NoUpload \ No newline at end of file diff --git a/deploy.cmd b/deploy.cmd deleted file mode 100644 index 0998692f9f..0000000000 --- a/deploy.cmd +++ /dev/null @@ -1 +0,0 @@ -powershell.exe .\script\deploy.ps1 staff development %1 -Force -NoCampfire \ No newline at end of file diff --git a/docs/developer/dialog-views-with-connections.md b/docs/developer/dialog-views-with-connections.md new file mode 100644 index 0000000000..856b4ae67e --- /dev/null +++ b/docs/developer/dialog-views-with-connections.md @@ -0,0 +1,24 @@ +# Dialog Views with Connections + +Some dialog views need a connection to operate - if there is no connection, a login dialog should be shown: for example, clicking Create Gist without a connection will first prompt the user to log in. + +Achieving this is simple, first make your view model interface implement `IConnectionInitializedViewModel` and do any initialization that requires a connection in the `InitializeAsync` method in your view model: + +```csharp +public Task InitializeAsync(IConnection connection) +{ + // .. at this point, you're guaranteed to have a connection. +} +``` + +To show the dialog, call `IShowDialogService.ShowWithFirstConnection` instead of `Show`: + +```csharp +public async Task ShowExampleDialog() +{ + var viewModel = serviceProvider.ExportProvider.GetExportedValue(); + await showDialog.ShowWithFirstConnection(viewModel); +} +``` + +`ShowFirstConnection` first checks if there are any logged in connections. If there are, the first logged in connection will be passed to `InitializeAsync` and the view shown immediately. If there are no logged in connections, the login view will first be shown. Once the user has successfully logged in, the new connection will be passed to `InitalizeAsync` and the view shown. \ No newline at end of file diff --git a/docs/developer/how-viewmodels-are-turned-into-views.md b/docs/developer/how-viewmodels-are-turned-into-views.md new file mode 100644 index 0000000000..a0df01241a --- /dev/null +++ b/docs/developer/how-viewmodels-are-turned-into-views.md @@ -0,0 +1,86 @@ +# How ViewModels are Turned into Views + +We make use of the [MVVM pattern](https://msdn.microsoft.com/en-us/library/ff798384.aspx), in which application level code is not aware of the view level. MVVM takes advantage of the fact that `DataTemplate`s can be used to create views from view models. + +## DataTemplates + +[`DataTemplate`](https://docs.microsoft.com/en-us/dotnet/framework/wpf/data/data-templating-overview)s are a WPF feature that allow you to define the presentation of your data. Consider a simple view model: + +```csharp +public class ViewModel +{ + public string Greeting => "Hello World!"; +} +``` + +And a window: + +```csharp +public class MainWindow : Window +{ + public MainWindow() + { + DataContext = new ViewModel(); + InitializeComponent(); + } +} +``` + +```xml + + + +``` + +Here we're binding the `Content` of the `Window` to the `Window.DataContext`, which we're setting in the constructor to be an instance of `ViewModel`. + +One can choose to display the `ViewModel` instance in any way we want by using a `DataTemplate`: + +```xml + + + + + + + + + + + + +``` + +This is the basis for converting view models to views. + +## ViewLocator + +There are currently two top-level controls for our UI: + +- [GitHubDialogWindow](../../src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml) for the dialog which shows the login, clone, etc views +- [GitHubPaneView](../../src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml) for the GitHub pane + +In the resources for each of these top-level controls we define a `DataTemplate` like so: + +```xml + + + + +``` + +The `DataTemplate.DataType` here applies the template to all classes inherited from [`GitHub.ViewModels.ViewModelBase`](../../src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs) [1]. The template defines a single `ContentControl` whose contents are created by a `ViewLocator`. + +The [`ViewLocator`](../../src/GitHub.VisualStudio/Views/ViewLocator.cs) class is an `IValueConverter` which then creates an instance of the appropriate view for the view model using MEF. + +And thus a view model becomes a view. + +[1]: it would be nice to make it apply to all classes that inherit `IViewModel` but unfortunately WPF's `DataTemplate`s don't work with interfaces. diff --git a/docs/developer/implementing-a-dialog-view.md b/docs/developer/implementing-a-dialog-view.md new file mode 100644 index 0000000000..332ddd4aa9 --- /dev/null +++ b/docs/developer/implementing-a-dialog-view.md @@ -0,0 +1,113 @@ +# Implementing a Dialog View + +GitHub for Visual Studio has a common dialog which is used to show the login, clone, create repository etc. operations. To add a new view to the dialog and show the dialog with this view, you need to do the following: + +## Create a View Model and Interface + +- Create an interface for the view model that implements `IDialogContentViewModel` in `GitHub.Exports.Reactive\ViewModels\Dialog` +- Create a view model that inherits from `NewViewModelBase` and implements the interface in `GitHub.App\ViewModels\Dialog` +- Export the view model with the interface as the contract and add a `[PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +A minimal example that just exposes a command that will dismiss the dialog: + +```csharp +using System; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + public interface IExampleDialogViewModel : IDialogContentViewModel + { + ReactiveCommand Dismiss { get; } + } +} +``` + +```csharp +using System; +using System.ComponentModel.Composition; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IExampleDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ExampleDialogViewModel : ViewModelBase, IExampleDialogViewModel + { + [ImportingConstructor] + public ExampleDialogViewModel() + { + Dismiss = ReactiveCommand.Create(); + } + + public string Title => "Example Dialog"; + + public ReactiveCommand Dismiss { get; } + + public IObservable Done => Dismiss; + } +} +``` + +## Create a View + +- Create a WPF `UserControl` under `GitHub.VisualStudio\Views\Dialog` +- Add an `ExportViewFor` attribute with the type of the view model interface +- Add a `PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +Continuing the example above: + +```xml + + + +``` + +```csharp +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + [ExportViewFor(typeof(IExampleDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class ExampleDialogView : UserControl + { + public ExampleDialogView() + { + InitializeComponent(); + } + } +} +``` + +## Show the Dialog! + +To show the dialog you will need an instance of the `IShowDialogService` service. Once you have that, simply call the `Show` method with an instance of your view model. + +```csharp +var viewModel = new ExampleDialogViewModel(); +showDialog.Show(viewModel) +``` + +## Optional: Add a method to `DialogService` + +Creating a view model like this may be the right thing to do, but it's not very reusable or testable. If you want your dialog to be easy reusable, add a method to `DialogService`: + +```csharp +public async Task ShowExampleDialog() +{ + var viewModel = factory.CreateViewModel(); + await showDialog.Show(viewModel); +} +``` + +Obviously, add this method to `IDialogService` too. + +Note that these methods are `async` - this means that if you need to do asynchronous initialization of your view model, you can do it here before calling `showDialog`. \ No newline at end of file diff --git a/docs/developer/implementing-github-pane-page.md b/docs/developer/implementing-github-pane-page.md new file mode 100644 index 0000000000..bd17b65af6 --- /dev/null +++ b/docs/developer/implementing-github-pane-page.md @@ -0,0 +1,122 @@ +# Implementing a GitHub Pane Page + +The GitHub pane displays GitHub-specific functionality in a dockable pane. To add a new page to the GitHub pane, you need to do the following: + +## Create a View Model and Interface + +- Create an interface for the view model that implements `IPanePageViewModel` in `GitHub.Exports.Reactive\ViewModels\GitHubPane` +- Create a view model that inherits from `PanePageViewModelBase` and implements the interface in `GitHub.App\ViewModels\GitHubPane` +- Export the view model with the interface as the contract and add a `[PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +A minimal example that just exposes a command that will navigate to the pull request list: + +```csharp +using System; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + public interface IExamplePaneViewModel : IPanePageViewModel + { + ReactiveCommand GoToPullRequests { get; } + } +} +``` + +```csharp +using System; +using System.ComponentModel.Composition; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + [Export(typeof(IExamplePaneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ExamplePaneViewModel : PanePageViewModelBase, IExamplePaneViewModel + { + [ImportingConstructor] + public ExamplePaneViewModel() + { + GoToPullRequests = ReactiveCommand.Create(); + GoToPullRequests.Subscribe(_ => NavigateTo("/pulls")); + } + + public ReactiveCommand GoToPullRequests { get; } + } +} +``` + +## Create a View + +- Create a WPF `UserControl` under `GitHub.VisualStudio\ViewsGitHubPane` +- Add an `ExportViewFor` attribute with the type of the view model interface +- Add a `PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +Continuing the example above: + +```xml + + + + +``` + +```csharp +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + [ExportViewFor(typeof(IExampleDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class ExampleDialogView : UserControl + { + public ExampleDialogView() + { + InitializeComponent(); + } + } +} +``` + +## Add a Route to GitHubPaneViewModel + +Now you need to add a route to the `GitHubPaneViewModel`. To add a route, you must do two things: + +- Add a method to `GitHubPaneViewModel` +- Add a URL handler to `GitHubPaneViewModel.NavigateTo` + +So lets add the `ShowExample` method to `GitHubPaneViewModel`: + +```csharp +public Task ShowExample() +{ + return NavigateTo(x => Task.CompletedTask); +} +``` +Here we call `NavigateTo` with the type of the interface of our view model. We're passing a lambda that simply returns `Task.CompletedTask` as the parameter: usually here you'd call an async initialization method on the view model, but since we don't have one in our simple example we just return a completed task. + +Next we add a URL handler: our URL is going to be `github://pane/example` so we need to add a route that checks that the URL's `AbsolutePath` is `/example` and if so call the method we added above. This code is added to `GitHubPaneViewModel.NavigateTo`: + +```csharp +else if (uri.AbsolutePath == "/example") +{ + await ShowExample(); +} +``` + +For the sake of the example, we're going to show our new page as soon as the GitHub Pane is shown and the user is logged-in with an open repository. To do this, simply change `GitHubPaneViewModel.ShowDefaultPage` to the following: + +```csharp +public Task ShowDefaultPage() => ShowExample(); +``` + +When you run the extension and show the GitHub pane, our new example page should be shown. Clicking on the button in the page will navigate to the pull request list. \ No newline at end of file diff --git a/docs/developer/multi-paged-dialogs.md b/docs/developer/multi-paged-dialogs.md new file mode 100644 index 0000000000..1e014f516c --- /dev/null +++ b/docs/developer/multi-paged-dialogs.md @@ -0,0 +1,77 @@ +# Multi-paged Dialogs + +Some dialogs will be multi-paged - for example the login dialog has a credentials page and a 2Fa page that is shown if two-factor authorization is required. + +## The View Model + +To help implement view models for a multi-page dialog there is a useful base class called `PagedDialogViewModelBase`. The typical way of implementing this is as follows: + +- Define each page of the dialog as you would [implement a single dialog view model](implementing-a-dialog-view.md) +- Implement a "container" view model for the dialog that inherits from `PagedDialogViewModel` +- Import each page into the container view model +- Add logic to switch between pages by setting the `PagedDialogViewModelBase.Content` property +- Add a `Done` observable + +Here's a simple example of a container dialog that has two pages. The pages are switched using `ReactiveCommand`s: + +```csharp +using System; +using System.ComponentModel.Composition; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IExamplePagedDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ExamplePagedDialogViewModel : PagedDialogViewModelBase, + IExamplePagedDialogViewModel + { + [ImportingConstructor] + public ExamplePagedDialogViewModel( + IPage1ViewModel page1, + IPage2ViewModel page2) + { + Content = page1; + page1.Next.Subscribe(_ => Content = page2); + page2.Previous.Subscribe(_ => Content = page1); + Done = Observable.Merge(page2.Done, page2.Done); + } + + public override IObservable Done { get; } + } +} +``` + +## The View + +The view in this case is very simple: it just needs to display the `Content` property of the container view model: + +```xml + + +``` + +```csharp +using System; +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + [ExportViewFor(typeof(IExamplePagedDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class ExamplePagedDialogView : UserControl + { + public NewLoginView() + { + InitializeComponent(); + } + } +} +``` + +> Note: this is such a common pattern, you don't actually need to define your own view! Simply add the `[ExportViewFor(...)]` attribute to the existing `ContentView` class. \ No newline at end of file diff --git a/docs/developer/readme.md b/docs/developer/readme.md new file mode 100644 index 0000000000..b0c06b2e81 --- /dev/null +++ b/docs/developer/readme.md @@ -0,0 +1,10 @@ +# Developer Documentation + +Documentation for hacking on GitHub for Visual Studio: + +- User Interface + - [How ViewModels are Turned into Views](how-viewmodels-are-turned-into-views.md) + - [Implementing a Dialog View](implementing-a-dialog-view.md) + - [Dialog Views with Connections](dialog-views-with-connections.md) + - [Multi-Paged Dialogs](multi-paged-dialogs.md) + diff --git a/docs/getting-started/authenticating-to-github.md b/docs/getting-started/authenticating-to-github.md new file mode 100644 index 0000000000..225c3dec8c --- /dev/null +++ b/docs/getting-started/authenticating-to-github.md @@ -0,0 +1,50 @@ +# Authenticating to GitHub + +## How to login to GitHub or GitHub Enterprise + +1. In Visual Studio, select **Team Explorer** from the **View** menu. +
Team Explorer in the view menu
+1. In the Team Explorer pane, click the **Manage Connections** toolbar icon. +
Manage connections toolbar icon in the Team Explorer pane
+1. Click the **Connect** link in the GitHub section. +
Connect to GitHub
+ + If you're connected to a TFS instance, click on the **Sign in** link instead +
Sign in to GitHub
+ + If none of these options are visible, click **Manage Connections** and then **Connect to GitHub**. +
Connect to GitHub in the manage connections dropdown in the Team Explorer pane
+1. In the **Connect to GitHub dialog** choose **GitHub** or **GitHub Enterprise**, depending on which product you're using. + +**GitHub option**: +
Connect to GitHub dialog view
+ +- To sign in with credentials, enter either username or email and password. +- To sign in with SSO, select `Sign in with your browser`. + +**GitHub Enterprise option**: +
Connect to GitHub Enterprise dialog view
+ +- To sign in with SSO, enter the GitHub Enterprise server address and select `Sign in with your browser`. +- To sign in with credentials, enter the GitHub Enterprise server address. + - If a `Password` field appears, enter your password. + - If a `Token` field appears, enter a valid token. You can create personal access tokens by [following the instructions in the section below](#personal_access_tokens). + +Before you authenticate, you must already have a GitHub or GitHub Enterprise account. + +- For more information on creating a GitHub account, see "[Signing up for a new GitHub account](https://help.github.com/articles/signing-up-for-a-new-github-account/)". +- For a GitHub Enterprise account, contact your GitHub Enterprise site administrator. + +### Personal access tokens + +If all signin options above fail, you can manually create a personal access token and use it as your password. + +The scopes for the personal access token are: `user`, `repo`, `gist`, and `write:public_key`. +- *user* scope: Grants access to the user profile data. We currently use this to display your avatar and check whether your plans lets you publish private repositories. +- *repo* scope: Grants read/write access to code, commit statuses, invitations, collaborators, adding team memberships, and deployment statuses for public and private repositories and organizations. This is needed for all git network operations (push, pull, fetch), and for getting information about the repository you're currently working on. +- *gist* scope: Grants write access to gists. We use this in our gist feature, so you can highlight code and create gists directly from Visual Studio +- *write:public_key* scope: Grants access to creating, listing, and viewing details for public keys. This will allows us to add ssh support to your repositories, if you are unable to go through https (this feature is not available yet, this scope is optional) + +For more information on creating personal access tokens, see "[Creating a personal access token for the command line](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line). + +For more information on authenticating with SAML single sign-on, see "[About authentication with SAML single sign-on](https://help.github.com/articles/about-authentication-with-saml-single-sign-on)." diff --git a/docs/getting-started/configuring-git-in-visual-studio.md b/docs/getting-started/configuring-git-in-visual-studio.md new file mode 100644 index 0000000000..1c122ba3c4 --- /dev/null +++ b/docs/getting-started/configuring-git-in-visual-studio.md @@ -0,0 +1,17 @@ +# Configuring Git in Visual Studio + +# Setting your name and email + +The name and email that will be displayed with your commits can be set from Team Explorer's settings. + +1. Open **Team Explorer** by clicking on its tab next to *Solution Explorer*, or via the *View* menu. + +2. Click the **Settings** button in Team Explorer. + + ![The settings button in Team Explorer pane](images/settings-button.png) + +3. Click **Global Settings** in the Team Explorer Settings page + + ![The global settings button in the Team Explorer settings page](images/global-settings-link.png) + +4. Enter the name and email that you would like to appear in commits. diff --git a/docs/getting-started/images/connect-to-github-dialog.png b/docs/getting-started/images/connect-to-github-dialog.png new file mode 100644 index 0000000000..c7ea91774a Binary files /dev/null and b/docs/getting-started/images/connect-to-github-dialog.png differ diff --git a/docs/getting-started/images/connect-to-github-enterprise-dialog.png b/docs/getting-started/images/connect-to-github-enterprise-dialog.png new file mode 100644 index 0000000000..4957025721 Binary files /dev/null and b/docs/getting-started/images/connect-to-github-enterprise-dialog.png differ diff --git a/docs/getting-started/images/connect_to_github.png b/docs/getting-started/images/connect_to_github.png new file mode 100644 index 0000000000..49cf9a707d Binary files /dev/null and b/docs/getting-started/images/connect_to_github.png differ diff --git a/docs/getting-started/images/global-settings-link.png b/docs/getting-started/images/global-settings-link.png new file mode 100644 index 0000000000..88ca43b6ea Binary files /dev/null and b/docs/getting-started/images/global-settings-link.png differ diff --git a/docs/getting-started/images/install-from-gallery.png b/docs/getting-started/images/install-from-gallery.png new file mode 100644 index 0000000000..5731d67192 Binary files /dev/null and b/docs/getting-started/images/install-from-gallery.png differ diff --git a/docs/getting-started/images/manage_connections.png b/docs/getting-started/images/manage_connections.png new file mode 100644 index 0000000000..a3f5a7c71a Binary files /dev/null and b/docs/getting-started/images/manage_connections.png differ diff --git a/docs/getting-started/images/settings-button.png b/docs/getting-started/images/settings-button.png new file mode 100644 index 0000000000..4e7b577faf Binary files /dev/null and b/docs/getting-started/images/settings-button.png differ diff --git a/docs/getting-started/images/sign-in-to-github-provider.png b/docs/getting-started/images/sign-in-to-github-provider.png new file mode 100644 index 0000000000..e4a9b1748e Binary files /dev/null and b/docs/getting-started/images/sign-in-to-github-provider.png differ diff --git a/docs/getting-started/images/sign-in-to-github.png b/docs/getting-started/images/sign-in-to-github.png new file mode 100644 index 0000000000..1ea95fb069 Binary files /dev/null and b/docs/getting-started/images/sign-in-to-github.png differ diff --git a/docs/getting-started/images/update-from-gallery.png b/docs/getting-started/images/update-from-gallery.png new file mode 100644 index 0000000000..f7cdb7c954 Binary files /dev/null and b/docs/getting-started/images/update-from-gallery.png differ diff --git a/docs/getting-started/images/view_team_explorer.png b/docs/getting-started/images/view_team_explorer.png new file mode 100644 index 0000000000..364934e038 Binary files /dev/null and b/docs/getting-started/images/view_team_explorer.png differ diff --git a/docs/getting-started/images/vs2015-installer.png b/docs/getting-started/images/vs2015-installer.png new file mode 100644 index 0000000000..5f442547ec Binary files /dev/null and b/docs/getting-started/images/vs2015-installer.png differ diff --git a/docs/getting-started/images/vs2017-installer.png b/docs/getting-started/images/vs2017-installer.png new file mode 100644 index 0000000000..b5c730fff2 Binary files /dev/null and b/docs/getting-started/images/vs2017-installer.png differ diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000000..dc0575d1a9 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,13 @@ +# Getting Started with GitHub for Visual Studio + +## [Installing GitHub for Visual Studio](installing-github-for-visual-studio.md) + +GitHub for Visual Studio is available for Visual Studio 2015 and later. The Community, Professional and Enterprise editions are supported. + +## [Authenticating to GitHub](authenticating-to-github.md) + +Add your GitHub.com or GitHub Enterprise account information to Visual Studio so you can access your repositories. + +### [Configuring Git in Visual Studio](configuring-git-in-visual-studio.md) + +Configure how Visual Studio interacts with GitHub \ No newline at end of file diff --git a/docs/getting-started/installing-github-for-visual-studio.md b/docs/getting-started/installing-github-for-visual-studio.md new file mode 100644 index 0000000000..11decbfb7e --- /dev/null +++ b/docs/getting-started/installing-github-for-visual-studio.md @@ -0,0 +1,71 @@ +# Installing the GitHub Extension for Visual Studio + +GitHub for Visual Studio is an extension for Microsoft Visual Studio 2015 and later. It is not supported on Visual Studio Code, Visual Studio Express or Visual Studio for Mac. + +## Installing for all versions of Visual Studio 2015 and higher + +If you already have Visual Studio 2015 or higher installed, you can install the extension into all your versions of Visual Studio with the following steps: + +1. Visit the [GitHub for Visual Studio](https://visualstudio.github.com/) site. +2. Click the **Download GitHub Extension for Visual Studio** button. +3. In your computer's **Downloads** folder, double-click **GitHub.VisualStudio.vsix**. +4. In the pop-up window, click **Install**. +5. After the installation is completed, run Visual Studio. + +## Installing from the Visual Studio gallery + +If you're currently running Visual Studio 2015 or higher, you can install the extension from the Visual Studio gallery. + +1. In Visual Studio, open the **Tools** menu and click **Extensions and Updates** + +2. On the left side of the **Extensions and Updates** dialog, select **Online - Visual Studio gallery** + +3. In the search box on the top right side, type **GitHub** + +4. Select the **GitHub Extension for Visual Studio** entry and click **Download** + + ![Installing GitHub for Visual Studio in the settings extensions and updates gallery](images/install-from-gallery.png) + +5. After installation is completed, restart Visual Studio. + +## If you do not have Visual Studio installed yet + +When you install Visual Studio, you can include the GitHub Extension for Visual Studio for installation, as it is available in the Visual Studio installer. + +### Visual Studio 2015 + +**Note:** The Visual Studio 2015 installer is not guaranteed to install the latest version of the extension. Once the Visual Studio installation is complete, [update the extension](#updating-the-extension) from the Visual Studio gallery, or [run the installer](#installing-for-all-versions-of-visual-studio-2015-and-higher) from our website. + +1. Start the Visual Studio 2015 installer. +2. Scroll down to **Common Tools** and check **GitHub Extension for Visual Studio**. + ![GitHub Extension for Visual Studio in the common tools section of the Visual Studio 2015 installer](images/vs2015-installer.png) +3. Click the **Install** button. +4. Once installation is complete, run Visual Studio 2015 and [update the extension](#updating-the-extension) + +### Visual Studio 2017 + +**Note:** The Visual Studio 2017 installer is not guaranteed to install the latest version of the extension. Once the Visual Studio installation is complete, [update the extension](#updating-the-extension) from the Visual Studio gallery, or [run the installer](#installing-for-all-versions-of-visual-studio-2015-and-higher) from our website. + +1. Start the Visual Studio 2017 installer. + +2. Select the **Individual components** tab at the top. + +3. Scroll down to **Code tools** and check **GitHub Extension for Visual Studio**. + + ![GitHub Extension for Visual Studio in the code tools section of the Visual Studio 2017 installer](images/vs2017-installer.png) + +4. Click the **Modify** button. + +5. Once installation is complete, run Visual Studio 2017 and [update the extension](#updating-the-extension) + +## Updating the extension + +**Note:** If you're currently running Visual Studio 2015 or higher and the extension is already installed, Visual Studio will check for and install updates automatically every 24 hours. For this update process to run, Visual Studio must not be running. + +Visual Studio 2017 will not run automatic updates until you update the extension at least once. + +1. In Visual Studio, open the **Tools** menu and click **Extensions and Updates** +2. On the left side of the **Extensions and Updates** dialog, select **Updates - Visual Studio gallery** +3. If there are updates available, an entry titled **GitHub Extension for Visual Studio** will appear on the list. Select it and click **Update** + ![Updating GitHub for Visual Studio in the settings extensions and updates gallery](images/update-from-gallery.png) +4. After installation is completed, restart Visual Studio. diff --git a/docs/readme.md b/docs/readme.md new file mode 100644 index 0000000000..52a611f8b0 --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,13 @@ +# GitHub for Visual Studio Documentation + +### [Getting Started with GitHub for Visual Studio](getting-started/index.md) + +Get GitHub for Visual Studio set up to bring the GitHub flow to Visual Studio. Authenticate to GitHub.com or GitHub Enterprise, keep the extension up-to-date, and review your preferred settings. + +### [Using GitHub for Visual Studio](using/index.md) + +Use GitHub for Visual Studio to manage your projects and work with pull requests. + +### [Developing GitHub for Visual Studio](developer/readme.md) + +Documentation for hacking on GitHub for Visual Studio. diff --git a/docs/using/cloning-a-repository-to-visual-studio.md b/docs/using/cloning-a-repository-to-visual-studio.md new file mode 100644 index 0000000000..49765274d7 --- /dev/null +++ b/docs/using/cloning-a-repository-to-visual-studio.md @@ -0,0 +1,49 @@ +# Cloning a repository to Visual Studio + +After you provide your GitHub or GitHub Enterprise credentials to GitHub for Visual Studio, the extension automatically detects the personal, collaborator and organization repositories you have access to on your account. + +## Opening the clone dialog + +### From **Team Explorer** + +Open **Team Explorer** by clicking on its tab next to *Solution Explorer*, or via the *View* menu. +Click the **Manage Connections** toolbar button. + +![Location of the manage connections toolbar button in Team Explorer](images/manage-connections.png) + +Next to the account you want to clone from, click **Clone**. + +![Clone button in the GitHub section of Team Explorer](images/clone-link.png) + +### From the **Start Page** + +Using Visual Studio 2017, click the `GitHub` button on the `Start Page` to open the clone dialog. + + +### From the **Start Window** + +Using Visual Studio 2019, on the `Start Window` select `Clone or check out code` and then click the `GitHub` button to open the clone dialog. + + +### From the **File** menu + +Go to `File > Open > Open From GitHub...` + + +## Clone repositories +1. In the list of repositories, scroll until you find the repository you'd like to clone. + +You can also filter the repository results by using the *Filter* text box. + +In addition to using the list of personal, collaborator and organization repositories, you can enter a repository URL to clone a public repository. + +![Unified clone and open dialog](images/unified-clone-dialog.png) + +2. If desired, change the local path that the repository will be cloned into, or leave the default as-is. +3. Once a repository is selected and the path is set, Click **Clone**. +4. In Team Explorer, under the list of solutions, double-click on a solution to open it in Visual Studio. + +## Open repositories +For any repository that you select from the list or provide a URL for that you already have cloned locally, the **Open** button becomes enabled and a message shows that you have already cloned the repository to that location. + +![Open option enabled in clone dialog](images/open-cloned-repository.png) diff --git a/docs/using/creating-a-pull-request.md b/docs/using/creating-a-pull-request.md new file mode 100644 index 0000000000..c2eb313cd0 --- /dev/null +++ b/docs/using/creating-a-pull-request.md @@ -0,0 +1,10 @@ +# Creating a pull request + +1. Open a solution in a GitHub repository. +2. Open **Team Explorer** and click the **Pull Requests** button to open the **GitHub** pane. +![Location of the pull requests button in the Team Explorer pane](images/pull-requests-button2.png) +3. Click the **Create New** link above the list of pull requests for the repository. +4. Select the target branch by clicking the link. If the current repository is a fork, then there will be two sets of branches in the dropdown - to submit a pull request upstream then select a branch with the `owner:` prefix of the upstream repository. +![The pull request creation form in the GitHub pane](images/pr-create.png) +5. Enter a pull request title and an optional description. +6. Click the **Create Pull Request** button. diff --git a/docs/using/creating-an-empty-repository-from-visual-studio.md b/docs/using/creating-an-empty-repository-from-visual-studio.md new file mode 100644 index 0000000000..4252863095 --- /dev/null +++ b/docs/using/creating-an-empty-repository-from-visual-studio.md @@ -0,0 +1,27 @@ +# Creating an empty repository from Visual Studio + +1. [Sign in](../getting-started/authenticating-to-github.md) to GitHub. + +2. Open **Team Explorer** by clicking on its tab next to *Solution Explorer*, or via the *View* menu. + +3. Click the **Manage Connections** toolbar button. + + ![The manage connections toolbar button in Team Explorer](images/manage-connections.png) + +4. Click the **Create** link next to the account you want to create the repository in. + + ![The create link in the Team Explorer pane](images/create-link.png) + +5. In the **Create a GitHub Repository** dialog, enter a name, description and local path for the repository. + + ![The create a GitHub repository dialog](images/create-dialog.png) + +6. Select a license for the repository. + +7. Check the **Private Repository** box if you want to upload the repository as a private repository on GitHub. You must have a [Developer, Team or Business account](https://github.com/pricing) to create private repositories. + +8. Click the **Create** button to create the repository + +9. When the repository is created, click the **Create a new Project or Solution** link in Team Explorer to create a project or solution in the repository. + + ![Successful repository creation message at the top of the Team Explorer pane](images/successful-creation-message.png) diff --git a/docs/using/creating-gists.md b/docs/using/creating-gists.md new file mode 100644 index 0000000000..ec2faee3fd --- /dev/null +++ b/docs/using/creating-gists.md @@ -0,0 +1,26 @@ +# Creating gists + +With GitHub for Visual Studio, you can easily create gists directly from the editor. + +1. [Sign in](../getting-started/authenticating-to-github.md) to GitHub. + +2. Open a file in the Visual Studio text editor. + +3. Select the section of text that you want to create a gist from. + +4. Right click and select **Create a GitHub Gist** from the **GitHub** submenu to create the gist on GitHub. + + ![Location of Create a GitHub Gist in the GitHub submenu](images/create-github-gist-menu.png) + +5. To create the gist on GitHub Enterprise select **Create an Enterprise Gist** option from the submenu. + ![Location of Create a Enterprise Gist in the GitHub submenu](images/create-enterprise-gist-menu.png) + +6. In the **Create a GitHub Gist** dialog, check that the filename is correct and optionally add a description. + + ![GitHub Gist creation dialog window](images/create-gist-dialog.png) + +7. If you want the gist to be private, check the **Private Gist** checkbox. + +8. Click **Create**. + +9. Once the gist is created it will be opened in your browser. diff --git a/docs/using/images/add-review-comment.png b/docs/using/images/add-review-comment.png new file mode 100644 index 0000000000..342a90b744 Binary files /dev/null and b/docs/using/images/add-review-comment.png differ diff --git a/docs/using/images/add-review.png b/docs/using/images/add-review.png new file mode 100644 index 0000000000..2ee0f94627 Binary files /dev/null and b/docs/using/images/add-review.png differ diff --git a/docs/using/images/add-to-source-control.png b/docs/using/images/add-to-source-control.png new file mode 100644 index 0000000000..7154c14a63 Binary files /dev/null and b/docs/using/images/add-to-source-control.png differ diff --git a/docs/using/images/clone-link.png b/docs/using/images/clone-link.png new file mode 100644 index 0000000000..eb8a26bae8 Binary files /dev/null and b/docs/using/images/clone-link.png differ diff --git a/docs/using/images/continue-your-review.png b/docs/using/images/continue-your-review.png new file mode 100644 index 0000000000..fa5af830c1 Binary files /dev/null and b/docs/using/images/continue-your-review.png differ diff --git a/docs/using/images/create-dialog.png b/docs/using/images/create-dialog.png new file mode 100644 index 0000000000..16f3dfd013 Binary files /dev/null and b/docs/using/images/create-dialog.png differ diff --git a/docs/using/images/create-enterprise-gist-menu.png b/docs/using/images/create-enterprise-gist-menu.png new file mode 100644 index 0000000000..07a9640ae4 Binary files /dev/null and b/docs/using/images/create-enterprise-gist-menu.png differ diff --git a/docs/using/images/create-gist-dialog.png b/docs/using/images/create-gist-dialog.png new file mode 100644 index 0000000000..fbab9a2abf Binary files /dev/null and b/docs/using/images/create-gist-dialog.png differ diff --git a/docs/using/images/create-gist-menu.png b/docs/using/images/create-gist-menu.png new file mode 100644 index 0000000000..015aafbe54 Binary files /dev/null and b/docs/using/images/create-gist-menu.png differ diff --git a/docs/using/images/create-github-gist-menu.png b/docs/using/images/create-github-gist-menu.png new file mode 100644 index 0000000000..16a5fab586 Binary files /dev/null and b/docs/using/images/create-github-gist-menu.png differ diff --git a/docs/using/images/create-link.png b/docs/using/images/create-link.png new file mode 100644 index 0000000000..1636ec6ae2 Binary files /dev/null and b/docs/using/images/create-link.png differ diff --git a/docs/using/images/delete-comment.png b/docs/using/images/delete-comment.png new file mode 100644 index 0000000000..6a9d8c7fe7 Binary files /dev/null and b/docs/using/images/delete-comment.png differ diff --git a/docs/using/images/edit-comment.png b/docs/using/images/edit-comment.png new file mode 100644 index 0000000000..a73c6fab20 Binary files /dev/null and b/docs/using/images/edit-comment.png differ diff --git a/docs/using/images/github-pane-toolbar.png b/docs/using/images/github-pane-toolbar.png new file mode 100644 index 0000000000..3eb6b4c795 Binary files /dev/null and b/docs/using/images/github-pane-toolbar.png differ diff --git a/docs/using/images/hover-to-add-comment.png b/docs/using/images/hover-to-add-comment.png new file mode 100644 index 0000000000..231441e0f9 Binary files /dev/null and b/docs/using/images/hover-to-add-comment.png differ diff --git a/docs/using/images/manage-connections.png b/docs/using/images/manage-connections.png new file mode 100644 index 0000000000..fb8c01d58e Binary files /dev/null and b/docs/using/images/manage-connections.png differ diff --git a/docs/using/images/open-cloned-repository.png b/docs/using/images/open-cloned-repository.png new file mode 100644 index 0000000000..9aa0596e53 Binary files /dev/null and b/docs/using/images/open-cloned-repository.png differ diff --git a/docs/using/images/open-on-github.png b/docs/using/images/open-on-github.png new file mode 100644 index 0000000000..186bda86d9 Binary files /dev/null and b/docs/using/images/open-on-github.png differ diff --git a/docs/using/images/open-team-explorer.png b/docs/using/images/open-team-explorer.png new file mode 100644 index 0000000000..25a750c85f Binary files /dev/null and b/docs/using/images/open-team-explorer.png differ diff --git a/docs/using/images/pending-review-comment.png b/docs/using/images/pending-review-comment.png new file mode 100644 index 0000000000..328144ee70 Binary files /dev/null and b/docs/using/images/pending-review-comment.png differ diff --git a/docs/using/images/pr-create.png b/docs/using/images/pr-create.png new file mode 100644 index 0000000000..02aededb85 Binary files /dev/null and b/docs/using/images/pr-create.png differ diff --git a/docs/using/images/pr-detail-view-checkout-branch.png b/docs/using/images/pr-detail-view-checkout-branch.png new file mode 100644 index 0000000000..55f63aacc9 Binary files /dev/null and b/docs/using/images/pr-detail-view-checkout-branch.png differ diff --git a/docs/using/images/pr-detail-view.png b/docs/using/images/pr-detail-view.png new file mode 100644 index 0000000000..23b4739a58 Binary files /dev/null and b/docs/using/images/pr-detail-view.png differ diff --git a/docs/using/images/pr-details-checkout-link.png b/docs/using/images/pr-details-checkout-link.png new file mode 100644 index 0000000000..543ac38e49 Binary files /dev/null and b/docs/using/images/pr-details-checkout-link.png differ diff --git a/docs/using/images/pr-details.png b/docs/using/images/pr-details.png new file mode 100644 index 0000000000..222b2edc1a Binary files /dev/null and b/docs/using/images/pr-details.png differ diff --git a/docs/using/images/pr-diff-files.png b/docs/using/images/pr-diff-files.png new file mode 100644 index 0000000000..5ae7800009 Binary files /dev/null and b/docs/using/images/pr-diff-files.png differ diff --git a/docs/using/images/pr-pull-changes.png b/docs/using/images/pr-pull-changes.png new file mode 100644 index 0000000000..3073e77536 Binary files /dev/null and b/docs/using/images/pr-pull-changes.png differ diff --git a/docs/using/images/publish-to-github.png b/docs/using/images/publish-to-github.png new file mode 100644 index 0000000000..59c3abb36b Binary files /dev/null and b/docs/using/images/publish-to-github.png differ diff --git a/docs/using/images/pull-request-assignable-user.png b/docs/using/images/pull-request-assignable-user.png new file mode 100644 index 0000000000..f0e48efc52 Binary files /dev/null and b/docs/using/images/pull-request-assignable-user.png differ diff --git a/docs/using/images/pull-request-list-filter.png b/docs/using/images/pull-request-list-filter.png new file mode 100644 index 0000000000..5dd960c4c8 Binary files /dev/null and b/docs/using/images/pull-request-list-filter.png differ diff --git a/docs/using/images/pull-request-list-view.png b/docs/using/images/pull-request-list-view.png new file mode 100644 index 0000000000..38678b8ab9 Binary files /dev/null and b/docs/using/images/pull-request-list-view.png differ diff --git a/docs/using/images/pull-requests-button.png b/docs/using/images/pull-requests-button.png new file mode 100644 index 0000000000..365494defb Binary files /dev/null and b/docs/using/images/pull-requests-button.png differ diff --git a/docs/using/images/pull-requests-button2.png b/docs/using/images/pull-requests-button2.png new file mode 100644 index 0000000000..4d03c1b0dc Binary files /dev/null and b/docs/using/images/pull-requests-button2.png differ diff --git a/docs/using/images/single-comment.png b/docs/using/images/single-comment.png new file mode 100644 index 0000000000..178d24ad39 Binary files /dev/null and b/docs/using/images/single-comment.png differ diff --git a/docs/using/images/submit-review-options.png b/docs/using/images/submit-review-options.png new file mode 100644 index 0000000000..d6fb4c2521 Binary files /dev/null and b/docs/using/images/submit-review-options.png differ diff --git a/docs/using/images/submit-review.png b/docs/using/images/submit-review.png new file mode 100644 index 0000000000..207e9d52ae Binary files /dev/null and b/docs/using/images/submit-review.png differ diff --git a/docs/using/images/successful-creation-message.png b/docs/using/images/successful-creation-message.png new file mode 100644 index 0000000000..45fe34e34b Binary files /dev/null and b/docs/using/images/successful-creation-message.png differ diff --git a/docs/using/images/team-explorer-sync.png b/docs/using/images/team-explorer-sync.png new file mode 100644 index 0000000000..32d4bce4a3 Binary files /dev/null and b/docs/using/images/team-explorer-sync.png differ diff --git a/docs/using/images/unified-clone-dialog.png b/docs/using/images/unified-clone-dialog.png new file mode 100644 index 0000000000..814fc5b658 Binary files /dev/null and b/docs/using/images/unified-clone-dialog.png differ diff --git a/docs/using/images/view-conversation.png b/docs/using/images/view-conversation.png new file mode 100644 index 0000000000..ea037cf511 Binary files /dev/null and b/docs/using/images/view-conversation.png differ diff --git a/docs/using/index.md b/docs/using/index.md new file mode 100644 index 0000000000..47ed5d50a7 --- /dev/null +++ b/docs/using/index.md @@ -0,0 +1,29 @@ +# Using GitHub for Visual Studio + +Use GitHub for Visual Studio to manage your projects and work with pull requests. + +### Table of Contents + +- Adding and cloning repositories + - [Publishing an existing project to GitHub](publishing-an-existing-project-to-github.md) + - [Creating an empty repository from Visual Studio](creating-an-empty-repository-from-visual-studio.md) + - [Cloning a repository to Visual Studio](cloning-a-repository-to-visual-studio.md) +- Working with pull requests + - [Viewing pull requests for a repository](viewing-the-pull-requests-for-a-repository.md) + - [Creating a pull request](creating-a-pull-request.md) + - [Reviewing a pull request in Visual Studio](reviewing-a-pull-request-in-visual-studio.md) + - [Making changes to a pull request](making-changes-to-a-pull-request.md) +- [Creating gists](creating-gists.md) +- [Viewing existing code on GitHub](viewing-code-on-github.md) + - [Viewing the selected code on GitHub](viewing-code-on-github.md#viewing-the-selected-code-on-github) + - [Copying the URL of the selected code's location on GitHub](viewing-code-on-github.md#copying-the-url-of-the-selected-codes-location-on-github) + - [Viewing the selected code in blame view on GitHub](viewing-code-on-github.md#viewing-the-selected-code-in-blame-view-on-github) +- [Using the GitHub toolbar](using-the-github-toolbar.md) +- Operations provided by Visual Studio + - [Committing](https://www.visualstudio.com/en-us/docs/git/tutorial/commits) + - [Pushing commits to the remote repository](https://www.visualstudio.com/en-us/docs/git/tutorial/pushing) + - [Fetching and pulling](https://www.visualstudio.com/en-us/docs/git/tutorial/pulling) + - [Working with branches](https://www.visualstudio.com/en-us/docs/git/tutorial/branches) + - [Viewing history](https://www.visualstudio.com/en-us/docs/git/tutorial/history) + - [Ignoring files](https://www.visualstudio.com/en-us/docs/git/tutorial/ignore-files) +- [Contact a human](https://github.com/contact) diff --git a/docs/using/making-changes-to-a-pull-request.md b/docs/using/making-changes-to-a-pull-request.md new file mode 100644 index 0000000000..d16d728dfb --- /dev/null +++ b/docs/using/making-changes-to-a-pull-request.md @@ -0,0 +1,17 @@ +# Making changes to a pull request + +When a topic branch is [checked out](review-a-pull-request-in-visual-studio.md), you can commit changes to it and push and pull like any other branch. If the pull request branch is located in a fork and was checked out from the Pull Request Details view in the GitHub pane, then a remote to that fork will be created automatically and the branch set to track the fork branch. + +## Pulling changes to your local clone + +If a Pull Request is checked out and the author adds new commits to the branch, then the option will be given to pull the changes locally. This works both for pull requests from the same repository and from a fork. + +![Pulling changes by clicking the pull link button](images/pr-pull-changes.png) + +## Pushing changes + +If you make commits locally to a topic branch, then you can push the changes to the remote branch. You can also do this from Git itself or from the Visual Studio Team Explorer **Sync** view. + +> Note: for this to work with Pull Requests that come from forks, then you must be a maintainer on the repository and the Pull Request submitter must have checked [Allow edits from maintainers](https://help.github.com/articles/allowing-changes-to-a-pull-request-branch-created-from-a-fork/) when submitting the Pull Request. + +If there are commits on the branch on the remote repository that you don't have on your local clone, you must pull them to your local clone and [resolve any conflicts](https://help.github.com/articles/addressing-merge-conflicts/) before you can push your local commits back to the remote repository. diff --git a/docs/using/publishing-an-existing-project-to-github.md b/docs/using/publishing-an-existing-project-to-github.md new file mode 100644 index 0000000000..d24874b10b --- /dev/null +++ b/docs/using/publishing-an-existing-project-to-github.md @@ -0,0 +1,14 @@ +# Publishing an existing project to GitHub + +1. Open a solution in Visual Studio. +2. If solution is not already initialized as a Git repository, select **Add to Source Control** from the **File** menu. +![Location of Add to Source Control option in the file menu](images/add-to-source-control.png) +3. Open **Team Explorer**. +![Location of Team Explorer option in the view menu](images/open-team-explorer.png) +4. In Team Explorer, click **Sync**. +![Location of the sync button in the Team Explorer pane](images/team-explorer-sync.png) +5. Click the **Publish to GitHub** button. +![Location of the Publish to GitHub button in the Team Explorer pane](images/publish-to-github.png) +6. Enter a name and description for the repository on GitHub. +7. Check the **Private Repository** box if you want to upload the repository as a private repository on GitHub. +8. Click the **Publish** button. diff --git a/docs/using/reviewing-a-pull-request-in-visual-studio.md b/docs/using/reviewing-a-pull-request-in-visual-studio.md new file mode 100644 index 0000000000..f046b15023 --- /dev/null +++ b/docs/using/reviewing-a-pull-request-in-visual-studio.md @@ -0,0 +1,102 @@ +# Reviewing a pull request in Visual Studio + +GitHub for Visual Studio provides facilities for reviewing a pull request directly in Visual Studio. + +1. Open a solution in a GitHub repository. + +2. Open **Team Explorer** and click the **Pull Requests** button to open the **GitHub** pane. + + ![Pull Requests button in the Team Explorer pane](images/pull-requests-button.png) + +3. Click the title of the pull request to be reviewed. + +## Viewing a pull request + +The Pull Request Details view shows the current state of the pull request, including: +- information about who created the pull request +- the source and target branch +- a description of the pull request (collapsed by default) +- reviewers and the status of their review +- checks (if checks have been enabled for the repository) +- the files changed + +![The details of a single pull request in the GitHub pane](images/pr-detail-view.png) + +## Viewing conversation details + +Click the comment count link in the GitHub pane to open up the conversation view. The conversation view shows the Pull Request description, a history of commits, and comments made. + +![View the conversation for a pull request](images/view-conversation.png) + +## Checking out a pull request + +To check out the pull request branch, click the **Checkout [branch]** link where [branch] is the name of the branch that will be checked out. + +![Location of the checkout link in the GitHub pull request details page](images/pr-detail-view-checkout-branch.png) + +If the pull request is from a fork then a remote will be added to the forked repository and the branch checked out locally. This remote will automatically be cleaned up when the local branch is deleted. + +> Note that you cannot check out a pull request branch when your working directory has uncommitted changes. First commit or stash your changes and then refresh the Pull Request view. + +## Viewing Changes + +To view the changes in the pull request for a file, double click a file in the **Changed Files** tree. This will open the Visual Studio diff viewer. + +![Diff of two files in the Visual Studio diff viewer](images/pr-diff-files.png) + +You can also right-click on a file in the changed files tree to get more options: + +- **View Changes**: This is the default option that is also triggered when the file is double-clicked. It shows the changes to the file that are introduced by the pull request. +- **View File**: This opens a read-only editor showing the contents of the file in the pull request. +- **View Changes in Solution**: This menu item is only available when the pull request branch is checked out. It shows the changes in the pull request, but the right hand side of the diff is the file in the working directory. This view allows you to use Visual Studio navigation commands such as **Go to Definition (F12)**. +- **Open File in Solution**: This menu item opens the working directory file in an editor. + +## Leaving Comments + +You can add comments to a pull request directly from Visual Studio. When a file is [open in the diff viewer](#viewing-changes) you can click the **Add Comment** icon in the margin to add a comment on a line. + +![Hover over margin to see add comment icon](images/hover-to-add-comment.png) + +Then click the icon on the desired line and leave a comment. +![Add a comment](images/add-review-comment.png) + +To just comment on this one line, select **Add a single comment**. This will add the comment immediately. + +To start a review of the Pull Request and all its changes, select **Start a review**. This will add this comment as pending, and the comment will be submitted once the review is complete. + +#### Viewing comments + +Existing comments left by you or other reviewers will also show up in this margin. Click the icon to open an inline conversation view from which you can review and reply to comments. + +Single comments appear like this when viewed inline. +![Single comment](images/single-comment.png) + +Review comments appear the same as single comments except for a Pending badge is added to the comment. +![Single comment](images/pending-review-comment.png) + +#### Modifying Comments + +Single and review comments can either be edited or deleted. + +To edit a comment, press the pencil symbol to edit the comment and then select **Update comment** once finished. +![Edit comment](images/edit-comment.png) + +To delete a comment, press the x symbol. A confirmation box to delete the comment will appear. +![Delete comment](images/delete-comment.png) + +#### Submitting a review + +From the pull request details view you can go to the review summary and submit a review. + +If you have pending review comments you'll see the option **Continue your review**: +![Continue review](images/continue-your-review.png) + +Otherwise you'll see an option to **Add your review**: + +![Add review](images/add-review.png) + +When you are redirected to the review summary view, leave a review summary and select **Submit review**. +![Submit review](images/submit-review.png) + +The options available are to **Approve**, **Comment only** or **Request changes**. +![Submit review options](images/submit-review-options.png) diff --git a/docs/using/using-the-github-toolbar.md b/docs/using/using-the-github-toolbar.md new file mode 100644 index 0000000000..0cc353babd --- /dev/null +++ b/docs/using/using-the-github-toolbar.md @@ -0,0 +1,29 @@ +# Using the GitHub pane toolbar + +The GitHub pane toolbar provides a way to navigate between views, refresh views, and open the current view on GitHub. + +![The GitHub pane toolbar](images/github-pane-toolbar.png) + +1. Open a solution in a GitHub repository. +2. Open **Team Explorer** and click the **Pull Requests** button to open the **GitHub** pane. + +## Using the back navigation button +1. At the top of the **GitHub** pane, locate the button furthest to the left. This is the **Back** button. +2. Click **Back** to navigate to the previous view. If **Back** is grey, you have reached the initial point in the view navigation. + +## Using the forward navigation button +1. At the top of the **GitHub** pane, locate the second button from the left, which is the **Forward** button. +2. Click **Forward** to navigate to the next view. If **Forward** is grey, you have reached the furthest point in the view navigation. + +## Using the pull request toolbar icon +1. At the top of the **GitHub** pane, locate the third button from the left. This is the **Pull Requests** button. +2. Click **Pull Requests** to navigate to the list of pull requests in the repository. + +## Using the GitHub toolbar button +1. At the top of the **GitHub** pane, locate the fourth button from the left. This is the **View On GitHub** button. +2. While viewing the list of pull requests, click **View On Github**. Your browser will open and navigate to the repository's pull requests on GitHub. +3. While viewing the details of a pull request, click **View On Github**. Your browser will open and navigate to the pull request on GitHub. + +## Using the refresh toolbar button +1. At the top of the **GitHub** pane, locate the fifth button from the left. This is the **Refresh** button. +2. Click **Refresh** to refresh the current view in the **GitHub** pane. diff --git a/docs/using/viewing-code-on-github.md b/docs/using/viewing-code-on-github.md new file mode 100644 index 0000000000..2f3d587fc3 --- /dev/null +++ b/docs/using/viewing-code-on-github.md @@ -0,0 +1,21 @@ +# Viewing existing code on GitHub + +GitHub for Visual Studio enables easy navigation to code that exists on GitHub directly from the Visual Studio code editor. + +1. Open a solution in a GitHub repository. +2. Open *Solution Explorer* by clicking on its tab, or via the *View* menu. +3. In *Solution Explorer*, double click on a file to open it in Visual Studio code editor. +3. In the code editor, highlight the section of text that you want to view in the browser. + +## Viewing the selected code on GitHub +1. Right click and select **Open on GitHub** from the **GitHub** submenu. +![Open on GitHub selection in the GitHub context submenu](images/open-on-github.png) +2. Your browser will open and navigate to the code on GitHub. + +## Copying the URL of the selected code's location on GitHub +1. Right click and select **Copy link to clipboard** from the **GitHub** submenu. The URL will be copied to the clipboard. +2. Paste the URL into your browser to view it on GitHub. + +## Viewing the selected code in blame view on GitHub +1. Right click and select **Blame** from the **GitHub** submenu. +2. Your browser will open and navigate to the code in blame view on GitHub. diff --git a/docs/using/viewing-the-pull-requests-for-a-repository.md b/docs/using/viewing-the-pull-requests-for-a-repository.md new file mode 100644 index 0000000000..eea77c6fc3 --- /dev/null +++ b/docs/using/viewing-the-pull-requests-for-a-repository.md @@ -0,0 +1,16 @@ +# Viewing the pull requests for a repository + +GitHub for Visual Studio exposes the pull requests for the current repository and lets you create new pull requests and review pull requests from other contributors. + +1. [Sign in](../getting-started/authenticating-to-github.md) to GitHub. +2. Open a solution in a GitHub repository. +3. Open **Team Explorer** and click the **Pull Requests** button to open the **GitHub** pane. +![Pull Requests button in the Team Explorer pane](images/pull-requests-button2.png) +4. The open pull requests will be displayed. +![Pull requests in the GitHub pane](images/pull-request-list-view.png) +5. Change the pull requests listed by clicking the **Open** link and selecting the filter you want to use from the dropdown with the options *Open/Closed/All*. +![Pull requests status dropdown filter](images/pull-request-list-filter.png) +6. Filter pull requests by author by clicking the person icon and selecting the user you want to view from the dropdown. You can also search for users from the dropdown. +![Pull requests assignable user dropdown filter](images/pull-request-assignable-user.png) +7. Double-click anywhere in the pull request item to [view the pull request details and review the pull request](reviewing-a-pull-request-in-visual-studio.md) +8. Click on the **Create New** link to [create a pull request from the current branch](creating-a-pull-request.md) diff --git a/documentation/UIController.md b/documentation/UIController.md deleted file mode 100644 index 60b9400a3f..0000000000 --- a/documentation/UIController.md +++ /dev/null @@ -1,59 +0,0 @@ -The `UIController` class creates views and associated viewmodels for each UI we have, -and controls the UI logic graph. It uses various state machines to define -the UI logic graph. - -**State machines** - -The UI logic graph is controlled by various state machines defined in -`ConfigureLogicStates` and `ConfigureUIHandlingStates` - -**ConfigureLogicStates** - -`ConfigureLogicStates` defines the one state machine per UI group: - - Authentication - - Repository Clone - - Repository Creation - - Repository Publish, - - Pull Requests (List, Detail, Creation) - -All state machines have a common state of `None` (nothing happened yet), -`End` (we're done, cleanup) and `Finish` (final state once cleanup is done), and -UI-specific states that relate to what views the UI needs to show (Login, TwoFactor, -PullRequestList, etc). States are defined in the enum `UIViewType` - -All state machines support a variety of triggers for going from one state to another. -These triggers are defined in the enum `Trigger`. `Cancel` and `Finish` are -supported by all states (any state machine regardless of its current state supports -exiting via `Cancel` (ends with success flag set to false) and `Finish` (ends with success flag set to true). -Since most UI flows we support are linear (login followed by 2fa followed by clone -followed by ending the flow), most states support the `Next` trigger to continue, -and when at the end of a ui flow, `Next` ends it with success. - -The Pull Requests UI flow is non-linear (there are more than one transition from the -list view - it can go to the detail view, or the pr creation view, or other views), -it does not support the `Next` trigger and instead has its own triggers. - -**ConfigureUIHandlingStates** - -`ConfigureUIHandlingStates` defines a state machine `uiStateMachine` connected -to the `transition` observable, and executes whatever logic is needed to load the -requested state, usually by creating the view and viewmodel that the UI state requires. -Whenever a state transition happens in `uiStateMachine`, if the state requires -loading a view, this view is sent back to the rendering code via the `transition` -observable. When the state machine reaches the `End` state, the `completion` observable -is completed with a flag indicating whether the ui flow ended successfully or was -cancelled (via the `Next`/`Finish` triggers or the `Cancel` trigger) . - -The transitions between states are dynamically evaluated at runtime based on the logic -defined in `ConfigureLogicStates`. When `uiStateMachine` receives a trigger request, -it passes that trigger to the state machine corresponding to the ui flow that's currently -running (Authentication, Clone, etc), and that state machine determines which state -to go to next. - -In theory, `uiStateMachine` has no knowledge of the logic graph, and -the only thing it's responsible for is loading UI whenever a state is entered, and cleaning -up objects when things are done - making it essentially a big switch with nice entry and exit -conditions. In practice, we need to configure the valid triggers from one state to the other -just so we can call the corresponding state machine that handles the logic (defined in `ConfigureLogicStates`). -There is a bit of code duplication because of this, and there's some room for improvement -here, but because we have a small number of triggers, it's not a huge deal. \ No newline at end of file diff --git a/documentation/manifest.md b/documentation/manifest.md deleted file mode 100644 index 51cea96035..0000000000 --- a/documentation/manifest.md +++ /dev/null @@ -1,266 +0,0 @@ -# First Launch -- **Start page is displayed by default on first launch** - - **Click on GitHub link on start page under Open > Checkout from** - - [ ] Team Explorer Window exposed - - [ ] Clone a GitHub Repository dialog visible - - **If last solution was in a git repo hosted on GitHub** - - **Team Explorer Home page shows:** - - [ ] GitHub header, repo information - - [ ] Pull Requests button - - [ ] Pulse button - - [ ] Graphs button - - [ ] Issues button - - **If last solution was in a git repo not hosted on GitHub** - - [ ] Team Explorer Home page does not show any github information - - **Go to Team Explorer Connect page** - - [ ] GitHub invitation section in Hosted Service Providers area is visible with Connect... and Sign up links - - [ ] **Click on Connect** - - [ ] Connect to GitHub dialog appears - - [ ] GitHub option underlined - - [ ] Cursor on username field - - [ ] Login button disabled - - [ ] Link to sign up at the bottom - - [ ] **Fill out login information** - - [ ] **On successful login** - - [ ] Connect dialog disappears - - [ ] GitHub invitation section in Connect page disappears - - [ ] GitHub connection appears above Hosted Service Providers area with Clone, Create and Sign out action links. As long as it's above Local Git Repositories, it's good - -# In Team Explorer Connect page (logged in) - - [ ] **Click on Clone action link** - - [ ] Clone dialog appears - - [ ] List of user repositories is populated - - [ ] Path field contains default cloning path C:\Users\[user]\Source\Repos - - [ ] Cursor is in Search Repositories field - - [ ] Clone button is disabled - - [ ] Typing in the Search Repositories field filters the list - - [ ] Clicking on the browse action link opens a file explorer - - [ ] Selecting a directory in the file explorer changes the contents of the Path field to the new path - - [ ] Selecting a repository from the list enables the clone button - - [ ] ctrl-clicking a selection in the list removes the selection and disables the clone button - - [ ] Hovering over the clone button (when enabled) animates the button (reversing colors) - - [ ] **Select a repository and click Clone** - - [ ] Clone dialog disappears - - [ ] Progress bar appears in the Team Explorer Connect page with cloning progress (depending on repo size) - - [ ] Notification appears in Team Explorer Connect page: "The repository was cloned successfully. username/repo-name has been successfully created. Create a new project or solution." with proper links displayed. - - [ ] Repository shows up in the "Local Git Repositories" list - - [ ] **Double-click the cloned repository in the "Local Git Repositories" list** - - [ ] Team Explorer view changes to Home page - - [ ] GitHub header and repo information is shown - - [ ] **Navigate back to Connect view, then click clone in "Local Git Repositories" list** - - [ ] Copy/paste a repo from .com. Clone and verify the message displays, "The repository was cloned successfully." - - [ ] **Click on Create action link in GitHub section** - - [ ] Create dialog appears - - [ ] Cursor is on the Name field - - [ ] Create button is disabled - - [ ] Local path is set to default cloning path C:\Users\[user]\Source\Repos - - [ ] Git ignore is set to VisualStudio - - [ ] User is set to current logged user - - [ ] Tabbing through the fields follows visual placement of fields - - [ ] Filling the name field enables the Create button - - [ ] Hovering over the Create button animates it (reversing colors) - - [ ] Clicking on the browse action link opens a file explorer - - [ ] Selecting a directory in the file explorer changes the contents of the Path field to the new path - - [ ] **Fill out the name field and click Create** - - [ ] Dialog disappears - - [ ] Notification appears in Team Explorer Connect page: "The repository was created successfully" - - [ ] Repository shows up in the "Local Git Repositories" list - - [ ] **Double-click the created repository in the "Local Git Repositories" list** - - [ ] Team Explorer view changes to Home page - - [ ] GitHub header and repo information is shown -- [ ] **Publishing a local repo** - - [ ] **File - New - Project - Console Application (or any type of project, doesn't matter much)** - - [ ] **Set "Create new Git Repository" checkbox to checked** - - [ ] **Click OK button** - - [ ] Verify that Team Explorer home page does *not* have a GitHub section -- [ ] **Click "Sync"** - - [ ] Synchronization page opens with "Publish to GitHub" section -- [ ] **Click "Publish to GitHub" in the "Publish to GitHub" section** - - [ ] Contents of section change to a publish form with: - - [ ] Account type dropdown - - [ ] User dropdown - - [ ] Pre-filled name field with project name - - [ ] Description field - - [ ] Private checkbox - - [ ] Publish button -- [ ] **Publish button is enabled and private checkbox is unchecked (and disabled if user cannot create private repos)** - - [ ] Click on "Publish" - - [ ] Form becomes disabled - - [ ] Progress bar appears above Synchronization title - - [ ] Team Explorer view changes to Home page - - [ ] Notification appears: "Repository published successfully" - - [ ] Publish a private repo and verify on .com that it's private -- [ ] **Project section (Home button)*** - - [ ] Click on "Home" icon - - [ ] Verify Project has the following sections/buttons when signed into GitHub.com and the Repository is enabled: Pull Requests, Pulse, Graphs, Issues, Wikis - - [ ] Verify Pulse button navigates to Pulse page on Github.com - - [ ] Verify Graphs button navigates to Graphs page on GitHub.com - - [ ] Verify Wikis button navigates to Wikis page on GitHub.com (when logged in and the repository is enabled) - - [ ] Verify Issues button navigates to Issues page on GitHub.com (when logged in and the repository is enabled) -- [ ] **Creating Multiple Repositories** - - [ ] Go to Team Explorer Connect page (logged in) - - [ ] Create additional repository - - [ ] Publish new repository - - [ ] Verify remote information present in repository settings -- [ ] **Validate Publish Repository Error** - - [ ] Remove remote settings from repository - - [ ] Go to Sync page and publish repository - - [ ] Go to Team Explorer Connect page (logged in) - - [ ] Click GitHub > Create - - [ ] Received error: "There is already a repository named [repo name] for the current account" -# Connect page when logged in to TFS - - [ ] **Connect to a TFS project** - - [ ] Login to GitHub - - [ ] Team Explorer Connect page: GitHub section appears above TFS section with Clone | Create | Sign out links - - [ ] Log out of GitHub - - [ ] Team Explorer Connect page: GitHub section appears above TFS section with Clone | Create | Login links - - [ ] Disconnect from TFS (right click on project and "Remove" - - [ ] Team Explorer Connect page: GitHub invitation section appears in Hosted Service Providers with Connect.. and Sign up links - -# In GitHub pane (logged into GitHub) -- [ ] **Go to Team Explorer > Home > Pull Requests** - - [ ] List of pull requests is displayed. - - [ ] Pane title displays "GitHub | (current repository)" - - [ ] Selected pane tab title (on the bottom) displays "GitHub" - - [ ] Changing the open/close/all filter dropdown filters the pull request list, by selected option (confirm on .com) - - [ ] Changing the Assignee filter dropdown filters the pull request list to display only selected user assigned (confirm on .com) - - [ ] Changing the Author filter dropdown filters the pull request list to display pull requests the user created (confirm on .com) - - [ ] Filter author selection displays recent pull request authors - - [ ] "Create New" Link button is displayed - - [ ] Clicking on a pull request number opens browser window to pull request on .com -- [ ] **Click on a pull request title** - - [ ] A detailed description of the pull request is displayed. - - [ ] Pull request number matches the number in the list. - - [ ] Pull request title matches the title in the list. - - [ ] Source branch is displayed on the left - - [ ] Mousing over the source branch shows the entire branch name in a tool tip - - [ ] Target branch is displayed on the right - - [ ] Mousing over the target branch shows the entire branch name in a tool tip - - [ ] "View conversation on GitHub" link navigates to pull request on .com - - [ ] List of changed files displayed under pull request description - - [ ] Right clicking on a specific file displays options to open or compare file - - [ ] Double clicking on a file opens a diff view of the file or a single file (depends on current file option) - - [ ] Files in file diff view match diff on .com (pick any file) -- [ ] **Make a change any file and save it** -- [ ] **Go to any pull request details and mouseover** - - [ ] "Checkout to [branch name]" is plain text (not hyperlink) and mouseover displays tool tip -- [ ] **Undo pending changes (Many option to do this. One way: Go to Team Explorer > Home > Changes > Right click on files > Undo Changes)** -- [ ] **Click on "checkout to [branch name]"** - - [ ] Git context (lower right) displays source branch name of pull request - - [ ] "Checkout to [branch name]" text has changed to "Local branch up to date" -- [ ] **Create a new branch (can alternatively make commits on an existing branch)** -- [ ] **Switch Git context to new branch** -- [ ] **Navigate to the Pull Request List (Pull request icon in navigation toolbar) and click on Create New button** - - [ ] Pane title displays "GitHub | (current repository)" - - [ ] Selected pane tab title (on the bottom) displays "GitHub" - - [ ] Create Pull Request button is disabled. - - [ ] Default/target branch name is displayed to the left of the arrow (<) - - [ ] Source branch is displayed to the right of the arrow - - [ ] Default/target branch is a dropdown of all branches - - [ ] Clicking on Cancel button navigates back to the pull request list - - [ ] Setting the target and source branch as the same branches disables the "Create Pull Request" button and displays an error message at the top of pane -- [ ] **Fill out pull request Title** - - [ ] "Create Pull Request" button is enabled. -- [ ] **Click on Create Pull Request** - - [ ] Pull request list is displayed with new pull request (that was just created) at the top of the list - - [ ] "The Pull Request has been created at [target branch]" message is displayed at the top of the GitHub pane - - [ ] Verify all non pushed commits prior to pull request creation have been pushed and exist on .com -- [ ] **Click on back/right/pull request/refresh navigation items (any order)** - - [ ] Verify that navigation buttons show views are consistent with clicked navigation buttons - -# In VS text/code Editor (logged in to GitHub, open a file that is hosted on Github) -- [ ] **Highlight code or text within solution, then right click.** - - [ ] GitHub context menu is visible - - [ ] Open on Github is visible if code is hosted on GitHub repository - - [ ] Copy link to clipboard is visible if code is hosted on GitHub repository - - [ ] "Create a GitHub Gist" is visible (repo independent) -- [ ] **Click "Open on GitHub"** - - [ ] Default browser navigates to last pushed commit on GitHub repo and highlights lines that were highlighted in editor. -- [ ] **Click "Copy link to clipboard"** - - [ ] Pasting into browser pastes a link to the last pushed commit on GitHub repo and highlights lines that were highlighted in editor. -- [ ] **Click "Create a GitHub Gist"** - - [ ] Create gist dialog appears - - [ ] File name textbox populated with name of file that highlighted source code came from - - [ ] Private Gist checkbox unchecked - - [ ] Create button enabled - - [ ] Clearing file name textbox disables the Create button -- [ ] **Click create button** - - [ ] Dialog disappears - - [ ] Default browser navigates to newly created Gist - - [ ] Create private gist and verify it's private on .com - -# In VS text/code Editor (NOT logged in to GitHub, open a file that is hosted on Github) -- [ ] **Highlight code or text within solution, then right click.** - - [ ] GitHub context menu is visible - - [ ] Open on Github is visible - - [ ] Copy link to clipboard is visible - - [ ] "Create a GitHub Gist" is visible (repo independent) -- [ ] **Click "Open on GitHub"** - - [ ] Default browser navigates to last pushed commit on GitHub repo and highlights lines that were highlighted in editor. -- [ ] **Click "Copy link to clipboard"** - - [ ] Pasting into browser pastes a link to the last pushed commit on GitHub repo and highlights lines that were highlighted in editor. -- [ ] **Click "Create a GitHub Gist"** - - [ ] "Connect to GitHub" dialog appears - - [ ] Create gist dialog appears (after logging in) - - [ ] File name textbox populated with name of file that highlighted source code came from - - [ ] Private Gist checkbox unchecked - - [ ] Create button enabled - - [ ] Clearing file name textbox disables the Create button -- [ ] **Click create button** - - [ ] Dialog disappears - - [ ] Default browser navigates to newly created Gist - - [ ] Create private gist and verify it's private on .com - -# In VS text/code Editor (logged in to GitHub, open a file that is NOT hosted on Github) -- [ ] **Highlight code or text within solution, then right click.** - - [ ] GitHub context menu is visible - - [ ] Open on Github is NOT visible - - [ ] Copy link to clipboard is NOT visible - - [ ] "Create a GitHub Gist" is visible (repo independent) -- [ ] **Click "Create a GitHub Gist"** - - [ ] Create gist dialog appears - - [ ] File name textbox populated with name of file that highlighted source code came from - - [ ] Private Gist checkbox unchecked - - [ ] Create button enabled - - [ ] Clearing file name textbox disables the Create button -- [ ] **Click create button** - - [ ] Dialog disappears - - [ ] Default browser navigates to newly created Gist - - [ ] Create private gist and verify it's private on .com browser navigates to newly created Gist - -# In VS text/code Editor (NOT logged in to GitHub, open a file that is NOT hosted on Github) -- [ ] **Highlight code or text within solution, then right click.** - - [ ] GitHub context menu is visible - - [ ] Open on Github is NOT visible - - [ ] Copy link to clipboard is NOT visible - - [ ] "Create a GitHub Gist" is visible (repo independent) -- [ ] **Click "Create a GitHub Gist"** - - [ ] "Connect to GitHub" dialog appears - - [ ] Create gist dialog appears (after logging in) - - [ ] File name textbox populated with name of file that highlighted source code came from - - [ ] Private Gist checkbox unchecked - - [ ] Create button enabled - - [ ] Clearing file name textbox disables the Create button -- [ ] **Click create button** - - [ ] Dialog disappears - - [ ] Default browser navigates to newly created Gist - - [ ] Create private gist and verify it's private on .com browser navigates to newly created Gist - -# Connections -- [ ] **Login to GitHub.com, then click on the "Manage Connections" header and "Connect to GitHub"** - - [ ] Login dialog appears - - [ ] GitHub Enterprise is underlined - - [ ] Form has 3 fields - username, password and address - - [ ] Login button is disabled -- [ ] **Login to an enterprise instance** - - [ ] Team Explorer Connect page shows two github connections - one titled GitHub, another with the enterprise url -- [ ] **Login with 2FA on** - - [ ] Login GitHub with 2FA turned on - - [ ] Resend works - -# Settings -- [ ] **Property page loads outside of team explorer view** - - [ ] Select solution explorer as focused pane - - [ ] Restart Visual Studio - - [ ] Go to GitHub for VS property pane in Options - - [ ] Property page loads without errors diff --git a/global.json b/global.json new file mode 100644 index 0000000000..a75963ff54 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "msbuild-sdks": { + "MSBuild.Sdk.Extras": "1.6.61" + } +} \ No newline at end of file diff --git a/install.cmd b/install.cmd deleted file mode 100644 index 41beb30a23..0000000000 --- a/install.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@if "%Configuration%" == "" set Configuration=Debug - -@set path=%cd%\tools\VsixUtil;%path% - -vsixutil /install "%cd%\build\%Configuration%\GitHub.VisualStudio.vsix" /s Enterprise;Professional;Community -@echo Installed %Configuration% build of GitHub for Visual Studio diff --git a/lib/.gitignore b/lib/.gitignore new file mode 100644 index 0000000000..296234c026 --- /dev/null +++ b/lib/.gitignore @@ -0,0 +1 @@ +!*.nupkg diff --git a/lib/14.0/Microsoft.VisualStudio.Shell.ViewManager.dll b/lib/14.0/Microsoft.VisualStudio.Shell.ViewManager.dll new file mode 100644 index 0000000000..85c116f284 Binary files /dev/null and b/lib/14.0/Microsoft.VisualStudio.Shell.ViewManager.dll differ diff --git a/lib/16.0/Microsoft.TeamFoundation.Client.dll b/lib/16.0/Microsoft.TeamFoundation.Client.dll new file mode 100644 index 0000000000..d42abde8f0 Binary files /dev/null and b/lib/16.0/Microsoft.TeamFoundation.Client.dll differ diff --git a/lib/16.0/Microsoft.TeamFoundation.Common.dll b/lib/16.0/Microsoft.TeamFoundation.Common.dll new file mode 100644 index 0000000000..d537d70f35 Binary files /dev/null and b/lib/16.0/Microsoft.TeamFoundation.Common.dll differ diff --git a/lib/16.0/Microsoft.TeamFoundation.Controls.dll b/lib/16.0/Microsoft.TeamFoundation.Controls.dll new file mode 100644 index 0000000000..bdb525c762 Binary files /dev/null and b/lib/16.0/Microsoft.TeamFoundation.Controls.dll differ diff --git a/lib/16.0/Microsoft.TeamFoundation.Git.Client.dll b/lib/16.0/Microsoft.TeamFoundation.Git.Client.dll new file mode 100644 index 0000000000..5911b7e179 Binary files /dev/null and b/lib/16.0/Microsoft.TeamFoundation.Git.Client.dll differ diff --git a/lib/16.0/Microsoft.TeamFoundation.Git.Controls.dll b/lib/16.0/Microsoft.TeamFoundation.Git.Controls.dll new file mode 100644 index 0000000000..29f24fe79e Binary files /dev/null and b/lib/16.0/Microsoft.TeamFoundation.Git.Controls.dll differ diff --git a/lib/16.0/Microsoft.TeamFoundation.Git.Provider.dll b/lib/16.0/Microsoft.TeamFoundation.Git.Provider.dll new file mode 100644 index 0000000000..7d28d587ad Binary files /dev/null and b/lib/16.0/Microsoft.TeamFoundation.Git.Provider.dll differ diff --git a/lib/BCC-MSBuildLog.0.0.2-alpha.nupkg b/lib/BCC-MSBuildLog.0.0.2-alpha.nupkg new file mode 100644 index 0000000000..4b2c344b4f Binary files /dev/null and b/lib/BCC-MSBuildLog.0.0.2-alpha.nupkg differ diff --git a/lib/BCC-Submission.0.0.2-alpha.nupkg b/lib/BCC-Submission.0.0.2-alpha.nupkg new file mode 100644 index 0000000000..ff36462340 Binary files /dev/null and b/lib/BCC-Submission.0.0.2-alpha.nupkg differ diff --git a/lib/Markdig.Wpf.Signed.0.2.1.nupkg b/lib/Markdig.Wpf.Signed.0.2.1.nupkg new file mode 100644 index 0000000000..70561c66c4 Binary files /dev/null and b/lib/Markdig.Wpf.Signed.0.2.1.nupkg differ diff --git a/lib/Rothko.0.0.3-ghfvs.nupkg b/lib/Rothko.0.0.3-ghfvs.nupkg new file mode 100644 index 0000000000..522e3c7b54 Binary files /dev/null and b/lib/Rothko.0.0.3-ghfvs.nupkg differ diff --git a/lib/Rx-Core.2.2.5-custom.nupkg b/lib/Rx-Core.2.2.5-custom.nupkg deleted file mode 100644 index 89b5699bc3..0000000000 Binary files a/lib/Rx-Core.2.2.5-custom.nupkg and /dev/null differ diff --git a/lib/Rx-Interfaces.2.2.5-custom.nupkg b/lib/Rx-Interfaces.2.2.5-custom.nupkg deleted file mode 100644 index 63e7ed12d8..0000000000 Binary files a/lib/Rx-Interfaces.2.2.5-custom.nupkg and /dev/null differ diff --git a/lib/Rx-Linq.2.2.5-custom.nupkg b/lib/Rx-Linq.2.2.5-custom.nupkg deleted file mode 100644 index 31f7ef23ca..0000000000 Binary files a/lib/Rx-Linq.2.2.5-custom.nupkg and /dev/null differ diff --git a/lib/Rx-Main.2.2.5-custom.nupkg b/lib/Rx-Main.2.2.5-custom.nupkg deleted file mode 100644 index 3d52306b71..0000000000 Binary files a/lib/Rx-Main.2.2.5-custom.nupkg and /dev/null differ diff --git a/lib/Rx-PlatformServices.2.2.5-custom.nupkg b/lib/Rx-PlatformServices.2.2.5-custom.nupkg deleted file mode 100644 index a0916570b6..0000000000 Binary files a/lib/Rx-PlatformServices.2.2.5-custom.nupkg and /dev/null differ diff --git a/lib/Rx-Testing.2.2.5-custom.nupkg b/lib/Rx-Testing.2.2.5-custom.nupkg deleted file mode 100644 index 71406001b6..0000000000 Binary files a/lib/Rx-Testing.2.2.5-custom.nupkg and /dev/null differ diff --git a/lib/Rx-XAML.2.2.5-custom.nupkg b/lib/Rx-XAML.2.2.5-custom.nupkg deleted file mode 100644 index 2e4676530c..0000000000 Binary files a/lib/Rx-XAML.2.2.5-custom.nupkg and /dev/null differ diff --git a/nuget.config b/nuget.config index ac070021ef..e8c7639ce6 100644 --- a/nuget.config +++ b/nuget.config @@ -1,10 +1,14 @@ - + + - \ No newline at end of file + + + + diff --git a/script b/script deleted file mode 160000 index 771d9f14ed..0000000000 --- a/script +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 771d9f14edea3538bc1a286d7fbc3b4f6e7aa0e8 diff --git a/scripts/Run-NUnit.ps1 b/scripts/Run-NUnit.ps1 deleted file mode 100644 index 804696062d..0000000000 --- a/scripts/Run-NUnit.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -<# -.SYNOPSIS - Runs NUnit -#> - -[CmdletBinding()] -Param( - [Parameter(Mandatory=$true)] - [ValidateNotNullOrEmpty()] - [string] - $Project, - [int] - $TimeoutDuration, - [string] - $Configuration, - [switch] - $AppVeyor = $false -) - -$rootDirectory = Split-Path (Split-Path $MyInvocation.MyCommand.Path) -Push-Location $rootDirectory -$dll = "src\$Project\bin\$Configuration\$Project.dll" - -if ($AppVeyor) { - $nunitDirectory = Join-Path $rootDirectory packages\NUnit.Runners.2.6.4\tools - $consoleRunner = Join-Path $nunitDirectory nunit-console-x86.exe - $args = "-noshadow", "-framework:net-4.5", "-exclude:Timings", $dll - [object[]] $output = "$consoleRunner " + ($args -join " ") - & $consoleRunner ($args | %{ "`"$_`"" }) - if($LastExitCode -ne 0) { - $host.SetShouldExit($LastExitCode) - } -} else { - $nunitDirectory = Join-Path $rootDirectory packages\NUnit.Runners.2.6.4\tools - $consoleRunner = Join-Path $nunitDirectory nunit-console-x86.exe - - $xml = Join-Path $rootDirectory "nunit-$Project.xml" - $outputPath = [System.IO.Path]::GetTempFileName() - - $args = "-noshadow", "-xml:$xml", "-framework:net-4.5", "-exclude:Timings", $dll - [object[]] $output = "$consoleRunner " + ($args -join " ") - - $process = Start-Process -PassThru -NoNewWindow -RedirectStandardOutput $outputPath $consoleRunner ($args | %{ "`"$_`"" }) - Wait-Process -InputObject $process -Timeout $TimeoutDuration -ErrorAction SilentlyContinue - if ($process.HasExited) { - $output += Get-Content $outputPath - $exitCode = $process.ExitCode - } else { - $output += "Tests timed out. Backtrace:" - $output += Get-DotNetStack $process.Id - $exitCode = 9999 - } - - Stop-Process -InputObject $process - Remove-Item $outputPath - Pop-Location - - $result = New-Object System.Object - $result | Add-Member -Type NoteProperty -Name Output -Value $output - $result | Add-Member -Type NoteProperty -Name ExitCode -Value $exitCode - $result -} diff --git a/scripts/Run-XUnit.ps1 b/scripts/Run-XUnit.ps1 deleted file mode 100644 index 58170208be..0000000000 --- a/scripts/Run-XUnit.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -<# -.SYNOPSIS - Runs xUnit -#> - -[CmdletBinding()] -Param( - [Parameter(Mandatory=$true)] - [ValidateNotNullOrEmpty()] - [string] - $Project, - [int] - $TimeoutDuration, - [string] - $Configuration, - [switch] - $AppVeyor = $false -) - -$rootDirectory = Split-Path (Split-Path $MyInvocation.MyCommand.Path) -Push-Location $rootDirectory - -$dll = "src\$Project\bin\$Configuration\$Project.dll" - -if ($AppVeyor) { - $xunitDirectory = Join-Path $rootDirectory packages\xunit.runner.console.2.1.0\tools - $consoleRunner = Join-Path $xunitDirectory xunit.console.x86.exe - $args = $dll, "-noshadow", "-parallel", "all", "-appveyor" - [object[]] $output = "$consoleRunner " + ($args -join " ") - & $consoleRunner ($args | %{ "`"$_`"" }) - if($LastExitCode -ne 0) { - $host.SetShouldExit($LastExitCode) - } -} else { - $xunitDirectory = Join-Path $rootDirectory packages\xunit.runner.console.2.1.0\tools - $consoleRunner = Join-Path $xunitDirectory xunit.console.x86.exe - $xml = Join-Path $rootDirectory "nunit-$Project.xml" - $outputPath = [System.IO.Path]::GetTempFileName() - - $args = $dll, "-noshadow", "-xml", $xml, "-parallel", "all" - - [object[]] $output = "$consoleRunner " + ($args -join " ") - - $process = Start-Process -PassThru -NoNewWindow -RedirectStandardOutput $outputPath $consoleRunner ($args | %{ "`"$_`"" }) - Wait-Process -InputObject $process -Timeout $TimeoutDuration -ErrorAction SilentlyContinue - if ($process.HasExited) { - $output += Get-Content $outputPath - $exitCode = $process.ExitCode - } else { - $output += "Tests timed out. Backtrace:" - $output += Get-DotNetStack $process.Id - $exitCode = 9999 - } - Stop-Process -InputObject $process - Remove-Item $outputPath - Pop-Location - - $result = New-Object System.Object - $result | Add-Member -Type NoteProperty -Name Output -Value $output - $result | Add-Member -Type NoteProperty -Name ExitCode -Value $exitCode - $result -} diff --git a/src/CredentialManagement/Credential.cs b/src/CredentialManagement/Credential.cs index 7f9ba21f3d..170bd479d0 100644 --- a/src/CredentialManagement/Credential.cs +++ b/src/CredentialManagement/Credential.cs @@ -4,7 +4,7 @@ using System.Security; using System.Security.Permissions; using System.Text; -using NullGuard; +using GitHub.Extensions; namespace GitHub.Authentication.CredentialManagement { @@ -35,9 +35,9 @@ public Credential() : this(null, (string)null) {} public Credential( - [AllowNull]string username, - [AllowNull]SecureString password, - [AllowNull]string target = null) + string username, + SecureString password, + string target = null) { Username = username; SecurePassword = password; @@ -48,9 +48,9 @@ public Credential( } public Credential( - [AllowNull]string username, - [AllowNull]string password, - [AllowNull]string target = null) + string username, + string password, + string target = null) { Username = username; Password = password; @@ -60,6 +60,28 @@ public Credential( _lastWriteTime = DateTime.MinValue; } + public static Credential Load(string key) + { + var result = new Credential(); + result.Target = key; + result.Type = CredentialType.Generic; + return result.Load() ? result : null; + } + + public static void Save(string key, string username, string password) + { + var result = new Credential(username, password, key); + result.Save(); + } + + public static void Delete(string key) + { + var result = new Credential(); + result.Target = key; + result.Type = CredentialType.Generic; + result.Delete(); + } + bool disposed; void Dispose(bool disposing) { @@ -86,10 +108,8 @@ private void CheckNotDisposed() } } - [AllowNull] public string Username { - [return: AllowNull] get { CheckNotDisposed(); @@ -102,10 +122,8 @@ public string Username } } - [AllowNull] public string Password { - [return: AllowNull] get { return SecureStringHelper.CreateString(SecurePassword); @@ -117,7 +135,6 @@ public string Password } } - [AllowNull] public SecureString SecurePassword { get @@ -138,10 +155,8 @@ public SecureString SecurePassword } } - [AllowNull] public string Target { - [return: AllowNull] get { CheckNotDisposed(); @@ -154,10 +169,8 @@ public string Target } } - [AllowNull] public string Description { - [return: AllowNull] get { CheckNotDisposed(); @@ -245,6 +258,8 @@ public bool Save() public bool Save(byte[] passwordBytes) { + Guard.ArgumentNotNull(passwordBytes, nameof(passwordBytes)); + CheckNotDisposed(); _unmanagedCodePermission.Demand(); @@ -340,6 +355,8 @@ internal void LoadInternal(NativeMethods.CREDENTIAL credential) static void ValidatePasswordLength(byte[] passwordBytes) { + Guard.ArgumentNotNull(passwordBytes, nameof(passwordBytes)); + if (passwordBytes.Length > maxPasswordLengthInBytes) { var message = string.Format(CultureInfo.InvariantCulture, diff --git a/src/CredentialManagement/CredentialManagement.csproj b/src/CredentialManagement/CredentialManagement.csproj index d54af939cb..72d481c6ce 100644 --- a/src/CredentialManagement/CredentialManagement.csproj +++ b/src/CredentialManagement/CredentialManagement.csproj @@ -1,96 +1,14 @@ - - - + - Debug - AnyCPU - {41A47C5B-C606-45B4-B83C-22B9239E4DA0} - Library - Properties - CredentialManagement - GitHub.CredentialManagement - v4.6.1 - 512 - - - - Internal - bin\$(Configuration)\ - - - true + net46 full - false - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - TRACE - prompt - 4 - - - ..\..\script\Key.snk - true - false + true + - - ..\..\packages\NullGuard.Fody.1.4.6\Lib\dotnet\NullGuard.dll - False - - - - - - - - - - - - - Key.snk - - - Properties\SolutionInfo.cs - - - - - - - - - - - - - - + - - {6afe2e2d-6db0-4430-a2ea-f5f5388d2f78} - GitHub.Extensions - + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - \ No newline at end of file + diff --git a/src/CredentialManagement/FodyWeavers.xml b/src/CredentialManagement/FodyWeavers.xml deleted file mode 100644 index 9344211210..0000000000 --- a/src/CredentialManagement/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/CredentialManagement/NativeMethods.cs b/src/CredentialManagement/NativeMethods.cs index cd5dc3ef42..c88a973f60 100644 --- a/src/CredentialManagement/NativeMethods.cs +++ b/src/CredentialManagement/NativeMethods.cs @@ -4,6 +4,10 @@ using System.Text; using Microsoft.Win32.SafeHandles; +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1051 // Do not declare visible instance fields +#pragma warning disable CA1707 // Identifiers should not contain underscores + namespace GitHub.Authentication.CredentialManagement { public static class NativeMethods diff --git a/src/CredentialManagement/Properties/AssemblyInfo.cs b/src/CredentialManagement/Properties/AssemblyInfo.cs deleted file mode 100644 index b58b8183dd..0000000000 --- a/src/CredentialManagement/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("GitHub.CredentialManagement")] -[assembly: AssemblyDescription("GitHub CredentialManagement")] -[assembly: Guid("41a47c5b-c606-45b4-b83c-22b9239e4da0")] diff --git a/src/CredentialManagement/packages.config b/src/CredentialManagement/packages.config deleted file mode 100644 index eae7272960..0000000000 --- a/src/CredentialManagement/packages.config +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/DesignTimeStyleHelper/App.config b/src/DesignTimeStyleHelper/App.config deleted file mode 100644 index 24fdc43407..0000000000 --- a/src/DesignTimeStyleHelper/App.config +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/DesignTimeStyleHelper/App.xaml b/src/DesignTimeStyleHelper/App.xaml deleted file mode 100644 index 0349ef699b..0000000000 --- a/src/DesignTimeStyleHelper/App.xaml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - diff --git a/src/DesignTimeStyleHelper/App.xaml.cs b/src/DesignTimeStyleHelper/App.xaml.cs deleted file mode 100644 index 76ca407c07..0000000000 --- a/src/DesignTimeStyleHelper/App.xaml.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.ComponentModel.Composition.Hosting; -using System.ComponentModel.Composition.Primitives; -using System.Globalization; -using System.Linq; -using System.Windows; -using GitHub.Services; -using GitHub.VisualStudio; -using Microsoft.VisualStudio.ComponentModelHost; -using Microsoft.VisualStudio.Shell; -using Moq; -using GitHub.Models; - -namespace DesignTimeStyleHelper -{ - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application - { - public static Holder ExportHolder { get; set; } - public static CustomServiceProvider ServiceProvider { get { return (CustomServiceProvider) ExportHolder.ServiceProvider; } } - - - public App() - { - var s = new CustomServiceProvider(); - ExportHolder = new Holder(s.DefaultCompositionService); - } - } - - public class Holder - { - [Import] - public SVsServiceProvider ServiceProvider; - - [Import] - public SComponentModel sc; - - [Import] - public IVisualStudioBrowser Browser; - - [Import] - public IExportFactoryProvider ExportFactoryProvider; - - [Import] - public IGitHubServiceProvider GitHubServiceProvider; - - public Holder(ICompositionService cc) - { - cc.SatisfyImportsOnce(this); - } - } - - - [Export(typeof(SVsServiceProvider))] - [Export(typeof(SComponentModel))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class CustomServiceProvider : SVsServiceProvider, IServiceProvider, - SComponentModel, IComponentModel - { - readonly CompositionContainer container; - public CompositionContainer Container { get { return container; } } - AggregateCatalog catalog; - - public CustomServiceProvider() - { - catalog = new AggregateCatalog( - new AssemblyCatalog(typeof(CustomServiceProvider).Assembly), - new AssemblyCatalog(typeof(Program).Assembly), // GitHub.VisualStudio - new AssemblyCatalog(typeof(GitHub.Api.ApiClient).Assembly), // GitHub.App - new AssemblyCatalog(typeof(GitHub.Api.SimpleApiClient).Assembly), // GitHub.Api - new AssemblyCatalog(typeof(Rothko.Environment).Assembly), // Rothko - new AssemblyCatalog(typeof(EnterpriseProbeTask).Assembly) // GitHub.Exports - ); - container = new CompositionContainer(catalog, CompositionOptions.IsThreadSafe | CompositionOptions.DisableSilentRejection); - - DefaultCatalog = catalog; - DefaultExportProvider = container; - DefaultCompositionService = DefaultCatalog.CreateCompositionService(); - - var batch = new CompositionBatch(); - batch.AddExportedValue(this); - batch.AddExportedValue(this); - batch.AddExportedValue(DefaultCompositionService); - container.Compose(batch); - } - - public object GetService(Type serviceType) - { - string contract = AttributedModelServices.GetContractName(serviceType); - var instance = container.GetExportedValues(contract).FirstOrDefault(); - - if (instance != null) - return instance; - - instance = Create(serviceType); - - if (instance != null) - return instance; - - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, - "Could not locate any instances of contract {0}.", contract)); - } - - T Create() where T : class - { - return new Mock().Object; - } - - static object Create(Type t) - { - var moq = typeof(Mock<>).MakeGenericType(t); - var ctor = moq.GetConstructor(new Type[] { }); - var m = ctor?.Invoke(new object[] { }) as Mock; - return m?.Object; - } - - public ExportProvider DefaultExportProvider { get; set; } - public ComposablePartCatalog DefaultCatalog { get; set; } - public ICompositionService DefaultCompositionService { get; set; } - - public ComposablePartCatalog GetCatalog(string catalogName) - { - throw new NotImplementedException(); - } - - public IEnumerable GetExtensions() where T : class - { - throw new NotImplementedException(); - } - - public T GetService() where T : class - { - throw new NotImplementedException(); - } - } -} diff --git a/src/DesignTimeStyleHelper/DesignTimeStyleHelper.csproj b/src/DesignTimeStyleHelper/DesignTimeStyleHelper.csproj deleted file mode 100644 index 631b07dc49..0000000000 --- a/src/DesignTimeStyleHelper/DesignTimeStyleHelper.csproj +++ /dev/null @@ -1,217 +0,0 @@ - - - - - Debug - AnyCPU - {B1F5C227-456F-437D-BD5F-4C11B7A8D1A0} - WinExe - Properties - DesignTimeStyleHelper - DesignTimeStyleHelper - v4.6.1 - 512 - {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 4 - true - bin\$(Configuration)\ - - - - AnyCPU - true - full - false - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - TRACE - prompt - 4 - true - - - AnyCPU - true - full - false - DEBUG;TRACE;XAML_DESIGNER - prompt - 4 - - - - ..\..\packages\VSSDK.ComponentModelHost.12.0.4\lib\net45\Microsoft.VisualStudio.ComponentModelHost.dll - False - - - ..\..\packages\Microsoft.VisualStudio.Shell.Immutable.10.0.10.0.30319\lib\net40\Microsoft.VisualStudio.Shell.Immutable.10.0.dll - True - - - ..\..\packages\Moq.4.2.1312.1319\lib\net40\Moq.dll - - - - - - ..\..\packages\Rx-Core.2.2.5-custom\lib\net45\System.Reactive.Core.dll - True - - - ..\..\packages\Rx-Interfaces.2.2.5-custom\lib\net45\System.Reactive.Interfaces.dll - True - - - ..\..\packages\Rx-Linq.2.2.5-custom\lib\net45\System.Reactive.Linq.dll - True - - - ..\..\packages\Rx-PlatformServices.2.2.5-custom\lib\net45\System.Reactive.PlatformServices.dll - True - - - ..\..\packages\Rx-XAML.2.2.5-custom\lib\net45\System.Reactive.Windows.Threading.dll - True - - - - - - - - 4.0 - - - - - - ..\..\lib\14.0\Microsoft.TeamFoundation.Git.Provider.dll - True - - - - - MSBuild:Compile - - - TwoFactorInputTester.xaml - - - WindowController.xaml - - - MSBuild:Compile - Designer - - - App.xaml - Code - - - MainWindow.xaml - Code - - - Designer - MSBuild:Compile - - - MSBuild:Compile - - - - - Properties\SolutionInfo.cs - - - Code - - - True - True - Resources.resx - - - True - Settings.settings - True - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - - - - - - - {4a84e568-ca86-4510-8cd0-90d3ef9b65f9} - Rothko - - - {b389adaf-62cc-486e-85b4-2d8b078df763} - GitHub.Api - - - {1a1da411-8d1f-4578-80a6-04576bea2dc5} - GitHub.App - - - {9aea02db-02b5-409c-b0ca-115d05331a6b} - GitHub.Exports - - - {e4ed0537-d1d9-44b6-9212-3096d7c3f7a1} - GitHub.Exports.Reactive - - - {161dbf01-1dbf-4b00-8551-c5c00f26720d} - GitHub.TeamFoundation.14 - - - {158b05e8-fdbc-4d71-b871-c96e28d5adf5} - GitHub.UI.Reactive - - - {346384dd-2445-4a28-af22-b45f3957bd89} - GitHub.UI - - - {d1dfbb0c-b570-4302-8f1e-2e3a19c41961} - GitHub.VisualStudio.UI - - - {11569514-5ae5-4b5b-92a2-f10b0967de5f} - GitHub.VisualStudio - - - {1ce2d235-8072-4649-ba5a-cfb1af8776e0} - ReactiveUI_Net45 - - - {252ce1c2-027a-4445-a3c2-e4d6c80a935a} - Splat-Net45 - - - - - \ No newline at end of file diff --git a/src/DesignTimeStyleHelper/MainWindow.xaml b/src/DesignTimeStyleHelper/MainWindow.xaml deleted file mode 100644 index 8008bb11d7..0000000000 --- a/src/DesignTimeStyleHelper/MainWindow.xaml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - Login - Clone - Create - Publish - Two Factor - - - - - - - - - diff --git a/src/DesignTimeStyleHelper/MainWindow.xaml.cs b/src/DesignTimeStyleHelper/MainWindow.xaml.cs deleted file mode 100644 index 2442ed7217..0000000000 --- a/src/DesignTimeStyleHelper/MainWindow.xaml.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Windows; -using GitHub.SampleData; -using GitHub.Services; -using GitHub.UI; -using GitHub.Extensions; -using GitHub.Models; - -namespace DesignTimeStyleHelper -{ - /// - /// Interaction logic for MainWindow.xaml - /// - public partial class MainWindow : Window - { - public MainWindow() - { - InitializeComponent(); - gitHubHomeSection.DataContext = new GitHubHomeSectionDesigner(); - } - - private void loginLink_Click(object sender, RoutedEventArgs e) - { - ShowDialog(UIControllerFlow.Authentication); - } - - private void cloneLink_Click(object sender, RoutedEventArgs e) - { - ShowDialog(UIControllerFlow.Clone); - } - - private void createLink_Click(object sender, RoutedEventArgs e) - { - ShowDialog(UIControllerFlow.Create); - } - - private void publishLink_Click(object sender, RoutedEventArgs e) - { - ShowDialog(UIControllerFlow.Publish); - } - - private void twoFactorTester_Click(object sender, RoutedEventArgs e) - { - var twoFactorTester = new TwoFactorInputTester(); - twoFactorTester.ShowDialog(); - } - - void ShowDialog(UIControllerFlow flow) - { - var ui = App.ServiceProvider.GetService(); - var controller = ui.Configure(flow); - var userControlObservable = controller.TransitionSignal; - var x = new WindowController(userControlObservable); - userControlObservable.Subscribe(_ => { }, _ => x.Close()); - x.Show(); - ui.RunInDialog(controller); - } - } -} diff --git a/src/DesignTimeStyleHelper/Properties/AssemblyInfo.cs b/src/DesignTimeStyleHelper/Properties/AssemblyInfo.cs deleted file mode 100644 index 3735fcdbec..0000000000 --- a/src/DesignTimeStyleHelper/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Windows; - -[assembly: AssemblyTitle("DesignTimeStyleHelper")] -[assembly: AssemblyDescription("Helper app for UI testing")] - -//In order to begin building localizable applications, set -//CultureYouAreCodingWith in your .csproj file -//inside a . For example, if you are using US english -//in your source files, set the to en-US. Then uncomment -//the NeutralResourceLanguage attribute below. Update the "en-US" in -//the line below to match the UICulture setting in the project file. - -//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] - - -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -)] diff --git a/src/DesignTimeStyleHelper/Properties/Resources.Designer.cs b/src/DesignTimeStyleHelper/Properties/Resources.Designer.cs deleted file mode 100644 index e18c9e8115..0000000000 --- a/src/DesignTimeStyleHelper/Properties/Resources.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace DesignTimeStyleHelper.Properties { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DesignTimeStyleHelper.Properties.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/src/DesignTimeStyleHelper/TwoFactorInputTester.xaml b/src/DesignTimeStyleHelper/TwoFactorInputTester.xaml deleted file mode 100644 index 5186888afc..0000000000 --- a/src/DesignTimeStyleHelper/TwoFactorInputTester.xaml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/src/DesignTimeStyleHelper/TwoFactorInputTester.xaml.cs b/src/DesignTimeStyleHelper/TwoFactorInputTester.xaml.cs deleted file mode 100644 index 581dc40f34..0000000000 --- a/src/DesignTimeStyleHelper/TwoFactorInputTester.xaml.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Windows; - -namespace DesignTimeStyleHelper -{ - /// - /// Interaction logic for TwoFactorInputTester.xaml - /// - public partial class TwoFactorInputTester : Window - { - public TwoFactorInputTester() - { - InitializeComponent(); - } - } -} diff --git a/src/DesignTimeStyleHelper/WindowController.xaml b/src/DesignTimeStyleHelper/WindowController.xaml deleted file mode 100644 index 3e88df1b23..0000000000 --- a/src/DesignTimeStyleHelper/WindowController.xaml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/src/DesignTimeStyleHelper/WindowController.xaml.cs b/src/DesignTimeStyleHelper/WindowController.xaml.cs deleted file mode 100644 index 61f2eddd87..0000000000 --- a/src/DesignTimeStyleHelper/WindowController.xaml.cs +++ /dev/null @@ -1,37 +0,0 @@ -using GitHub.UI; -using System; -using System.Windows; -using System.Windows.Controls; - -namespace DesignTimeStyleHelper -{ - /// - /// Interaction logic for WindowController.xaml - /// - public partial class WindowController : Window - { - IDisposable disposable; - - public WindowController(IObservable controls) - { - InitializeComponent(); - - disposable = controls.Subscribe(c => Load(c.View), - Close - ); - } - - protected override void OnClosed(EventArgs e) - { - disposable.Dispose(); - base.OnClosed(e); - } - - public void Load(IView view) - { - var control = view as UserControl; - Container.Children.Clear(); - Container.Children.Add(control); - } - } -} diff --git a/src/DesignTimeStyleHelper/packages.config b/src/DesignTimeStyleHelper/packages.config deleted file mode 100644 index 98abcd9cb9..0000000000 --- a/src/DesignTimeStyleHelper/packages.config +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.Api/ApiClientConfiguration.cs b/src/GitHub.Api/ApiClientConfiguration.cs new file mode 100644 index 0000000000..4336ea36df --- /dev/null +++ b/src/GitHub.Api/ApiClientConfiguration.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Text; + +namespace GitHub.Api +{ + /// + /// Holds the configuration for API clients. + /// + public static partial class ApiClientConfiguration + { + /// + /// Initializes static members of the class. + /// + static ApiClientConfiguration() + { + Configure(); + } + + /// + /// Gets the application's OAUTH client ID. + /// + public static string ClientId { get; private set; } + + /// + /// Gets the application's OAUTH client secret. + /// + public static string ClientSecret { get; private set; } + + /// + /// Gets the minimum scopes required by the application. + /// + public static IReadOnlyList MinimumScopes { get; } = new[] { "user", "repo", "gist", "write:public_key" }; + + /// + /// Gets the ideal scopes requested by the application. + /// + public static IReadOnlyList RequestedScopes { get; } = new[] { "user", "repo", "gist", "write:public_key", "read:org", "workflow" }; + + /// + /// Gets a note that will be stored with the OAUTH token. + /// + public static string AuthorizationNote + { + get { return Info.ApplicationInfo.ApplicationDescription + " on " + GetMachineNameSafe(); } + } + + /// + /// Gets the machine fingerprint that will be registered with the OAUTH token, allowing + /// multiple authorizations to be created for a single user. + /// + public static string MachineFingerprint + { + get + { + return GetSha256Hash( + Info.ApplicationInfo.ApplicationDescription + ":" + + GetMachineIdentifier() + ":" + + GetMachineNameSafe()); + } + } + + static partial void Configure(); + + static string GetMachineIdentifier() + { + try + { + // adapted from http://stackoverflow.com/a/1561067 + var fastestValidNetworkInterface = NetworkInterface.GetAllNetworkInterfaces() + .OrderByDescending(nic => nic.Speed) + .Where(nic => nic.OperationalStatus == OperationalStatus.Up) + .Select(nic => nic.GetPhysicalAddress().ToString()) + .FirstOrDefault(address => address.Length >= 12); + + return fastestValidNetworkInterface ?? GetMachineNameSafe(); + } + catch (Exception) + { + //log.Info("Could not retrieve MAC address. Fallback to using machine name.", e); + return GetMachineNameSafe(); + } + } + + static string GetMachineNameSafe() + { + try + { + return Dns.GetHostName(); + } + catch (Exception) + { + //log.Info("Failed to retrieve host name using `DNS.GetHostName`.", e); + + try + { + return Environment.MachineName; + } + catch (Exception) + { + //log.Info("Failed to retrieve host name using `Environment.MachineName`.", ex); + return "(unknown)"; + } + } + } + + static string GetSha256Hash(string input) + { + try + { + using (var sha256 = SHA256.Create()) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha256.ComputeHash(bytes); + + return string.Join("", hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); + } + } + catch (Exception) + { + //log.Error("IMPOSSIBLE! Generating Sha256 hash caused an exception.", e); + return null; + } + } + } +} diff --git a/src/GitHub.Api/Caching/FileCache.cs b/src/GitHub.Api/Caching/FileCache.cs new file mode 100644 index 0000000000..dd5f937e61 --- /dev/null +++ b/src/GitHub.Api/Caching/FileCache.cs @@ -0,0 +1,1295 @@ +/* +Copyright 2012, 2013, 2017 Adam Carter (http://adam-carter.com) + +This file is part of FileCache (http://github.com/acarteas/FileCache). + +FileCache is distributed under the Apache License 2.0. +Consult "LICENSE.txt" included in this package for the Apache License 2.0. +*/ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Formatters.Binary; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Runtime.Caching +{ + public class FileCache : ObjectCache + { + private static int _nameCounter = 1; + private string _name = ""; + private SerializationBinder _binder; + private string _cacheSubFolder = "cache"; + private string _policySubFolder = "policy"; + private TimeSpan _cleanInterval = new TimeSpan(7, 0, 0, 0); // default to 1 week + private const string LastCleanedDateFile = "cache.lcd"; + private const string CacheSizeFile = "cache.size"; + // this is a file used to prevent multiple processes from trying to "clean" at the same time + private const string SemaphoreFile = "cache.sem"; + private long _currentCacheSize = 0; + private PayloadMode _readMode = PayloadMode.Serializable; + public string CacheDir { get; protected set; } + + + /// + /// Used to store the default region when accessing the cache via [] calls + /// + public string DefaultRegion { get; set; } + + /// + /// Used to set the default policy when setting cache values via [] calls + /// + public CacheItemPolicy DefaultPolicy { get; set; } + + /// + /// Specified how the cache payload is to be handled. + /// + public enum PayloadMode + { + /// + /// Treat the payload a a serializable object. + /// + Serializable, + /// + /// Treat the payload as a file name. File content will be copied on add, while get returns the file name. + /// + Filename, + /// + /// Treat the paylad as raw bytes. A byte[] and readable streams are supported on add. + /// + RawBytes + } + + /// + /// Specified whether the payload is deserialized or just the file name. + /// + public PayloadMode PayloadReadMode + { + get => _readMode; + set + { + if (value == PayloadMode.RawBytes) + { + throw new ArgumentException("The read mode cannot be set to RawBytes. Use the file name please."); + } + _readMode = value; + } + } + + /// + /// Specified how the payload is to be handled on add operations. + /// + public PayloadMode PayloadWriteMode { get; set; } = PayloadMode.Serializable; + + /// + /// The amount of time before expiry that a filename will be used as a payoad. I.e. + /// the amount of time the cache's user can safely use the file delivered as a payload. + /// Default 10 minutes. + /// + public TimeSpan FilenameAsPayloadSafetyMargin = TimeSpan.FromMinutes(10); + + /// + /// Used to determine how long the FileCache will wait for a file to become + /// available. Default (00:00:00) is indefinite. Should the timeout be + /// reached, an exception will be thrown. + /// + public TimeSpan AccessTimeout { get; set; } + + /// + /// Used to specify the disk size, in bytes, that can be used by the File Cache + /// + public long MaxCacheSize { get; set; } + + /// + /// Returns the approximate size of the file cache + /// + public long CurrentCacheSize + { + get + { + // if this is the first query, we need to load the cache size from somewhere + if (_currentCacheSize == 0) + { + // Read the system file for cache size + object cacheSizeObj = ReadSysFile(CacheSizeFile); + + // Did we successfully get data from the file? + if (cacheSizeObj != null) + { + _currentCacheSize = (long)cacheSizeObj; + } + } + + return _currentCacheSize; + } + private set + { + // no need to do a pointless re-store of the same value + if (_currentCacheSize != value || value == 0) + { + WriteSysFile(CacheSizeFile, value); + _currentCacheSize = value; + } + } + } + + /// + /// Event that will be called when is reached. + /// + public event EventHandler MaxCacheSizeReached = delegate { }; + + public event EventHandler CacheResized = delegate { }; + + /// + /// The default cache path used by FC. + /// + private string DefaultCachePath + { + get + { + return Directory.GetCurrentDirectory(); + } + } + + #region constructors + + /// + /// Creates a default instance of the file cache. Don't use if you plan to serialize custom objects + /// + /// If true, will calcualte the cache's current size upon new object creation. + /// Turned off by default as directory traversal is somewhat expensive and may not always be necessary based on + /// use case. + /// + /// If supplied, sets the interval of time that must occur between self cleans + public FileCache(bool calculateCacheSize = false, TimeSpan cleanInterval = new TimeSpan()) + { + // CT note: I moved this code to an init method because if the user specified a cache root, that needs to + // be set before checking if we should clean (otherwise it will look for the file in the wrong place) + Init(calculateCacheSize, cleanInterval); + } + + /// + /// Creates an instance of the file cache using the supplied path as the root save path. + /// + /// The cache's root file path + /// If true, will calcualte the cache's current size upon new object creation. + /// Turned off by default as directory traversal is somewhat expensive and may not always be necessary based on + /// use case. + /// + /// If supplied, sets the interval of time that must occur between self cleans + public FileCache(string cacheRoot, bool calculateCacheSize = false, TimeSpan cleanInterval = new TimeSpan()) + { + CacheDir = cacheRoot; + Init(calculateCacheSize, cleanInterval, false); + } + + /// + /// Creates an instance of the file cache. + /// + /// The SerializationBinder used to deserialize cached objects. Needed if you plan + /// to cache custom objects. + /// + /// If true, will calcualte the cache's current size upon new object creation. + /// Turned off by default as directory traversal is somewhat expensive and may not always be necessary based on + /// use case. + /// + /// If supplied, sets the interval of time that must occur between self cleans + public FileCache(SerializationBinder binder, bool calculateCacheSize = false, TimeSpan cleanInterval = new TimeSpan()) + { + _binder = binder; + Init(calculateCacheSize, cleanInterval, true, false); + } + + /// + /// Creates an instance of the file cache. + /// + /// The cache's root file path + /// The SerializationBinder used to deserialize cached objects. Needed if you plan + /// to cache custom objects. + /// If true, will calcualte the cache's current size upon new object creation. + /// Turned off by default as directory traversal is somewhat expensive and may not always be necessary based on + /// use case. + /// + /// If supplied, sets the interval of time that must occur between self cleans + public FileCache(string cacheRoot, SerializationBinder binder, bool calculateCacheSize = false, TimeSpan cleanInterval = new TimeSpan()) + { + _binder = binder; + CacheDir = cacheRoot; + Init(calculateCacheSize, cleanInterval, false, false); + } + + #endregion + + #region custom methods + + private void Init(bool calculateCacheSize = false, TimeSpan cleanInterval = new TimeSpan(), bool setCacheDirToDefault = true, bool setBinderToDefault = true) + { + _name = "FileCache_" + _nameCounter; + _nameCounter++; + + DefaultRegion = null; + DefaultPolicy = new CacheItemPolicy(); + AccessTimeout = new TimeSpan(); + MaxCacheSize = long.MaxValue; + + // set default values if not already set + if (setCacheDirToDefault) + CacheDir = DefaultCachePath; + if (setBinderToDefault) + _binder = new FileCacheBinder(); + + // if it doesn't exist, we need to make it + if (!Directory.Exists(CacheDir)) + Directory.CreateDirectory(CacheDir); + + // only set the clean interval if the user supplied it + if (cleanInterval > new TimeSpan()) + { + _cleanInterval = cleanInterval; + } + + //check to see if cache is in need of immediate cleaning + if (ShouldClean()) + { + CleanCacheAsync(); + } + else if (calculateCacheSize || CurrentCacheSize == 0) + { + // This is in an else if block, because CleanCacheAsync will + // update the cache size, so no need to do it twice. + UpdateCacheSizeAsync(); + } + + MaxCacheSizeReached += FileCache_MaxCacheSizeReached; + } + + private void FileCache_MaxCacheSizeReached(object sender, FileCacheEventArgs e) + { + Task.Factory.StartNew((Action)(() => + { + // Shrink the cache to 75% of the max size + // that way there's room for it to grow a bit + // before we have to do this again. + long newSize = ShrinkCacheToSize((long)(MaxCacheSize * 0.75)); + })); + } + + + // Returns the cleanlock file if it can be opened, otherwise it is being used by another process so return null + private FileStream GetCleaningLock() + { + try + { + return File.Open(Path.Combine(CacheDir, SemaphoreFile), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (Exception) + { + return null; + } + } + + // Determines whether or not enough time has passed that the cache should clean itself + private bool ShouldClean() + { + try + { + // if the file can't be found, or is corrupt this will throw an exception + DateTime? lastClean = ReadSysFile(LastCleanedDateFile) as DateTime?; + + //AC: rewrote to be safer in null cases + if (lastClean == null) + { + return true; + } + + // return true if the amount of time between now and the last clean is greater than or equal to the + // clean interval, otherwise return false. + return DateTime.Now - lastClean >= _cleanInterval; + } + catch (Exception) + { + return true; + } + } + + /// + /// Shrinks the cache until the cache size is less than + /// or equal to the size specified (in bytes). This is a + /// rather expensive operation, so use with discretion. + /// + /// The new size of the cache + public long ShrinkCacheToSize(long newSize, string regionName = null) + { + long originalSize = 0, amount = 0, removed = 0; + + //lock down other treads from trying to shrink or clean + using (FileStream cLock = GetCleaningLock()) + { + if (cLock == null) + return -1; + + // if we're shrinking the whole cache, we can use the stored + // size if it's available. If it's not available we calculate it and store + // it for next time. + if (regionName == null) + { + if (CurrentCacheSize == 0) + { + CurrentCacheSize = GetCacheSize(); + } + + originalSize = CurrentCacheSize; + } + else + { + originalSize = GetCacheSize(regionName); + } + + // Find out how much we need to get rid of + amount = originalSize - newSize; + + // CT note: This will update CurrentCacheSize + removed = DeleteOldestFiles(amount, regionName); + + // unlock the semaphore for others + cLock.Close(); + } + + // trigger the event + CacheResized(this, new FileCacheEventArgs(originalSize - removed, MaxCacheSize)); + + // return the final size of the cache (or region) + return originalSize - removed; + } + + public void CleanCacheAsync() + { + Task.Factory.StartNew((Action)(() => + { + CleanCache(); + })); + } + + /// + /// Loop through the cache and delete all expired files + /// + /// The amount removed (in bytes) + public long CleanCache(string regionName = null) + { + long removed = 0; + + //lock down other treads from trying to shrink or clean + using (FileStream cLock = GetCleaningLock()) + { + if (cLock == null) + return 0; + + foreach (string key in GetKeys(regionName)) + { + CacheItemPolicy policy = GetPolicy(key, regionName); + if (policy.AbsoluteExpiration < DateTime.Now) + { + try + { + string cachePath = GetCachePath(key, regionName); + string policyPath = GetPolicyPath(key, regionName); + CacheItemReference ci = new CacheItemReference(key, cachePath, policyPath); + Remove(key, regionName); // CT note: Remove will update CurrentCacheSize + removed += ci.Length; + } + catch (Exception) // skip if the file cannot be accessed + { } + } + } + + // mark that we've cleaned the cache + WriteSysFile(LastCleanedDateFile, DateTime.Now); + + // unlock + cLock.Close(); + } + + return removed; + } + + public void ClearRegion(string regionName) + { + using (var cLock = GetCleaningLock()) + { + if (cLock == null) + return; + + foreach (var key in GetKeys(regionName)) + { + Remove(key, regionName); + } + + cLock.Close(); + } + } + + /// + /// Delete the oldest items in the cache to shrink the chache by the + /// specified amount (in bytes). + /// + /// The amount of data that was actually removed + private long DeleteOldestFiles(long amount, string regionName = null) + { + // Verify that we actually need to shrink + if (amount <= 0) + { + return 0; + } + + //Heap of all CacheReferences + PriortyQueue cacheReferences = new PriortyQueue(); + + //build a heap of all files in cache region + foreach (string key in GetKeys(regionName)) + { + try + { + //build item reference + string cachePath = GetCachePath(key, regionName); + string policyPath = GetPolicyPath(key, regionName); + CacheItemReference ci = new CacheItemReference(key, cachePath, policyPath); + cacheReferences.Enqueue(ci); + } + catch (FileNotFoundException) + { + } + } + + //remove cache items until size requirement is met + long removedBytes = 0; + while (removedBytes < amount && cacheReferences.GetSize() > 0) + { + //remove oldest item + CacheItemReference oldest = cacheReferences.Dequeue(); + removedBytes += oldest.Length; + Remove(oldest.Key, regionName); + } + return removedBytes; + } + + /// + /// This method calls GetCacheSize on a separate thread to + /// calculate and then store the size of the cache. + /// + public void UpdateCacheSizeAsync() + { + Task.Factory.StartNew((Action)(() => + { + CurrentCacheSize = GetCacheSize(); + })); + } + + //AC Note: From MSDN / SO (http://stackoverflow.com/questions/468119/whats-the-best-way-to-calculate-the-size-of-a-directory-in-net) + /// + /// Calculates the size, in bytes of the file cache + /// + /// The region to calculate. If NULL, will return total size. + /// + public long GetCacheSize(string regionName = null) + { + long size = 0; + + //AC note: First parameter is unused, so just pass in garbage ("DummyValue") + string policyPath = Path.GetDirectoryName(GetPolicyPath("DummyValue", regionName)); + string cachePath = Path.GetDirectoryName(GetCachePath("DummyValue", regionName)); + size += CacheSizeHelper(new DirectoryInfo(policyPath)); + size += CacheSizeHelper(new DirectoryInfo(cachePath)); + return size; + } + + /// + /// Helper method for public . + /// + /// + /// + private long CacheSizeHelper(DirectoryInfo root) + { + long size = 0; + + // Add file sizes. + var fis = root.EnumerateFiles(); + foreach (FileInfo fi in fis) + { + size += fi.Length; + } + // Add subdirectory sizes. + var dis = root.EnumerateDirectories(); + foreach (DirectoryInfo di in dis) + { + size += CacheSizeHelper(di); + } + return size; + } + + /// + /// Flushes the file cache using DateTime.Now as the minimum date + /// + /// + public void Flush(string regionName = null) + { + Flush(DateTime.Now, regionName); + } + + /// + /// Flushes the cache based on last access date, filtered by optional region + /// + /// + /// + public void Flush(DateTime minDate, string regionName = null) + { + // prevent other threads from altering stuff while we delete junk + using (FileStream cLock = GetCleaningLock()) + { + if (cLock == null) + return; + + //AC note: First parameter is unused, so just pass in garbage ("DummyValue") + string policyPath = Path.GetDirectoryName(GetPolicyPath("DummyValue", regionName)); + string cachePath = Path.GetDirectoryName(GetCachePath("DummyValue", regionName)); + FlushHelper(new DirectoryInfo(policyPath), minDate); + FlushHelper(new DirectoryInfo(cachePath), minDate); + + // Update the Cache size + CurrentCacheSize = GetCacheSize(); + + // unlock + cLock.Close(); + } + } + + /// + /// Helper method for public flush + /// + /// + /// + private void FlushHelper(DirectoryInfo root, DateTime minDate) + { + // check files. + foreach (FileInfo fi in root.EnumerateFiles()) + { + //is the file stale? + if (minDate > File.GetLastAccessTime(fi.FullName)) + { + File.Delete(fi.FullName); + } + } + + // check subdirectories + foreach (DirectoryInfo di in root.EnumerateDirectories()) + { + FlushHelper(di, minDate); + } + } + + /// + /// Returns the policy attached to a given cache item. + /// + /// The key of the item + /// The region in which the key exists + /// + public CacheItemPolicy GetPolicy(string key, string regionName = null) + { + CacheItemPolicy policy = new CacheItemPolicy(); + FileCachePayload payload = ReadFile(PayloadMode.Filename, key, regionName) as FileCachePayload; + if (payload != null) + { + try + { + policy.SlidingExpiration = payload.Policy.SlidingExpiration; + policy.AbsoluteExpiration = payload.Policy.AbsoluteExpiration; + } + catch (Exception) + { + } + } + return policy; + } + + /// + /// Returns a list of keys for a given region. + /// + /// + /// + public IEnumerable GetKeys(string regionName = null) + { + string region = ""; + if (string.IsNullOrEmpty(regionName) == false) + { + region = regionName; + } + string directory = Path.Combine(CacheDir, _cacheSubFolder, region); + if (Directory.Exists(directory)) + { + foreach (string file in Directory.EnumerateFiles(directory)) + { + yield return Path.GetFileNameWithoutExtension(file); + } + } + } + + #endregion + + #region helper methods + + /// + /// This function servies to centralize file stream access within this class. + /// + /// + /// + /// + /// + /// + private FileStream GetStream(string path, FileMode mode, FileAccess access, FileShare share) + { + FileStream stream = null; + TimeSpan interval = new TimeSpan(0, 0, 0, 0, 50); + TimeSpan totalTime = new TimeSpan(); + while (stream == null) + { + try + { + stream = File.Open(path, mode, access, share); + } + catch (IOException ex) + { + Thread.Sleep(interval); + totalTime += interval; + + //if we've waited too long, throw the original exception. + if (AccessTimeout.Ticks != 0) + { + if (totalTime > AccessTimeout) + { + throw ex; + } + } + } + } + return stream; + } + + /// + /// This function serves to centralize file reads within this class. + /// + /// the payload reading mode + /// + /// + /// + private FileCachePayload ReadFile(PayloadMode mode, string key, string regionName = null, SerializationBinder objectBinder = null) + { + object data = null; + SerializableCacheItemPolicy policy = new SerializableCacheItemPolicy(); + string cachePath = GetCachePath(key, regionName); + string policyPath = GetPolicyPath(key, regionName); + FileCachePayload payload = new FileCachePayload(null); + + if (File.Exists(cachePath)) + { + switch (mode) + { + default: + case PayloadMode.Filename: + data = cachePath; + break; + case PayloadMode.Serializable: + data = DeserializePayloadData(objectBinder, cachePath); + break; + case PayloadMode.RawBytes: + data = LoadRawPayloadData(cachePath); + break; + } + } + if (File.Exists(policyPath)) + { + using (FileStream stream = GetStream(policyPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + BinaryFormatter formatter = new BinaryFormatter(); + formatter.Binder = new LocalCacheBinder(); + try + { + policy = formatter.Deserialize(stream) as SerializableCacheItemPolicy; + } + catch (SerializationException) + { + policy = new SerializableCacheItemPolicy(); + } + } + } + payload.Payload = data; + payload.Policy = policy; + return payload; + } + + private object LoadRawPayloadData(string cachePath) + { + throw new NotSupportedException("Reading raw payload is not currently supported."); + } + + private object DeserializePayloadData(SerializationBinder objectBinder, string cachePath) + { + object data; + using (FileStream stream = GetStream(cachePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + BinaryFormatter formatter = new BinaryFormatter(); + + //AC: From http://spazzarama.com//2009/06/25/binary-deserialize-unable-to-find-assembly/ + // Needed to deserialize custom objects + if (objectBinder != null) + { + //take supplied binder over default binder + formatter.Binder = objectBinder; + } + else if (_binder != null) + { + formatter.Binder = _binder; + } + + try + { + data = formatter.Deserialize(stream); + } + catch (SerializationException) + { + data = null; + } + } + + return data; + } + + /// + /// This function serves to centralize file writes within this class + /// + private void WriteFile(PayloadMode mode, string key, FileCachePayload data, string regionName = null, bool policyUpdateOnly = false) + { + string cachedPolicy = GetPolicyPath(key, regionName); + string cachedItemPath = GetCachePath(key, regionName); + + + if (!policyUpdateOnly) + { + long oldBlobSize = 0; + if (File.Exists(cachedItemPath)) + { + oldBlobSize = new FileInfo(cachedItemPath).Length; + } + + switch (mode) + { + case PayloadMode.Serializable: + using (FileStream stream = GetStream(cachedItemPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + + BinaryFormatter formatter = new BinaryFormatter(); + formatter.Serialize(stream, data.Payload); + } + break; + case PayloadMode.RawBytes: + using (FileStream stream = GetStream(cachedItemPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + + if (data.Payload is byte[]) + { + byte[] dataPayload = (byte[])data.Payload; + stream.Write(dataPayload, 0, dataPayload.Length); + } + else if (data.Payload is Stream) + { + Stream dataPayload = (Stream)data.Payload; + dataPayload.CopyTo(stream); + // no close or the like, we are not the owner + } + } + break; + + case PayloadMode.Filename: + File.Copy((string)data.Payload, cachedItemPath, true); + break; + } + + //adjust cache size (while we have the file to ourselves) + CurrentCacheSize += new FileInfo(cachedItemPath).Length - oldBlobSize; + } + + //remove current policy file from cache size calculations + if (File.Exists(cachedPolicy)) + { + CurrentCacheSize -= new FileInfo(cachedPolicy).Length; + } + + //write the cache policy + using (FileStream stream = GetStream(cachedPolicy, FileMode.Create, FileAccess.Write, FileShare.None)) + { + BinaryFormatter formatter = new BinaryFormatter(); + formatter.Serialize(stream, data.Policy); + + // adjust cache size + CurrentCacheSize += new FileInfo(cachedPolicy).Length; + + stream.Close(); + } + + //check to see if limit was reached + if (CurrentCacheSize > MaxCacheSize) + { + MaxCacheSizeReached(this, new FileCacheEventArgs(CurrentCacheSize, MaxCacheSize)); + } + } + + /// + /// Reads data in from a system file. System files are not part of the + /// cache itself, but serve as a way for the cache to store data it + /// needs to operate. + /// + /// The name of the sysfile (without directory) + /// The data from the file + private object ReadSysFile(string filename) + { + // sys files go in the root directory + string path = Path.Combine(CacheDir, filename); + object data = null; + + if (File.Exists(path)) + { + for (int i = 5; i > 0; i--) // try 5 times to read the file, if we can't, give up + { + try + { + using (FileStream stream = GetStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + BinaryFormatter formatter = new BinaryFormatter(); + try + { + data = formatter.Deserialize(stream); + } + catch (Exception) + { + data = null; + } + finally + { + stream.Close(); + } + } + break; + } + catch (IOException) + { + // we timed out... so try again + } + } + } + + return data; + } + + /// + /// Writes data to a system file that is not part of the cache itself, + /// but is used to help it function. + /// + /// The name of the sysfile (without directory) + /// The data to write to the file + private void WriteSysFile(string filename, object data) + { + // sys files go in the root directory + string path = Path.Combine(CacheDir, filename); + + // write the data to the file + using (FileStream stream = GetStream(path, FileMode.Create, FileAccess.Write, FileShare.Write)) + { + BinaryFormatter formatter = new BinaryFormatter(); + formatter.Serialize(stream, data); + stream.Close(); + } + } + + /// + /// Builds a string that will place the specified file name within the appropriate + /// cache and workspace folder. + /// + /// + /// + /// + private string GetCachePath(string FileName, string regionName = null) + { + if (regionName == null) + { + regionName = ""; + } + string directory = Path.Combine(CacheDir, _cacheSubFolder, regionName); + string filePath = Path.Combine(directory, Path.GetFileNameWithoutExtension(FileName) + ".dat"); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + return filePath; + } + + /// + /// Builds a string that will get the path to the supplied file's policy file + /// + /// + /// + /// + private string GetPolicyPath(string FileName, string regionName = null) + { + if (regionName == null) + { + regionName = ""; + } + string directory = Path.Combine(CacheDir, _policySubFolder, regionName); + string filePath = Path.Combine(directory, Path.GetFileNameWithoutExtension(FileName) + ".policy"); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + return filePath; + } + + #endregion + + #region ObjectCache overrides + + public override object AddOrGetExisting(string key, object value, CacheItemPolicy policy, string regionName = null) + { + string path = GetCachePath(key, regionName); + object oldData = null; + + //pull old value if it exists + if (File.Exists(path)) + { + try + { + oldData = Get(key, regionName); + } + catch (Exception) + { + oldData = null; + } + } + SerializableCacheItemPolicy cachePolicy = new SerializableCacheItemPolicy(policy); + FileCachePayload newPayload = new FileCachePayload(value, cachePolicy); + WriteFile(PayloadWriteMode, key, newPayload, regionName); + + //As documented in the spec (http://msdn.microsoft.com/en-us/library/dd780602.aspx), return the old + //cached value or null + return oldData; + } + + public override CacheItem AddOrGetExisting(CacheItem value, CacheItemPolicy policy) + { + object oldData = AddOrGetExisting(value.Key, value.Value, policy, value.RegionName); + CacheItem returnItem = null; + if (oldData != null) + { + returnItem = new CacheItem(value.Key) + { + Value = oldData, + RegionName = value.RegionName + }; + } + return returnItem; + } + + public override object AddOrGetExisting(string key, object value, DateTimeOffset absoluteExpiration, string regionName = null) + { + CacheItemPolicy policy = new CacheItemPolicy(); + policy.AbsoluteExpiration = absoluteExpiration; + return AddOrGetExisting(key, value, policy, regionName); + } + + public override bool Contains(string key, string regionName = null) + { + string path = GetCachePath(key, regionName); + return File.Exists(path); + } + + public override CacheEntryChangeMonitor CreateCacheEntryChangeMonitor(IEnumerable keys, string regionName = null) + { + throw new NotImplementedException(); + } + + public override DefaultCacheCapabilities DefaultCacheCapabilities + { + get + { + //AC note: can use boolean OR "|" to set multiple flags. + return DefaultCacheCapabilities.CacheRegions + | + DefaultCacheCapabilities.AbsoluteExpirations + | + DefaultCacheCapabilities.SlidingExpirations + ; + } + } + + public override object Get(string key, string regionName = null) + { + FileCachePayload payload = ReadFile(PayloadReadMode, key, regionName) as FileCachePayload; + string cachedItemPath = GetCachePath(key, regionName); + + DateTime cutoff = DateTime.Now; + if (PayloadReadMode == PayloadMode.Filename) + { + cutoff += FilenameAsPayloadSafetyMargin; + } + + //null payload? + if (payload != null) + { + //did the item expire? + if (payload.Policy.AbsoluteExpiration < cutoff) + { + //set the payload to null + payload.Payload = null; + + //delete the file from the cache + try + { + // CT Note: I changed this to Remove from File.Delete so that the coresponding + // policy file will be deleted as well, and CurrentCacheSize will be updated. + Remove(key, regionName); + } + catch (Exception) + { + } + } + else + { + //does the item have a sliding expiration? + if (payload.Policy.SlidingExpiration > new TimeSpan()) + { + payload.Policy.AbsoluteExpiration = DateTime.Now.Add(payload.Policy.SlidingExpiration); + WriteFile(PayloadWriteMode, cachedItemPath, payload, regionName, true); + } + + } + } + else + { + //remove null payload + Remove(key, regionName); + + //create dummy one for return + payload = new FileCachePayload(null); + } + return payload.Payload; + } + + public override CacheItem GetCacheItem(string key, string regionName = null) + { + object value = Get(key, regionName); + CacheItem item = new CacheItem(key); + item.Value = value; + item.RegionName = regionName; + return item; + } + + public override long GetCount(string regionName = null) + { + if (regionName == null) + { + regionName = ""; + } + string path = Path.Combine(CacheDir, _cacheSubFolder, regionName); + if (Directory.Exists(path)) + return Directory.GetFiles(path).Count(); + else + return 0; + } + + /// + /// Returns an enumerator for the specified region (defaults to base-level cache directory). + /// This function *WILL NOT* recursively locate files in subdirectories. + /// + /// + /// + public IEnumerator> GetEnumerator(string regionName = null) + { + string region = ""; + if (string.IsNullOrEmpty(regionName) == false) + { + region = regionName; + } + List> enumerator = new List>(); + + string directory = Path.Combine(CacheDir, _cacheSubFolder, region); + foreach (string filePath in Directory.EnumerateFiles(directory)) + { + string key = Path.GetFileNameWithoutExtension(filePath); + enumerator.Add(new KeyValuePair(key, this.Get(key, regionName))); + } + return enumerator.GetEnumerator(); + } + + /// + /// Will return an enumerator with all cache items listed in the root file path ONLY. Use the other + /// if you want to specify a region + /// + /// + protected override IEnumerator> GetEnumerator() + { + return GetEnumerator(null); + } + + public override IDictionary GetValues(IEnumerable keys, string regionName = null) + { + Dictionary values = new Dictionary(); + foreach (string key in keys) + { + values[key] = Get(key, regionName); + } + return values; + } + + public override string Name + { + get { return _name; } + } + + public override object Remove(string key, string regionName = null) + { + object valueToDelete = null; + if (Contains(key, regionName)) + { + // Because of the possibility of multiple threads accessing this, it's possible that + // while we're trying to remove something, another thread has already removed it. + try + { + //remove cache entry + // CT note: calling Get from remove leads to an infinite loop and stack overflow, + // so I replaced it with a simple ReadFile call. None of the code here actually + // uses this object returned, but just in case someone else's outside code does. + FileCachePayload fcp = ReadFile(PayloadMode.Filename, key, regionName); + valueToDelete = fcp.Payload; + string path = GetCachePath(key, regionName); + CurrentCacheSize -= new FileInfo(path).Length; + File.Delete(path); + + //remove policy file + string cachedPolicy = GetPolicyPath(key, regionName); + CurrentCacheSize -= new FileInfo(cachedPolicy).Length; + File.Delete(cachedPolicy); + } + catch (IOException) + { + } + + } + return valueToDelete; + } + + public override void Set(string key, object value, CacheItemPolicy policy, string regionName = null) + { + Add(key, value, policy, regionName); + } + + public override void Set(CacheItem item, CacheItemPolicy policy) + { + Add(item, policy); + } + + public override void Set(string key, object value, DateTimeOffset absoluteExpiration, string regionName = null) + { + Add(key, value, absoluteExpiration, regionName); + } + + public override object this[string key] + { + get + { + return this.Get(key, DefaultRegion); + } + set + { + this.Set(key, value, DefaultPolicy, DefaultRegion); + } + } + + #endregion + + private class LocalCacheBinder : System.Runtime.Serialization.SerializationBinder + { + public override Type BindToType(string assemblyName, string typeName) + { + Type typeToDeserialize = null; + + String currentAssembly = Assembly.GetAssembly(typeof(LocalCacheBinder)).FullName; + assemblyName = currentAssembly; + + // Get the type using the typeName and assemblyName + typeToDeserialize = Type.GetType(String.Format("{0}, {1}", + typeName, assemblyName)); + + return typeToDeserialize; + } + } + + // CT: This private class is used to help shrink the cache. + // It computes the total size of an entry including it's policy file. + // It also implements IComparable functionality to allow for sorting based on access time + private class CacheItemReference : IComparable + { + public readonly DateTime LastAccessTime; + public readonly long Length; + public readonly string Key; + + public CacheItemReference(string key, string cachePath, string policyPath) + { + Key = key; + FileInfo cfi = new FileInfo(cachePath); + FileInfo pfi = new FileInfo(policyPath); + cfi.Refresh(); + LastAccessTime = cfi.LastAccessTime; + Length = cfi.Length + pfi.Length; + } + + public int CompareTo(CacheItemReference other) + { + int i = LastAccessTime.CompareTo(other.LastAccessTime); + + // It's possible, although rare, that two different items will have + // the same LastAccessTime. So in that case, we need to check to see + // if they're actually the same. + if (i == 0) + { + // second order should be length (but from smallest to largest, + // that way we delete smaller files first) + i = -1 * Length.CompareTo(other.Length); + if (i == 0) + { + i = Key.CompareTo(other.Key); + } + } + + return i; + } + + public static bool operator >(CacheItemReference lhs, CacheItemReference rhs) + { + if (lhs.CompareTo(rhs) > 0) + { + return true; + } + return false; + } + + public static bool operator <(CacheItemReference lhs, CacheItemReference rhs) + { + if (lhs.CompareTo(rhs) < 0) + { + return true; + } + return false; + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.Api/Caching/FileCacheBinder.cs b/src/GitHub.Api/Caching/FileCacheBinder.cs new file mode 100644 index 0000000000..dd03649603 --- /dev/null +++ b/src/GitHub.Api/Caching/FileCacheBinder.cs @@ -0,0 +1,34 @@ +/* +Copyright 2012, 2013, 2017 Adam Carter (http://adam-carter.com) + +This file is part of FileCache (http://github.com/acarteas/FileCache). + +FileCache is distributed under the Apache License 2.0. +Consult "LICENSE.txt" included in this package for the Apache License 2.0. +*/ +using System.Reflection; + +namespace System.Runtime.Caching +{ + /// + /// You should be able to copy & paste this code into your local project to enable caching custom objects. + /// + public sealed class FileCacheBinder : System.Runtime.Serialization.SerializationBinder + { + public override Type BindToType(string assemblyName, string typeName) + { + Type typeToDeserialize = null; + + String currentAssembly = Assembly.GetExecutingAssembly().FullName; + + // In this case we are always using the current assembly + assemblyName = currentAssembly; + + // Get the type using the typeName and assemblyName + typeToDeserialize = Type.GetType(String.Format("{0}, {1}", + typeName, assemblyName)); + + return typeToDeserialize; + } + } +} \ No newline at end of file diff --git a/src/GitHub.Api/Caching/FileCacheEventArgs.cs b/src/GitHub.Api/Caching/FileCacheEventArgs.cs new file mode 100644 index 0000000000..917ff89e95 --- /dev/null +++ b/src/GitHub.Api/Caching/FileCacheEventArgs.cs @@ -0,0 +1,22 @@ +/* +Copyright 2012, 2013, 2017 Adam Carter (http://adam-carter.com) + +This file is part of FileCache (http://github.com/acarteas/FileCache). + +FileCache is distributed under the Apache License 2.0. +Consult "LICENSE.txt" included in this package for the Apache License 2.0. +*/ + +namespace System.Runtime.Caching +{ + public class FileCacheEventArgs : EventArgs + { + public long CurrentCacheSize { get; private set; } + public long MaxCacheSize { get; private set; } + public FileCacheEventArgs(long currentSize, long maxSize) + { + CurrentCacheSize = currentSize; + MaxCacheSize = maxSize; + } + } +} \ No newline at end of file diff --git a/src/GitHub.Api/Caching/FileCachePayload.cs b/src/GitHub.Api/Caching/FileCachePayload.cs new file mode 100644 index 0000000000..1361e6f663 --- /dev/null +++ b/src/GitHub.Api/Caching/FileCachePayload.cs @@ -0,0 +1,33 @@ +/* +Copyright 2012, 2013, 2017 Adam Carter (http://adam-carter.com) + +This file is part of FileCache (http://github.com/acarteas/FileCache). + +FileCache is distributed under the Apache License 2.0. +Consult "LICENSE.txt" included in this package for the Apache License 2.0. +*/ + +namespace System.Runtime.Caching +{ + [Serializable] + public class FileCachePayload + { + public object Payload { get; set; } + public SerializableCacheItemPolicy Policy { get; set; } + + public FileCachePayload(object payload) + { + Payload = payload; + Policy = new SerializableCacheItemPolicy() + { + AbsoluteExpiration = DateTime.Now.AddYears(10) + }; + } + + public FileCachePayload(object payload, SerializableCacheItemPolicy policy) + { + Payload = payload; + Policy = policy; + } + } +} \ No newline at end of file diff --git a/src/GitHub.Api/Caching/PriortyQueue.cs b/src/GitHub.Api/Caching/PriortyQueue.cs new file mode 100644 index 0000000000..cda897b9d5 --- /dev/null +++ b/src/GitHub.Api/Caching/PriortyQueue.cs @@ -0,0 +1,207 @@ +/* +Copyright 2012, 2013, 2017 Adam Carter (http://adam-carter.com) + +This file is part of FileCache (http://github.com/acarteas/FileCache). + +FileCache is distributed under the Apache License 2.0. +Consult "LICENSE.txt" included in this package for the Apache License 2.0. +*/ +using System.Collections.Generic; + +namespace System.Runtime.Caching +{ + /// + /// A basic min priorty queue (min heap) + /// + /// Data type to store + public class PriortyQueue where T : IComparable + { + + private List _items; + private IComparer _comparer; + + /// + /// Default constructor. + /// + /// The comparer to use. The default comparer will make the smallest item the root of the heap. + /// + /// + public PriortyQueue(IComparer comparer = null) + { + _items = new List(); + if (comparer == null) + { + _comparer = new GenericComparer(); + } + } + + /// + /// Constructor that will convert an existing list into a min heap + /// + /// The unsorted list of items + /// The comparer to use. The default comparer will make the smallest item the root of the heap. + public PriortyQueue(List unsorted, IComparer comparer = null) + : this(comparer) + { + for (int i = 0; i < unsorted.Count; i++) + { + _items.Add(unsorted[i]); + } + BuildHeap(); + } + + private void BuildHeap() + { + for (int i = _items.Count / 2; i >= 0; i--) + { + adjustHeap(i); + } + } + + //Percolates the item specified at by index down into its proper location within a heap. Used + //for dequeue operations and array to heap conversions + private void adjustHeap(int index) + { + //cannot percolate empty list + if (_items.Count == 0) + { + return; + } + + //GOAL: get value at index, make sure this value is less than children + // IF NOT: swap with smaller of two + // (continue to do so until we can't swap) + T item = _items[index]; + + //helps us figure out if a given index has children + int end_location = _items.Count; + + //keeps track of smallest index + int smallest_index = index; + + //while we're not the last thing in the heap + while (index < end_location) + { + //get left child index + int left_child_index = (2 * index) + 1; + int right_child_index = left_child_index + 1; + + //Three cases: + // 1. left index is out of range + // 2. right index is out or range + // 3. both indices are valid + if (left_child_index < end_location) + { + //CASE 1 is FALSE + //remember that left index is the smallest + smallest_index = left_child_index; + + if (right_child_index < end_location) + { + //CASE 2 is FALSE (CASE 3 is true) + //TODO: find value of smallest index + smallest_index = (_comparer.Compare(_items[left_child_index], _items[right_child_index]) < 0) + ? left_child_index + : right_child_index; + } + } + + //we have two things: original index and (potentially) a child index + if (_comparer.Compare(_items[index], _items[smallest_index]) > 0) + { + //move parent down (it was too big) + T temp = _items[index]; + _items[index] = _items[smallest_index]; + _items[smallest_index] = temp; + + //update index + index = smallest_index; + } + else + { + //no swap necessary + break; + } + } + } + + public bool isEmpty() + { + return _items.Count == 0; + } + + public int GetSize() + { + return _items.Count; + } + + + public void Enqueue(T item) + { + //calculate positions + int current_position = _items.Count; + int parent_position = (current_position - 1) / 2; + + //insert element (note: may get erased if we hit the WHILE loop) + _items.Add(item); + + //find parent, but be careful if we are an empty queue + T parent = default(T); + if (parent_position >= 0) + { + //find parent + parent = _items[parent_position]; + + //bubble up until we're done + while (_comparer.Compare(parent, item) > 0 && current_position > 0) + { + //move parent down + _items[current_position] = parent; + + //recalculate position + current_position = parent_position; + parent_position = (current_position - 1) / 2; + + //make sure that we have a valid index + if (parent_position >= 0) + { + //find parent + parent = _items[parent_position]; + } + } + } //end check for nullptr + + //after WHILE loop, current_position will point to the place that + //variable "item" needs to go + _items[current_position] = item; + + } + + public T GetFirst() + { + return _items[0]; + } + + public T Dequeue() + { + int last_position = _items.Count - 1; + T last_item = _items[last_position]; + T top = _items[0]; + _items[0] = last_item; + _items.RemoveAt(_items.Count - 1); + + //percolate down + adjustHeap(0); + return top; + } + + + private class GenericComparer : IComparer where TInner : IComparable + { + public int Compare(TInner x, TInner y) + { + return x.CompareTo(y); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.Api/Caching/SerializableCacheItemPolicy.cs b/src/GitHub.Api/Caching/SerializableCacheItemPolicy.cs new file mode 100644 index 0000000000..a3a22f5c54 --- /dev/null +++ b/src/GitHub.Api/Caching/SerializableCacheItemPolicy.cs @@ -0,0 +1,44 @@ +/* +Copyright 2012, 2013, 2017 Adam Carter (http://adam-carter.com) + +This file is part of FileCache (http://github.com/acarteas/FileCache). + +FileCache is distributed under the Apache License 2.0. +Consult "LICENSE.txt" included in this package for the Apache License 2.0. +*/ + +namespace System.Runtime.Caching +{ + [Serializable] + public class SerializableCacheItemPolicy + { + public DateTimeOffset AbsoluteExpiration { get; set; } + + private TimeSpan _slidingExpiration; + public TimeSpan SlidingExpiration + { + get + { + return _slidingExpiration; + } + set + { + _slidingExpiration = value; + if (_slidingExpiration > new TimeSpan()) + { + AbsoluteExpiration = DateTimeOffset.Now.Add(_slidingExpiration); + } + } + } + public SerializableCacheItemPolicy(CacheItemPolicy policy) + { + AbsoluteExpiration = policy.AbsoluteExpiration; + SlidingExpiration = policy.SlidingExpiration; + } + + public SerializableCacheItemPolicy() + { + SlidingExpiration = new TimeSpan(); + } + } +} \ No newline at end of file diff --git a/src/GitHub.Api/GitHub.Api.csproj b/src/GitHub.Api/GitHub.Api.csproj index d437363164..421201ab15 100644 --- a/src/GitHub.Api/GitHub.Api.csproj +++ b/src/GitHub.Api/GitHub.Api.csproj @@ -1,97 +1,65 @@ - - - + - Debug - AnyCPU - {B389ADAF-62CC-486E-85B4-2D8B078DF763} - Library - Properties - GitHub.Api - GitHub.Api - v4.6.1 - 512 - Internal - bin\$(Configuration)\ - - - - true + net46 full - false - DEBUG;TRACE - prompt - 4 - true - ..\common\GitHubVS.ruleset - true - true - - - pdbonly - true - TRACE - prompt - 4 - true - true - true - ..\common\GitHubVS.ruleset + true - - ..\..\script\Key.snk - true - false + + + + + 2454a3e6102fd41cc212 + 2157c138e970165d955d09562230afcfbcda23f2 + + + + + + + $(IntermediateOutputPath)ApiClientConfiguration.$(GitHubVS_ClientId).cs + + + + + + + + + + - - - - - - - - - - - - Key.snk - - - - Properties\SolutionInfo.cs - + + - - + + + + + + + + - - {08dd4305-7787-4823-a53f-4d0f725a07f3} - Octokit - - - {41a47c5b-c606-45b4-b83c-22b9239e4da0} - CredentialManagement - - - {9aea02db-02b5-409c-b0ca-115d05331a6b} - GitHub.Exports - - - {6afe2e2d-6db0-4430-a2ea-f5f5388d2f78} - GitHub.Extensions - + + - - - \ No newline at end of file + diff --git a/src/GitHub.Api/GlobalSuppressions.cs b/src/GitHub.Api/GlobalSuppressions.cs new file mode 100644 index 0000000000..3753e44dee --- /dev/null +++ b/src/GitHub.Api/GlobalSuppressions.cs @@ -0,0 +1,9 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Discouraged for VSSDK projects.")] + diff --git a/src/GitHub.Api/GraphQLClient.cs b/src/GitHub.Api/GraphQLClient.cs new file mode 100644 index 0000000000..155bd2bd07 --- /dev/null +++ b/src/GitHub.Api/GraphQLClient.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.Caching; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Extensions; +using Octokit.GraphQL; +using Octokit.GraphQL.Core; + +namespace GitHub.Api +{ + public class GraphQLClient : IGraphQLClient + { + public static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromHours(8); + readonly IConnection connection; + readonly FileCache cache; + + public GraphQLClient( + IConnection connection, + FileCache cache) + { + this.connection = connection; + this.cache = cache; + } + + public Task ClearCache(string regionName) + { + // Switch to background thread because FileCache does not provide an async API. + return Task.Run(() => cache.ClearRegion(GetFullRegionName(regionName))); + } + + public Task Run( + IQueryableValue query, + Dictionary variables = null, + bool refresh = false, + TimeSpan? cacheDuration = null, + string regionName = null, + CancellationToken cancellationToken = default) + { + return Run(query.Compile(), variables, refresh, cacheDuration, regionName, cancellationToken); + } + + public Task> Run( + IQueryableList query, + Dictionary variables = null, + bool refresh = false, + TimeSpan? cacheDuration = null, + string regionName = null, + CancellationToken cancellationToken = default) + { + return Run(query.Compile(), variables, refresh, cacheDuration, regionName, cancellationToken); + } + + public async Task Run( + ICompiledQuery query, + Dictionary variables = null, + bool refresh = false, + TimeSpan? cacheDuration = null, + string regionName = null, + CancellationToken cancellationToken = default) + { + if (!query.IsMutation) + { + var wrapper = new CachingWrapper( + this, + refresh, + cacheDuration ?? DefaultCacheDuration, + GetFullRegionName(regionName)); + return await wrapper.Run(query, variables, cancellationToken); + } + else + { + return await connection.Run(query, variables, cancellationToken); + } + } + + string GetFullRegionName(string regionName) + { + var result = connection.Uri.Host; + + if (!string.IsNullOrWhiteSpace(regionName)) + { + result += Path.DirectorySeparatorChar + regionName; + } + + return result.EnsureValidPath(); + } + + static string GetHash(string input) + { + var sb = new StringBuilder(); + + using (var hash = SHA256.Create()) + { + var result = hash.ComputeHash(Encoding.UTF8.GetBytes(input)); + + foreach (var b in result) + { + sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + } + + return sb.ToString(); + } + + class CachingWrapper : IConnection + { + readonly GraphQLClient owner; + readonly bool refresh; + readonly TimeSpan cacheDuration; + readonly string regionName; + + public CachingWrapper( + GraphQLClient owner, + bool refresh, + TimeSpan cacheDuration, + string regionName) + { + this.owner = owner; + this.refresh = refresh; + this.cacheDuration = cacheDuration; + this.regionName = regionName; + } + + public Uri Uri => owner.connection.Uri; + + public Task Run(string query, CancellationToken cancellationToken = default) + { + // Switch to background thread because FileCache does not provide an async API. + return Task.Run(async () => + { + var hash = GetHash(query); + + if (refresh) + { + owner.cache.Remove(hash, regionName); + } + + var data = (string) owner.cache.Get(hash, regionName); + + if (data != null) + { + return data; + } + + var result = await owner.connection.Run(query, cancellationToken); + owner.cache.Add(hash, result, DateTimeOffset.Now + cacheDuration, regionName); + return result; + }, cancellationToken); + } + } + } +} diff --git a/src/GitHub.Api/GraphQLClientFactory.cs b/src/GitHub.Api/GraphQLClientFactory.cs new file mode 100644 index 0000000000..635467a845 --- /dev/null +++ b/src/GitHub.Api/GraphQLClientFactory.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel.Composition; +using System.IO; +using System.Runtime.Caching; +using System.Threading.Tasks; +using GitHub.Info; +using GitHub.Models; +using GitHub.Primitives; +using Octokit.GraphQL; + +namespace GitHub.Api +{ + /// + /// Creates GraphQL s for querying the + /// GitHub GraphQL API. + /// + [Export(typeof(IGraphQLClientFactory))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class GraphQLClientFactory : IGraphQLClientFactory + { + readonly IKeychain keychain; + readonly IProgram program; + readonly FileCache cache; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The program details. + [ImportingConstructor] + public GraphQLClientFactory(IKeychain keychain, IProgram program) + { + this.keychain = keychain; + this.program = program; + + var cachePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + ApplicationInfo.ApplicationName, + "GraphQLCache"); + cache = new FileCache(cachePath); + } + + /// + public Task CreateConnection(HostAddress address) + { + var credentials = new GraphQLKeychainCredentialStore(keychain, address); + var header = new ProductHeaderValue(program.ProductHeader.Name, program.ProductHeader.Version); + var connection = new Connection(header, address.GraphQLUri, credentials); + return Task.FromResult(new GraphQLClient(connection, cache)); + } + } +} diff --git a/src/GitHub.Api/GraphQLKeychainCredentialStore.cs b/src/GitHub.Api/GraphQLKeychainCredentialStore.cs new file mode 100644 index 0000000000..4ad122fb88 --- /dev/null +++ b/src/GitHub.Api/GraphQLKeychainCredentialStore.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Primitives; +using Octokit.GraphQL; + +namespace GitHub.Api +{ + /// + /// An Octokit.GraphQL credential store that reads from an . + /// + public class GraphQLKeychainCredentialStore : ICredentialStore + { + readonly IKeychain keychain; + readonly HostAddress address; + + public GraphQLKeychainCredentialStore(IKeychain keychain, HostAddress address) + { + Guard.ArgumentNotNull(keychain, nameof(keychain)); + Guard.ArgumentNotNull(address, nameof(keychain)); + + this.keychain = keychain; + this.address = address; + } + + public async Task GetCredentials(CancellationToken cancellationToken = default) + { + var userPass = await keychain.Load(address).ConfigureAwait(false); + return userPass?.Item2; + } + } +} diff --git a/src/GitHub.Api/IGraphQLClient.cs b/src/GitHub.Api/IGraphQLClient.cs new file mode 100644 index 0000000000..d45062c6b4 --- /dev/null +++ b/src/GitHub.Api/IGraphQLClient.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Octokit.GraphQL; +using Octokit.GraphQL.Core; + +namespace GitHub.Api +{ + public interface IGraphQLClient + { + Task ClearCache(string regionName); + + Task Run( + IQueryableValue query, + Dictionary variables = null, + bool refresh = false, + TimeSpan? cacheDuration = null, + string regionName = null, + CancellationToken cancellationToken = default); + + Task> Run( + IQueryableList query, + Dictionary variables = null, + bool refresh = false, + TimeSpan? cacheDuration = null, + string regionName = null, + CancellationToken cancellationToken = default); + + Task Run( + ICompiledQuery query, + Dictionary variables = null, + bool refresh = false, + TimeSpan? cacheDuration = null, + string regionName = null, + CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/GitHub.Api/IGraphQLClientFactory.cs b/src/GitHub.Api/IGraphQLClientFactory.cs new file mode 100644 index 0000000000..f29fba4b7f --- /dev/null +++ b/src/GitHub.Api/IGraphQLClientFactory.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using GitHub.Primitives; + +namespace GitHub.Api +{ + /// + /// Creates s for querying the GitHub GraphQL API. + /// + public interface IGraphQLClientFactory + { + /// + /// Creates a new . + /// + /// The address of the server. + /// A task returning the created client. + Task CreateConnection(HostAddress address); + } +} \ No newline at end of file diff --git a/src/GitHub.Api/IKeychain.cs b/src/GitHub.Api/IKeychain.cs new file mode 100644 index 0000000000..88d824c98a --- /dev/null +++ b/src/GitHub.Api/IKeychain.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using GitHub.Primitives; + +namespace GitHub.Api +{ + /// + /// Represents a keychain used to store credentials. + /// + public interface IKeychain + { + /// + /// Loads the credentials for the specified host address. + /// + /// The host address. + /// + /// A task returning a tuple consisting of the retrieved username and password or null + /// if the credentials were not found. + /// + Task> Load(HostAddress address); + + /// + /// Saves the credentials for the specified host address. + /// + /// The username. + /// The password. + /// The host address. + /// A task tracking the operation. + Task Save(string userName, string password, HostAddress address); + + /// + /// Deletes the login details for the specified host address. + /// + /// + /// A task tracking the operation. + Task Delete(HostAddress address); + } +} diff --git a/src/GitHub.Api/ILoginManager.cs b/src/GitHub.Api/ILoginManager.cs new file mode 100644 index 0000000000..46fd4d254d --- /dev/null +++ b/src/GitHub.Api/ILoginManager.cs @@ -0,0 +1,79 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Primitives; +using GitHub.VisualStudio; +using Octokit; + +namespace GitHub.Api +{ + /// + /// Provides services for logging into a GitHub server. + /// + [Guid(Guids.LoginManagerId)] + public interface ILoginManager + { + /// + /// Attempts to log into a GitHub server. + /// + /// The address of the server. + /// An octokit client configured to access the server. + /// The username. + /// The password. + /// A with the details of the successful login. + /// + /// The login authorization failed. + /// + Task Login(HostAddress hostAddress, IGitHubClient client, string userName, string password); + + /// + /// Attempts to log into a GitHub server via OAuth in the browser. + /// + /// The address of the server. + /// An octokit client configured to access the server. + /// An octokit OAuth client configured to access the server. + /// A callback that should open a browser at the requested URL. + /// A cancellation token used to cancel the operation. + /// A with the details of the successful login. + /// + /// The login authorization failed. + /// + Task LoginViaOAuth( + HostAddress hostAddress, + IGitHubClient client, + IOauthClient oauthClient, + Action openBrowser, + CancellationToken cancel); + + /// + /// Attempts to log into a GitHub server with a token. + /// + /// The address of the server. + /// An octokit client configured to access the server. + /// The token. + /// A with the details of the successful login. + Task LoginWithToken( + HostAddress hostAddress, + IGitHubClient client, + string token); + + /// + /// Attempts to log into a GitHub server using existing credentials. + /// + /// The address of the server. + /// An octokit client configured to access the server. + /// A with the details of the successful login. + /// + /// The login authorization failed. + /// + Task LoginFromCache(HostAddress hostAddress, IGitHubClient client); + + /// + /// Logs out of GitHub server. + /// + /// The address of the server. + /// An octokit client configured to access the server. + Task Logout(HostAddress hostAddress, IGitHubClient client); + } +} \ No newline at end of file diff --git a/src/GitHub.Api/IOAuthCallbackListener.cs b/src/GitHub.Api/IOAuthCallbackListener.cs new file mode 100644 index 0000000000..19a7c8f428 --- /dev/null +++ b/src/GitHub.Api/IOAuthCallbackListener.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GitHub.Api +{ + /// + /// Listens for a callback from the OAuth endpoint signalling successful login. + /// + public interface IOAuthCallbackListener + { + /// + /// Listens for a callback with a `state` matching . + /// + /// The ID of the operation. + /// A cancellation token. + /// The temporary code included in the callback. + Task Listen(string id, CancellationToken cancel); + + /// + /// Redirects the last context to respond with listen and stops the underlying http listener + /// + /// Url to redirect to. + void RedirectLastContext(Uri url); + } +} diff --git a/src/GitHub.Api/ITwoFactorChallengeHandler.cs b/src/GitHub.Api/ITwoFactorChallengeHandler.cs new file mode 100644 index 0000000000..8edfcf5fda --- /dev/null +++ b/src/GitHub.Api/ITwoFactorChallengeHandler.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Octokit; + +namespace GitHub.Api +{ + /// + /// Interface for handling Two Factor Authentication challenges in a + /// operation. + /// + public interface ITwoFactorChallengeHandler + { + /// + /// Called when the GitHub API responds to a login request with a 2FA challenge. + /// + /// The 2FA exception that initiated the challenge. + /// A task returning a . + Task HandleTwoFactorException(TwoFactorAuthorizationException exception); + + /// + /// Called when an error occurs sending the 2FA challenge response. + /// + /// The exception that occurred. + /// + /// This method is called when, on sending the challenge response returned by + /// , an exception of + /// a type other than is thrown. This + /// indicates that the login attempt is over and any 2FA dialog being shown should close. + /// + Task ChallengeFailed(Exception e); + } +} diff --git a/src/GitHub.Api/IncorrectScopesException.cs b/src/GitHub.Api/IncorrectScopesException.cs new file mode 100644 index 0000000000..5cdddec7d7 --- /dev/null +++ b/src/GitHub.Api/IncorrectScopesException.cs @@ -0,0 +1,32 @@ +using System; +using System.Runtime.Serialization; + +namespace GitHub.Api +{ + /// + /// Thrown when the login for a user does not have the required API scopes. + /// + [Serializable] + public class IncorrectScopesException : Exception + { + public IncorrectScopesException() + : this("Incorrect API scopes.") + { + } + + public IncorrectScopesException(string message) + : base(message) + { + } + + public IncorrectScopesException(string message, Exception innerException) + : base(message, innerException) + { + } + + protected IncorrectScopesException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/GitHub.Api/KeychainCredentialStore.cs b/src/GitHub.Api/KeychainCredentialStore.cs new file mode 100644 index 0000000000..1a039a52ec --- /dev/null +++ b/src/GitHub.Api/KeychainCredentialStore.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Primitives; +using Octokit; + +namespace GitHub.Api +{ + /// + /// An Octokit credential store that reads from an . + /// + public class KeychainCredentialStore : ICredentialStore + { + readonly IKeychain keychain; + readonly HostAddress address; + + public KeychainCredentialStore(IKeychain keychain, HostAddress address) + { + Guard.ArgumentNotNull(keychain, nameof(keychain)); + Guard.ArgumentNotNull(address, nameof(keychain)); + + this.keychain = keychain; + this.address = address; + } + + public async Task GetCredentials() + { + var userPass = await keychain.Load(address).ConfigureAwait(false); + return userPass != null ? new Credentials(userPass.Item1, userPass.Item2) : Credentials.Anonymous; + } + } +} diff --git a/src/GitHub.Api/LoginManager.cs b/src/GitHub.Api/LoginManager.cs new file mode 100644 index 0000000000..bdfe84c2c9 --- /dev/null +++ b/src/GitHub.Api/LoginManager.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using Octokit; +using Serilog; + +namespace GitHub.Api +{ + /// + /// Provides services for logging into a GitHub server. + /// + public class LoginManager : ILoginManager + { + const string ScopesHeader = "X-OAuth-Scopes"; + static readonly ILogger log = LogManager.ForContext(); + static readonly Uri UserEndpoint = new Uri("user", UriKind.Relative); + readonly IKeychain keychain; + readonly Lazy twoFactorChallengeHandler; + readonly string clientId; + readonly string clientSecret; + readonly IReadOnlyList minimumScopes; + readonly IReadOnlyList requestedScopes; + readonly string authorizationNote; + readonly string fingerprint; + IOAuthCallbackListener oauthListener; + + /// + /// Initializes a new instance of the class. + /// + /// The keychain in which to store credentials. + /// The handler for 2FA challenges. + /// The callback listener to signal successful login. + /// The application's client API ID. + /// The application's client API secret. + /// The minimum acceptable scopes. + /// The scopes to request when logging in. + /// An note to store with the authorization. + /// The machine fingerprint. + public LoginManager( + IKeychain keychain, + Lazy twoFactorChallengeHandler, + IOAuthCallbackListener oauthListener, + string clientId, + string clientSecret, + IReadOnlyList minimumScopes, + IReadOnlyList requestedScopes, + string authorizationNote = null, + string fingerprint = null) + { + Guard.ArgumentNotNull(keychain, nameof(keychain)); + Guard.ArgumentNotNull(twoFactorChallengeHandler, nameof(twoFactorChallengeHandler)); + Guard.ArgumentNotEmptyString(clientId, nameof(clientId)); + Guard.ArgumentNotEmptyString(clientSecret, nameof(clientSecret)); + + this.keychain = keychain; + this.twoFactorChallengeHandler = twoFactorChallengeHandler; + this.oauthListener = oauthListener; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.minimumScopes = minimumScopes; + this.requestedScopes = requestedScopes; + this.authorizationNote = authorizationNote; + this.fingerprint = fingerprint; + } + + /// + public async Task Login( + HostAddress hostAddress, + IGitHubClient client, + string userName, + string password) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + Guard.ArgumentNotNull(client, nameof(client)); + Guard.ArgumentNotEmptyString(userName, nameof(userName)); + Guard.ArgumentNotEmptyString(password, nameof(password)); + + // Start by saving the username and password, these will be used by the `IGitHubClient` + // until an authorization token has been created and acquired: + await keychain.Save(userName, password, hostAddress).ConfigureAwait(false); + + var newAuth = new NewAuthorization + { + Scopes = requestedScopes, + Note = authorizationNote, + Fingerprint = fingerprint, + }; + + ApplicationAuthorization auth = null; + + do + { + try + { + auth = await CreateAndDeleteExistingApplicationAuthorization(client, newAuth, null) + .ConfigureAwait(false); + EnsureNonNullAuthorization(auth); + } + catch (TwoFactorAuthorizationException e) + { + auth = await HandleTwoFactorAuthorization(hostAddress, client, newAuth, e) + .ConfigureAwait(false); + } + catch (Exception e) + { + // Some enterpise instances don't support OAUTH, so fall back to using the + // supplied password - on intances that don't support OAUTH the user should + // be using a personal access token as the password. + if (EnterpriseWorkaround(hostAddress, e)) + { + auth = new ApplicationAuthorization(0, + null, null, null, null, null, null, null, + DateTimeOffset.MinValue, DateTimeOffset.MinValue, null, password); + } + else + { + await keychain.Delete(hostAddress).ConfigureAwait(false); + throw; + } + } + } while (auth == null); + + await keychain.Save(userName, auth.Token, hostAddress).ConfigureAwait(false); + return await ReadUserWithRetry(client).ConfigureAwait(false); + } + + /// + public async Task LoginViaOAuth( + HostAddress hostAddress, + IGitHubClient client, + IOauthClient oauthClient, + Action openBrowser, + CancellationToken cancel) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + Guard.ArgumentNotNull(client, nameof(client)); + Guard.ArgumentNotNull(oauthClient, nameof(oauthClient)); + Guard.ArgumentNotNull(openBrowser, nameof(openBrowser)); + + var state = Guid.NewGuid().ToString(); + var loginUrl = GetLoginUrl(oauthClient, state); + var listen = oauthListener.Listen(state, cancel); + + openBrowser(loginUrl); + + var code = await listen.ConfigureAwait(false); + var request = new OauthTokenRequest(clientId, clientSecret, code); + var token = await oauthClient.CreateAccessToken(request).ConfigureAwait(false); + + await keychain.Save("[oauth]", token.AccessToken, hostAddress).ConfigureAwait(false); + var result = await ReadUserWithRetry(client).ConfigureAwait(false); + await keychain.Save(result.User.Login, token.AccessToken, hostAddress).ConfigureAwait(false); + oauthListener.RedirectLastContext(hostAddress.WebUri.Append(result.User.Login)); + + return result; + } + + /// + public async Task LoginWithToken( + HostAddress hostAddress, + IGitHubClient client, + string token) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + Guard.ArgumentNotNull(client, nameof(client)); + Guard.ArgumentNotEmptyString(token, nameof(token)); + + await keychain.Save("[token]", token, hostAddress).ConfigureAwait(false); + + try + { + var result = await ReadUserWithRetry(client).ConfigureAwait(false); + await keychain.Save(result.User.Login, token, hostAddress).ConfigureAwait(false); + return result; + } + catch + { + await keychain.Delete(hostAddress).ConfigureAwait(false); + throw; + } + } + + /// + public Task LoginFromCache(HostAddress hostAddress, IGitHubClient client) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + Guard.ArgumentNotNull(client, nameof(client)); + + return ReadUserWithRetry(client); + } + + /// + public async Task Logout(HostAddress hostAddress, IGitHubClient client) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + Guard.ArgumentNotNull(client, nameof(client)); + + await keychain.Delete(hostAddress).ConfigureAwait(false); + } + + async Task CreateAndDeleteExistingApplicationAuthorization( + IGitHubClient client, + NewAuthorization newAuth, + string twoFactorAuthenticationCode) + { + ApplicationAuthorization result; + var retry = 0; + + do + { + if (twoFactorAuthenticationCode == null) + { + result = await client.Authorization.GetOrCreateApplicationAuthentication( + clientId, + clientSecret, + newAuth).ConfigureAwait(false); + } + else + { + result = await client.Authorization.GetOrCreateApplicationAuthentication( + clientId, + clientSecret, + newAuth, + twoFactorAuthenticationCode).ConfigureAwait(false); + } + + if (string.IsNullOrEmpty(result.Token)) + { + if (twoFactorAuthenticationCode == null) + { + await client.Authorization.Delete(result.Id).ConfigureAwait(false); + } + else + { + await client.Authorization.Delete(result.Id, twoFactorAuthenticationCode).ConfigureAwait(false); + } + } + } while (string.IsNullOrEmpty(result.Token) && retry++ == 0); + + return result; + } + + async Task HandleTwoFactorAuthorization( + HostAddress hostAddress, + IGitHubClient client, + NewAuthorization newAuth, + TwoFactorAuthorizationException exception) + { + for (;;) + { + var challengeResult = await twoFactorChallengeHandler.Value.HandleTwoFactorException(exception).ConfigureAwait(false); + + if (challengeResult == null) + { + throw new InvalidOperationException( + "ITwoFactorChallengeHandler.HandleTwoFactorException returned null."); + } + + if (!challengeResult.ResendCodeRequested) + { + try + { + var auth = await CreateAndDeleteExistingApplicationAuthorization( + client, + newAuth, + challengeResult.AuthenticationCode).ConfigureAwait(false); + return EnsureNonNullAuthorization(auth); + } + catch (TwoFactorAuthorizationException e) + { + exception = e; + } + catch (Exception e) + { + await twoFactorChallengeHandler.Value.ChallengeFailed(e).ConfigureAwait(false); + await keychain.Delete(hostAddress).ConfigureAwait(false); + throw; + } + } + else + { + return null; + } + } + } + + static ApplicationAuthorization EnsureNonNullAuthorization(ApplicationAuthorization auth) + { + // If a mock IGitHubClient is not set up correctly, it can return null from + // IGutHubClient.Authorization.Create - this will cause an infinite loop in Login() + // so prevent that. + if (auth == null) + { + throw new InvalidOperationException("IGutHubClient.Authorization.Create returned null."); + } + + return auth; + } + + bool EnterpriseWorkaround(HostAddress hostAddress, Exception e) + { + // Older Enterprise hosts either don't have the API end-point to PUT an authorization, or they + // return 422 because they haven't white-listed our client ID. In that case, we just ignore + // the failure, using basic authentication (with username and password) instead of trying + // to get an authorization token. + // Since enterprise 2.1 and https://github.com/github/github/pull/36669 the API returns 403 + // instead of 404 to signal that it's not allowed. In the name of backwards compatibility we + // test for both 404 (NotFoundException) and 403 (ForbiddenException) here. + var apiException = e as ApiException; + return !hostAddress.IsGitHubDotCom() && + (e is NotFoundException || + e is ForbiddenException || + apiException?.StatusCode == (HttpStatusCode)422); + } + + async Task ReadUserWithRetry(IGitHubClient client) + { + var retry = 0; + + while (true) + { + try + { + return await GetUserAndCheckScopes(client).ConfigureAwait(false); + } + catch (AuthorizationException) + { + if (retry++ == 3) throw; + } + + // It seems that attempting to use a token immediately sometimes fails, retry a few + // times with a delay of of 1s to allow the token to propagate. + await Task.Delay(1000).ConfigureAwait(false); + } + } + + async Task GetUserAndCheckScopes(IGitHubClient client) + { + var response = await client.Connection.Get( + UserEndpoint, null, null).ConfigureAwait(false); + + var scopes = response.HttpResponse.Headers + .Where(h => string.Equals(h.Key, ScopesHeader, StringComparison.OrdinalIgnoreCase)) + .Select(h => h.Value) + .FirstOrDefault(); + + if (scopes != null) + { + var returnedScopes = new ScopesCollection(scopes + .Split(',') + .Select(x => x.Trim()) + .ToArray()); + + if (returnedScopes.Matches(minimumScopes)) + { + return new LoginResult(response.Body, returnedScopes); + } + else + { + log.Error("Incorrect API scopes: require {RequiredScopes} but got {Scopes}", minimumScopes, returnedScopes); + } + } + else + { + log.Error("Error reading scopes: /user succeeded but scopes header was not present"); + } + + throw new IncorrectScopesException( + "Incorrect API scopes. Required: " + string.Join(",", minimumScopes)); + } + + Uri GetLoginUrl(IOauthClient client, string state) + { + var request = new OauthLoginRequest(ApiClientConfiguration.ClientId); + + request.State = state; + + foreach (var scope in requestedScopes) + { + request.Scopes.Add(scope); + } + + var uri = client.GetGitHubLoginUrl(request); + + // OauthClient.GetGitHubLoginUrl seems to give the wrong URL. Fix this. + return new Uri(uri.ToString().Replace("/api/v3", "")); + } + } +} diff --git a/src/GitHub.Api/LoginResult.cs b/src/GitHub.Api/LoginResult.cs new file mode 100644 index 0000000000..da960ba700 --- /dev/null +++ b/src/GitHub.Api/LoginResult.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using GitHub.Models; +using Octokit; + +namespace GitHub.Api +{ + /// + /// Holds the result of a successful login by . + /// + public class LoginResult + { + /// + /// Initializes a new instance of the class. + /// + /// The logged-in user. + /// The login scopes. + public LoginResult(User user, ScopesCollection scopes) + { + User = user; + Scopes = scopes; + } + + /// + /// Gets the login scopes. + /// + public ScopesCollection Scopes { get; } + + /// + /// Gets the logged-in user. + /// + public User User { get; } + } +} diff --git a/src/GitHub.Api/Properties/AssemblyInfo.cs b/src/GitHub.Api/Properties/AssemblyInfo.cs deleted file mode 100644 index 637b41cdbe..0000000000 --- a/src/GitHub.Api/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("GitHub.Api")] -[assembly: AssemblyDescription("GitHub Api")] -[assembly: Guid("b389adaf-62cc-486e-85b4-2d8b078df763")] diff --git a/src/GitHub.Api/SimpleApiClient.cs b/src/GitHub.Api/SimpleApiClient.cs index 992f974e82..5ae5de86c9 100644 --- a/src/GitHub.Api/SimpleApiClient.cs +++ b/src/GitHub.Api/SimpleApiClient.cs @@ -14,8 +14,7 @@ public class SimpleApiClient : ISimpleApiClient public HostAddress HostAddress { get; } public UriString OriginalUrl { get; } - readonly IGitHubClient client; - readonly Lazy enterpriseProbe; + readonly Lazy enterpriseProbe; readonly Lazy wikiProbe; static readonly SemaphoreSlim sem = new SemaphoreSlim(1); @@ -25,18 +24,20 @@ public class SimpleApiClient : ISimpleApiClient bool? hasWiki; public SimpleApiClient(UriString repoUrl, IGitHubClient githubClient, - Lazy enterpriseProbe, Lazy wikiProbe) + Lazy enterpriseProbe, Lazy wikiProbe) { Guard.ArgumentNotNull(repoUrl, nameof(repoUrl)); Guard.ArgumentNotNull(githubClient, nameof(githubClient)); HostAddress = HostAddress.Create(repoUrl); OriginalUrl = repoUrl; - client = githubClient; + Client = githubClient; this.enterpriseProbe = enterpriseProbe; this.wikiProbe = wikiProbe; } + public IGitHubClient Client { get; } + public async Task GetRepository() { // fast path to avoid locking when the cache has already been set @@ -51,7 +52,7 @@ public bool IsAuthenticated() { // this doesn't account for auth revoke on the server but its much faster // than doing the API hit. - var authType = client.Connection.Credentials?.AuthenticationType ?? AuthenticationType.Anonymous; + var authType = Client.Connection.Credentials?.AuthenticationType ?? AuthenticationType.Anonymous; return authType != AuthenticationType.Anonymous; } @@ -67,11 +68,11 @@ async Task GetRepositoryInternal() if (ownerLogin != null && repositoryName != null) { - var repo = await client.Repository.Get(ownerLogin, repositoryName); + var repo = await Client.Repository.Get(ownerLogin, repositoryName); if (repo != null) { hasWiki = await HasWikiInternal(repo); - isEnterprise = await IsEnterpriseInternal(); + isEnterprise = isEnterprise ?? await IsEnterpriseInternal(); repositoryCache = repo; } owner = ownerLogin; @@ -98,9 +99,14 @@ public bool HasWiki() return hasWiki.HasValue && hasWiki.Value; } - public bool IsEnterprise() + public async Task IsEnterprise() { - return isEnterprise.HasValue && isEnterprise.Value; + if (!isEnterprise.HasValue) + { + isEnterprise = await IsEnterpriseInternal(); + } + + return isEnterprise ?? false; } async Task HasWikiInternal(Repository repo) @@ -126,13 +132,16 @@ async Task HasWikiInternal(Repository repo) async Task IsEnterpriseInternal() { + if (HostAddress == HostAddress.GitHubDotComHostAddress) + return false; + var probe = enterpriseProbe.Value; Debug.Assert(probe != null, "Lazy probe is not set, something is wrong."); #if !DEBUG if (probe == null) return false; #endif - var ret = await probe.ProbeAsync(HostAddress.WebUri); + var ret = await probe.Probe(HostAddress.WebUri); return (ret == EnterpriseProbeResult.Ok); } } diff --git a/src/GitHub.Api/SimpleApiClientFactory.cs b/src/GitHub.Api/SimpleApiClientFactory.cs index 503cc09668..9ad8d90cb5 100644 --- a/src/GitHub.Api/SimpleApiClientFactory.cs +++ b/src/GitHub.Api/SimpleApiClientFactory.cs @@ -1,11 +1,11 @@ using System; -using System.Collections.Generic; using System.ComponentModel.Composition; using GitHub.Models; using GitHub.Primitives; using GitHub.Services; using Octokit; using System.Collections.Concurrent; +using System.Threading.Tasks; namespace GitHub.Api { @@ -13,26 +13,34 @@ namespace GitHub.Api [PartCreationPolicy(CreationPolicy.Shared)] public class SimpleApiClientFactory : ISimpleApiClientFactory { + readonly IKeychain keychain; readonly ProductHeaderValue productHeader; - readonly Lazy lazyEnterpriseProbe; + readonly Lazy lazyEnterpriseProbe; readonly Lazy lazyWikiProbe; static readonly ConcurrentDictionary cache = new ConcurrentDictionary(); [ImportingConstructor] - public SimpleApiClientFactory(IProgram program, Lazy enterpriseProbe, Lazy wikiProbe) + public SimpleApiClientFactory( + IProgram program, + IKeychain keychain, + Lazy enterpriseProbe, + Lazy wikiProbe) { + this.keychain = keychain; productHeader = program.ProductHeader; lazyEnterpriseProbe = enterpriseProbe; lazyWikiProbe = wikiProbe; } - public ISimpleApiClient Create(UriString repositoryUrl) + public Task Create(UriString repositoryUrl) { var hostAddress = HostAddress.Create(repositoryUrl); - return cache.GetOrAdd(repositoryUrl, new SimpleApiClient(repositoryUrl, - new GitHubClient(productHeader, new SimpleCredentialStore(hostAddress), hostAddress.ApiUri), + var credentialStore = new KeychainCredentialStore(keychain, hostAddress); + var result = cache.GetOrAdd(repositoryUrl, new SimpleApiClient(repositoryUrl, + new GitHubClient(productHeader, credentialStore, hostAddress.ApiUri), lazyEnterpriseProbe, lazyWikiProbe)); + return Task.FromResult(result); } public void ClearFromCache(ISimpleApiClient client) diff --git a/src/GitHub.Api/SimpleCredentialStore.cs b/src/GitHub.Api/SimpleCredentialStore.cs deleted file mode 100644 index a50e080152..0000000000 --- a/src/GitHub.Api/SimpleCredentialStore.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Threading.Tasks; -using GitHub.Primitives; -using Octokit; -using System; -using GitHub.Authentication.CredentialManagement; - -namespace GitHub.Api -{ - public class SimpleCredentialStore : ICredentialStore - { - readonly HostAddress hostAddress; - - public SimpleCredentialStore(HostAddress hostAddress) - { - this.hostAddress = hostAddress; - } - - public Task GetCredentials() - { - var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost); - using (var credential = new Credential()) - { - credential.Target = keyHost; - credential.Type = CredentialType.Generic; - if (credential.Load()) - return Task.FromResult(new Credentials(credential.Username, credential.Password)); - } - return Task.FromResult(Credentials.Anonymous); - } - - public static Task RemoveCredentials(string key) - { - var keyGit = GetKeyGit(key); - if (!DeleteKey(keyGit)) - return Task.FromResult(false); - - var keyHost = GetKeyHost(key); - if (!DeleteKey(keyHost)) - return Task.FromResult(false); - - return Task.FromResult(true); - } - - static bool DeleteKey(string key) - { - using (var credential = new Credential()) - { - credential.Target = key; - if (!credential.Load()) - return false; - return credential.Delete(); - } - } - - static string GetKeyHost(string key) - { - key = FormatKey(key); - if (key.StartsWith("git:", StringComparison.Ordinal)) - key = key.Substring("git:".Length); - if (!key.EndsWith("/", StringComparison.Ordinal)) - key += '/'; - return key; - } - - static string FormatKey(string key) - { - if (key.StartsWith("login:", StringComparison.Ordinal)) - key = key.Substring("login:".Length); - return key; - } - - static string GetKeyGit(string key) - { - key = FormatKey(key); - // it appears this is how MS expects the host key - if (!key.StartsWith("git:", StringComparison.Ordinal)) - key = "git:" + key; - if (key.EndsWith("/", StringComparison.Ordinal)) - key = key.Substring(0, key.Length - 1); - return key; - } - } -} diff --git a/src/GitHub.Api/WindowsKeychain.cs b/src/GitHub.Api/WindowsKeychain.cs new file mode 100644 index 0000000000..5e44d21f2a --- /dev/null +++ b/src/GitHub.Api/WindowsKeychain.cs @@ -0,0 +1,127 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.Authentication.CredentialManagement; +using GitHub.Extensions; +using GitHub.Primitives; + +namespace GitHub.Api +{ + /// + /// A keychain that stores logins in the windows credential store. + /// + [Export(typeof(IKeychain))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class WindowsKeychain : IKeychain + { + /// + public Task> Load(HostAddress hostAddress) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + + var key = GetKey(hostAddress.CredentialCacheKeyHost); + var keyGit = GetKeyGit(hostAddress.CredentialCacheKeyHost); + var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost); + Tuple result = null; + + // Visual Studio requires two credentials, keyed as "{hostname}" (e.g. "https://github.com/") and + // "git:{hostname}" (e.g. "git:https://github.com"). We have a problem in that these credentials can + // potentially be overwritten by other applications, so we store an extra "master" key as + // "GitHub for Visual Studio - {hostname}". Whenever we read the credentials we overwrite the other + // two keys with the value from the master key. Older versions of GHfVS did not store this master key + // so if it does not exist, try to get the value from the "{hostname}" key. + using (var credential = Credential.Load(key)) + using (var credentialGit = Credential.Load(keyGit)) + using (var credentialHost = Credential.Load(keyHost)) + { + if (credential != null) + { + result = Tuple.Create(credential.Username, credential.Password); + } + else if (credentialHost != null) + { + result = Tuple.Create(credentialHost.Username, credentialHost.Password); + } + + if (result != null) + { + Save(result.Item1, result.Item2, hostAddress); + } + } + + return Task.FromResult(result); + } + + /// + public Task Save(string userName, string password, HostAddress hostAddress) + { + Guard.ArgumentNotEmptyString(userName, nameof(userName)); + Guard.ArgumentNotEmptyString(password, nameof(password)); + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + + var key = GetKey(hostAddress.CredentialCacheKeyHost); + var keyGit = GetKeyGit(hostAddress.CredentialCacheKeyHost); + var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost); + + Credential.Save(key, userName, password); + Credential.Save(keyGit, userName, password); + Credential.Save(keyHost, userName, password); + + return Task.CompletedTask; + } + + /// + public Task Delete(HostAddress hostAddress) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + + var key = GetKey(hostAddress.CredentialCacheKeyHost); + var keyGit = GetKeyGit(hostAddress.CredentialCacheKeyHost); + var keyHost = GetKeyHost(hostAddress.CredentialCacheKeyHost); + + Credential.Delete(key); + Credential.Delete(keyGit); + Credential.Delete(keyHost); + + return Task.CompletedTask; + } + + static string GetKey(string key) + { + key = FormatKey(key); + if (key.StartsWith("git:", StringComparison.Ordinal)) + key = key.Substring("git:".Length); + if (!key.EndsWith("/", StringComparison.Ordinal)) + key += '/'; + return "GitHub for Visual Studio - " + key; + } + + static string GetKeyGit(string key) + { + key = FormatKey(key); + // it appears this is how MS expects the host key + if (!key.StartsWith("git:", StringComparison.Ordinal)) + key = "git:" + key; + if (key.EndsWith("/", StringComparison.Ordinal)) + key = key.Substring(0, key.Length - 1); + return key; + } + + static string GetKeyHost(string key) + { + key = FormatKey(key); + if (key.StartsWith("git:", StringComparison.Ordinal)) + key = key.Substring("git:".Length); + if (!key.EndsWith("/", StringComparison.Ordinal)) + key += '/'; + return key; + } + + static string FormatKey(string key) + { + if (key.StartsWith("login:", StringComparison.Ordinal)) + key = key.Substring("login:".Length); + return key; + } + } +} diff --git a/src/GitHub.App/Api/ApiClient.cs b/src/GitHub.App/Api/ApiClient.cs index 79adcf3b0b..56257e82a3 100644 --- a/src/GitHub.App/Api/ApiClient.cs +++ b/src/GitHub.App/Api/ApiClient.cs @@ -7,49 +7,37 @@ using System.Reactive.Linq; using System.Security.Cryptography; using System.Text; +using GitHub.Extensions; +using GitHub.Logging; using GitHub.Primitives; -using NLog; -using NullGuard; using Octokit; using Octokit.Reactive; -using ReactiveUI; -using System.Threading.Tasks; -using System.Reactive.Threading.Tasks; -using Octokit.Internal; -using System.Collections.Generic; -using GitHub.Models; -using GitHub.Extensions; +using Serilog; namespace GitHub.Api { public partial class ApiClient : IApiClient { - const string ScopesHeader = "X-OAuth-Scopes"; const string ProductName = Info.ApplicationInfo.ApplicationDescription; - static readonly Logger log = LogManager.GetCurrentClassLogger(); - static readonly Uri userEndpoint = new Uri("user", UriKind.Relative); + static readonly ILogger log = LogManager.ForContext(); readonly IObservableGitHubClient gitHubClient; - // There are two sets of authorization scopes, old and new: - // The old scopes must be used by older versions of Enterprise that don't support the new scopes: - readonly string[] oldAuthorizationScopes = { "user", "repo", "gist" }; - // These new scopes include write:public_key, which allows us to add public SSH keys to an account: - readonly string[] newAuthorizationScopes = { "user", "repo", "gist", "write:public_key" }; - readonly static Lazy lazyNote = new Lazy(() => ProductName + " on " + GetMachineNameSafe()); - readonly static Lazy lazyFingerprint = new Lazy(GetFingerprint); string ClientId { get; set; } string ClientSecret { get; set; } public ApiClient(HostAddress hostAddress, IObservableGitHubClient gitHubClient) { - Configure(); + ClientId = ApiClientConfiguration.ClientId; + ClientSecret = ApiClientConfiguration.ClientSecret; HostAddress = hostAddress; this.gitHubClient = gitHubClient; } partial void Configure(); + public IGitHubClient GitHubClient => new GitHubClient(gitHubClient.Connection); + public IObservable CreateRepository(NewRepository repository, string login, bool isUser) { Guard.ArgumentNotEmptyString(login, nameof(login)); @@ -59,71 +47,104 @@ public IObservable CreateRepository(NewRepository repository, string return (isUser ? client.Create(repository) : client.Create(login, repository)); } - public IObservable CreateGist(NewGist newGist) + public IObservable ForkRepository(string owner, string name, NewRepositoryFork repository) { - return gitHubClient.Gist.Create(newGist); + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + Guard.ArgumentNotNull(repository, nameof(repository)); + + var client = gitHubClient.Repository.Forks; + + return client.Create(owner, name, repository); } - public IObservable GetUser() + public IObservable PostPullRequestReview( + string owner, + string name, + int number, + string commitId, + string body, + PullRequestReviewEvent e) { - return GetUserInternal().ToObservable(); + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + + var review = new PullRequestReviewCreate + { + Body = body, + CommitId = commitId, + Event = e, + }; + + return gitHubClient.PullRequest.Review.Create(owner, name, number, review); } - async Task GetUserInternal() + public IObservable CreatePullRequestReviewComment( + string owner, + string name, + int number, + string body, + string commitId, + string path, + int position) { - var response = await gitHubClient.Connection.Get( - userEndpoint, null, null).ConfigureAwait(false); - var scopes = default(string[]); + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + Guard.ArgumentNotEmptyString(body, nameof(body)); + Guard.ArgumentNotEmptyString(commitId, nameof(commitId)); + Guard.ArgumentNotEmptyString(path, nameof(path)); + + var comment = new PullRequestReviewCommentCreate(body, commitId, path, position); + return gitHubClient.PullRequest.ReviewComment.Create(owner, name, number, comment); + } - if (response.HttpResponse.Headers.ContainsKey(ScopesHeader)) - { - scopes = response.HttpResponse.Headers[ScopesHeader] - .Split(',') - .Select(x => x.Trim()) - .ToArray(); - } - else - { - log.Error($"Error reading scopes: /user succeeded but {ScopesHeader} was not present."); - } + public IObservable CreatePullRequestReviewComment( + string owner, + string name, + int number, + string body, + int inReplyTo) + { + var comment = new PullRequestReviewCommentReplyCreate(body, inReplyTo); + return gitHubClient.PullRequest.ReviewComment.CreateReply(owner, name, number, comment); + } - return new UserAndScopes(response.Body, scopes); + public IObservable EditPullRequestReviewComment( + string owner, + string name, + int number, + string body) + { + var pullRequestReviewCommentEdit = new PullRequestReviewCommentEdit(body); + return gitHubClient.PullRequest.ReviewComment.Edit(owner, name, number, pullRequestReviewCommentEdit); } - public IObservable GetOrCreateApplicationAuthenticationCode( - Func> twoFactorChallengeHander, - string authenticationCode = null, - bool useOldScopes = false, - bool useFingerPrint = true) + public IObservable DeletePullRequestReviewComment( + string owner, + string name, + int number) { - var newAuthorization = new NewAuthorization - { - Scopes = useOldScopes - ? oldAuthorizationScopes - : newAuthorizationScopes, - Note = lazyNote.Value, - Fingerprint = useFingerPrint ? lazyFingerprint.Value : null - }; + return gitHubClient.PullRequest.ReviewComment.Delete(owner, name, number); + } + + public IObservable CreateGist(NewGist newGist) + { + return gitHubClient.Gist.Create(newGist); + } + + public IObservable GetForks(string owner, string name) + { + return gitHubClient.Repository.Forks.GetAll(owner, name); + } + + public IObservable GetUser() + { + return gitHubClient.User.Current(); + } - Func> dispatchedHandler = - ex => Observable.Start(() => twoFactorChallengeHander(ex), RxApp.MainThreadScheduler).Merge(); - - var authorizationsClient = gitHubClient.Authorization; - - return string.IsNullOrEmpty(authenticationCode) - ? authorizationsClient.CreateAndDeleteExistingApplicationAuthorization( - ClientId, - ClientSecret, - newAuthorization, - dispatchedHandler, - true) - : authorizationsClient.CreateAndDeleteExistingApplicationAuthorization( - ClientId, - ClientSecret, - newAuthorization, - dispatchedHandler, - authenticationCode, - true); + public IObservable GetUser(string login) + { + return gitHubClient.User.Get(login); } public IObservable GetOrganizations() @@ -161,28 +182,10 @@ public IObservable GetLicenses() public HostAddress HostAddress { get; } - static string GetSha256Hash(string input) - { - try - { - using (var sha256 = SHA256.Create()) - { - var bytes = Encoding.UTF8.GetBytes(input); - var hash = sha256.ComputeHash(bytes); - - return string.Join("", hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); - } - } - catch (Exception e) - { - log.Error("IMPOSSIBLE! Generating Sha256 hash caused an exception.", e); - return null; - } - } - static string GetFingerprint() { - return GetSha256Hash(ProductName + ":" + GetMachineIdentifier()); + var fingerprint = ProductName + ":" + GetMachineIdentifier(); + return fingerprint.GetSha256Hash(); } static string GetMachineNameSafe() @@ -193,14 +196,14 @@ static string GetMachineNameSafe() } catch (Exception e) { - log.Info("Failed to retrieve host name using `DNS.GetHostName`.", e); + log.Warning(e, "Failed to retrieve host name using `DNS.GetHostName`"); try { return Environment.MachineName; } catch (Exception ex) { - log.Info("Failed to retrieve host name using `Environment.MachineName`.", ex); + log.Warning(ex, "Failed to retrieve host name using `Environment.MachineName`"); return "(unknown)"; } } @@ -221,35 +224,65 @@ static string GetMachineIdentifier() } catch (Exception e) { - log.Info("Could not retrieve MAC address. Fallback to using machine name.", e); + log.Warning(e, "Could not retrieve MAC address. Fallback to using machine name"); return GetMachineNameSafe(); } } public IObservable GetRepositoriesForOrganization(string organization) { + Guard.ArgumentNotEmptyString(organization, nameof(organization)); + return gitHubClient.Repository.GetAllForOrg(organization); } - public IObservable DeleteApplicationAuthorization(int id, [AllowNull]string twoFactorAuthorizationCode) + public IObservable DeleteApplicationAuthorization(int id, string twoFactorAuthorizationCode) { + Guard.ArgumentNotEmptyString(twoFactorAuthorizationCode, nameof(twoFactorAuthorizationCode)); + return gitHubClient.Authorization.Delete(id, twoFactorAuthorizationCode); } + public IObservable GetIssueComments(string owner, string name, int number) + { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + + return gitHubClient.Issue.Comment.GetAllForIssue(owner, name, number); + } + public IObservable GetPullRequest(string owner, string name, int number) { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + return gitHubClient.PullRequest.Get(owner, name, number); } public IObservable GetPullRequestFiles(string owner, string name, int number) { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + return gitHubClient.PullRequest.Files(owner, name, number); } + public IObservable GetPullRequestReviewComments(string owner, string name, int number) + { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + + return gitHubClient.PullRequest.ReviewComment.GetAll(owner, name, number); + } + public IObservable GetPullRequestsForRepository(string owner, string name) { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + return gitHubClient.PullRequest.GetAllForRepository(owner, name, - new PullRequestRequest { + new PullRequestRequest + { State = ItemStateFilter.All, SortProperty = PullRequestSort.Updated, SortDirection = SortDirection.Descending @@ -258,6 +291,10 @@ public IObservable GetPullRequestsForRepository(string owner, strin public IObservable CreatePullRequest(NewPullRequest pullRequest, string owner, string repo) { + Guard.ArgumentNotNull(pullRequest, nameof(pullRequest)); + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(repo, nameof(repo)); + return gitHubClient.PullRequest.Create(owner, repo, pullRequest); } @@ -268,20 +305,27 @@ public IObservable GetRepositories() public IObservable GetBranches(string owner, string repo) { -#pragma warning disable CS0618 - // GetAllBranches is obsolete, but don't want to introduce the change to fix the - // warning in the PR, so disabling for now. - return gitHubClient.Repository.GetAllBranches(owner, repo); -#pragma warning restore + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(repo, nameof(repo)); + + return gitHubClient.Repository.Branch.GetAll(owner, repo); } public IObservable GetRepository(string owner, string repo) { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(repo, nameof(repo)); + return gitHubClient.Repository.Get(owner, repo); } public IObservable GetFileContents(string owner, string name, string reference, string path) { + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + Guard.ArgumentNotEmptyString(reference, nameof(reference)); + Guard.ArgumentNotEmptyString(path, nameof(path)); + return gitHubClient.Repository.Content.GetAllContentsByRef(owner, name, reference, path); } } diff --git a/src/GitHub.App/Api/ApiClientConfiguration.cs b/src/GitHub.App/Api/ApiClientConfiguration.cs deleted file mode 100644 index 16f7eb2e97..0000000000 --- a/src/GitHub.App/Api/ApiClientConfiguration.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace GitHub.Api -{ - public partial class ApiClient : IApiClient - { - const string clientId = "YOUR CLIENT ID HERE"; - const string clientSecret = "YOUR CLIENT SECRET HERE"; - - partial void Configure() - { - ClientId = clientId; - ClientSecret = clientSecret; - } - } -} diff --git a/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs b/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs index 952a6235c3..1c0fbb3cb9 100644 --- a/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs +++ b/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs @@ -4,37 +4,64 @@ using GitHub.ViewModels; using Octokit; using ReactiveUI; -using NullGuard; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.ViewModels.Dialog; +using Microsoft.VisualStudio.Threading; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; namespace GitHub.Authentication { [Export(typeof(ITwoFactorChallengeHandler))] + [Export(typeof(IDelegatingTwoFactorChallengeHandler))] [PartCreationPolicy(CreationPolicy.Shared)] - public class TwoFactorChallengeHandler : ReactiveObject, ITwoFactorChallengeHandler + public class TwoFactorChallengeHandler : ReactiveObject, IDelegatingTwoFactorChallengeHandler { - ITwoFactorDialogViewModel twoFactorDialog; - [AllowNull] + [ImportingConstructor] + public TwoFactorChallengeHandler([Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) + { + JoinableTaskContext = joinableTaskContext ?? ThreadHelper.JoinableTaskContext; + } + + ILogin2FaViewModel twoFactorDialog; public IViewModel CurrentViewModel { - [return:AllowNull] get { return twoFactorDialog; } - private set { this.RaiseAndSetIfChanged(ref twoFactorDialog, (ITwoFactorDialogViewModel)value); } + private set { this.RaiseAndSetIfChanged(ref twoFactorDialog, (ILogin2FaViewModel)value); } } - public void SetViewModel([AllowNull]IViewModel vm) + public void SetViewModel(IViewModel vm) { CurrentViewModel = vm; } - public IObservable HandleTwoFactorException(TwoFactorAuthorizationException exception) + public async Task HandleTwoFactorException(TwoFactorAuthorizationException exception) { + Guard.ArgumentNotNull(exception, nameof(exception)); + + await JoinableTaskContext.Factory.SwitchToMainThreadAsync(); + var userError = new TwoFactorRequiredUserError(exception); - return twoFactorDialog.Show(userError) - .ObserveOn(RxApp.MainThreadScheduler) - .SelectMany(x => - x == RecoveryOptionResult.RetryOperation - ? Observable.Return(userError.ChallengeResult) - : Observable.Throw(exception)); + var result = await twoFactorDialog.Show(userError); + + if (result != null) + { + return result; + } + else + { + throw exception; + } } + + public async Task ChallengeFailed(Exception exception) + { + await JoinableTaskContext.Factory.SwitchToMainThreadAsync(); + twoFactorDialog.Cancel(); + } + + JoinableTaskContext JoinableTaskContext { get; } } } \ No newline at end of file diff --git a/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs b/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs index 1695673eb9..cd472813a8 100644 --- a/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs +++ b/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs @@ -1,16 +1,27 @@ using System; -using NullGuard; +using GitHub.Extensions; using Octokit; using ReactiveUI; +using ReactiveUI.Legacy; namespace GitHub.Authentication { +#pragma warning disable CS0618 // Type or member is obsolete public class TwoFactorRequiredUserError : UserError { public TwoFactorRequiredUserError(TwoFactorAuthorizationException exception) + : this(exception, exception.TwoFactorType) + { + } + + public TwoFactorRequiredUserError( + TwoFactorAuthorizationException exception, + TwoFactorType twoFactorType) : base(exception.Message, innerException: exception) { - TwoFactorType = exception.TwoFactorType; + Guard.ArgumentNotNull(exception, nameof(exception)); + + TwoFactorType = twoFactorType; RetryFailed = exception is TwoFactorChallengeFailedException; } @@ -18,17 +29,10 @@ public TwoFactorRequiredUserError(TwoFactorAuthorizationException exception) public TwoFactorType TwoFactorType { get; private set; } - [AllowNull] - public TwoFactorChallengeResult ChallengeResult - { - [return: AllowNull] - get; - set; - } - public IObservable Throw() { return Throw(this); } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/GitHub.App/Caches/CacheIndex.cs b/src/GitHub.App/Caches/CacheIndex.cs index c189d05cf1..d3ad77de7f 100644 --- a/src/GitHub.App/Caches/CacheIndex.cs +++ b/src/GitHub.App/Caches/CacheIndex.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Reactive.Linq; using Akavache; -using NullGuard; +using GitHub.Extensions; namespace GitHub.Caches { @@ -29,6 +29,9 @@ public CacheIndex() public CacheIndex Add(string indexKey, CacheItem item) { + Guard.ArgumentNotEmptyString(indexKey, nameof(indexKey)); + Guard.ArgumentNotNull(item, nameof(item)); + var k = string.Format(CultureInfo.InvariantCulture, "{0}|{1}", IndexKey, item.Key); if (!Keys.Contains(k)) Keys.Add(k); @@ -39,6 +42,10 @@ public CacheIndex Add(string indexKey, CacheItem item) public IObservable AddAndSave(IBlobCache cache, string indexKey, CacheItem item, DateTimeOffset? absoluteExpiration = null) { + Guard.ArgumentNotNull(cache, nameof(cache)); + Guard.ArgumentNotEmptyString(indexKey, nameof(indexKey)); + Guard.ArgumentNotNull(item, nameof(item)); + var k = string.Format(CultureInfo.InvariantCulture, "{0}|{1}", IndexKey, item.Key); if (!Keys.Contains(k)) Keys.Add(k); @@ -50,7 +57,12 @@ public IObservable AddAndSave(IBlobCache cache, string indexKey, Cac public static IObservable AddAndSaveToIndex(IBlobCache cache, string indexKey, CacheItem item, DateTimeOffset? absoluteExpiration = null) { + Guard.ArgumentNotNull(cache, nameof(cache)); + Guard.ArgumentNotEmptyString(indexKey, nameof(indexKey)); + Guard.ArgumentNotNull(item, nameof(item)); + return cache.GetOrCreateObject(indexKey, () => Create(indexKey)) + .Select(x => x.IndexKey == null ? Create(indexKey) : x) .Do(index => { var k = string.Format(CultureInfo.InvariantCulture, "{0}|{1}", index.IndexKey, item.Key); @@ -73,12 +85,13 @@ public CacheIndex Clear() public IObservable Save(IBlobCache cache, DateTimeOffset? absoluteExpiration = null) { + Guard.ArgumentNotNull(cache, nameof(cache)); + return cache.InsertObject(IndexKey, this, absoluteExpiration) .Select(x => this); } - [AllowNull] - public string IndexKey {[return: AllowNull] get; set; } + public string IndexKey { get; set; } public List Keys { get; set; } public DateTimeOffset UpdatedAt { get; set; } public List OldKeys { get; set; } diff --git a/src/GitHub.App/Caches/CacheItem.cs b/src/GitHub.App/Caches/CacheItem.cs index fa18dafcb5..33e92d1b18 100644 --- a/src/GitHub.App/Caches/CacheItem.cs +++ b/src/GitHub.App/Caches/CacheItem.cs @@ -2,19 +2,21 @@ using System.Globalization; using System.Reactive.Linq; using Akavache; -using NullGuard; +using GitHub.Extensions; namespace GitHub.Caches { public class CacheItem { - [AllowNull] - public string Key {[return: AllowNull] get; set; } + public string Key { get; set; } public DateTimeOffset Timestamp { get; set; } public IObservable Save(IBlobCache cache, string key, DateTimeOffset? absoluteExpiration = null) where T : CacheItem { + Guard.ArgumentNotNull(cache, nameof(cache)); + Guard.ArgumentNotEmptyString(key, nameof(key)); + var k = string.Format(CultureInfo.InvariantCulture, "{0}|{1}", key, Key); return cache .InvalidateObject(k) diff --git a/src/GitHub.App/Caches/CredentialCache.cs b/src/GitHub.App/Caches/CredentialCache.cs deleted file mode 100644 index 9d51d4fab1..0000000000 --- a/src/GitHub.App/Caches/CredentialCache.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text; -using Akavache; -using GitHub.Helpers; -using GitHub.Authentication.CredentialManagement; -using System.Security; - -namespace GitHub.Caches -{ - public class CredentialCache : ISecureBlobCache, IObjectBlobCache - { - public IScheduler Scheduler { get; protected set; } - - readonly AsyncSubject shutdown = new AsyncSubject(); - public IObservable Shutdown => shutdown; - - public IObservable Flush() - { - if (disposed) return ExceptionHelper.ObservableThrowObjectDisposedException("CredentialCache"); - return Observable.Return(Unit.Default); - } - - public IObservable Get(string key) - { - if (disposed) return ExceptionHelper.ObservableThrowObjectDisposedException("CredentialCache"); - - var keyHost = GetKeyHost(key); - using (var credential = new Credential()) - { - credential.Target = keyHost; - if (credential.Load()) - return Observable.Return(Encoding.Unicode.GetBytes(credential.Password)); - } - return ExceptionHelper.ObservableThrowKeyNotFoundException(key); - } - - public IObservable> GetAllKeys() - { - throw new NotImplementedException(); - } - - public IObservable GetCreatedAt(string key) - { - throw new NotImplementedException(); - } - - public IObservable Insert(string key, byte[] data, DateTimeOffset? absoluteExpiration = default(DateTimeOffset?)) - { - return ExceptionHelper.ObservableThrowInvalidOperationException(key); - } - - public IObservable Invalidate(string key) - { - if (disposed) return ExceptionHelper.ObservableThrowObjectDisposedException("CredentialCache"); - - var keyGit = GetKeyGit(key); - if (!DeleteKey(keyGit)) - return ExceptionHelper.ObservableThrowKeyNotFoundException(keyGit); - - var keyHost = GetKeyHost(key); - if (!DeleteKey(keyHost)) - return ExceptionHelper.ObservableThrowKeyNotFoundException(keyHost); - - return Observable.Return(Unit.Default); - } - - public IObservable InvalidateAll() - { - throw new NotImplementedException(); - } - - public IObservable Vacuum() - { - throw new NotImplementedException(); - } - - public IObservable InsertObject(string key, T value, DateTimeOffset? absoluteExpiration = default(DateTimeOffset?)) - { - if (disposed) return ExceptionHelper.ObservableThrowObjectDisposedException("CredentialCache"); - - if (value is Tuple) - return InsertTuple(key, value as Tuple); - if (value is Tuple) - return InsertTuple(key, value as Tuple); - - return ExceptionHelper.ObservableThrowInvalidOperationException(key); - } - - static IObservable InsertTuple(string key, Tuple values) - { - var keyGit = GetKeyGit(key); - if (!SaveKey(keyGit, values.Item1, values.Item2)) - return ExceptionHelper.ObservableThrowInvalidOperationException(keyGit); - - var keyHost = GetKeyHost(key); - if (!SaveKey(keyHost, values.Item1, values.Item2)) - return ExceptionHelper.ObservableThrowInvalidOperationException(keyGit); - - return Observable.Return(Unit.Default); - } - - static IObservable InsertTuple(string key, Tuple values) - { - var keyGit = GetKeyGit(key); - if (!SaveKey(keyGit, values.Item1, values.Item2)) - return ExceptionHelper.ObservableThrowInvalidOperationException(keyGit); - - var keyHost = GetKeyHost(key); - if (!SaveKey(keyHost, values.Item1, values.Item2)) - return ExceptionHelper.ObservableThrowInvalidOperationException(keyGit); - - return Observable.Return(Unit.Default); - } - - public IObservable GetObject(string key) - { - if (typeof(T) == typeof(Tuple)) - return (IObservable) GetTuple(key); - if (typeof(T) == typeof(Tuple)) - return (IObservable)GetSecureTuple(key); - return ExceptionHelper.ObservableThrowInvalidOperationException(key); - } - - IObservable> GetTuple(string key) - { - if (disposed) return ExceptionHelper.ObservableThrowObjectDisposedException>("CredentialCache"); - - var keyHost = GetKeyHost(key); - var ret = GetKey(keyHost); - return ret != null - ? Observable.Return(ret) - : ExceptionHelper.ObservableThrowKeyNotFoundException>(keyHost); - } - - IObservable> GetSecureTuple(string key) - { - if (disposed) return ExceptionHelper.ObservableThrowObjectDisposedException>("CredentialCache"); - - var keyHost = GetKeyHost(key); - var ret = GetSecureKey(keyHost); - return ret != null - ? Observable.Return(ret) - : ExceptionHelper.ObservableThrowKeyNotFoundException>(keyHost); - } - - public IObservable> GetAllObjects() - { - throw new NotImplementedException(); - } - - public IObservable GetObjectCreatedAt(string key) - { - throw new NotImplementedException(); - } - - public IObservable InvalidateObject(string key) - { - if (disposed) return ExceptionHelper.ObservableThrowObjectDisposedException("CredentialCache"); - - var keyGit = GetKeyGit(key); - if (!DeleteKey(keyGit)) - return ExceptionHelper.ObservableThrowKeyNotFoundException(keyGit); - - var keyHost = GetKeyHost(key); - if (!DeleteKey(keyHost)) - return ExceptionHelper.ObservableThrowKeyNotFoundException(key); - - return Observable.Return(Unit.Default); - } - - public IObservable InvalidateAllObjects() - { - throw new NotImplementedException(); - } - - static string FormatKey(string key) - { - if (key.StartsWith("login:", StringComparison.Ordinal)) - key = key.Substring("login:".Length); - return key; - } - - static string GetKeyGit(string key) - { - key = FormatKey(key); - // it appears this is how MS expects the host key - if (!key.StartsWith("git:", StringComparison.Ordinal)) - key = "git:" + key; - if (key.EndsWith("/", StringComparison.Ordinal)) - key = key.Substring(0, key.Length - 1); - return key; - } - - static string GetKeyHost(string key) - { - key = FormatKey(key); - if (key.StartsWith("git:", StringComparison.Ordinal)) - key = key.Substring("git:".Length); - if (!key.EndsWith("/", StringComparison.Ordinal)) - key += '/'; - return key; - } - - static bool DeleteKey(string key) - { - using (var credential = new Credential()) - { - credential.Target = key; - return credential.Load() && credential.Delete(); - } - } - - static bool SaveKey(string key, string user, string pwd) - { - using (var credential = new Credential(user, pwd, key)) - { - return credential.Save(); - } - } - - static bool SaveKey(string key, string user, SecureString pwd) - { - using (var credential = new Credential(user, pwd, key)) - { - return credential.Save(); - } - } - - static Tuple GetKey(string key) - { - using (var credential = new Credential()) - { - credential.Target = key; - return credential.Load() - ? new Tuple(credential.Username, credential.Password) - : null; - } - } - - static Tuple GetSecureKey(string key) - { - using (var credential = new Credential()) - { - credential.Target = key; - return credential.Load() - ? new Tuple(credential.Username, credential.SecurePassword) - : null; - } - } - - bool disposed; - void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - - Scheduler = null; - shutdown.OnNext(Unit.Default); - shutdown.OnCompleted(); - disposed = true; - } - } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.App/Caches/ImageCache.cs b/src/GitHub.App/Caches/ImageCache.cs index 2da4ce8f76..175911018a 100644 --- a/src/GitHub.App/Caches/ImageCache.cs +++ b/src/GitHub.App/Caches/ImageCache.cs @@ -11,8 +11,10 @@ using GitHub.Extensions; using GitHub.Extensions.Reactive; using GitHub.Factories; +using GitHub.Logging; using GitHub.Services; using Rothko; +using Serilog; namespace GitHub.Caches { @@ -20,7 +22,7 @@ namespace GitHub.Caches [PartCreationPolicy(CreationPolicy.Shared)] public sealed class ImageCache : IImageCache { - static readonly NLog.Logger log = NLog.LogManager.GetCurrentClassLogger(); + static readonly ILogger log = LogManager.ForContext(); public const string ImageCacheFileName = "images.cache.db"; readonly IObservable cacheFactory; @@ -159,7 +161,7 @@ static IObservable VacuumIfNecessary(IBlobCache blobCache, bool force = fa .SelectMany(Observable.Defer(() => blobCache.Insert(key, new byte[] { 1 }))) .Catch(ex => { - log.Error("Could not vacuum image cache", ex); + log.Error(ex, "Could not vacuum image cache"); return Observable.Return(Unit.Default); }) .AsCompletion(); @@ -184,7 +186,7 @@ IObservable LoadImage(byte[] x, Uri url) static IObservable LoadImage(Stream sourceStream, float? desiredWidth = null, float? desiredHeight = null) { - Debug.Assert(sourceStream != null, "Cannot load image from a null sourceStream"); + Guard.ArgumentNotNull(sourceStream, nameof(sourceStream)); return Observable.Defer(() => { diff --git a/src/GitHub.App/Caches/LoginCache.cs b/src/GitHub.App/Caches/LoginCache.cs deleted file mode 100644 index eec74552b3..0000000000 --- a/src/GitHub.App/Caches/LoginCache.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Globalization; -using System.Reactive; -using System.Reactive.Linq; -using Akavache; -using GitHub.Extensions; -using GitHub.Primitives; -using NLog; - -namespace GitHub.Caches -{ - [Export(typeof(ILoginCache))] - [PartCreationPolicy(CreationPolicy.Shared)] - public sealed class LoginCache : ILoginCache - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - readonly ISharedCache cache; - - static readonly LoginInfo empty = new LoginInfo("", ""); - - [ImportingConstructor] - public LoginCache(ISharedCache cache) - { - this.cache = cache; - } - - public static LoginInfo EmptyLoginInfo - { - get { return empty; } - } - - public IObservable GetLoginAsync(HostAddress hostAddress) - { - return cache.Secure.GetLoginAsync(hostAddress.CredentialCacheKeyHost) - .Catch(e => Observable.Return(empty)); - } - - public IObservable SaveLogin(string user, string password, HostAddress hostAddress) - { - Guard.ArgumentNotEmptyString(user, nameof(user)); - Guard.ArgumentNotEmptyString(password, nameof(password)); - - return cache.Secure.SaveLogin(user, password, hostAddress.CredentialCacheKeyHost); - } - - public IObservable EraseLogin(HostAddress hostAddress) - { - log.Info(CultureInfo.CurrentCulture, "Erasing the git credential cache for host '{0}'", - hostAddress.CredentialCacheKeyHost); - return cache.Secure.EraseLogin(hostAddress.CredentialCacheKeyHost); - } - - public IObservable Flush() - { - log.Info("Flushing the login cache"); - return cache.Secure.Flush(); - } - - public void Dispose() - {} - } -} diff --git a/src/GitHub.App/Caches/SharedCache.cs b/src/GitHub.App/Caches/SharedCache.cs index 51c2fe0a97..fb1e420a0c 100644 --- a/src/GitHub.App/Caches/SharedCache.cs +++ b/src/GitHub.App/Caches/SharedCache.cs @@ -4,7 +4,8 @@ using System.Reactive; using System.Reactive.Linq; using Akavache; -using NLog; +using GitHub.Logging; +using Serilog; namespace GitHub.Caches { @@ -16,7 +17,7 @@ namespace GitHub.Caches public class SharedCache : ISharedCache { const string enterpriseHostApiBaseUriCacheKey = "enterprise-host-api-base-uri"; - static readonly Logger log = LogManager.GetCurrentClassLogger(); + static readonly ILogger log = LogManager.ForContext(); static SharedCache() { @@ -26,37 +27,22 @@ static SharedCache() } catch (Exception e) { - log.Error("Error while running the static inializer for SharedCache", e); + log.Error(e, "Error while running the static inializer for SharedCache"); } } - public SharedCache() : this(null, null, null) + public SharedCache() : this(null, null) { } - protected SharedCache(IBlobCache userAccountCache, IBlobCache localMachineCache, ISecureBlobCache secureCache) + protected SharedCache(IBlobCache userAccountCache, IBlobCache localMachineCache) { - if (secureCache == null) - { - try - { - BlobCache.Secure = new CredentialCache(); - secureCache = BlobCache.Secure; - } - catch (Exception e) - { - log.Error("Failed to set up secure cache.", e); - secureCache = new InMemoryBlobCache(); - } - } UserAccount = userAccountCache ?? GetBlobCacheWithFallback(() => BlobCache.UserAccount, "UserAccount"); LocalMachine = localMachineCache ?? GetBlobCacheWithFallback(() => BlobCache.LocalMachine, "LocalMachine"); - Secure = secureCache; } public IBlobCache UserAccount { get; private set; } public IBlobCache LocalMachine { get; private set; } - public ISecureBlobCache Secure { get; private set; } public IObservable GetEnterpriseHostUri() { @@ -84,8 +70,6 @@ protected virtual void Dispose(bool disposing) UserAccount.Shutdown.Wait(); LocalMachine.Dispose(); LocalMachine.Shutdown.Wait(); - Secure.Dispose(); - Secure.Shutdown.Wait(); disposed = true; } } @@ -104,7 +88,7 @@ static IBlobCache GetBlobCacheWithFallback(Func blobCacheFunc, strin } catch (Exception e) { - log.Error(string.Format(CultureInfo.InvariantCulture, "Failed to set the {0} cache.", cacheName), e); + log.Error(e, "Failed to set the {CacheName} cache", cacheName); return new InMemoryBlobCache(); } } diff --git a/src/GitHub.App/Collections/IVirtualizingListSource.cs b/src/GitHub.App/Collections/IVirtualizingListSource.cs new file mode 100644 index 0000000000..f8480b1e28 --- /dev/null +++ b/src/GitHub.App/Collections/IVirtualizingListSource.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; + +namespace GitHub.Collections +{ + /// + /// A loader for a virtualizing list. + /// + /// The item type. + /// + /// This interface is used by the class to load pages of data. + /// + public interface IVirtualizingListSource : IDisposable, INotifyPropertyChanged + { + /// + /// Gets a value that indicates where loading is in progress. + /// + bool IsLoading { get; } + + /// + /// Gets the page size of the list source. + /// + int PageSize { get; } + + /// + /// Gets the total number of items in the list. + /// + /// A task returning the count. + Task GetCount(); + + /// + /// Gets the numbered page of items. + /// + /// The page number. + /// A task returning the page contents. + Task> GetPage(int pageNumber); + } +} \ No newline at end of file diff --git a/src/GitHub.App/Collections/SequentialListSource.cs b/src/GitHub.App/Collections/SequentialListSource.cs new file mode 100644 index 0000000000..97baec70b1 --- /dev/null +++ b/src/GitHub.App/Collections/SequentialListSource.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using GitHub.Logging; +using GitHub.Models; +using ReactiveUI; +using Serilog; + +namespace GitHub.Collections +{ + /// + /// An that loads GraphQL pages sequentially, and + /// transforms items into a view model after reading. + /// + /// The type of the model read from the remote data source. + /// The type of the transformed view model. + /// + /// GraphQL can only read pages of data sequentally, so in order to read item 450 (assuming a + /// page size of 100), the list source must read pages 1, 2, 3 and 4 in that order. Classes + /// deriving from this class only need to implement to load a + /// single page and this class will handle the rest. + /// + /// In addition, items will usually need to be transformed into a view model after reading. The + /// implementing class overrides to carry out that + /// transformation. + /// + public abstract class SequentialListSource : ReactiveObject, IVirtualizingListSource + { + static readonly ILogger log = LogManager.ForContext>(); + + readonly Dispatcher dispatcher; + readonly object loadLock = new object(); + Dictionary> pages = new Dictionary>(); + Task loading = Task.CompletedTask; + bool disposed; + bool isLoading; + int? count; + int nextPage; + int loadTo; + string after; + + /// + /// Initializes a new instance of the class. + /// + public SequentialListSource() + { + dispatcher = Application.Current?.Dispatcher; + } + + /// + public bool IsLoading + { + get { return isLoading; } + private set { this.RaiseAndSetIfChanged(ref isLoading, value); } + } + + /// + public virtual int PageSize => 100; + + event EventHandler PageLoaded; + + public void Dispose() => disposed = true; + + /// + public async Task GetCount() + { + dispatcher?.VerifyAccess(); + + if (!count.HasValue) + { + count = (await EnsureLoaded(0).ConfigureAwait(false)).TotalCount; + } + + return count.Value; + } + + /// + public async Task> GetPage(int pageNumber) + { + dispatcher?.VerifyAccess(); + + var page = await EnsureLoaded(pageNumber); + + if (page == null) + { + return null; + } + + var result = page.Items + .Select(CreateViewModel) + .ToList(); + pages.Remove(pageNumber); + return result; + } + + /// + /// When overridden in a derived class, transforms a model into a view model after loading. + /// + /// The model. + /// The view model. + protected abstract TViewModel CreateViewModel(TModel model); + + /// + /// When overridden in a derived class reads a page of results from GraphQL. + /// + /// The GraphQL after cursor. + /// A task which returns the page of results. + protected abstract Task> LoadPage(string after); + + /// + /// Called when the source begins loading pages. + /// + protected virtual void OnBeginLoading() + { + IsLoading = true; + } + + /// + /// Called when the source finishes loading pages. + /// + protected virtual void OnEndLoading() + { + IsLoading = false; + } + + async Task> EnsureLoaded(int pageNumber) + { + if (pageNumber < nextPage) + { + return pages[pageNumber]; + } + + var pageLoaded = WaitPageLoaded(pageNumber); + loadTo = Math.Max(loadTo, pageNumber); + + while (!disposed) + { + lock (loadLock) + { + if (loading.IsCompleted) + { + loading = Load(); + } + } + + var completed = await Task.WhenAny(loading, pageLoaded).ConfigureAwait(false); + + if (completed.IsFaulted) + { + throw completed.Exception; + } + + if (pageLoaded.IsCompleted) + { + // A previous waiting task may have already returned the page. If so, return null. + pages.TryGetValue(pageNumber, out var result); + return result; + } + } + + return null; + } + + Task WaitPageLoaded(int page) + { + var tcs = new TaskCompletionSource(); + EventHandler handler = null; + handler = (s, e) => + { + if (nextPage > page) + { + tcs.SetResult(true); + PageLoaded -= handler; + } + }; + PageLoaded += handler; + return tcs.Task; + } + + async Task Load() + { + OnBeginLoading(); + + try + { + while (nextPage <= loadTo && !disposed) + { + await LoadNextPage().ConfigureAwait(false); + PageLoaded?.Invoke(this, EventArgs.Empty); + } + } + finally + { + OnEndLoading(); + } + } + + async Task LoadNextPage() + { + log.Debug("Loading page {Number} of {ModelType}", nextPage, typeof(TModel)); + + var page = await LoadPage(after).ConfigureAwait(false); + pages[nextPage++] = page; + after = page.EndCursor; + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Collections/VirtualizingList.cs b/src/GitHub.App/Collections/VirtualizingList.cs new file mode 100644 index 0000000000..1e4f1edb4f --- /dev/null +++ b/src/GitHub.App/Collections/VirtualizingList.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using GitHub.Logging; +using Serilog; + +#pragma warning disable CA1010 // Collections should implement generic interface +#pragma warning disable CA1033 // Interface methods should be callable by child types +#pragma warning disable CA1710 // Identifiers should have correct suffix + +namespace GitHub.Collections +{ + /// + /// A virtualizing list that loads data only when needed. + /// + /// The list item type. + /// + /// This class exposes a read-only list where the data is fetched as needed. When the indexer + /// getter is called, if the requested item is not yet available it calls the associated + /// to load the page of data containing the requested + /// item. While the data is being read, is returned and when the + /// data is read is raised. + /// + /// Note that this implementation currently represents the minimum required for interaction + /// with WPF and as such many members are not yet implemented. In addition, if filtering is + /// required in the UI then the collection can be wrapped in a + /// . + /// + public class VirtualizingList : IReadOnlyList, IList, INotifyCollectionChanged, INotifyPropertyChanged + { + static readonly ILogger log = LogManager.ForContext>(); + readonly Dictionary> pages = new Dictionary>(); + readonly IVirtualizingListSource source; + readonly IList emptyPage; + readonly IReadOnlyList placeholderPage; + readonly Dispatcher dispatcher; + int? count; + + /// + /// Initializes a new instance of the class. + /// + /// The list source. + /// The placeholder item. + public VirtualizingList( + IVirtualizingListSource source, + T placeholder) + { + this.source = source; + Placeholder = placeholder; + emptyPage = Enumerable.Repeat(default(T), PageSize).ToList(); + placeholderPage = Enumerable.Repeat(placeholder, PageSize).ToList(); + dispatcher = Application.Current?.Dispatcher; + } + + /// + /// Gets an item by index. + /// + /// The index of the item. + /// The item, or if the item is not yet loaded. + public T this[int index] + { + get + { + var pageNumber = index / PageSize; + var pageIndex = index % PageSize; + IReadOnlyList page; + + if (pages.TryGetValue(pageNumber, out page)) + { + return page[pageIndex]; + } + else + { + LoadPage(pageNumber); + + if (pages.TryGetValue(pageNumber, out page)) + { + return page[pageIndex]; + } + else + { + return placeholderPage[0]; + } + } + } + + set { throw new NotImplementedException(); } + } + + /// + /// Gets the total count of the collection, including not-yet-loaded items. + /// + /// + /// If the count has not yet been loaded, this will return 0 and then raise a + /// event when the count is loaded. + /// + public int Count + { + get + { + if (!count.HasValue) + { + count = 0; + LoadCount(); + } + + return count.Value; + } + } + + /// + /// Gets the placeholder item that will be displayed while an item is loading. + /// + public T Placeholder { get; } + + /// + /// Gets the loaded pages of data. + /// + public IReadOnlyDictionary> Pages => pages; + + /// + /// Gets the page size of the associated . + /// + public int PageSize => source.PageSize; + + object IList.this[int index] + { + get { return this[index]; } + set { this[index] = (T)value; } + } + + bool IList.IsReadOnly => true; + bool IList.IsFixedSize => false; + int ICollection.Count => Count; + object ICollection.SyncRoot => null; + bool ICollection.IsSynchronized => false; + + public event NotifyCollectionChangedEventHandler CollectionChanged; + public event PropertyChangedEventHandler PropertyChanged; + public event EventHandler InitializationError; + + public IEnumerator GetEnumerator() + { + var i = 0; + while (i < Count) yield return this[i++]; + } + + int IList.Add(object value) => throw new NotImplementedException(); + void IList.Clear() => throw new NotImplementedException(); + bool IList.Contains(object value) => throw new NotImplementedException(); + int IList.IndexOf(object value) => throw new NotImplementedException(); + void IList.Insert(int index, object value) => throw new NotImplementedException(); + void IList.Remove(object value) => throw new NotImplementedException(); + void IList.RemoveAt(int index) => throw new NotImplementedException(); + void ICollection.CopyTo(Array array, int index) => throw new NotImplementedException(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + void LoadCount() + { + dispatcher?.VerifyAccess(); + + try + { + var countTask = source.GetCount(); + + if (countTask.IsCompleted) + { + // Don't send a Reset if the count is available immediately, as this causes + // a NullReferenceException in ListCollectionView. + count = countTask.Result; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); + } + else + { + countTask.ContinueWith(x => + { + if (x.IsFaulted) + { + RaiseInitializationError(x.Exception); + } + else if (!x.IsCanceled) + { + count = x.Result; + SendReset(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); + } + }, TaskScheduler.FromCurrentSynchronizationContext()); + } + } + catch (Exception ex) + { + RaiseInitializationError(ex); + log.Error(ex, "Error loading virtualizing list count"); + } + } + + async void LoadPage(int number) + { + dispatcher?.VerifyAccess(); + + try + { + pages.Add(number, placeholderPage); + var page = await source.GetPage(number); + + if (page != null) + { + pages[number] = page; + SendReset(); + } + } + catch (Exception ex) + { + log.Error(ex, "Error loading virtualizing list page {Number}", number); + pages.Remove(number); + } + } + + void RaiseInitializationError(Exception e) + { + if (InitializationError != null) + { + if (e is AggregateException ae) + { + e = ae = ae.Flatten(); + + if (ae.InnerExceptions.Count == 1) + { + e = ae.InnerException; + } + } + + InitializationError(this, new ErrorEventArgs(e)); + } + } + + void SendReset() + { + // ListCollectionView (which is used internally by the WPF list controls) doesn't + // support multi-item Replace notifications, so sending a Reset is actually the + // best thing we can do to notify of items being loaded. + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Collections/VirtualizingListCollectionView.cs b/src/GitHub.App/Collections/VirtualizingListCollectionView.cs new file mode 100644 index 0000000000..1cd16b8f13 --- /dev/null +++ b/src/GitHub.App/Collections/VirtualizingListCollectionView.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Windows.Data; + +#pragma warning disable CA1010 // Collections should implement generic interface +#pragma warning disable CA1033 // Interface methods should be callable by child types +#pragma warning disable CA1710 // Identifiers should have correct suffix + +namespace GitHub.Collections +{ + /// + /// A that adds filtering to a . + /// + /// The item type. + public class VirtualizingListCollectionView : CollectionView, IList + { + List filtered; + + /// + /// Initializes a new instance of the class. + /// + /// The inner virtualizing list. + public VirtualizingListCollectionView(VirtualizingList inner) + : base(inner) + { + } + + /// + /// Gets the count of the filtered items. + /// + public override int Count => filtered?.Count ?? Inner.Count; + + /// + public override bool IsEmpty => Count == 0; + + bool IList.IsReadOnly => true; + bool IList.IsFixedSize => false; + object ICollection.SyncRoot => null; + bool ICollection.IsSynchronized => false; + + /// + /// Gets the inner virtualizing list. + /// + protected VirtualizingList Inner => (VirtualizingList)SourceCollection; + + object IList.this[int index] + { + get { return GetItemAt(index); } + set { throw new NotImplementedException(); } + } + + /// + public override object GetItemAt(int index) + { + if (filtered == null) + { + return Inner[index]; + } + else + { + return Inner[filtered[index]]; + } + } + + int IList.Add(object value) => throw new NotSupportedException(); + bool IList.Contains(object value) => throw new NotImplementedException(); + void IList.Clear() => throw new NotSupportedException(); + int IList.IndexOf(object value) => throw new NotImplementedException(); + void IList.Insert(int index, object value) => throw new NotSupportedException(); + void IList.Remove(object value) => throw new NotSupportedException(); + void IList.RemoveAt(int index) => throw new NotSupportedException(); + void ICollection.CopyTo(Array array, int index) => throw new NotImplementedException(); + + protected override void RefreshOverride() + { + if (Filter != null) + { + var result = new List(); + var count = Inner.Count; + var pageCount = (int)Math.Ceiling((double)count / Inner.PageSize); + + for (var i = 0; i < pageCount; ++i) + { + IReadOnlyList page; + + if (Inner.Pages.TryGetValue(i, out page)) + { + var j = 0; + + foreach (var item in page) + { + if (Equals(item, Inner.Placeholder) || Filter(item)) + { + result.Add((i * Inner.PageSize) + j); + } + + ++j; + } + } + else + { + for (var j = 0; j < Inner.PageSize; ++j) + { + result.Add((i * Inner.PageSize) + j); + } + } + } + + filtered = result; + } + else + { + filtered = null; + } + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Commands/UsageTrackingCommand.cs b/src/GitHub.App/Commands/UsageTrackingCommand.cs new file mode 100644 index 0000000000..8115f83c23 --- /dev/null +++ b/src/GitHub.App/Commands/UsageTrackingCommand.cs @@ -0,0 +1,51 @@ +using System; +using System.Windows.Input; +using System.Linq.Expressions; +using GitHub.Models; +using GitHub.Services; +using GitHub.Extensions; + +namespace GitHub.Commands +{ + /// + /// A proxy that increments a usage counter after executing the command. + /// + public class UsageTrackingCommand : ICommand + { + readonly ICommand command; + readonly Lazy usageTracker; + readonly Expression> counter; + + /// + /// The usage tracker and counter to increment after the target command is executed. + /// + /// The usage tracker. + /// The counter to increment. + /// The target command. + public UsageTrackingCommand( + Lazy usageTracker, Expression> counter, + ICommand command) + { + this.command = command; + this.usageTracker = usageTracker; + this.counter = counter; + } + + public event EventHandler CanExecuteChanged + { + add { command.CanExecuteChanged += value; } + remove { command.CanExecuteChanged -= value; } + } + + public bool CanExecute(object parameter) + { + return command.CanExecute(parameter); + } + + public void Execute(object parameter) + { + command.Execute(parameter); + usageTracker.Value.IncrementCounter(counter).Forget(); + } + } +} diff --git a/src/GitHub.App/Controllers/NavigationController.cs b/src/GitHub.App/Controllers/NavigationController.cs deleted file mode 100644 index 111586628f..0000000000 --- a/src/GitHub.App/Controllers/NavigationController.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Disposables; -using GitHub.Models; -using GitHub.Primitives; -using GitHub.Services; -using GitHub.UI; -using GitHub.ViewModels; -using NullGuard; -using System.Diagnostics; - -namespace GitHub.Controllers -{ - [NullGuard(ValidationFlags.None)] - public class NavigationController : NotificationAwareObject, IDisposable, IHasBusy - { - readonly List history = new List(); - readonly Dictionary reusableControllers = new Dictionary(); - readonly IUIProvider uiProvider; - - int current = -1; - - public bool HasBack => current > 0; - public bool HasForward => current < history.Count - 1; - public IUIController Current => current >= 0 ? history[current] : null; - - readonly CompositeDisposable disposablesForCurrentView = new CompositeDisposable(); - - int Pointer - { - get - { - return current; - } - set - { - if (current == value) - return; - - bool raiseBack = false, raiseForward = false; - if ((value == 0 && HasBack) || (value > 0 && !HasBack)) - raiseBack = true; - if ((value == history.Count - 1 && !HasForward) || (value < history.Count - 1 && HasForward)) - raiseForward = true; - current = value; - this.RaisePropertyChanged(nameof(Current)); - if (raiseBack) this.RaisePropertyChanged(nameof(HasBack)); - if (raiseForward) this.RaisePropertyChanged(nameof(HasForward)); - } - } - - bool isBusy; - public bool IsBusy - { - get { return isBusy; } - set { isBusy = value; this.RaisePropertyChanged(); } - } - - public NavigationController(IUIProvider uiProvider) - { - this.uiProvider = uiProvider; - } - - public void LoadView(IConnection connection, ViewWithData data, Action onViewLoad) - { - switch (data.MainFlow) - { - case UIControllerFlow.PullRequestCreation: - if (data.Data == null && Current?.SelectedFlow == UIControllerFlow.PullRequestCreation) - { - Reload(); - } - else - { - CreateView(connection, data, onViewLoad); - } - break; - - case UIControllerFlow.PullRequestDetail: - if (data.Data == null && Current?.SelectedFlow == UIControllerFlow.PullRequestDetail) - { - Reload(); - } - else - { - CreateView(connection, data, onViewLoad); - } - break; - - case UIControllerFlow.PullRequestList: - case UIControllerFlow.Home: - default: - if (data.MainFlow == Current?.SelectedFlow) - { - Reload(); - } - else - { - CreateOrReuseView(connection, data, onViewLoad); - } - break; - } - } - - /// - /// Existing views are not reused - /// - /// - /// - /// - void CreateView(IConnection connection, ViewWithData data, Action onViewLoad) - { - IsBusy = true; - - var controller = CreateController(connection, data, onViewLoad); - Push(controller); - } - - void CreateOrReuseView(IConnection connection, ViewWithData data, Action onViewLoad) - { - IUIController controller; - var exists = reusableControllers.TryGetValue(data.MainFlow, out controller); - - if (!exists) - { - IsBusy = true; - - Action handler = view => - { - disposablesForCurrentView?.Clear(); - - var action = view as ICanLoad; - if (action != null) - { - disposablesForCurrentView.Add(action?.Load.Subscribe(d => - { - LoadView(connection, d, onViewLoad); - })); - } - onViewLoad?.Invoke(view); - }; - - controller = CreateController(connection, data, handler); - reusableControllers.Add(data.MainFlow, controller); - } - - Push(controller); - - if (exists) - { - Reload(); - } - } - - public void Reload() - { - if (IsBusy) - return; - IsBusy = true; - Current?.Reload(); - } - - public void Back() - { - if (!HasBack) - return; - Pointer--; - Reload(); - } - - public void Forward() - { - if (!HasForward) - return; - Pointer++; - Reload(); - } - - IUIController CreateController(IConnection connection, ViewWithData data, Action onViewLoad) - { - var controller = uiProvider.Configure(data.MainFlow, connection, data); - controller.TransitionSignal.Subscribe( - loadData => - { - onViewLoad?.Invoke(loadData.View); - IsBusy = false; - }, - () => { - Pop(controller); - Reload(); - }); - controller.Start(); - return controller; - } - - void Push(IUIController controller) - { - while (history.Count > Pointer + 1) - { - history.RemoveAt(history.Count - 1); - } - - history.Add(controller); - Pointer++; - } - - void Pop(IUIController controller = null) - { - var c = current; - controller = controller ?? history[history.Count - 1]; - var count = history.Count; - for (int i = 0; i < count; i++) - { - if (history[i] == controller) - { - history.RemoveAt(i); - if (i <= c) - c--; - i--; - count--; - } - } - reusableControllers.Remove(controller.SelectedFlow); - controller.Stop(); - Pointer = c; - } - - bool disposed = false; - protected void Dispose(bool disposing) - { - if (disposing) - { - if (!disposed) - { - disposed = true; - disposablesForCurrentView.Dispose(); - reusableControllers.Values.ForEach(c => uiProvider.StopUI(c)); - reusableControllers.Clear(); - history.Clear(); - } - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.App/Controllers/UIController.cs b/src/GitHub.App/Controllers/UIController.cs deleted file mode 100644 index 00351ac46b..0000000000 --- a/src/GitHub.App/Controllers/UIController.cs +++ /dev/null @@ -1,708 +0,0 @@ -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using NullGuard; -using ReactiveUI; -using Stateless; -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Diagnostics; -using System.Linq; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Windows; - -namespace GitHub.Controllers -{ - /* - This class creates views and associated viewmodels for each UI we have, - and controls the UI logic graph. It uses various state machines to define - the UI logic graph. - - **State machines** - - The UI logic graph is controlled by various state machines defined in - `ConfigureLogicStates` and `ConfigureUIHandlingStates` - - **ConfigureLogicStates** - - `ConfigureLogicStates` defines the one state machine per UI group: - - Authentication - - Repository Clone - - Repository Creation - - Repository Publish, - - Pull Requests (List, Detail, Creation) - - All state machines have a common state of `None` (nothing happened yet), - `End` (we're done, cleanup) and `Finish` (final state once cleanup is done), and - UI-specific states that relate to what views the UI needs to show (Login, TwoFactor, - PullRequestList, etc). States are defined in the enum `UIViewType` - - All state machines support a variety of triggers for going from one state to another. - These triggers are defined in the enum `Trigger`. `Cancel` and `Finish` are - supported by all states (any state machine regardless of its current state supports - exiting via `Cancel` (ends with success flag set to false) and `Finish` (ends with success flag set to true). - Since most UI flows we support are linear (login followed by 2fa followed by clone - followed by ending the flow), most states support the `Next` trigger to continue, - and when at the end of a ui flow, `Next` ends it with success. - - The Pull Requests UI flow is non-linear (there are more than one transition from the - list view - it can go to the detail view, or the pr creation view, or other views), - it does not support the `Next` trigger and instead has its own triggers. - - **ConfigureUIHandlingStates** - - `ConfigureUIHandlingStates` defines a state machine `uiStateMachine` connected - to the `transition` observable, and executes whatever logic is needed to load the - requested state, usually by creating the view and viewmodel that the UI state requires. - Whenever a state transition happens in `uiStateMachine`, if the state requires - loading a view, this view is sent back to the rendering code via the `transition` - observable. When the state machine reaches the `End` state, the `completion` observable - is completed with a flag indicating whether the ui flow ended successfully or was - cancelled (via the `Next`/`Finish` triggers or the `Cancel` trigger) . - - The transitions between states are dynamically evaluated at runtime based on the logic - defined in `ConfigureLogicStates`. When `uiStateMachine` receives a trigger request, - it passes that trigger to the state machine corresponding to the ui flow that's currently - running (Authentication, Clone, etc), and that state machine determines which state - to go to next. - - In theory, `uiStateMachine` has no knowledge of the logic graph, and - the only thing it's responsible for is loading UI whenever a state is entered, and cleaning - up objects when things are done - making it essentially a big switch with nice entry and exit - conditions. In practice, we need to configure the valid triggers from one state to the other - just so we can call the corresponding state machine that handles the logic (defined in `ConfigureLogicStates`). - There is a bit of code duplication because of this, and there's some room for improvement - here, but because we have a small number of triggers, it's not a huge deal. - */ - - using App.Factories; - using StateMachineType = StateMachine; - - public class UIController : IUIController - { - internal enum Trigger - { - None, - Cancel, - Next, - Reload, - Finish - } - - readonly IUIFactory factory; - readonly IGitHubServiceProvider gitHubServiceProvider; - readonly IRepositoryHosts hosts; - readonly IConnectionManager connectionManager; - - readonly CompositeDisposable disposables = new CompositeDisposable(); - - // holds state machines for each of the individual ui flows - // does not load UI, merely tracks valid transitions - readonly Dictionary machines = - new Dictionary(); - - readonly Dictionary> uiObjects = - new Dictionary>(); - - // loads UI for each state corresponding to a view type - // queries the individual ui flow (stored in machines) for the next state - // for a given transition trigger - readonly StateMachineType uiStateMachine; - - readonly Dictionary> triggers = - new Dictionary>(); - - Subject transition; - public IObservable TransitionSignal => transition; - - // the main ui flow that this ui controller was set up to control - UIControllerFlow selectedFlow; - - // The currently active ui flow. This might be different from the main ui flow - // setup at the start if, for instance, the ui flow is tied - // to a connection that requires being logged in. In this case, when loading - // the ui, the active flow is switched to authentication until it's done, and then - // back to the main ui flow. - UIControllerFlow activeFlow; - NotifyCollectionChangedEventHandler connectionAdded; - - Subject completion; - IConnection connection; - ViewWithData requestedTarget; - bool stopping; - - public UIController(IGitHubServiceProvider serviceProvider) - : this(serviceProvider, - serviceProvider.TryGetService(), - serviceProvider.TryGetService(), - serviceProvider.TryGetService()) - { - } - - public UIController(IGitHubServiceProvider gitHubServiceProvider, - IRepositoryHosts hosts, IUIFactory factory, - IConnectionManager connectionManager) - { - this.factory = factory; - this.gitHubServiceProvider = gitHubServiceProvider; - this.hosts = hosts; - this.connectionManager = connectionManager; - -#if DEBUG - if (Application.Current != null && !Splat.ModeDetector.InUnitTestRunner()) - { - var waitDispatcher = RxApp.MainThreadScheduler as WaitForDispatcherScheduler; - if (waitDispatcher != null) - { - Debug.Assert(DispatcherScheduler.Current.Dispatcher == Application.Current.Dispatcher, - "DispatcherScheduler is set correctly"); - } - else - { - Debug.Assert(((DispatcherScheduler)RxApp.MainThreadScheduler).Dispatcher == Application.Current.Dispatcher, - "The MainThreadScheduler is using the wrong dispatcher"); - } - } -#endif - ConfigureLogicStates(); - - uiStateMachine = new StateMachineType(UIViewType.None); - - ConfigureUIHandlingStates(); - - } - - public IObservable Configure(UIControllerFlow choice, - [AllowNull] IConnection conn = null, - [AllowNull] ViewWithData parameters = null) - { - connection = conn; - selectedFlow = choice; - requestedTarget = parameters; - - transition = new Subject(); - transition.Subscribe(_ => {}, _ => Fire(Trigger.Next)); - - return transition; - } - - /// - /// Allows listening to the completion state of the ui flow - whether - /// it was completed because it was cancelled or whether it succeeded. - /// - /// true for success, false for cancel - public IObservable ListenToCompletionState() - { - if (completion == null) - completion = new Subject(); - return completion; - } - - public void Start() - { - if (connection != null) - { - if (selectedFlow != UIControllerFlow.Authentication) - gitHubServiceProvider.AddService(this, connection); - else // sanity check: it makes zero sense to pass a connection in when calling the auth flow - Debug.Assert(false, "Calling the auth flow with a connection makes no sense!"); - - connection.Login() - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => { }, () => - { - Debug.WriteLine("Start ({0})", GetHashCode()); - Fire(Trigger.Next); - }); - } - else - { - connectionManager - .GetLoggedInConnections(hosts) - .FirstOrDefaultAsync() - .Select(c => - { - bool loggedin = c != null; - if (selectedFlow != UIControllerFlow.Authentication) - { - if (loggedin) // register the first available connection so the viewmodel can use it - { - connection = c; - gitHubServiceProvider.AddService(this, c); - } - else - { - // a connection will be added to the list when auth is done, register it so the next - // viewmodel can use it - connectionAdded = (s, e) => - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - connection = e.NewItems[0] as IConnection; - if (connection != null) - gitHubServiceProvider.AddService(typeof(IConnection), this, connection); - } - }; - connectionManager.Connections.CollectionChanged += connectionAdded; - } - } - return loggedin; - }) - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => { }, () => - { - Debug.WriteLine("Start ({0})", GetHashCode()); - Fire(Trigger.Next); - }); - } - } - - public void Stop() - { - if (stopping || transition == null) - return; - - Debug.WriteLine("Stopping {0} ({1})", activeFlow + (activeFlow != selectedFlow ? " and " + selectedFlow : ""), GetHashCode()); - stopping = true; - Fire(Trigger.Finish); - } - - public void Reload() - { - Fire(Trigger.Reload); - } - - /// - /// Configures the UI that gets loaded when entering a certain state and which state - /// to go to for each trigger. Which state to go to depends on which ui flow state machine - /// is currently active - when a trigger happens, the PermitDynamic conditions will - /// lookup the currently active state machine from the list of available ones in `machines`, - /// fire the requested trigger on it, and return the state that it went to, which causes - /// `uiStateMachine` to load a new UI (or exit) - /// There is a bit of redundant information regarding valid state transitions between this - /// state machine and the individual state machines for each ui flow. This is unavoidable - /// because permited transition triggers have to be explicit, so care should be taken to - /// make sure permitted triggers per view here match the permitted triggers in the individual - /// state machines. - /// - void ConfigureUIHandlingStates() - { - triggers.Add(Trigger.Next, uiStateMachine.SetTriggerParameters(Trigger.Next)); - - uiStateMachine.Configure(UIViewType.None) - .OnEntry(tr => stopping = false) - .PermitDynamic(Trigger.Next, () => - { - activeFlow = SelectActiveFlow(); - return Go(Trigger.Next); - }) - .PermitDynamic(Trigger.Finish, () => Go(Trigger.Finish)); - - ConfigureEntriesExitsForView(UIViewType.Clone); - ConfigureEntriesExitsForView(UIViewType.Create); - ConfigureEntriesExitsForView(UIViewType.Publish); - ConfigureEntriesExitsForView(UIViewType.PRList); - ConfigureEntriesExitsForView(UIViewType.PRDetail); - ConfigureEntriesExitsForView(UIViewType.PRCreation); - ConfigureEntriesExitsForView(UIViewType.Login); - ConfigureEntriesExitsForView(UIViewType.TwoFactor); - ConfigureEntriesExitsForView(UIViewType.Gist); - ConfigureEntriesExitsForView(UIViewType.LogoutRequired); - ConfigureEntriesExitsForView(UIViewType.StartPageClone); - - uiStateMachine.Configure(UIViewType.End) - .OnEntryFrom(Trigger.Cancel, () => End(false)) - .OnEntryFrom(Trigger.Next, () => End(true)) - .OnEntryFrom(Trigger.Finish, () => End(true)) - // clear all the views and viewmodels created by a subflow - .OnExit(() => - { - // it's important to have the stopping flag set before we do this - if (activeFlow == selectedFlow) - { - completion?.OnNext(Success.Value); - completion?.OnCompleted(); - } - - DisposeFlow(activeFlow); - - if (activeFlow == selectedFlow) - { - gitHubServiceProvider.RemoveService(typeof(IConnection), this); - transition.OnCompleted(); - Reset(); - } - else - activeFlow = stopping || LoggedIn ? selectedFlow : UIControllerFlow.Authentication; - }) - .PermitDynamic(Trigger.Next, () => - { - // sets the state to None for the current flow - var state = Go(Trigger.Next); - - if (activeFlow != selectedFlow) - { - if (stopping) - { - // triggering the End state again so that it can clear up the main flow and really end - state = Go(Trigger.Finish, selectedFlow); - } - else - { - // sets the state to the first UI of the main flow or the auth flow, depending on whether you're logged in - state = Go(Trigger.Next, LoggedIn ? selectedFlow : UIControllerFlow.Authentication); - } - } - return state; - }) - .Permit(Trigger.Finish, UIViewType.None); - } - - StateMachineType.StateConfiguration ConfigureEntriesExitsForView(UIViewType viewType) - { - return uiStateMachine.Configure(viewType) - .OnEntryFrom(triggers[Trigger.Next], (arg, tr) => RunView(tr.Destination, arg)) - .OnEntry(tr => { - // Trigger.Next is always called in OnEntryFrom, don't want to run it twice - if (tr.Trigger != Trigger.Next) RunView(tr.Destination); - }) - .PermitReentry(Trigger.Reload) - .PermitDynamic(Trigger.Next, () => Go(Trigger.Next)) - .PermitDynamic(Trigger.Cancel, () => Go(Trigger.Cancel)) - .PermitDynamic(Trigger.Finish, () => Go(Trigger.Finish)); - } - - /// - /// Configure all the logical state transitions for each of the - /// ui flows we support that have more than one view - /// - void ConfigureLogicStates() - { - StateMachineType logic; - - // no selected flow - logic = new StateMachine(UIViewType.None); - logic.Configure(UIViewType.None) - .Ignore(Trigger.Next) - .Ignore(Trigger.Finish); - machines.Add(UIControllerFlow.None, logic); - - // authentication flow - logic = new StateMachine(UIViewType.None); - logic.Configure(UIViewType.None) - .Permit(Trigger.Next, UIViewType.Login) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.Login) - .Permit(Trigger.Next, UIViewType.TwoFactor) - .Permit(Trigger.Cancel, UIViewType.End) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.TwoFactor) - .Permit(Trigger.Next, UIViewType.End) - .Permit(Trigger.Cancel, UIViewType.Login) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.End) - .Permit(Trigger.Next, UIViewType.None); - - machines.Add(UIControllerFlow.Authentication, logic); - - ConfigureSingleViewLogic(UIControllerFlow.Clone, UIViewType.Clone); - ConfigureSingleViewLogic(UIControllerFlow.Create, UIViewType.Create); - ConfigureSingleViewLogic(UIControllerFlow.Gist, UIViewType.Gist); - ConfigureSingleViewLogic(UIControllerFlow.Home, UIViewType.PRList); - ConfigureSingleViewLogic(UIControllerFlow.LogoutRequired, UIViewType.LogoutRequired); - ConfigureSingleViewLogic(UIControllerFlow.Publish, UIViewType.Publish); - ConfigureSingleViewLogic(UIControllerFlow.PullRequestList, UIViewType.PRList); - ConfigureSingleViewLogic(UIControllerFlow.PullRequestDetail, UIViewType.PRDetail); - ConfigureSingleViewLogic(UIControllerFlow.PullRequestCreation, UIViewType.PRCreation); - ConfigureSingleViewLogic(UIControllerFlow.ReClone, UIViewType.StartPageClone); - } - - void ConfigureSingleViewLogic(UIControllerFlow flow, UIViewType type) - { - var logic = new StateMachineType(UIViewType.None); - logic.Configure(UIViewType.None) - .Permit(Trigger.Next, type) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(type) - .Permit(Trigger.Next, UIViewType.End) - .Permit(Trigger.Cancel, UIViewType.End) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.End) - .Permit(Trigger.Next, UIViewType.None); - machines.Add(flow, logic); - } - - UIControllerFlow SelectActiveFlow() - { - var host = connection != null ? hosts.LookupHost(connection.HostAddress) : null; - var loggedIn = host?.IsLoggedIn ?? false; - if (!loggedIn || selectedFlow != UIControllerFlow.Gist) - return loggedIn ? selectedFlow : UIControllerFlow.Authentication; - - var supportsGist = host?.SupportsGist ?? false; - return supportsGist ? selectedFlow : UIControllerFlow.LogoutRequired; - } - - /// - /// End state for a flow has been called. Clear handlers related to that flow - /// and call completion handlers if this means the controller is being stopped. - /// Handlers registered via `ListenToCompletionState` might need to access the - /// view/viewmodel objects, so those can only be disposed when we're leaving - /// the End state, not here. - /// - /// - void End(bool success) - { - if (!Success.HasValue) - Success = success; - - ShutdownFlow(activeFlow); - - // if the auth was cancelled, we need to stop everything, otherwise we'll go into a loop - if (activeFlow == selectedFlow || !Success.Value) - stopping = true; - - Fire(Trigger.Next); - } - - void ShutdownFlow(UIControllerFlow flow) - { - var list = GetObjectsForFlow(flow); - foreach (var i in list.Values) - i.ClearHandlers(); - } - - void DisposeFlow(UIControllerFlow flow) - { - var list = GetObjectsForFlow(flow); - foreach (var i in list.Values) - i.Dispose(); - list.Clear(); - } - - void DisposeView(UIControllerFlow flow, UIViewType type) - { - var list = GetObjectsForFlow(flow); - IUIPair uipair = null; - if (list.TryGetValue(type, out uipair)) - { - list.Remove(type); - uipair.Dispose(); - } - } - - void RunView(UIViewType viewType, ViewWithData arg = null) - { - if (requestedTarget?.ViewType == viewType || (requestedTarget?.ViewType == UIViewType.None && requestedTarget?.MainFlow == CurrentFlow)) - { - arg = requestedTarget; - } - - if (arg == null) - arg = new ViewWithData { ActiveFlow = activeFlow, MainFlow = selectedFlow, ViewType = viewType }; - bool firstTime = CreateViewAndViewModel(viewType, arg); - var view = GetObjectsForFlow(activeFlow)[viewType].View; - transition.OnNext(new LoadData - { - Flow = activeFlow, - View = view, - Data = arg - }); - - // controller might have been stopped in the OnNext above - if (IsStopped) - return; - - // if it's not the first time we've shown this view, no need - // to set it up - if (!firstTime) - return; - - SetupView(viewType, view); - } - - void SetupView(UIViewType viewType, IView view) - { - var list = GetObjectsForFlow(activeFlow); - var pair = list[viewType]; - - // 2FA is set up when login is set up, so nothing to do - if (viewType == UIViewType.TwoFactor) - return; - - // we're setting up the login dialog, we need to setup the 2fa as - // well to continue the flow if it's needed, since the - // authenticationresult callback won't happen until - // everything is done - if (viewType == UIViewType.Login) - { - var pair2fa = list[UIViewType.TwoFactor]; - pair2fa.AddHandler(pair2fa.ViewModel.WhenAny(x => x.IsShowing, x => x.Value) - .Where(x => x) - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(Trigger.Next))); - - pair2fa.AddHandler(pair2fa.View.Cancel - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(uiStateMachine.CanFire(Trigger.Cancel) ? Trigger.Cancel : Trigger.Finish))); - - pair.AddHandler(view.Done - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(Trigger.Finish))); - } - else - { - pair.AddHandler(view.Done - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(uiStateMachine.CanFire(Trigger.Next) ? Trigger.Next : Trigger.Finish))); - } - - pair.AddHandler(view.Cancel - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(uiStateMachine.CanFire(Trigger.Cancel) ? Trigger.Cancel : Trigger.Finish))); - } - - /// - /// Creates View/ViewModel instances for the specified if they - /// haven't been created yet in the current flow - /// - /// - /// true if the View/ViewModel didn't exist and had to be created - bool CreateViewAndViewModel(UIViewType viewType, ViewWithData data = null) - { - var list = GetObjectsForFlow(activeFlow); - if (viewType == UIViewType.Login) - { - if (!list.ContainsKey(viewType)) - { - var d = factory.CreateViewAndViewModel(UIViewType.TwoFactor); - list.Add(UIViewType.TwoFactor, d); - } - } - - // 2fa view/viewmodel is created when login is created 'cause login needs the 2fa viewmodel - // so the only thing we want to do is connect the viewmodel to the view when it's showing - else if (viewType == UIViewType.TwoFactor) - { - var d = list[viewType]; - if (d.View.ViewModel == null) - { - d.ViewModel.Initialize(data); - d.View.DataContext = d.ViewModel; - } - } - - IUIPair pair = null; - var firstTime = !list.TryGetValue(viewType, out pair); - - if (firstTime) - pair = factory.CreateViewAndViewModel(viewType); - - pair.ViewModel.Initialize(data); - - if (firstTime) - { - pair.View.DataContext = pair.ViewModel; - list.Add(viewType, pair); - } - - return firstTime; - } - - - /// - /// Returns the view/viewmodel pair for a given flow - /// - Dictionary GetObjectsForFlow(UIControllerFlow flow) - { - Dictionary list; - if (!uiObjects.TryGetValue(flow, out list)) - { - list = new Dictionary(); - uiObjects.Add(flow, list); - } - return list; - } - - void Fire(Trigger next, ViewWithData arg = null) - { - Debug.WriteLine("Firing {0} from {1} ({2})", next, uiStateMachine.State, GetHashCode()); - if (triggers.ContainsKey(next)) - uiStateMachine.Fire(triggers[next], arg); - else - uiStateMachine.Fire(next); - } - - UIViewType Go(Trigger trigger) - { - return Go(trigger, activeFlow); - } - - UIViewType Go(Trigger trigger, UIControllerFlow flow) - { - var m = machines[flow]; - Debug.WriteLine("Firing {0} from {1} for flow {2} ({3})", trigger, m.State, flow, GetHashCode()); - m.Fire(trigger); - return m.State; - } - - void Reset() - { - if (connectionAdded != null) - connectionManager.Connections.CollectionChanged -= connectionAdded; - connectionAdded = null; - - var tr = transition; - var cmp = completion; - transition = null; - completion = null; - disposables.Clear(); - tr?.Dispose(); - cmp?.Dispose(); - stopping = false; - connection = null; - } - - bool disposed; // To detect redundant calls - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - disposed = true; - - Debug.WriteLine("Disposing ({0})", GetHashCode()); - - if (connectionAdded != null) - connectionManager.Connections.CollectionChanged -= connectionAdded; - connectionAdded = null; - - var tr = transition; - var cmp = completion; - transition = null; - completion = null; - disposables.Dispose(); - tr?.Dispose(); - cmp?.Dispose(); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public bool IsStopped => uiStateMachine.IsInState(UIViewType.None) || stopping; - public UIControllerFlow CurrentFlow => activeFlow; - public UIControllerFlow SelectedFlow => selectedFlow; - bool LoggedIn => connection != null && hosts.LookupHost(connection.HostAddress).IsLoggedIn; - bool? Success { get; set; } - } -} diff --git a/src/GitHub.App/Extensions/AkavacheExtensions.cs b/src/GitHub.App/Extensions/AkavacheExtensions.cs index a95e4c5c8d..f1adcb1ef6 100644 --- a/src/GitHub.App/Extensions/AkavacheExtensions.cs +++ b/src/GitHub.App/Extensions/AkavacheExtensions.cs @@ -200,7 +200,11 @@ static IObservable GetAndFetchLatestFromIndex(this IBlobCache This, bool shouldInvalidateOnError = false) where T : CacheItem { - var idx = Observable.Defer(() => This.GetOrCreateObject(key, () => CacheIndex.Create(key))).Replay().RefCount(); + var idx = Observable.Defer(() => This + .GetOrCreateObject(key, () => CacheIndex.Create(key))) + .Select(x => x.IndexKey == null ? CacheIndex.Create(key) : x) + .Replay() + .RefCount(); var fetch = idx @@ -248,11 +252,12 @@ static IObservable GetAndFetchLatestFromIndex(this IBlobCache This, /// /// The cache to retrieve the object from. /// The key to look up the cache value with. - /// The item to add to the database + /// The fetch function. /// /// The maximum age of a cache object before the object is treated as /// expired and unusable. Cache objects older than this will be treated /// as a cache miss. + /// /// public static IObservable PutAndUpdateIndex(this IBlobCache blobCache, string key, @@ -264,6 +269,7 @@ public static IObservable PutAndUpdateIndex(this IBlobCache blobCache, { var absoluteExpiration = blobCache.Scheduler.Now + maxCacheDuration; return blobCache.GetOrCreateObject(key, () => CacheIndex.Create(key)) + .Select(x => x.IndexKey == null ? CacheIndex.Create(key) : x) .SelectMany(index => fetchFunc() .Catch(Observable.Throw) .SelectMany(x => x.Save(blobCache, key, absoluteExpiration)) diff --git a/src/GitHub.App/Factories/ApiClientFactory.cs b/src/GitHub.App/Factories/ApiClientFactory.cs index 78dbc2bc96..f2d55a2e51 100644 --- a/src/GitHub.App/Factories/ApiClientFactory.cs +++ b/src/GitHub.App/Factories/ApiClientFactory.cs @@ -1,13 +1,13 @@ -using System.ComponentModel.Composition; +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; using GitHub.Api; -using GitHub.Caches; +using GitHub.Infrastructure; using GitHub.Models; using GitHub.Primitives; -using GitHub.Services; using Octokit; using Octokit.Reactive; using ApiClient = GitHub.Api.ApiClient; -using GitHub.Infrastructure; namespace GitHub.Factories { @@ -18,22 +18,26 @@ public class ApiClientFactory : IApiClientFactory readonly ProductHeaderValue productHeader; [ImportingConstructor] - public ApiClientFactory(ILoginCache loginCache, IProgram program, ILoggingConfiguration config) + public ApiClientFactory(IKeychain keychain, IProgram program) { - LoginCache = loginCache; + Keychain = keychain; productHeader = program.ProductHeader; - config.Configure(); } - public IApiClient Create(HostAddress hostAddress) + public Task CreateGitHubClient(HostAddress hostAddress) { - var apiBaseUri = hostAddress.ApiUri; + var credentialStore = new KeychainCredentialStore(Keychain, hostAddress); + var result = new GitHubClient(productHeader, credentialStore, hostAddress.ApiUri); + return Task.FromResult(result); + } + public async Task Create(HostAddress hostAddress) + { return new ApiClient( hostAddress, - new ObservableGitHubClient(new GitHubClient(productHeader, new GitHubCredentialStore(hostAddress, LoginCache), apiBaseUri))); + new ObservableGitHubClient(await CreateGitHubClient(hostAddress))); } - protected ILoginCache LoginCache { get; private set; } + protected IKeychain Keychain { get; private set; } } } diff --git a/src/GitHub.App/Factories/HostCacheFactory.cs b/src/GitHub.App/Factories/HostCacheFactory.cs index dad8e20403..ad96e64a82 100644 --- a/src/GitHub.App/Factories/HostCacheFactory.cs +++ b/src/GitHub.App/Factories/HostCacheFactory.cs @@ -1,10 +1,12 @@ using System; using System.ComponentModel.Composition; using System.IO; +using System.Reactive.Linq; using Akavache; using GitHub.Primitives; using Rothko; using GitHub.Extensions; +using System.Threading.Tasks; namespace GitHub.Factories { @@ -12,6 +14,9 @@ namespace GitHub.Factories [PartCreationPolicy(CreationPolicy.Shared)] public class HostCacheFactory : IHostCacheFactory { + const int CacheVersion = 1; + const string CacheVersionKey = "cacheVersion"; + readonly Lazy blobCacheFactory; readonly Lazy operatingSystem; @@ -22,7 +27,7 @@ public HostCacheFactory(Lazy blobCacheFactory, Lazy Create(HostAddress hostAddress) { var environment = OperatingSystem.Environment; // For GitHub.com, the cache file name should be "api.github.com.cache.db" @@ -34,7 +39,17 @@ public IBlobCache Create(HostAddress hostAddress) // CreateDirectory is a noop if the directory already exists. OperatingSystem.Directory.CreateDirectory(Path.GetDirectoryName(userAccountPath)); - return BlobCacheFactory.CreateBlobCache(userAccountPath); + + var cache = BlobCacheFactory.CreateBlobCache(userAccountPath); + var version = await cache.GetOrCreateObject(CacheVersionKey, () => 0); + + if (version != CacheVersion) + { + await cache.InvalidateAll(); + await cache.InsertObject(CacheVersionKey, CacheVersion); + } + + return cache; } IOperatingSystem OperatingSystem { get { return operatingSystem.Value; } } diff --git a/src/GitHub.App/Factories/IHostCacheFactory.cs b/src/GitHub.App/Factories/IHostCacheFactory.cs index 7f5a59c686..2e29ad2b10 100644 --- a/src/GitHub.App/Factories/IHostCacheFactory.cs +++ b/src/GitHub.App/Factories/IHostCacheFactory.cs @@ -1,11 +1,12 @@ using Akavache; using GitHub.Primitives; using System; +using System.Threading.Tasks; namespace GitHub.Factories { public interface IHostCacheFactory : IDisposable { - IBlobCache Create(HostAddress hostAddress); + Task Create(HostAddress hostAddress); } } diff --git a/src/GitHub.App/Factories/ModelServiceFactory.cs b/src/GitHub.App/Factories/ModelServiceFactory.cs new file mode 100644 index 0000000000..5ff0c3b1ba --- /dev/null +++ b/src/GitHub.App/Factories/ModelServiceFactory.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Caches; +using GitHub.Models; +using GitHub.Services; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; + +namespace GitHub.Factories +{ + [Export(typeof(IModelServiceFactory))] + [PartCreationPolicy(CreationPolicy.Shared)] + public sealed class ModelServiceFactory : IModelServiceFactory, IDisposable + { + readonly IApiClientFactory apiClientFactory; + readonly IHostCacheFactory hostCacheFactory; + readonly IAvatarProvider avatarProvider; + readonly Dictionary cache = new Dictionary(); + readonly SemaphoreSlim cacheLock = new SemaphoreSlim(1); + + [ImportingConstructor] + public ModelServiceFactory( + IApiClientFactory apiClientFactory, + IHostCacheFactory hostCacheFactory, + IAvatarProvider avatarProvider, + [Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) + { + this.apiClientFactory = apiClientFactory; + this.hostCacheFactory = hostCacheFactory; + this.avatarProvider = avatarProvider; + JoinableTaskContext = joinableTaskContext ?? ThreadHelper.JoinableTaskContext; + } + + public async Task CreateAsync(IConnection connection) + { + ModelService result; + + await cacheLock.WaitAsync(); + + try + { + if (!cache.TryGetValue(connection, out result)) + { + result = new ModelService( + await apiClientFactory.Create(connection.HostAddress), + await hostCacheFactory.Create(connection.HostAddress), + avatarProvider); + result.InsertUser(AccountCacheItem.Create(connection.User)); + cache.Add(connection, result); + } + } + finally + { + cacheLock.Release(); + } + + return result; + } + + public IModelService CreateBlocking(IConnection connection) + { + return JoinableTaskContext.Factory.Run(() => CreateAsync(connection)); + } + + public void Dispose() => cacheLock.Dispose(); + + JoinableTaskContext JoinableTaskContext { get; } + } +} diff --git a/src/GitHub.App/Factories/RepositoryHostFactory.cs b/src/GitHub.App/Factories/RepositoryHostFactory.cs deleted file mode 100644 index f30a91a39a..0000000000 --- a/src/GitHub.App/Factories/RepositoryHostFactory.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using GitHub.Authentication; -using GitHub.Caches; -using GitHub.Models; -using GitHub.Primitives; -using GitHub.Services; -using System.Reactive.Disposables; - -namespace GitHub.Factories -{ - [Export(typeof(IRepositoryHostFactory))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class RepositoryHostFactory : IRepositoryHostFactory - { - readonly IApiClientFactory apiClientFactory; - readonly IHostCacheFactory hostCacheFactory; - readonly ILoginCache loginCache; - readonly IAvatarProvider avatarProvider; - readonly ITwoFactorChallengeHandler twoFactorChallengeHandler; - readonly CompositeDisposable hosts = new CompositeDisposable(); - readonly IUsageTracker usage; - - [ImportingConstructor] - public RepositoryHostFactory( - IApiClientFactory apiClientFactory, - IHostCacheFactory hostCacheFactory, - ILoginCache loginCache, - IAvatarProvider avatarProvider, - ITwoFactorChallengeHandler twoFactorChallengeHandler, - IUsageTracker usage) - { - this.apiClientFactory = apiClientFactory; - this.hostCacheFactory = hostCacheFactory; - this.loginCache = loginCache; - this.avatarProvider = avatarProvider; - this.twoFactorChallengeHandler = twoFactorChallengeHandler; - this.usage = usage; - } - - public IRepositoryHost Create(HostAddress hostAddress) - { - var apiClient = apiClientFactory.Create(hostAddress); - var hostCache = hostCacheFactory.Create(hostAddress); - var modelService = new ModelService(apiClient, hostCache, avatarProvider); - var host = new RepositoryHost(apiClient, modelService, loginCache, twoFactorChallengeHandler, usage); - hosts.Add(host); - return host; - } - - public void Remove(IRepositoryHost host) - { - hosts.Remove(host); - } - - bool disposed; - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - disposed = true; - hosts.Dispose(); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.App/Factories/SqlitePersistentBlobCacheFactory.cs b/src/GitHub.App/Factories/SqlitePersistentBlobCacheFactory.cs index a48dbb9c48..8d75c97948 100644 --- a/src/GitHub.App/Factories/SqlitePersistentBlobCacheFactory.cs +++ b/src/GitHub.App/Factories/SqlitePersistentBlobCacheFactory.cs @@ -1,12 +1,13 @@ using Akavache; using Akavache.Sqlite3; -using NLog; using System; using System.Collections.Generic; using System.ComponentModel.Composition; using System.Reactive.Linq; using System.Threading.Tasks; using GitHub.Extensions; +using GitHub.Logging; +using Serilog; namespace GitHub.Factories { @@ -14,7 +15,7 @@ namespace GitHub.Factories [PartCreationPolicy(CreationPolicy.Shared)] public class SqlitePersistentBlobCacheFactory : IBlobCacheFactory { - static readonly Logger log = LogManager.GetCurrentClassLogger(); + static readonly ILogger log = LogManager.ForContext(); Dictionary cache = new Dictionary(); public IBlobCache CreateBlobCache(string path) @@ -31,7 +32,7 @@ public IBlobCache CreateBlobCache(string path) } catch(Exception ex) { - log.Error("Error while creating SQLitePersistentBlobCache for {0}. {1}", path, ex); + log.Error(ex, "Error while creating SQLitePersistentBlobCache for {Path}", path); return new InMemoryBlobCache(); } } diff --git a/src/GitHub.App/Factories/UIFactory.cs b/src/GitHub.App/Factories/UIFactory.cs deleted file mode 100644 index e688217f50..0000000000 --- a/src/GitHub.App/Factories/UIFactory.cs +++ /dev/null @@ -1,111 +0,0 @@ -using GitHub.Exports; -using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; -using NullGuard; -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Disposables; -using System.Text; -using System.Threading.Tasks; - -namespace GitHub.App.Factories -{ - [Export(typeof(IUIFactory))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class UIFactory : IUIFactory - { - readonly IExportFactoryProvider factory; - - [ImportingConstructor] - public UIFactory(IExportFactoryProvider factory) - { - this.factory = factory; - } - - /// - /// Creates View/ViewModel instances for the specified if they - /// haven't been created yet in the current flow - /// - /// - /// true if the View/ViewModel didn't exist and had to be created - public IUIPair CreateViewAndViewModel(UIViewType viewType) - { - return new UIPair(viewType, factory.GetView(viewType), factory.GetViewModel(viewType)); - } - - protected virtual void Dispose(bool disposing) - {} - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - } - - /// - /// This class holds ExportLifetimeContexts (i.e., Lazy Disposable containers) for IView and IViewModel objects - /// A view type (login, clone, etc) is composed of a pair of view and viewmodel, which this class represents. - /// - public class UIPair : IUIPair - { - readonly ExportLifetimeContext view; - readonly ExportLifetimeContext viewModel; - readonly CompositeDisposable handlers = new CompositeDisposable(); - readonly UIViewType viewType; - - public UIViewType ViewType => viewType; - public IView View => view.Value; - public IViewModel ViewModel => viewModel?.Value; - - /// The UIViewType - /// The IView - /// The IViewModel. Might be null because the 2fa view shares the same viewmodel as the login dialog, so it's - /// set manually in the view outside of this - public UIPair(UIViewType type, ExportLifetimeContext v, [AllowNull]ExportLifetimeContext vm) - { - viewType = type; - view = v; - viewModel = vm; - handlers = new CompositeDisposable(); - } - - /// - /// Register disposable event handlers or observable subscriptions so they get cleared - /// when the View/Viewmodel get disposed/destroyed - /// - /// - public void AddHandler(IDisposable disposable) - { - handlers.Add(disposable); - } - - public void ClearHandlers() - { - handlers.Dispose(); - } - - bool disposed = false; - void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - if (!handlers.IsDisposed) - handlers.Dispose(); - view?.Dispose(); - viewModel?.Dispose(); - disposed = true; - } - } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.App/Factories/ViewViewModelFactory.cs b/src/GitHub.App/Factories/ViewViewModelFactory.cs new file mode 100644 index 0000000000..4d96df5064 --- /dev/null +++ b/src/GitHub.App/Factories/ViewViewModelFactory.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Windows; +using GitHub.Exports; +using GitHub.Services; +using GitHub.ViewModels; + +namespace GitHub.Factories +{ + /// + /// Factory for creating views and view models. + /// + [Export(typeof(IViewViewModelFactory))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class ViewViewModelFactory : IViewViewModelFactory + { + readonly IGitHubServiceProvider serviceProvider; + + [ImportingConstructor] + public ViewViewModelFactory( + IGitHubServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + [ImportMany] + IEnumerable> Views { get; set; } + + /// + public TViewModel CreateViewModel() where TViewModel : IViewModel + { + return serviceProvider.ExportProvider.GetExport().Value; + } + + /// + public FrameworkElement CreateView() where TViewModel : IViewModel + { + return CreateView(typeof(TViewModel)); + } + + /// + public FrameworkElement CreateView(Type viewModel) + { + var f = Views.FirstOrDefault(x => x.Metadata.ViewModelType.Contains(viewModel.FullName)); + return f?.CreateExport().Value; + } + } +} diff --git a/src/GitHub.App/FodyWeavers.xml b/src/GitHub.App/FodyWeavers.xml deleted file mode 100644 index 92f9336e03..0000000000 --- a/src/GitHub.App/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/GitHub.App/GitHub.App.csproj b/src/GitHub.App/GitHub.App.csproj index 426f6b410e..d9b0ef5354 100644 --- a/src/GitHub.App/GitHub.App.csproj +++ b/src/GitHub.App/GitHub.App.csproj @@ -1,335 +1,62 @@ - - - - + - Debug - AnyCPU - {1A1DA411-8D1F-4578-80A6-04576BEA2DC5} - Library - Properties - GitHub.App - GitHub.App - v4.6.1 - 512 - {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - - - 4 - true - ..\common\GitHubVS.ruleset - true - Internal - bin\$(Configuration)\ - - - - true + net46 full - false - DEBUG;TRACE - prompt - 4 - true - ..\common\GitHubVS.ruleset - true - true - - - pdbonly - true - TRACE - prompt - 4 - true - true - true - ..\common\GitHubVS.ruleset - - - ..\..\script\Key.snk - true - false + true + + + - - ..\..\packages\LibGit2Sharp.0.22.0\lib\net40\LibGit2Sharp.dll - True - - - ..\..\packages\VSSDK.ComponentModelHost.12.0.4\lib\net45\Microsoft.VisualStudio.ComponentModelHost.dll - False - - - ..\..\packages\Microsoft.VisualStudio.Shell.14.0.14.3.25407\lib\Microsoft.VisualStudio.Shell.14.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Immutable.10.0.10.0.30319\lib\net40\Microsoft.VisualStudio.Shell.Immutable.10.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6070\lib\Microsoft.VisualStudio.TextManager.Interop.dll - True - - - False - ..\..\packages\Microsoft.VisualStudio.Threading.14.1.131\lib\net45\Microsoft.VisualStudio.Threading.dll - - - False - ..\..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll - False - - - ..\..\packages\NLog.3.1.0.0\lib\net45\NLog.dll - - - ..\..\packages\NullGuard.Fody.1.4.6\Lib\dotnet\NullGuard.dll - False - - - - - ..\..\packages\SQLitePCL.raw_basic.0.7.3.0-vs2012\lib\net45\SQLitePCL.raw.dll - True - - - ..\..\packages\Stateless.2.5.56.0\lib\portable-net40+sl50+win+wp80+MonoAndroid10+xamarinios10+MonoTouch10\Stateless.dll - True - - - - - - ..\..\packages\Rx-Core.2.2.5-custom\lib\net45\System.Reactive.Core.dll - True - - - ..\..\packages\Rx-Interfaces.2.2.5-custom\lib\net45\System.Reactive.Interfaces.dll - True - - - ..\..\packages\Rx-Linq.2.2.5-custom\lib\net45\System.Reactive.Linq.dll - True - - - ..\..\packages\Rx-PlatformServices.2.2.5-custom\lib\net45\System.Reactive.PlatformServices.dll - True - - - ..\..\packages\Rx-XAML.2.2.5-custom\lib\net45\System.Reactive.Windows.Threading.dll - True - + + - - - - - + + + - - Key.snk - - - - - - - - - - - - - - True - True - Resources.resx - - - - - - - - - - - - - - - - - - - - - - - Properties\SolutionInfo.cs - - - Api\ApiClientConfiguration.cs - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - Designer - - - + + + + + + + + + - - - - - - - {241c47df-ca8e-4296-aa03-2c48bb646abd} - Akavache.Sqlite3 - False - - - {b4e665e5-6caf-4414-a6e2-8de1c3bcf203} - Akavache_Net45 - False - - - {674b69b8-0780-4d54-ae2b-c15821fa51cb} - Octokit.Reactive - - - {08dd4305-7787-4823-a53f-4d0f725a07f3} - Octokit - - - {1ce2d235-8072-4649-ba5a-cfb1af8776e0} - ReactiveUI_Net45 - - - {252ce1c2-027a-4445-a3c2-e4d6c80a935a} - Splat-Net45 - - - {41a47c5b-c606-45b4-b83c-22b9239e4da0} - CredentialManagement - - - {e4ed0537-d1d9-44b6-9212-3096d7c3f7a1} - GitHub.Exports.Reactive - - - {9aea02db-02b5-409c-b0ca-115d05331a6b} - GitHub.Exports - True - - - {6559e128-8b40-49a5-85a8-05565ed0c7e3} - GitHub.Extensions.Reactive - - - {6afe2e2d-6db0-4430-a2ea-f5f5388d2f78} - GitHub.Extensions - - - {158b05e8-fdbc-4d71-b871-c96e28d5adf5} - GitHub.UI.Reactive - - - {4a84e568-ca86-4510-8cd0-90d3ef9b65f9} - Rothko - - - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - - - This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.App/GlobalSuppressions.cs b/src/GitHub.App/GlobalSuppressions.cs index 0ec82878a6..997cb18a85 100644 --- a/src/GitHub.App/GlobalSuppressions.cs +++ b/src/GitHub.App/GlobalSuppressions.cs @@ -1,9 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - -[assembly: SuppressMessage("Microsoft.Performance", "CA1822:MarkMembersAsStatic", Scope = "member", Target = "GitHub.ViewModels.CreateRepoViewModel.#ResetState()")] -[assembly: SuppressMessage("Microsoft.Naming", "CA1703:ResourceStringsShouldBeSpelledCorrectly", MessageId = "Git", Scope = "resource", Target = "GitHub.Resources.resources")] -[assembly: SuppressMessage("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily", Scope = "member", Target = "GitHub.Caches.CredentialCache.#InsertObject`1(System.String,!!0,System.Nullable`1)")] -[assembly: SuppressMessage("Microsoft.Naming", "CA1703:ResourceStringsShouldBeSpelledCorrectly", MessageId = "Git", Scope = "resource", Target = "GitHub.App.Resources.resources")] // This file is used by Code Analysis to maintain SuppressMessage // attributes that are applied to this project. // Project-level suppressions either have no target or are given @@ -13,3 +7,15 @@ // Code Analysis results, point to "Suppress Message", and click // "In Suppression File". // You do not need to add suppressions to this file manually. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Microsoft.Performance", "CA1822:MarkMembersAsStatic", Scope = "member", Target = "GitHub.ViewModels.CreateRepoViewModel.#ResetState()")] +[assembly: SuppressMessage("Microsoft.Naming", "CA1703:ResourceStringsShouldBeSpelledCorrectly", MessageId = "Git", Scope = "resource", Target = "GitHub.Resources.resources")] +[assembly: SuppressMessage("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily", Scope = "member", Target = "GitHub.Caches.CredentialCache.#InsertObject`1(System.String,!!0,System.Nullable`1)")] +[assembly: SuppressMessage("Microsoft.Naming", "CA1703:ResourceStringsShouldBeSpelledCorrectly", MessageId = "Git", Scope = "resource", Target = "GitHub.App.Resources.resources")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1305:SpecifyIFormatProvider", MessageId = "System.String.Format(System.String,System.Object,System.Object,System.Object)", Scope = "member", Target = "GitHub.Services.PullRequestService.#CreateTempFile(System.String,System.String,System.String)")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1305:SpecifyIFormatProvider", MessageId = "System.String.Format(System.String,System.Object,System.Object,System.Object)", Scope = "member", Target = "GitHub.Services.PullRequestService.#CreateTempFile(System.String,System.String,System.String,System.Text.Encoding)")] +[assembly: SuppressMessage("Design", "CA1056:Uri properties should not be strings")] +[assembly: SuppressMessage("Design", "CA1054:Uri parameters should not be strings")] +[assembly: SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Discouraged for VSSDK projects.")] diff --git a/src/GitHub.App/Info/GitHubUrls.cs b/src/GitHub.App/Info/GitHubUrls.cs index 5db9afa582..97705bc203 100644 --- a/src/GitHub.App/Info/GitHubUrls.cs +++ b/src/GitHub.App/Info/GitHubUrls.cs @@ -94,5 +94,10 @@ public static string Billing(this IAccount account) : string.Format(CultureInfo.InvariantCulture, GitHub + "/organizations/{0}/settings/billing", account.Login); } + + /// + /// The URL for the GitHub for Visual Studio documentation. + /// + public const string Documentation = "https://github.com/github/VisualStudio/tree/master/docs"; } } diff --git a/src/GitHub.App/Infrastructure/ExportWrappers.cs b/src/GitHub.App/Infrastructure/ExportWrappers.cs index 5dfa07ff6f..a4dd184b2c 100644 --- a/src/GitHub.App/Infrastructure/ExportWrappers.cs +++ b/src/GitHub.App/Infrastructure/ExportWrappers.cs @@ -1,7 +1,10 @@ -using System.ComponentModel.Composition; +using System; +using System.ComponentModel.Composition; +using System.Net; +using GitHub.Models; +using Octokit; using Octokit.Internal; -using System; -using System.Net.Http; +using Rothko; namespace GitHub.Infrastructure { @@ -16,4 +19,23 @@ public ExportedHttpClient() : base(HttpMessageHandlerFactory.CreateDefault) {} } + + [Export(typeof(IHttpListener))] + public class ExportedHttpListener : HttpListenerWrapper + { + public ExportedHttpListener() + : base(new HttpListener()) + { + } + } + + [Export(typeof(IEnterpriseProbe))] + public class ExportedEnterpriseProbe : EnterpriseProbe + { + [ImportingConstructor] + public ExportedEnterpriseProbe(IProgram program, IHttpClient client) + : base(program.ProductHeader, client) + { + } + } } diff --git a/src/GitHub.App/Infrastructure/LoggingConfiguration.cs b/src/GitHub.App/Infrastructure/LoggingConfiguration.cs deleted file mode 100644 index d079d320bc..0000000000 --- a/src/GitHub.App/Infrastructure/LoggingConfiguration.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Globalization; -using System.IO; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using NLog; -using NLog.Config; -using NLog.Targets; -using Rothko; - -namespace GitHub.Infrastructure -{ - public interface ILoggingConfiguration - { - void Configure(); - } - [Export(typeof(ILoggingConfiguration))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class LoggingConfiguration : ILoggingConfiguration - { - // See http://nlog-project.org/wiki/Layout_Renderers for logging layout options. - const string layout = "${longdate}|${level:uppercase=true}|thread:${threadid:padding=2}|${logger:shortName=true}|${message}${onexception:inner=${newline}${exception:innerformat=ToString,StackTrace:format=ToString,StackTrace}}"; - - [ImportingConstructor] - public LoggingConfiguration(IProgram program, IOperatingSystem os, IVSServices vsservice) - { - NLog.Config.LoggingConfiguration conf; - string assemblyFolder = program.ExecutingAssemblyDirectory; - try - { - conf = new XmlLoggingConfiguration(Path.Combine(assemblyFolder, "NLog.config"), true); - } - catch (Exception ex) - { - vsservice.ActivityLogError(string.Format(CultureInfo.InvariantCulture, "Error loading nlog.config. {0}", ex)); - conf = new NLog.Config.LoggingConfiguration(); - } - - var fileTarget = conf.FindTargetByName("file") as FileTarget; - if (fileTarget == null) - { - fileTarget = new FileTarget(); - conf.AddTarget(Path.GetRandomFileName(), fileTarget); - conf.LoggingRules.Add(new LoggingRule("*", LogLevel.Info, fileTarget)); - } - fileTarget.FileName = Path.Combine(os.Environment.GetLocalGitHubApplicationDataPath(), "extension.log"); - fileTarget.Layout = layout; - - try - { - LogManager.Configuration = conf; - } - catch (Exception ex) - { - vsservice.ActivityLogError(string.Format(CultureInfo.InvariantCulture, "Error configuring the log. {0}", ex)); - } - } - - public void Configure() - { - } - } -} diff --git a/src/GitHub.App/Models/Account.cs b/src/GitHub.App/Models/Account.cs index 9a0d48b771..09a318dc6d 100644 --- a/src/GitHub.App/Models/Account.cs +++ b/src/GitHub.App/Models/Account.cs @@ -3,8 +3,8 @@ using System.Globalization; using System.Reactive.Linq; using System.Windows.Media.Imaging; +using GitHub.Extensions; using GitHub.Primitives; -using NullGuard; using Octokit; using ReactiveUI; @@ -23,8 +23,11 @@ public Account( bool isEnterprise, int ownedPrivateRepositoryCount, long privateRepositoryInPlanCount, + string avatarUrl, IObservable bitmapSource) { + Guard.ArgumentNotEmptyString(login, nameof(login)); + Login = login; IsUser = isUser; IsEnterprise = isEnterprise; @@ -32,6 +35,7 @@ public Account( PrivateReposInPlan = privateRepositoryInPlanCount; IsOnFreePlan = privateRepositoryInPlanCount == 0; HasMaximumPrivateRepositories = OwnedPrivateRepos >= PrivateReposInPlan; + AvatarUrl = avatarUrl; this.bitmapSource = bitmapSource; bitmapSourceSubscription = bitmapSource @@ -41,6 +45,8 @@ public Account( public Account(Octokit.Account account) { + Guard.ArgumentNotNull(account, nameof(account)); + Login = account.Login; IsUser = (account as User) != null; Uri htmlUrl; @@ -50,6 +56,7 @@ public Account(Octokit.Account account) OwnedPrivateRepos = account.OwnedPrivateRepos; IsOnFreePlan = PrivateReposInPlan == 0; HasMaximumPrivateRepositories = OwnedPrivateRepos >= PrivateReposInPlan; + AvatarUrl = account.AvatarUrl; } public Account(Octokit.Account account, IObservable bitmapSource) @@ -73,15 +80,15 @@ public Account(Octokit.Account account, IObservable bitmapSource) public long PrivateReposInPlan { get; private set; } - [AllowNull] + public string AvatarUrl { get; set; } + public BitmapSource Avatar { - [return: AllowNull] get { return avatar; } set { avatar = value; this.RaisePropertyChanged(); } } -#region Equality things + #region Equality things public void CopyFrom(IAccount other) { if (!Equals(other)) @@ -103,7 +110,7 @@ public void CopyFrom(IAccount other) } } - public override bool Equals([AllowNull]object obj) + public override bool Equals(object obj) { if (ReferenceEquals(this, obj)) return true; @@ -113,47 +120,46 @@ public override bool Equals([AllowNull]object obj) public override int GetHashCode() { - return (Login?.GetHashCode() ?? 0) ^ IsUser .GetHashCode() ^ IsEnterprise.GetHashCode(); + return (Login?.GetHashCode() ?? 0) ^ IsUser.GetHashCode() ^ IsEnterprise.GetHashCode(); } - bool IEquatable.Equals([AllowNull]IAccount other) + bool IEquatable.Equals(IAccount other) { if (ReferenceEquals(this, other)) return true; return other != null && Login == other.Login && IsUser == other.IsUser && IsEnterprise == other.IsEnterprise; } - public int CompareTo([AllowNull]IAccount other) + public int CompareTo(IAccount other) { return other != null ? String.Compare(Login, other.Login, StringComparison.CurrentCulture) : 1; } - public static bool operator >([AllowNull]Account lhs, [AllowNull]Account rhs) + public static bool operator >(Account lhs, Account rhs) { if (ReferenceEquals(lhs, rhs)) return false; return lhs?.CompareTo(rhs) > 0; } - public static bool operator <([AllowNull]Account lhs, [AllowNull]Account rhs) + public static bool operator <(Account lhs, Account rhs) { if (ReferenceEquals(lhs, rhs)) return false; return (object)lhs == null || lhs.CompareTo(rhs) < 0; } - public static bool operator ==([AllowNull]Account lhs, [AllowNull]Account rhs) + public static bool operator ==(Account lhs, Account rhs) { return Equals(lhs, rhs) && ((object)lhs == null || lhs.CompareTo(rhs) == 0); } - public static bool operator !=([AllowNull]Account lhs, [AllowNull]Account rhs) + public static bool operator !=(Account lhs, Account rhs) { return !(lhs == rhs); } #endregion - [return: AllowNull] public override string ToString() { return Login; diff --git a/src/GitHub.App/Models/ConnectionRepositoryHostMap.cs b/src/GitHub.App/Models/ConnectionRepositoryHostMap.cs deleted file mode 100644 index 856f657eca..0000000000 --- a/src/GitHub.App/Models/ConnectionRepositoryHostMap.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.ComponentModel.Composition; -using GitHub.Models; -using GitHub.Services; -using GitHub.Extensions; - -namespace GitHub.ViewModels -{ - [Export(typeof(IConnectionRepositoryHostMap))] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class ConnectionRepositoryHostMap : IConnectionRepositoryHostMap - { - [ImportingConstructor] - public ConnectionRepositoryHostMap(IGitHubServiceProvider provider, IRepositoryHosts hosts) - : this(provider.TryGetService(), hosts) - { - } - - public ConnectionRepositoryHostMap(IRepositoryHost repositoryHost) - { - CurrentRepositoryHost = repositoryHost; - } - - protected ConnectionRepositoryHostMap(IConnection connection, IRepositoryHosts hosts) - : this(hosts.LookupHost(connection.HostAddress)) - { - } - - /// - /// The current repository host. This is set in the MEF sub-container when the user clicks on an action - /// related to a host such as clone or create. - /// - public IRepositoryHost CurrentRepositoryHost { get; private set; } - } -} diff --git a/src/GitHub.App/Models/DisconnectedRepositoryHosts.cs b/src/GitHub.App/Models/DisconnectedRepositoryHosts.cs deleted file mode 100644 index e2fb8bd3bc..0000000000 --- a/src/GitHub.App/Models/DisconnectedRepositoryHosts.cs +++ /dev/null @@ -1,56 +0,0 @@ - -using System; -using System.Reactive; -using System.Reactive.Linq; -using GitHub.Api; -using GitHub.Authentication; -using GitHub.Primitives; -using GitHub.Services; -using NullGuard; -using ReactiveUI; - -namespace GitHub.Models -{ - public class DisconnectedRepositoryHost : ReactiveObject, IRepositoryHost - { - public DisconnectedRepositoryHost() - { - Address = HostAddress.Create(new Uri("https://null/")); - Organizations = new ReactiveList(); - Accounts = new ReactiveList(); - } - - public HostAddress Address { get; private set; } - public IApiClient ApiClient { get; private set; } - - [AllowNull] - public bool IsLoggedIn { get; private set; } - public bool IsLoggingIn { get; private set; } - public bool SupportsGist { get; private set; } - public ReactiveList Organizations { get; private set; } - public ReactiveList Accounts { get; private set; } - public string Title { get; private set; } - public IAccount User { get; private set; } - public IModelService ModelService { get; private set; } - - public IObservable LogIn(string usernameOrEmail, string password) - { - return Observable.Return(AuthenticationResult.CredentialFailure); - } - - public IObservable LogInFromCache() - { - return Observable.Return(AuthenticationResult.CredentialFailure); - } - - public IObservable LogOut() - { - return Observable.Return(Unit.Default); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.App/Models/Drafts/CommentDraft.cs b/src/GitHub.App/Models/Drafts/CommentDraft.cs new file mode 100644 index 0000000000..a55f23aab3 --- /dev/null +++ b/src/GitHub.App/Models/Drafts/CommentDraft.cs @@ -0,0 +1,15 @@ +using GitHub.ViewModels; + +namespace GitHub.Models.Drafts +{ + /// + /// Stores a draft for a + /// + public class CommentDraft + { + /// + /// Gets or sets the draft comment body. + /// + public string Body { get; set; } + } +} diff --git a/src/GitHub.App/Models/Drafts/PullRequestDraft.cs b/src/GitHub.App/Models/Drafts/PullRequestDraft.cs new file mode 100644 index 0000000000..fa359f4c13 --- /dev/null +++ b/src/GitHub.App/Models/Drafts/PullRequestDraft.cs @@ -0,0 +1,20 @@ +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.Models.Drafts +{ + /// + /// Stores a draft for a . + /// + public class PullRequestDraft + { + /// + /// Gets or sets the draft pull request title. + /// + public string Title { get; set; } + + /// + /// Gets or sets the draft pull request body. + /// + public string Body { get; set; } + } +} diff --git a/src/GitHub.App/Models/Drafts/PullRequestReviewCommentDraft.cs b/src/GitHub.App/Models/Drafts/PullRequestReviewCommentDraft.cs new file mode 100644 index 0000000000..e9e29be412 --- /dev/null +++ b/src/GitHub.App/Models/Drafts/PullRequestReviewCommentDraft.cs @@ -0,0 +1,21 @@ +using System; +using GitHub.ViewModels; + +namespace GitHub.Models.Drafts +{ + /// + /// Stores a draft for a + /// + public class PullRequestReviewCommentDraft : CommentDraft + { + /// + /// Gets or sets the side of the diff that the draft comment was left on. + /// + public DiffSide Side { get; set; } + + /// + /// Gets or sets the time that the draft was last updated. + /// + public DateTimeOffset UpdatedAt { get; set; } + } +} diff --git a/src/GitHub.App/Models/Drafts/PullRequestReviewDraft.cs b/src/GitHub.App/Models/Drafts/PullRequestReviewDraft.cs new file mode 100644 index 0000000000..3dd3a891fb --- /dev/null +++ b/src/GitHub.App/Models/Drafts/PullRequestReviewDraft.cs @@ -0,0 +1,15 @@ +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.Models.Drafts +{ + /// + /// Stores a draft for a . + /// + public class PullRequestReviewDraft + { + /// + /// Gets or sets the draft review body. + /// + public string Body { get; set; } + } +} diff --git a/src/GitHub.App/Models/PullRequestDetailArgument.cs b/src/GitHub.App/Models/PullRequestDetailArgument.cs new file mode 100644 index 0000000000..3710e75a68 --- /dev/null +++ b/src/GitHub.App/Models/PullRequestDetailArgument.cs @@ -0,0 +1,22 @@ +using System; +using GitHub.ViewModels; +using GitHub.Primitives; + +namespace GitHub.Models +{ + /// + /// Passes arguments to a + /// + public class PullRequestDetailArgument + { + /// + /// Gets or sets the owner of the repository containing the pull request. + /// + public string RepositoryOwner { get; set; } + + /// + /// Gets or sets the number of the pull request. + /// + public int Number { get; set; } + } +} diff --git a/src/GitHub.App/Models/PullRequestFileModel.cs b/src/GitHub.App/Models/PullRequestFileModel.cs deleted file mode 100644 index c1ce5b98bb..0000000000 --- a/src/GitHub.App/Models/PullRequestFileModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace GitHub.Models -{ - public class PullRequestFileModel : IPullRequestFileModel - { - public PullRequestFileModel(string fileName, string sha, PullRequestFileStatus status) - { - FileName = fileName; - Sha = sha; - Status = status; - } - - public string FileName { get; set; } - public string Sha { get; set; } - public PullRequestFileStatus Status { get; set; } - } -} diff --git a/src/GitHub.App/Models/PullRequestModel.cs b/src/GitHub.App/Models/PullRequestModel.cs index 739e7ca912..c1f6bd3837 100644 --- a/src/GitHub.App/Models/PullRequestModel.cs +++ b/src/GitHub.App/Models/PullRequestModel.cs @@ -2,14 +2,13 @@ using System.Globalization; using GitHub.Primitives; using GitHub.VisualStudio.Helpers; -using NullGuard; using System.Diagnostics; using System.Collections.Generic; +using GitHub.Extensions; namespace GitHub.Models { [DebuggerDisplay("{DebuggerDisplay,nq}")] - [NullGuard(ValidationFlags.None)] public sealed class PullRequestModel : NotificationAwareObject, IPullRequestModel, IEquatable, IComparable @@ -36,7 +35,7 @@ public void CopyFrom(IPullRequestModel other) Assignee = other.Assignee; } - public override bool Equals([AllowNull]object obj) + public override bool Equals(object obj) { if (ReferenceEquals(this, obj)) return true; @@ -49,50 +48,50 @@ public override int GetHashCode() return Number.GetHashCode(); } - bool IEquatable.Equals([AllowNull]IPullRequestModel other) + bool IEquatable.Equals(IPullRequestModel other) { if (ReferenceEquals(this, other)) return true; return other != null && Number == other.Number; } - bool IEquatable.Equals([AllowNull]PullRequestModel other) + bool IEquatable.Equals(PullRequestModel other) { if (ReferenceEquals(this, other)) return true; return other != null && Number == other.Number; } - public int CompareTo([AllowNull]IPullRequestModel other) + public int CompareTo(IPullRequestModel other) { return other != null ? UpdatedAt.CompareTo(other.UpdatedAt) : 1; } - public int CompareTo([AllowNull]PullRequestModel other) + public int CompareTo(PullRequestModel other) { return other != null ? UpdatedAt.CompareTo(other.UpdatedAt) : 1; } - public static bool operator >([AllowNull]PullRequestModel lhs, [AllowNull]PullRequestModel rhs) + public static bool operator >(PullRequestModel lhs, PullRequestModel rhs) { if (ReferenceEquals(lhs, rhs)) return false; return lhs?.CompareTo(rhs) > 0; } - public static bool operator <([AllowNull]PullRequestModel lhs, [AllowNull]PullRequestModel rhs) + public static bool operator <(PullRequestModel lhs, PullRequestModel rhs) { if (ReferenceEquals(lhs, rhs)) return false; return (object)lhs == null || lhs.CompareTo(rhs) < 0; } - public static bool operator ==([AllowNull]PullRequestModel lhs, [AllowNull]PullRequestModel rhs) + public static bool operator ==(PullRequestModel lhs, PullRequestModel rhs) { return ReferenceEquals(lhs, rhs); } - public static bool operator !=([AllowNull]PullRequestModel lhs, [AllowNull]PullRequestModel rhs) + public static bool operator !=(PullRequestModel lhs, PullRequestModel rhs) { return !(lhs == rhs); } @@ -103,11 +102,16 @@ public int CompareTo([AllowNull]PullRequestModel other) public string Title { get { return title; } - set { title = value; this.RaisePropertyChange(); } + set + { + Guard.ArgumentNotNull(value, nameof(value)); + title = value; + this.RaisePropertyChange(); + } } - PullRequestStateEnum status; - public PullRequestStateEnum State + PullRequestState status; + public PullRequestState State { get { return status; } set @@ -122,8 +126,8 @@ public PullRequestStateEnum State } // TODO: Remove these property once maintainer workflow has been merged to master. - public bool IsOpen => State == PullRequestStateEnum.Open; - public bool Merged => State == PullRequestStateEnum.Merged; + public bool IsOpen => State == PullRequestState.Open; + public bool Merged => State == PullRequestState.Merged; int commentCount; public int CommentCount @@ -154,23 +158,18 @@ public string Body } public GitReferenceModel Base { get; set; } - [AllowNull] public GitReferenceModel Head { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public IAccount Author { get; set; } - public IReadOnlyCollection ChangedFiles { get; set; } = new IPullRequestFileModel[0]; IAccount assignee; - [AllowNull] public IAccount Assignee { - [return: AllowNull] get { return assignee; } set { assignee = value; this.RaisePropertyChange(); } } - [return: AllowNull] // nullguard thinks a string.Format can return null. sigh. public override string ToString() { return string.Format(CultureInfo.InvariantCulture, "id:{0} title:{1} created:{2:u} updated:{3:u}", Number, Title, CreatedAt, UpdatedAt); diff --git a/src/GitHub.App/Models/RemoteRepositoryModel.cs b/src/GitHub.App/Models/RemoteRepositoryModel.cs deleted file mode 100644 index 513a57bfc7..0000000000 --- a/src/GitHub.App/Models/RemoteRepositoryModel.cs +++ /dev/null @@ -1,167 +0,0 @@ -using GitHub.Primitives; -using NullGuard; -using System; -using System.Globalization; -using GitHub.Extensions; - -namespace GitHub.Models -{ - /// - /// A repository read from the GitHub API. - /// - public class RemoteRepositoryModel : RepositoryModel, IRemoteRepositoryModel, - IEquatable, IComparable - { - /// - /// Initializes a new instance of the class. - /// - /// The API ID of the repository. - /// The repository name. - /// The repository's clone URL. - /// Whether the repository is private. - /// Whether the repository is a fork. - /// The repository owner account. - public RemoteRepositoryModel(long id, string name, UriString cloneUrl, bool isPrivate, bool isFork, IAccount ownerAccount) - : base(name, cloneUrl) - { - Id = id; - OwnerAccount = ownerAccount; - IsFork = isFork; - SetIcon(isPrivate, isFork); - // this is an assumption, we'd have to load the repo information from octokit to know for sure - // probably not worth it for this ctor - DefaultBranch = new BranchModel("master", this); - } - - /// - /// Initializes a new instance of the class. - /// - /// The source octokit repository. - public RemoteRepositoryModel(Octokit.Repository repository) - : base(repository.Name, repository.CloneUrl) - { - Id = repository.Id; - IsFork = repository.Fork; - SetIcon(repository.Private, IsFork); - OwnerAccount = new Account(repository.Owner); - DefaultBranch = new BranchModel(repository.DefaultBranch, this); - Parent = repository.Parent != null ? new RemoteRepositoryModel(repository.Parent) : null; - if (Parent != null) - Parent.DefaultBranch.DisplayName = Parent.DefaultBranch.Id; - } - -#region Equality Things - public void CopyFrom(IRemoteRepositoryModel other) - { - if (!Equals(other)) - throw new ArgumentException("Instance to copy from doesn't match this instance. this:(" + this + ") other:(" + other + ")", nameof(other)); - Icon = other.Icon; - } - - public override int GetHashCode() - { - return Id.GetHashCode(); - } - - public override bool Equals([AllowNull]object obj) - { - if (ReferenceEquals(this, obj)) - return true; - var other = obj as RemoteRepositoryModel; - return Equals(other); - } - - public bool Equals([AllowNull]IRemoteRepositoryModel other) - { - if (ReferenceEquals(this, other)) - return true; - return other != null && Id == other.Id; - } - - public bool Equals([AllowNull]RemoteRepositoryModel other) - { - if (ReferenceEquals(this, other)) - return true; - return other != null && Id == other.Id; - } - - public int CompareTo([AllowNull]IRemoteRepositoryModel other) - { - return other != null ? UpdatedAt.CompareTo(other.UpdatedAt) : 1; - } - - public int CompareTo([AllowNull]RemoteRepositoryModel other) - { - return other != null ? UpdatedAt.CompareTo(other.UpdatedAt) : 1; - } - - public static bool operator >([AllowNull]RemoteRepositoryModel lhs, [AllowNull]RemoteRepositoryModel rhs) - { - if (ReferenceEquals(lhs, rhs)) - return false; - return lhs?.CompareTo(rhs) > 0; - } - - public static bool operator <([AllowNull]RemoteRepositoryModel lhs, [AllowNull]RemoteRepositoryModel rhs) - { - if (ReferenceEquals(lhs, rhs)) - return false; - return (object)lhs == null || lhs.CompareTo(rhs) < 0; - } - - public static bool operator ==([AllowNull]RemoteRepositoryModel lhs, [AllowNull]RemoteRepositoryModel rhs) - { - return ReferenceEquals(lhs, rhs); - } - - public static bool operator !=([AllowNull]RemoteRepositoryModel lhs, [AllowNull]RemoteRepositoryModel rhs) - { - return !(lhs == rhs); - } -#endregion - - /// - /// Gets the account that is the ower of the repository. - /// - public IAccount OwnerAccount { get; } - - /// - /// Gets the repository's API ID. - /// - public long Id { get; } - - /// - /// Gets the date and time at which the repository was created. - /// - public DateTimeOffset CreatedAt { get; set; } - - /// - /// Gets the repository's last update date and time. - /// - public DateTimeOffset UpdatedAt { get; set; } - - /// - /// Gets a value indicating whether the repository is a fork. - /// - public bool IsFork { get; } - - /// - /// Gets the repository from which this repository was forked, if any. - /// - [AllowNull] public IRemoteRepositoryModel Parent { [return: AllowNull] get; } - - /// - /// Gets the default branch for the repository. - /// - public IBranch DefaultBranch { get; } - - internal string DebuggerDisplay - { - get - { - return String.Format(CultureInfo.InvariantCulture, - "{4}\tId: {0} Name: {1} CloneUrl: {2} Account: {3}", Id, Name, CloneUrl, Owner, GetHashCode()); - } - } - } -} diff --git a/src/GitHub.App/Models/RepositoryHost.cs b/src/GitHub.App/Models/RepositoryHost.cs deleted file mode 100644 index 4367e0f8c1..0000000000 --- a/src/GitHub.App/Models/RepositoryHost.cs +++ /dev/null @@ -1,299 +0,0 @@ -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net; -using System.Reactive; -using System.Reactive.Linq; -using GitHub.Api; -using GitHub.Authentication; -using GitHub.Caches; -using GitHub.Extensions.Reactive; -using GitHub.Primitives; -using GitHub.Services; -using NLog; -using Octokit; -using ReactiveUI; -using System.Linq; -using System.Reactive.Threading.Tasks; -using System.Collections.Generic; -using GitHub.Extensions; - -namespace GitHub.Models -{ - [DebuggerDisplay("{DebuggerDisplay,nq}")] - public class RepositoryHost : ReactiveObject, IRepositoryHost - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - static readonly UserAndScopes unverifiedUser = new UserAndScopes(null, null); - - readonly ITwoFactorChallengeHandler twoFactorChallengeHandler; - readonly HostAddress hostAddress; - readonly ILoginCache loginCache; - readonly IUsageTracker usage; - - bool isLoggedIn; - readonly bool isEnterprise; - - public RepositoryHost( - IApiClient apiClient, - IModelService modelService, - ILoginCache loginCache, - ITwoFactorChallengeHandler twoFactorChallengeHandler, - IUsageTracker usage) - { - ApiClient = apiClient; - ModelService = modelService; - this.loginCache = loginCache; - this.twoFactorChallengeHandler = twoFactorChallengeHandler; - this.usage = usage; - - Debug.Assert(apiClient.HostAddress != null, "HostAddress of an api client shouldn't be null"); - Address = apiClient.HostAddress; - hostAddress = apiClient.HostAddress; - isEnterprise = !hostAddress.IsGitHubDotCom(); - Title = apiClient.HostAddress.Title; - } - - public HostAddress Address { get; private set; } - - public IApiClient ApiClient { get; private set; } - - public bool IsLoggedIn - { - get { return isLoggedIn; } - private set { this.RaiseAndSetIfChanged(ref isLoggedIn, value); } - } - - public bool SupportsGist { get; private set; } - - public string Title { get; private set; } - - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")] - public IObservable LogInFromCache() - { - return GetUserFromApi() - .ObserveOn(RxApp.MainThreadScheduler) - .Catch(ex => - { - if (ex is AuthorizationException) - { - log.Warn("Got an authorization exception", ex); - } - return Observable.Return(null); - }) - .SelectMany(LoginWithApiUser) - .PublishAsync(); - } - - public IObservable LogIn(string usernameOrEmail, string password) - { - Guard.ArgumentNotEmptyString(usernameOrEmail, nameof(usernameOrEmail)); - Guard.ArgumentNotEmptyString(password, nameof(password)); - - // If we need to retry on fallback, we'll store the 2FA token - // from the first request to re-use: - string authenticationCode = null; - - // We need to intercept the 2FA handler to get the token: - var interceptingTwoFactorChallengeHandler = - new Func>(ex => - twoFactorChallengeHandler.HandleTwoFactorException(ex) - .Do(twoFactorChallengeResult => - authenticationCode = twoFactorChallengeResult.AuthenticationCode)); - - // Keep the function to save the authorization token here because it's used - // in multiple places in the chain below: - var saveAuthorizationToken = new Func>(authorization => - { - var token = authorization?.Token; - if (string.IsNullOrWhiteSpace(token)) - return Observable.Return(Unit.Default); - - return loginCache.SaveLogin(usernameOrEmail, token, Address) - .ObserveOn(RxApp.MainThreadScheduler); - }); - - // Start be saving the username and password, as they will be used for older versions of Enterprise - // that don't support authorization tokens, and for the API client to use until an authorization - // token has been created and acquired: - return loginCache.SaveLogin(usernameOrEmail, password, Address) - .ObserveOn(RxApp.MainThreadScheduler) - // Try to get an authorization token, save it, then get the user to log in: - .SelectMany(fingerprint => ApiClient.GetOrCreateApplicationAuthenticationCode(interceptingTwoFactorChallengeHandler)) - .SelectMany(saveAuthorizationToken) - .SelectMany(_ => GetUserFromApi()) - .Catch(firstTryEx => - { - var exception = firstTryEx as AuthorizationException; - if (isEnterprise - && exception != null - && exception.Message == "Bad credentials") - { - return Observable.Throw(exception); - } - - // If the Enterprise host doesn't support the write:public_key scope, it'll return a 422. - // EXCEPT, there's a bug where it doesn't, and instead creates a bad token, and in - // that case we'd get a 401 here from the GetUser invocation. So to be safe (and consistent - // with the Mac app), we'll just retry after any API error for Enterprise hosts: - if (isEnterprise && !(firstTryEx is TwoFactorChallengeFailedException)) - { - // Because we potentially have a bad authorization token due to the Enterprise bug, - // we need to reset to using username and password authentication: - return loginCache.SaveLogin(usernameOrEmail, password, Address) - .ObserveOn(RxApp.MainThreadScheduler) - .SelectMany(_ => - { - // Retry with the old scopes. If we have a stashed 2FA token, we use it: - if (authenticationCode != null) - { - return ApiClient.GetOrCreateApplicationAuthenticationCode( - interceptingTwoFactorChallengeHandler, - authenticationCode, - useOldScopes: true, - useFingerprint: false); - } - - // Otherwise, we use the default handler: - return ApiClient.GetOrCreateApplicationAuthenticationCode( - interceptingTwoFactorChallengeHandler, - useOldScopes: true, - useFingerprint: false); - }) - // Then save the authorization token (if there is one) and get the user: - .SelectMany(saveAuthorizationToken) - .SelectMany(_ => GetUserFromApi()); - } - - return Observable.Throw(firstTryEx); - }) - .Catch(retryEx => - { - // Older Enterprise hosts either don't have the API end-point to PUT an authorization, or they - // return 422 because they haven't white-listed our client ID. In that case, we just ignore - // the failure, using basic authentication (with username and password) instead of trying - // to get an authorization token. - // Since enterprise 2.1 and https://github.com/github/github/pull/36669 the API returns 403 - // instead of 404 to signal that it's not allowed. In the name of backwards compatibility we - // test for both 404 (NotFoundException) and 403 (ForbiddenException) here. - if (isEnterprise && (retryEx is NotFoundException || retryEx is ForbiddenException || retryEx.StatusCode == (HttpStatusCode)422)) - return GetUserFromApi(); - - // Other errors are "real" so we pass them along: - return Observable.Throw(retryEx); - }) - .ObserveOn(RxApp.MainThreadScheduler) - .Catch(ex => - { - // If we get here, we have an actual login failure: - if (ex is TwoFactorChallengeFailedException) - { - return Observable.Return(unverifiedUser); - } - if (ex is AuthorizationException) - { - return Observable.Return(default(UserAndScopes)); - } - return Observable.Throw(ex); - }) - .SelectMany(LoginWithApiUser) - .PublishAsync(); - } - - public IObservable LogOut() - { - if (!IsLoggedIn) return Observable.Return(Unit.Default); - - log.Info(CultureInfo.InvariantCulture, "Logged off of host '{0}'", hostAddress.ApiUri); - - return loginCache.EraseLogin(Address) - .Catch(e => - { - log.Warn("ASSERT! Failed to erase login. Going to invalidate cache anyways.", e); - return Observable.Return(Unit.Default); - }) - .SelectMany(_ => ModelService.InvalidateAll()) - .Catch(e => - { - log.Warn("ASSERT! Failed to invaldiate caches", e); - return Observable.Return(Unit.Default); - }) - .ObserveOn(RxApp.MainThreadScheduler) - .Finally(() => - { - IsLoggedIn = false; - }); - } - - static IObservable GetAuthenticationResultForUser(UserAndScopes account) - { - return Observable.Return(account == null ? AuthenticationResult.CredentialFailure - : account == unverifiedUser - ? AuthenticationResult.VerificationFailure - : AuthenticationResult.Success); - } - - IObservable LoginWithApiUser(UserAndScopes userAndScopes) - { - return GetAuthenticationResultForUser(userAndScopes) - .SelectMany(result => - { - if (result.IsSuccess()) - { - var accountCacheItem = new AccountCacheItem(userAndScopes.User); - usage.IncrementLoginCount().Forget(); - return ModelService.InsertUser(accountCacheItem).Select(_ => result); - } - - if (result == AuthenticationResult.VerificationFailure) - { - return loginCache.EraseLogin(Address).Select(_ => result); - } - return Observable.Return(result); - }) - .ObserveOn(RxApp.MainThreadScheduler) - .Do(result => - { - if (result.IsSuccess()) - { - SupportsGist = userAndScopes.Scopes?.Contains("gist") ?? true; - IsLoggedIn = true; - } - - log.Info("Log in from cache for login '{0}' to host '{1}' {2}", - userAndScopes?.User?.Login ?? "(null)", - hostAddress.ApiUri, - result.IsSuccess() ? "SUCCEEDED" : "FAILED"); - }); - } - - IObservable GetUserFromApi() - { - return Observable.Defer(() => ApiClient.GetUser()); - } - - protected virtual void Dispose(bool disposing) - {} - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - internal string DebuggerDisplay - { - get - { - return string.Format(CultureInfo.InvariantCulture, "RepositoryHost: {0} {1}", Title, hostAddress.ApiUri); - } - } - - public IModelService ModelService - { - get; - private set; - } - } -} diff --git a/src/GitHub.App/Models/RepositoryHosts.cs b/src/GitHub.App/Models/RepositoryHosts.cs deleted file mode 100644 index f94538101d..0000000000 --- a/src/GitHub.App/Models/RepositoryHosts.cs +++ /dev/null @@ -1,260 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Globalization; -using System.Linq; -using System.Reactive; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using Akavache; -using GitHub.Authentication; -using GitHub.Caches; -using GitHub.Extensions.Reactive; -using GitHub.Factories; -using GitHub.Primitives; -using NullGuard; -using ReactiveUI; - -namespace GitHub.Models -{ - [Export(typeof(IRepositoryHosts))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class RepositoryHosts : ReactiveObject, IRepositoryHosts - { - static readonly NLog.Logger log = NLog.LogManager.GetCurrentClassLogger(); - - public static DisconnectedRepositoryHost DisconnectedRepositoryHost = new DisconnectedRepositoryHost(); - public const string EnterpriseHostApiBaseUriCacheKey = "enterprise-host-api-base-uri"; - readonly ObservableAsPropertyHelper isLoggedInToAnyHost; - readonly IConnectionManager connectionManager; - readonly CompositeDisposable disposables = new CompositeDisposable(); - - [ImportingConstructor] - public RepositoryHosts( - IRepositoryHostFactory repositoryHostFactory, - ISharedCache sharedCache, - IConnectionManager connectionManager) - { - this.connectionManager = connectionManager; - - RepositoryHostFactory = repositoryHostFactory; - GitHubHost = DisconnectedRepositoryHost; - EnterpriseHost = DisconnectedRepositoryHost; - - var persistEntepriseHostObs = this.WhenAny(x => x.EnterpriseHost, x => x.Value) - .Skip(1) // The first value will be null or something already in the db - .SelectMany(enterpriseHost => - { - if (!enterpriseHost.IsLoggedIn) - { - return sharedCache.UserAccount - .InvalidateObject(EnterpriseHostApiBaseUriCacheKey) - .Catch(ex => - { - log.Warn("Failed to invalidate enterprise host uri", ex); - return Observable.Return(Unit.Default); - }); - } - - return sharedCache.UserAccount - .InsertObject(EnterpriseHostApiBaseUriCacheKey, enterpriseHost.Address.ApiUri) - .Catch(ex => - { - log.Warn("Failed to persist enterprise host uri", ex); - return Observable.Return(Unit.Default); - }); - }); - - isLoggedInToAnyHost = this.WhenAny( - x => x.GitHubHost.IsLoggedIn, - x => x.EnterpriseHost.IsLoggedIn, - (githubLoggedIn, enterpriseLoggedIn) => githubLoggedIn.Value || enterpriseLoggedIn.Value) - .ToProperty(this, x => x.IsLoggedInToAnyHost); - - // This part is strictly to support having the IConnectionManager request that a connection - // be logged in. It doesn't know about hosts or load anything reactive, so it gets - // informed of logins by an observable returned by the event - connectionManager.DoLogin += RunLoginHandler; - - // monitor the list of connections so we can log out hosts when connections are removed - disposables.Add( - connectionManager.Connections.CreateDerivedCollection(x => x) - .ItemsRemoved - .Select(x => - { - var host = LookupHost(x.HostAddress); - if (host.Address != x.HostAddress) - host = RepositoryHostFactory.Create(x.HostAddress); - return host; - }) - .Select(h => LogOut(h)) - .Merge().ToList().Select(_ => Unit.Default).Subscribe()); - - - // Wait until we've loaded (or failed to load) an enterprise uri from the db and then - // start tracking changes to the EnterpriseHost property and persist every change to the db - disposables.Add(persistEntepriseHostObs.Subscribe()); - } - - IObservable RunLoginHandler(IConnection connection) - { - var handler = new ReplaySubject(); - var address = connection.HostAddress; - var host = LookupHost(address); - if (host == DisconnectedRepositoryHost) - LogInFromCache(address) - .Subscribe(c => handler.OnNext(connection), () => handler.OnCompleted()); - else - { - handler.OnNext(connection); - handler.OnCompleted(); - } - return handler; - } - - public IRepositoryHost LookupHost([AllowNull] HostAddress address) - { - if (address == GitHubHost.Address) - return GitHubHost; - if (address == EnterpriseHost.Address) - return EnterpriseHost; - return DisconnectedRepositoryHost; - } - - public IObservable LogIn( - HostAddress address, - string usernameOrEmail, - string password) - { - var isDotCom = HostAddress.GitHubDotComHostAddress == address; - var host = RepositoryHostFactory.Create(address); - return host.LogIn(usernameOrEmail, password) - .Catch(Observable.Throw) - .Do(result => - { - bool successful = result.IsSuccess(); - log.Info(CultureInfo.InvariantCulture, "Log in to {3} host '{0}' with username '{1}' {2}", - address.ApiUri, - usernameOrEmail, - successful ? "SUCCEEDED" : "FAILED", - isDotCom ? "GitHub.com" : address.WebUri.Host - ); - if (successful) - { - // Make sure that GitHubHost/EnterpriseHost are set when the connections - // changed event is raised and likewise that the connection is added when - // the property changed notification is sent. - if (isDotCom) - githubHost = host; - else - enterpriseHost = host; - - connectionManager.AddConnection(address, usernameOrEmail); - - if (isDotCom) - this.RaisePropertyChanged(nameof(GitHubHost)); - else - this.RaisePropertyChanged(nameof(EnterpriseHost)); - } - }); - } - - /// - /// This is only called by the connection manager when logging in connections - /// that already exist so we don't have to add the connection. - /// - /// - /// - public IObservable LogInFromCache(HostAddress address) - { - var isDotCom = HostAddress.GitHubDotComHostAddress == address; - var host = RepositoryHostFactory.Create(address); - return host.LogInFromCache() - .Catch(Observable.Throw) - .Do(result => - { - bool successful = result.IsSuccess(); - if (successful) - { - if (isDotCom) - GitHubHost = host; - else - EnterpriseHost = host; - } - }); - } - - public IObservable LogOut(IRepositoryHost host) - { - var address = host.Address; - var isDotCom = HostAddress.GitHubDotComHostAddress == address; - return host.LogOut() - .Do(result => - { - // reset the logged out host property to null - // it'll know what to do - if (isDotCom) - GitHubHost = null; - else - EnterpriseHost = null; - connectionManager.RemoveConnection(address); - RepositoryHostFactory.Remove(host); - }); - } - - bool disposed; - protected void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - - try - { - connectionManager.DoLogin -= RunLoginHandler; - disposables.Dispose(); - } - catch (Exception e) - { - log.Warn("Exception occured while disposing RepositoryHosts", e); - } - disposed = true; - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - IRepositoryHost githubHost; - [AllowNull] - public IRepositoryHost GitHubHost - { - get { return githubHost; } - private set - { - var newHost = value ?? DisconnectedRepositoryHost; - this.RaiseAndSetIfChanged(ref githubHost, newHost); - } - } - - IRepositoryHost enterpriseHost; - [AllowNull] - public IRepositoryHost EnterpriseHost - { - get { return enterpriseHost; } - set - { - var newHost = value ?? DisconnectedRepositoryHost; - this.RaiseAndSetIfChanged(ref enterpriseHost, newHost); - } - } - - public IRepositoryHostFactory RepositoryHostFactory { get; private set; } - - public bool IsLoggedInToAnyHost { get { return isLoggedInToAnyHost.Value; } } - } -} \ No newline at end of file diff --git a/src/GitHub.App/Models/SuggestionItem.cs b/src/GitHub.App/Models/SuggestionItem.cs new file mode 100644 index 0000000000..713e0357e9 --- /dev/null +++ b/src/GitHub.App/Models/SuggestionItem.cs @@ -0,0 +1,51 @@ +using System; +using GitHub.Extensions; +using GitHub.Helpers; + +namespace GitHub.Models +{ + /// + /// Represents a single auto completion suggestion (mentions, emojis, issues) in a generic format that can be + /// easily cached. + /// + public class SuggestionItem + { + public SuggestionItem(string name, string description) + { + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotEmptyString(description, "description"); + + Name = name; + Description = description; + } + + public SuggestionItem(string name, string description, string imageUrl) + { + Guard.ArgumentNotEmptyString(name, "name"); + + Name = name; + Description = description; + ImageUrl = imageUrl; + } + + /// + /// The name to display for this entry + /// + public string Name { get; set; } + + /// + /// Additional details about the entry + /// + public string Description { get; set; } + + /// + /// An image url for this entry + /// + public string ImageUrl { get; set; } + + /// + /// The date this suggestion was last modified according to the API. + /// + public DateTimeOffset? LastModifiedDate { get; set; } + } +} diff --git a/src/GitHub.App/Properties/AssemblyInfo.cs b/src/GitHub.App/Properties/AssemblyInfo.cs index 07fe8b166e..0ad9954b61 100644 --- a/src/GitHub.App/Properties/AssemblyInfo.cs +++ b/src/GitHub.App/Properties/AssemblyInfo.cs @@ -1,6 +1,10 @@ -using System.Reflection; -using System.Runtime.InteropServices; +using System.Windows.Markup; -[assembly: AssemblyTitle("GitHub.App")] -[assembly: AssemblyDescription("Provides the view models for the GitHub for Visual Studio extension")] -[assembly: Guid("a8b9a236-d238-4733-b116-716872a1e8e0")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData.Dialog.Clone")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData.Documents")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Dialog")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Dialog.Clone")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Documents")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.GitHubPane")] diff --git a/src/GitHub.App/Resources.Designer.cs b/src/GitHub.App/Resources.Designer.cs deleted file mode 100644 index 944fbf1b75..0000000000 --- a/src/GitHub.App/Resources.Designer.cs +++ /dev/null @@ -1,594 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace GitHub.App { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GitHub.App.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to add. - /// - internal static string AddedFileStatus { - get { - return ResourceManager.GetString("AddedFileStatus", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Select a containing folder for your new repository.. - /// - internal static string BrowseForDirectory { - get { - return ResourceManager.GetString("BrowseForDirectory", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Clone a {0} Repository. - /// - internal static string CloneTitle { - get { - return ResourceManager.GetString("CloneTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Could not connect to github.com. - /// - internal static string CouldNotConnectToGitHub { - get { - return ResourceManager.GetString("CouldNotConnectToGitHub", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Create a GitHub Gist. - /// - internal static string CreateGistTitle { - get { - return ResourceManager.GetString("CreateGistTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Create a {0} Repository. - /// - internal static string CreateTitle { - get { - return ResourceManager.GetString("CreateTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to GistFromVisualStudio.cs. - /// - internal static string DefaultGistFileName { - get { - return ResourceManager.GetString("DefaultGistFileName", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter an Enterprise URL. - /// - internal static string EnterpriseUrlValidatorEmpty { - get { - return ResourceManager.GetString("EnterpriseUrlValidatorEmpty", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter a valid Enterprise URL. - /// - internal static string EnterpriseUrlValidatorInvalid { - get { - return ResourceManager.GetString("EnterpriseUrlValidatorInvalid", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Not an Enterprise server. Please enter an Enterprise URL. - /// - internal static string EnterpriseUrlValidatorNotAGitHubHost { - get { - return ResourceManager.GetString("EnterpriseUrlValidatorNotAGitHubHost", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to (forgot your password?). - /// - internal static string ForgotPasswordLink { - get { - return ResourceManager.GetString("ForgotPasswordLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to fork. - /// - internal static string Fork { - get { - return ResourceManager.GetString("Fork", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to [invalid]. - /// - internal static string InvalidBranchName { - get { - return ResourceManager.GetString("InvalidBranchName", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Make sure to use your password and not a Personal Access token to sign in.. - /// - internal static string LoginFailedForbiddenMessage { - get { - return ResourceManager.GetString("LoginFailedForbiddenMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Check your username and password, then try again. - /// - internal static string LoginFailedMessage { - get { - return ResourceManager.GetString("LoginFailedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign in failed.. - /// - internal static string LoginFailedText { - get { - return ResourceManager.GetString("LoginFailedText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Connect To GitHub. - /// - internal static string LoginTitle { - get { - return ResourceManager.GetString("LoginTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to create a Gist. - /// - internal static string LogoutRequiredFeatureGist { - get { - return ResourceManager.GetString("LogoutRequiredFeatureGist", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to To {0} you need to sign out and back in.. - /// - internal static string LogoutRequiredMessage { - get { - return ResourceManager.GetString("LogoutRequiredMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign Out Required. - /// - internal static string LogoutRequiredTitle { - get { - return ResourceManager.GetString("LogoutRequiredTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You must pull before you can push. - /// - internal static string MustPullBeforePush { - get { - return ResourceManager.GetString("MustPullBeforePush", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No commits to pull. - /// - internal static string NoCommitsToPull { - get { - return ResourceManager.GetString("NoCommitsToPull", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No commits to push. - /// - internal static string NoCommitsToPush { - get { - return ResourceManager.GetString("NoCommitsToPush", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to *No description provided.*. - /// - internal static string NoDescriptionProvidedMarkdown { - get { - return ResourceManager.GetString("NoDescriptionProvidedMarkdown", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter your password. - /// - internal static string PasswordValidatorEmpty { - get { - return ResourceManager.GetString("PasswordValidatorEmpty", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pull Request for branch **{0}** created successfully at [{1}]({2}). - /// - internal static string PRCreatedUpstream { - get { - return ResourceManager.GetString("PRCreatedUpstream", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish repository. - /// - internal static string PublishTitle { - get { - return ResourceManager.GetString("PublishTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish repository to {0}. - /// - internal static string PublishToTitle { - get { - return ResourceManager.GetString("PublishToTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter a title for the Pull Request. - /// - internal static string PullRequestCreationTitleValidatorEmpty { - get { - return ResourceManager.GetString("PullRequestCreationTitleValidatorEmpty", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Checkout {0}. - /// - internal static string PullRequestDetailsCheckout { - get { - return ResourceManager.GetString("PullRequestDetailsCheckout", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Checkout to {0}. - /// - internal static string PullRequestDetailsCheckoutTo { - get { - return ResourceManager.GetString("PullRequestDetailsCheckoutTo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pull from {0} branch {1}. - /// - internal static string PullRequestDetailsPullToolTip { - get { - return ResourceManager.GetString("PullRequestDetailsPullToolTip", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Push to {0} branch {1}. - /// - internal static string PullRequestDetailsPushToolTip { - get { - return ResourceManager.GetString("PullRequestDetailsPushToolTip", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pull Request. - /// - internal static string PullRequestNavigationItemText { - get { - return ResourceManager.GetString("PullRequestNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pull Requests. - /// - internal static string PullRequestsNavigationItemText { - get { - return ResourceManager.GetString("PullRequestsNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Source and target branch cannot be the same. - /// - internal static string PullRequestSourceAndTargetBranchTheSame { - get { - return ResourceManager.GetString("PullRequestSourceAndTargetBranchTheSame", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Source branch doesn't exist remotely, have you pushed it?. - /// - internal static string PullRequestSourceBranchDoesNotExist { - get { - return ResourceManager.GetString("PullRequestSourceBranchDoesNotExist", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to remote. - /// - internal static string Remote { - get { - return ResourceManager.GetString("Remote", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to rename. - /// - internal static string RenamedFileStatus { - get { - return ResourceManager.GetString("RenamedFileStatus", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No selected repository.. - /// - internal static string RepositoryCloneFailedNoSelectedRepo { - get { - return ResourceManager.GetString("RepositoryCloneFailedNoSelectedRepo", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter a repository path. - /// - internal static string RepositoryCreationClonePathEmpty { - get { - return ResourceManager.GetString("RepositoryCreationClonePathEmpty", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter a valid path. - /// - internal static string RepositoryCreationClonePathInvalid { - get { - return ResourceManager.GetString("RepositoryCreationClonePathInvalid", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Path contains invalid characters. - /// - internal static string RepositoryCreationClonePathInvalidCharacters { - get { - return ResourceManager.GetString("RepositoryCreationClonePathInvalidCharacters", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Path too long. - /// - internal static string RepositoryCreationClonePathTooLong { - get { - return ResourceManager.GetString("RepositoryCreationClonePathTooLong", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Repository '{0}/{1}' already exists.. - /// - internal static string RepositoryCreationFailedAlreadyExists { - get { - return ResourceManager.GetString("RepositoryCreationFailedAlreadyExists", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Change the repository name or select a different account and try again.. - /// - internal static string RepositoryCreationFailedAlreadyExistsMessage { - get { - return ResourceManager.GetString("RepositoryCreationFailedAlreadyExistsMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Exceeded private repositories quota.. - /// - internal static string RepositoryCreationFailedQuota { - get { - return ResourceManager.GetString("RepositoryCreationFailedQuota", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Repository with same name already exists at this location. - /// - internal static string RepositoryNameValidatorAlreadyExists { - get { - return ResourceManager.GetString("RepositoryNameValidatorAlreadyExists", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter a repository name. - /// - internal static string RepositoryNameValidatorEmpty { - get { - return ResourceManager.GetString("RepositoryNameValidatorEmpty", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Repository name must be fewer than 100 characters. - /// - internal static string RepositoryNameValidatorTooLong { - get { - return ResourceManager.GetString("RepositoryNameValidatorTooLong", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Will be created as {0}. - /// - internal static string SafeRepositoryNameWarning { - get { - return ResourceManager.GetString("SafeRepositoryNameWarning", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The source repository is no longer available.. - /// - internal static string SourceRepositoryNoLongerAvailable { - get { - return ResourceManager.GetString("SourceRepositoryNoLongerAvailable", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open the two-factor authentication app on your device to view your authentication code.. - /// - internal static string TwoFactorApp { - get { - return ResourceManager.GetString("TwoFactorApp", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to We sent you a message via SMS with your authentication code.. - /// - internal static string TwoFactorSms { - get { - return ResourceManager.GetString("TwoFactorSms", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Two-Factor authentication required. - /// - internal static string TwoFactorTitle { - get { - return ResourceManager.GetString("TwoFactorTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Enter a sign in authentication code here. - /// - internal static string TwoFactorUnknown { - get { - return ResourceManager.GetString("TwoFactorUnknown", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please enter your username or email address. - /// - internal static string UsernameOrEmailValidatorEmpty { - get { - return ResourceManager.GetString("UsernameOrEmailValidatorEmpty", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Username or email address must not have spaces. - /// - internal static string UsernameOrEmailValidatorSpaces { - get { - return ResourceManager.GetString("UsernameOrEmailValidatorSpaces", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Cannot checkout as your working directory has uncommitted changes.. - /// - internal static string WorkingDirectoryHasUncommittedCHanges { - get { - return ResourceManager.GetString("WorkingDirectoryHasUncommittedCHanges", resourceCulture); - } - } - } -} diff --git a/src/GitHub.App/Resources.resx b/src/GitHub.App/Resources.resx deleted file mode 100644 index 21ec70c920..0000000000 --- a/src/GitHub.App/Resources.resx +++ /dev/null @@ -1,297 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Select a containing folder for your new repository. - - - Clone a {0} Repository - - - Could not connect to github.com - - - Create a GitHub Gist - - - Create a {0} Repository - - - GistFromVisualStudio.cs - - - Please enter an Enterprise URL - - - Please enter a valid Enterprise URL - - - Not an Enterprise server. Please enter an Enterprise URL - - - (forgot your password?) - - - Make sure to use your password and not a Personal Access token to sign in. - - - Check your username and password, then try again - - - Sign in failed. - - - Connect To GitHub - - - create a Gist - - - To {0} you need to sign out and back in. - - - Sign Out Required - - - Please enter your password - - - Pull Request for branch **{0}** created successfully at [{1}]({2}) - - - Publish repository - - - Publish repository to {0} - - - Please enter a title for the Pull Request - - - Source and target branch cannot be the same - - - Source branch doesn't exist remotely, have you pushed it? - - - No selected repository. - - - Please enter a repository path - - - Please enter a valid path - - - Path contains invalid characters - - - Path too long - - - Repository '{0}/{1}' already exists. - - - Change the repository name or select a different account and try again. - - - Exceeded private repositories quota. - - - Repository with same name already exists at this location - - - Please enter a repository name - - - Repository name must be fewer than 100 characters - - - Will be created as {0} - - - Open the two-factor authentication app on your device to view your authentication code. - - - We sent you a message via SMS with your authentication code. - - - Two-Factor authentication required - - - Enter a sign in authentication code here - - - Please enter your username or email address - - - Username or email address must not have spaces - - - Pull Requests - - - Pull Request - - - add - - - fork - - - [invalid] - - - You must pull before you can push - - - No commits to pull - - - No commits to push - - - *No description provided.* - - - Checkout {0} - - - Checkout to {0} - - - Pull from {0} branch {1} - - - Push to {0} branch {1} - - - remote - - - rename - - - The source repository is no longer available. - - - Cannot checkout as your working directory has uncommitted changes. - - \ No newline at end of file diff --git a/src/GitHub.App/SampleData/AccountDesigner.cs b/src/GitHub.App/SampleData/AccountDesigner.cs index 8d555e2cdb..68770f8336 100644 --- a/src/GitHub.App/SampleData/AccountDesigner.cs +++ b/src/GitHub.App/SampleData/AccountDesigner.cs @@ -32,6 +32,7 @@ public BitmapSource Avatar public string Login { get; set; } public int OwnedPrivateRepos { get; set; } public long PrivateReposInPlan { get; set; } + public string AvatarUrl { get; set; } public override string ToString() { diff --git a/src/GitHub.App/SampleData/ActorViewModelDesigner.cs b/src/GitHub.App/SampleData/ActorViewModelDesigner.cs new file mode 100644 index 0000000000..7edc14b0e9 --- /dev/null +++ b/src/GitHub.App/SampleData/ActorViewModelDesigner.cs @@ -0,0 +1,26 @@ +using System; +using System.Windows.Media.Imaging; +using GitHub.Services; +using GitHub.ViewModels; + +namespace GitHub.SampleData +{ + public class ActorViewModelDesigner : ViewModelBase, IActorViewModel + { + public ActorViewModelDesigner() + { + AvatarUrl = "pack://application:,,,/GitHub.App;component/Images/default_user_avatar.png"; + Avatar = AvatarProvider.CreateBitmapImage(AvatarUrl); + } + + public ActorViewModelDesigner(string login) + : this() + { + Login = login; + } + + public BitmapSource Avatar { get; } + public string AvatarUrl { get; } + public string Login { get; set; } + } +} diff --git a/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs new file mode 100644 index 0000000000..aaf13bafef --- /dev/null +++ b/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses")] + public class CommentThreadViewModelDesigner : ViewModelBase, ICommentThreadViewModel + { + public CommentThreadViewModelDesigner() + { + Comments = new ReactiveList(){new CommentViewModelDesigner() + { + Author = new ActorViewModel{ Login = "shana"}, + Body = "You can use a `CompositeDisposable` type here, it's designed to handle disposables in an optimal way (you can just call `Dispose()` on it and it will handle disposing everything it holds)." + }}; + + } + + public IReadOnlyReactiveList Comments { get; } + = new ReactiveList(); + + public IActorViewModel CurrentUser { get; set; } + = new ActorViewModel { Login = "shana" }; + + public Task DeleteComment(ICommentViewModel comment) => Task.CompletedTask; + public Task EditComment(ICommentViewModel comment) => Task.CompletedTask; + public Task PostComment(ICommentViewModel comment) => Task.CompletedTask; + } +} diff --git a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs new file mode 100644 index 0000000000..132fe03568 --- /dev/null +++ b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs @@ -0,0 +1,48 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses")] + public class CommentViewModelDesigner : ReactiveObject, ICommentViewModel + { + public CommentViewModelDesigner() + { + Author = new ActorViewModel { Login = "shana" }; + } + + public string Id { get; set; } + public int PullRequestId { get; set; } + public int DatabaseId { get; set; } + public string Body { get; set; } + public string ErrorMessage { get; set; } + public CommentEditState EditState { get; set; } + public bool IsReadOnly { get; set; } + public bool IsSubmitting { get; set; } + public bool CanCancel { get; } = true; + public bool CanDelete { get; } = true; + public string CommitCaption { get; set; } = "Comment"; + public ICommentThreadViewModel Thread { get; } + public DateTimeOffset CreatedAt => DateTime.Now.Subtract(TimeSpan.FromDays(3)); + public IActorViewModel Author { get; set; } + public Uri WebUrl { get; } + + public ReactiveCommand BeginEdit { get; } + public ReactiveCommand CancelEdit { get; } + public ReactiveCommand CommitEdit { get; } + public ReactiveCommand OpenOnGitHub { get; } = ReactiveCommand.Create(() => { }); + public ReactiveCommand Delete { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + + public Task InitializeAsync(ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, CommentEditState state) + { + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/SampleData/Dialog/Clone/RepositoryCloneViewModelDesigner.cs b/src/GitHub.App/SampleData/Dialog/Clone/RepositoryCloneViewModelDesigner.cs new file mode 100644 index 0000000000..6ce69138d9 --- /dev/null +++ b/src/GitHub.App/SampleData/Dialog/Clone/RepositoryCloneViewModelDesigner.cs @@ -0,0 +1,37 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog.Clone; +using ReactiveUI; + +namespace GitHub.SampleData.Dialog.Clone +{ + public class RepositoryCloneViewModelDesigner : ViewModelBase, IRepositoryCloneViewModel + { + public RepositoryCloneViewModelDesigner() + { + GitHubTab = new SelectPageViewModelDesigner(); + EnterpriseTab = new SelectPageViewModelDesigner(); + } + + public string Path { get; set; } + public UriString Url { get; set; } + public string PathWarning { get; set; } + public int SelectedTabIndex { get; set; } + public string Title => null; + public IObservable Done => null; + public IRepositorySelectViewModel GitHubTab { get; } + public IRepositorySelectViewModel EnterpriseTab { get; } + public ReactiveCommand Browse { get; } + public ReactiveCommand Clone { get; } + public ReactiveCommand LoginAsDifferentUser { get; } + + public Task InitializeAsync(IConnection connection) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/GitHub.App/SampleData/Dialog/Clone/SelectPageViewModelDesigner.cs b/src/GitHub.App/SampleData/Dialog/Clone/SelectPageViewModelDesigner.cs new file mode 100644 index 0000000000..14ad9d514e --- /dev/null +++ b/src/GitHub.App/SampleData/Dialog/Clone/SelectPageViewModelDesigner.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using System.Windows.Data; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog.Clone; +using ReactiveUI; + +namespace GitHub.SampleData.Dialog.Clone +{ + public class SelectPageViewModelDesigner : ViewModelBase, IRepositorySelectViewModel + { + public SelectPageViewModelDesigner() + { + var items = new[] + { + new RepositoryListItemModel { Name = "encourage", Owner = "haacked" }, + new RepositoryListItemModel { Name = "haacked.com", Owner = "haacked", IsFork = true }, + new RepositoryListItemModel { Name = "octokit.net", Owner = "octokit" }, + new RepositoryListItemModel { Name = "octokit.rb", Owner = "octokit" }, + new RepositoryListItemModel { Name = "octokit.objc", Owner = "octokit" }, + new RepositoryListItemModel { Name = "windows", Owner = "github" }, + new RepositoryListItemModel { Name = "mac", Owner = "github", IsPrivate = true }, + new RepositoryListItemModel { Name = "github", Owner = "github", IsPrivate = true } + }; + + Items = items.Select(x => new RepositoryItemViewModel(x, x.Owner)).ToList(); + ItemsView = CollectionViewSource.GetDefaultView(Items); + ItemsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(RepositoryItemViewModel.Group))); + } + + public Exception Error { get; set; } + public string Filter { get; set; } + public bool IsEnabled { get; set; } = true; + public bool IsLoading { get; set; } + public IReadOnlyList Items { get; } + public ICollectionView ItemsView { get; } + public IRepositoryItemViewModel SelectedItem { get; set; } + public RepositoryModel Repository { get; } + + public void Initialize(IConnection connection) + { + } + + public Task Activate() + { + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/SampleData/Documents/IssueishCommentThreadViewModelDesigner.cs b/src/GitHub.App/SampleData/Documents/IssueishCommentThreadViewModelDesigner.cs new file mode 100644 index 0000000000..888f09cd73 --- /dev/null +++ b/src/GitHub.App/SampleData/Documents/IssueishCommentThreadViewModelDesigner.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Documents; +using ReactiveUI; + +namespace GitHub.SampleData.Documents +{ + public class IssueishCommentThreadViewModelDesigner : ViewModelBase, IIssueishCommentThreadViewModel + { + public IActorViewModel CurrentUser { get; } = new ActorViewModelDesigner("grokys"); + public Task InitializeAsync(ActorModel currentUser, IssueishDetailModel model, bool addPlaceholder) => Task.CompletedTask; + public Task DeleteComment(ICommentViewModel comment) => Task.CompletedTask; + public Task EditComment(ICommentViewModel comment) => Task.CompletedTask; + public Task PostComment(ICommentViewModel comment) => Task.CompletedTask; + public Task CloseOrReopen(ICommentViewModel comment) => Task.CompletedTask; + } +} diff --git a/src/GitHub.App/SampleData/Documents/PullRequestPageViewModelDesigner.cs b/src/GitHub.App/SampleData/Documents/PullRequestPageViewModelDesigner.cs new file mode 100644 index 0000000000..82ecf90d70 --- /dev/null +++ b/src/GitHub.App/SampleData/Documents/PullRequestPageViewModelDesigner.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Documents; +using ReactiveUI; + +namespace GitHub.SampleData.Documents +{ + public class PullRequestPageViewModelDesigner : ViewModelBase, IPullRequestPageViewModel + { + public PullRequestPageViewModelDesigner() + { + Body = @"Save drafts of inline comments, PR reviews and PRs. + +> Note: This feature required a refactoring of the comment view models because they now need async initialization and to be available from GitHub.App. This part of the PR has been submitted separately as #1993 to ease review. The two PRs can alternatively be reviewed as one if that's more convenient. + +As described in #1905, it is easy to lose a comment that you're working on if you close the diff view accidentally. This PR saves drafts of comments as they are being written to an SQLite database. + +In addition to saving drafts of inline comments, it also saves comments to PR reviews and PRs themselves. + +The comments are written to an SQLite database directly instead of going through Akavache because in the case of inline reviews, there can be many drafts in progress on a separate file. When a diff is opened we need to look for any comments present on that file and show the most recent. That use-case didn't fit well with Akavache (being a pure key/value store). + +## Testing + +### Inline Comments + +- Open a PR +- Open the diff of a file +- Start adding a comment +- Close the comment by closing the peek view, or the document tab +- Reopen the diff +- You should see the comment displayed in edit mode with the draft of the comment you were previously writing + +### PR reviews + +- Open a PR +- Click ""Add your review"" +- Start adding a review +- Click the ""Back"" button and navigate to a different PR +- Click the ""Back"" button and navigate to the original PR +- Click ""Add your review"" +- You should see the the draft of the review you were previously writing + +### PRs + +-Click ""Create new"" at the top of the PR list +- Start adding a PR title/ description +- Close VS +- Restart VS and click ""Create new"" again +- You should see the the draft of the PR you were previously writing + +Depends on #1993 +Fixes #1905"; + Timeline = new IViewModel[] + { + new CommitListViewModel( + new CommitSummaryViewModel(new CommitModel + { + Author = new CommitActorModel { User = new ActorModel{ Login = "grokys" }}, + AbbreviatedOid = "c7c7d25", + MessageHeadline = "Refactor comment view models." + }), + new CommitSummaryViewModel(new CommitModel + { + Author = new CommitActorModel { User = new ActorModel{ Login = "shana" }}, + AbbreviatedOid = "04e6a90", + MessageHeadline = "Refactor comment view models.", + })), + new CommentViewModelDesigner + { + Author = new ActorViewModelDesigner("meaghanlewis"), + Body = @"This is looking great! Really enjoying using this feature so far. + +When leaving an inline comment, the comment posts successfully and then a new comment is drafted with the same text.", + }, + new CommentViewModelDesigner + { + Author = new ActorViewModelDesigner("grokys"), + Body = @"Oops, sorry about that @meaghanlewis - I was sure I tested those things, but must have got messed up again at some point. Should be fixed now.", + }, + }; + } + + public string Id { get; set; } + public PullRequestState State { get; set; } = PullRequestState.Open; + public IReadOnlyList Timeline { get; } + public string SourceBranchDisplayName { get; set; } = "feature/save-drafts"; + public string TargetBranchDisplayName { get; set; } = "master"; + public IActorViewModel Author { get; set; } = new ActorViewModelDesigner("grokys"); + public int CommitCount { get; set; } = 2; + public string Body { get; set; } + public int Number { get; set; } = 1994; + public LocalRepositoryModel LocalRepository { get; } + public RemoteRepositoryModel Repository { get; set; } + public string Title { get; set; } = "Save drafts of comments"; + public Uri WebUrl { get; set; } + public ReactiveCommand OpenOnGitHub { get; } + public ReactiveCommand ShowCommit { get; } + + + public Task InitializeAsync(RemoteRepositoryModel repository, LocalRepositoryModel localRepository, ActorModel currentUser, PullRequestDetailModel model) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/GitHub.App/SampleData/ForkRepositoryExecuteViewModelDesigner.cs b/src/GitHub.App/SampleData/ForkRepositoryExecuteViewModelDesigner.cs new file mode 100644 index 0000000000..74d05021ab --- /dev/null +++ b/src/GitHub.App/SampleData/ForkRepositoryExecuteViewModelDesigner.cs @@ -0,0 +1,65 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using Octokit; +using ReactiveUI; +using IConnection = GitHub.Models.IConnection; + +namespace GitHub.SampleData +{ + public class ForkRepositoryExecuteViewModelDesigner : ViewModelBase, IForkRepositoryExecuteViewModel + { + public ForkRepositoryExecuteViewModelDesigner() + { + SourceRepository = new RemoteRepositoryModelDesigner + { + Owner = "github", + Name = "VisualStudio", + CloneUrl = "https://github.com/github/VisualStudio", + }; + DestinationRepository = new RemoteRepositoryModelDesigner + { + Owner = "user", + Name = "VisualStudio", + CloneUrl = "https://github.com/user/VisualStudio", + }; + DestinationAccount = new AccountDesigner(); + } + + public IObservable Done => null; + + public IObservable Back => null; + + public string Title => null; + + public RepositoryModel SourceRepository { get; set; } + + public RepositoryModel DestinationRepository { get; set; } + + public IAccount DestinationAccount { get; } + + public ReactiveCommand CreateFork => null; + + public ReactiveCommand BackCommand => null; + + public bool ResetMasterTracking { get; set; } = true; + + public bool AddUpstream { get; set; } = true; + + public bool UpdateOrigin { get; set; } = true; + + public bool CanAddUpstream => UpdateOrigin; + + public bool CanResetMasterTracking => UpdateOrigin && AddUpstream; + + public string Error { get; } = "I AM ERROR"; + + public Task InitializeAsync(LocalRepositoryModel sourceRepository, IAccount destinationAccount, IConnection connection) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/ForkRepositorySelectViewModelDesigner.cs b/src/GitHub.App/SampleData/ForkRepositorySelectViewModelDesigner.cs new file mode 100644 index 0000000000..bb57782311 --- /dev/null +++ b/src/GitHub.App/SampleData/ForkRepositorySelectViewModelDesigner.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class ForkRepositorySelectViewModelDesigner : ViewModelBase, IForkRepositorySelectViewModel + { + public ForkRepositorySelectViewModelDesigner() + { + Accounts = new[] + { + new AccountDesigner { Login = "Myself", AvatarUrl = "https://identicons.github.com/myself.png" }, + new AccountDesigner { Login = "MyOrg1", AvatarUrl = "https://identicons.github.com/myorg1.png" }, + new AccountDesigner { Login = "MyOrg2", AvatarUrl = "https://identicons.github.com/myorg2.png" }, + new AccountDesigner { Login = "MyOrg3", AvatarUrl = "https://identicons.github.com/myorg3.png" }, + new AccountDesigner { Login = "a-long-org-name", AvatarUrl = "https://identicons.github.com/a-long-org-name.png" }, + }; + + ExistingForks = new[] + { + new RemoteRepositoryModelDesigner { Owner = "MyOrg5", Name = "MyRepo" }, + new RemoteRepositoryModelDesigner { Owner = "MyOrg6", Name = "MyRepo" }, + }; + } + + public IReadOnlyList Accounts { get; set; } + + public IObservable Done => null; + + public IReadOnlyList ExistingForks { get; set; } + + public bool IsLoading { get; set; } + + public string Title => null; + + public ReactiveCommand SelectedAccount => null; + + public ReactiveCommand SwitchOrigin => null; + + public Task InitializeAsync(LocalRepositoryModel repository, IConnection connection) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/ForkRepositorySwitchViewModelDesigner.cs b/src/GitHub.App/SampleData/ForkRepositorySwitchViewModelDesigner.cs new file mode 100644 index 0000000000..2fa298fa05 --- /dev/null +++ b/src/GitHub.App/SampleData/ForkRepositorySwitchViewModelDesigner.cs @@ -0,0 +1,48 @@ +using System; +using System.Reactive; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class ForkRepositorySwitchViewModelDesigner : ViewModelBase, IForkRepositorySwitchViewModel + { + public ForkRepositorySwitchViewModelDesigner() + { + SourceRepository = new RemoteRepositoryModelDesigner + { + Owner = "github", + Name = "VisualStudio", + CloneUrl = "https://github.com/github/VisualStudio", + }; + DestinationRepository = new RemoteRepositoryModelDesigner + { + Owner = "user", + Name = "VisualStudio", + CloneUrl = "https://github.com/user/VisualStudio", + }; + } + + public string Title => null; + + public IObservable Done => null; + + public RepositoryModel SourceRepository { get; } + + public RepositoryModel DestinationRepository { get; } + + public ReactiveCommand SwitchFork => null; + + public bool ResetMasterTracking { get; set; } = true; + + public bool AddUpstream { get; set; } = true; + + public bool UpdateOrigin { get; set; } = true; + + public void Initialize(LocalRepositoryModel sourceRepository, RemoteRepositoryModel remoteRepository) + { + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/GitServiceDesigner.cs b/src/GitHub.App/SampleData/GitServiceDesigner.cs new file mode 100644 index 0000000000..1e125d1a6e --- /dev/null +++ b/src/GitHub.App/SampleData/GitServiceDesigner.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using LibGit2Sharp; + +namespace GitHub.SampleData +{ + class GitServiceDesigner : IGitService + { + public LocalRepositoryModel CreateLocalRepositoryModel(string localPath) => null; + public BranchModel GetBranch(LocalRepositoryModel model) => null; + public Task GetLatestPushedSha(string path, string remote = "origin") => Task.FromResult(null); + public UriString GetRemoteUri(IRepository repo, string remote = "origin") => null; + public IRepository GetRepository(string path) => null; + public UriString GetUri(string path, string remote = "origin") => null; + public UriString GetUri(IRepository repository, string remote = "origin") => null; + public Task Compare(IRepository repository, string sha1, string sha2, string relativePath) => null; + public Task CompareWith(IRepository repository, string sha1, string sha2, string path, byte[] contents) => null; + public Task Compare(IRepository repository, string sha1, string sha2, bool detectRenames = false) => null; + } +} diff --git a/src/GitHub.App/SampleData/InlineAnnotationViewModelDesigner.cs b/src/GitHub.App/SampleData/InlineAnnotationViewModelDesigner.cs new file mode 100644 index 0000000000..4aea77efb7 --- /dev/null +++ b/src/GitHub.App/SampleData/InlineAnnotationViewModelDesigner.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using GitHub.Models; +using GitHub.ViewModels; + +namespace GitHub.SampleData +{ + public class InlineAnnotationViewModelDesigner : IInlineAnnotationViewModel + { + public InlineAnnotationViewModelDesigner() + { + var checkRunAnnotationModel = new CheckRunAnnotationModel + { + AnnotationLevel = CheckAnnotationLevel.Failure, + Path = "SomeFile.cs", + EndLine = 12, + StartLine = 12, + Message = "Some Error Message", + Title = "CS12345" + }; + + var checkRunModel = + new CheckRunModel + { + Annotations = new List {checkRunAnnotationModel}, + Name = "Fake Check Run" + }; + + var checkSuiteModel = new CheckSuiteModel() + { + ApplicationName = "Fake Check Suite", + HeadSha = "ed6198c37b13638e902716252b0a17d54bd59e4a", + CheckRuns = new List { checkRunModel} + }; + + Model= new InlineAnnotationModel(checkSuiteModel, checkRunModel, checkRunAnnotationModel); + } + + public InlineAnnotationModel Model { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/LocalRepositoryModelDesigner.cs b/src/GitHub.App/SampleData/LocalRepositoryModelDesigner.cs index 804a6f165a..1a1b4f7189 100644 --- a/src/GitHub.App/SampleData/LocalRepositoryModelDesigner.cs +++ b/src/GitHub.App/SampleData/LocalRepositoryModelDesigner.cs @@ -1,40 +1,16 @@ -using System; -using System.ComponentModel; -using System.Threading.Tasks; -using GitHub.Models; +using GitHub.Models; using GitHub.Primitives; using GitHub.UI; -using NullGuard; namespace GitHub.App.SampleData { - [NullGuard(ValidationFlags.None)] - public class LocalRepositoryModelDesigner : ILocalRepositoryModel + public class LocalRepositoryModelDesigner : LocalRepositoryModel { - public UriString CloneUrl { get; set; } - public IBranch CurrentBranch { get; set; } - public Octicon Icon { get; set; } - public string LocalPath { get; set; } - public string Name { get; set; } - public string Owner { get; set; } - -#pragma warning disable CS0067 - public event PropertyChangedEventHandler PropertyChanged; -#pragma warning restore CS0067 - - public Task GenerateUrl(string path = null, int startLine = -1, int endLine = -1) - { - throw new NotImplementedException(); - } - - public void Refresh() - { - throw new NotImplementedException(); - } - - public void SetIcon(bool isPrivate, bool isFork) - { - throw new NotImplementedException(); - } + public new UriString CloneUrl { get; set; } + public BranchModel CurrentBranch { get; set; } + public new Octicon Icon { get; set; } + public new string LocalPath { get; set; } + public new string Name { get; set; } + public new string Owner { get; set; } } } diff --git a/src/GitHub.App/SampleData/LoggedOutViewModelDesigner.cs b/src/GitHub.App/SampleData/LoggedOutViewModelDesigner.cs deleted file mode 100644 index e40742da21..0000000000 --- a/src/GitHub.App/SampleData/LoggedOutViewModelDesigner.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Reactive.Linq; -using System.Windows.Input; -using GitHub.Collections; -using GitHub.Models; -using GitHub.ViewModels; - -namespace GitHub.SampleData -{ - [ExcludeFromCodeCoverage] - public class LoggedOutViewModelDesigner : BaseViewModel, IViewModel - { - public LoggedOutViewModelDesigner() - { - } - } -} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestAnnotationItemViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestAnnotationItemViewModelDesigner.cs new file mode 100644 index 0000000000..03f4e7d056 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestAnnotationItemViewModelDesigner.cs @@ -0,0 +1,18 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public sealed class PullRequestAnnotationItemViewModelDesigner : IPullRequestAnnotationItemViewModel + { + public CheckRunAnnotationModel Annotation { get; set; } + public bool IsExpanded { get; set; } + public string LineDescription => $"{Annotation.StartLine}:{Annotation.EndLine}"; + public bool IsFileInPullRequest { get; set; } + public ReactiveCommand OpenAnnotation { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestAnnotationsViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestAnnotationsViewModelDesigner.cs new file mode 100644 index 0000000000..8e728ee8f3 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestAnnotationsViewModelDesigner.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public sealed class PullRequestAnnotationsViewModelDesigner : PanePageViewModelBase, IPullRequestAnnotationsViewModel + { + public LocalRepositoryModel LocalRepository { get; set; } + public string RemoteRepositoryOwner { get; set; } + public int PullRequestNumber { get; set; } = 123; + public string CheckRunId { get; set; } + public ReactiveCommand NavigateToPullRequest { get; } + public string PullRequestTitle { get; } = "Fixing stuff in this PR"; + public string CheckSuiteName { get; } = "Awesome Check Suite"; + public string CheckRunSummary { get; } = "Awesome Check Run Summary"; + public string CheckRunText { get; } = "Awesome Check Run Text"; + + public IReadOnlyDictionary AnnotationsDictionary { get; } + = new Dictionary + { + { + "asdf/asdf.cs", + new IPullRequestAnnotationItemViewModel[] + { + new PullRequestAnnotationItemViewModelDesigner + { + Annotation = new CheckRunAnnotationModel + { + AnnotationLevel = CheckAnnotationLevel.Warning, + StartLine = 3, + EndLine = 4, + Path = "asdf/asdf.cs", + Message = "; is expected", + Title = "CS 12345" + }, + IsExpanded = true, + IsFileInPullRequest = true + }, + new PullRequestAnnotationItemViewModelDesigner + { + Annotation = new CheckRunAnnotationModel + { + AnnotationLevel = CheckAnnotationLevel.Failure, + StartLine = 3, + EndLine = 4, + Path = "asdf/asdf.cs", + Message = "; is expected", + Title = "CS 12345" + }, + IsExpanded = true, + IsFileInPullRequest = true + }, + } + }, + { + "blah.cs", + new IPullRequestAnnotationItemViewModel[] + { + new PullRequestAnnotationItemViewModelDesigner + { + Annotation = new CheckRunAnnotationModel + { + AnnotationLevel = CheckAnnotationLevel.Notice, + StartLine = 3, + EndLine = 4, + Path = "blah.cs", + Message = "; is expected", + Title = "CS 12345" + }, + IsExpanded = true, + } + } + }, + }; + + public string CheckRunName { get; } = "Psuedo Check Run"; + + public Task InitializeAsync(LocalRepositoryModel localRepository, IConnection connection, string owner, + string repo, + int pullRequestNumber, string checkRunId) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestCheckViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestCheckViewModelDesigner.cs new file mode 100644 index 0000000000..ec75bf27be --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestCheckViewModelDesigner.cs @@ -0,0 +1,28 @@ +using System; +using System.Reactive; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public sealed class PullRequestCheckViewModelDesigner : ViewModelBase, IPullRequestCheckViewModel + { + public string Title { get; set; } = "continuous-integration/appveyor/pr"; + + public string Description { get; set; } = "AppVeyor build failed"; + + public PullRequestCheckStatus Status { get; set; } = PullRequestCheckStatus.Failure; + + public Uri DetailsUrl { get; set; } = new Uri("http://github.com"); + + public ReactiveCommand OpenDetailsUrl { get; set; } = null; + + public PullRequestCheckType CheckType { get; set; } = PullRequestCheckType.ChecksApi; + + public string CheckRunId { get; set; } + + public bool HasAnnotations { get; } = true; + } +} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs index 422b33a67c..77edc7ab89 100644 --- a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs @@ -1,26 +1,36 @@ -using System.Diagnostics.CodeAnalysis; -using GitHub.ViewModels; +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using GitHub.Validation; +using GitHub.ViewModels.GitHubPane; using ReactiveUI; namespace GitHub.SampleData { [ExcludeFromCodeCoverage] - public class PullRequestCreationViewModelDesigner : BaseViewModel, IPullRequestCreationViewModel + public class PullRequestCreationViewModelDesigner : PanePageViewModelBase, IPullRequestCreationViewModel { public PullRequestCreationViewModelDesigner() { - Branches = new List + var repositoryModel = new LocalRepositoryModel + { + Name = "repo", + CloneUrl = "http://github.com/user/repo" + }; + + Branches = new List { - new BranchModel("master", new LocalRepositoryModel("http://github.com/user/repo")), - new BranchModel("don/stub-ui", new LocalRepositoryModel("http://github.com/user/repo")), - new BranchModel("feature/pr/views", new LocalRepositoryModel("http://github.com/user/repo")), - new BranchModel("release-1.0.17.0", new LocalRepositoryModel("http://github.com/user/repo")), + new BranchModel("master", repositoryModel), + new BranchModel("don/stub-ui", repositoryModel), + new BranchModel("feature/pr/views", repositoryModel), + new BranchModel("release-1.0.17.0", repositoryModel), }.AsReadOnly(); - TargetBranch = new BranchModel("master", new LocalRepositoryModel("http://github.com/user/repo")); + TargetBranch = new BranchModel("master", repositoryModel); SourceBranch = Branches[2]; SelectedAssignee = "Haacked (Phil Haack)"; @@ -31,20 +41,23 @@ public PullRequestCreationViewModelDesigner() }; } - public IBranch SourceBranch { get; set; } - public IBranch TargetBranch { get; set; } - public IReadOnlyList Branches { get; set; } + public BranchModel SourceBranch { get; set; } + public BranchModel TargetBranch { get; set; } + public IReadOnlyList Branches { get; set; } public string SelectedAssignee { get; set; } public List Users { get; set; } - public IReactiveCommand CreatePullRequest { get; } + public ReactiveCommand CreatePullRequest { get; } + public ReactiveCommand Cancel { get; } public string PRTitle { get; set; } public ReactivePropertyValidator TitleValidator { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } public ReactivePropertyValidator BranchValidator { get; } + public Task InitializeAsync(LocalRepositoryModel repository, IConnection connection) => Task.CompletedTask; } } \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs index a7f84838fc..1b99d9b7bf 100644 --- a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs @@ -1,10 +1,15 @@ +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reactive; using System.Threading.Tasks; -using GitHub.Models; -using GitHub.ViewModels; -using ReactiveUI; +using GitHub.SampleData; +using ReactiveUI.Legacy; namespace GitHub.SampleData { @@ -25,19 +30,18 @@ public class PullRequestUpdateStateDesigner : IPullRequestUpdateState } [ExcludeFromCodeCoverage] - public class PullRequestDetailViewModelDesigner : BaseViewModel, IPullRequestDetailViewModel + public class PullRequestDetailViewModelDesigner : PanePageViewModelBase, IPullRequestDetailViewModel { public PullRequestDetailViewModelDesigner() { var repoPath = @"C:\Repo"; - Model = new PullRequestModel(419, - "Error handling/bubbling from viewmodels to views to viewhosts", - new AccountDesigner { Login = "shana", IsUser = true }, - DateTime.Now.Subtract(TimeSpan.FromDays(3))) + Model = new PullRequestDetailModel { - State = PullRequestStateEnum.Open, - CommitCount = 9, + Number = 419, + Title = "Error handling/bubbling from viewmodels to views to viewhosts", + Author = new ActorModel { Login = "shana" }, + UpdatedAt = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(3)), }; SourceBranchDisplayName = "shana/error-handling"; @@ -55,7 +59,7 @@ public PullRequestDetailViewModelDesigner() var repositoriesDir = new PullRequestDirectoryNode("Repositories"); var itrackingBranch = new PullRequestFileNode(repoPath, @"GitHub\Models\ITrackingBranch.cs", "abc", PullRequestFileStatus.Modified, null); var oldBranchModel = new PullRequestFileNode(repoPath, @"GitHub\Models\OldBranchModel.cs", "abc", PullRequestFileStatus.Removed, null); - var concurrentRepositoryConnection = new PullRequestFileNode(repoPath, @"GitHub\Repositories\ConcurrentRepositoryConnection.cs", "abc", PullRequestFileStatus.Added, "add"); + var concurrentRepositoryConnection = new PullRequestFileNode(repoPath, @"GitHub\Repositories\ConcurrentRepositoryConnection.cs", "abc", PullRequestFileStatus.Added, null); repositoriesDir.Files.Add(concurrentRepositoryConnection); modelsDir.Directories.Add(repositoriesDir); @@ -63,33 +67,73 @@ public PullRequestDetailViewModelDesigner() modelsDir.Files.Add(oldBranchModel); gitHubDir.Directories.Add(modelsDir); - ChangedFilesTree = new ReactiveList(); - ChangedFilesTree.Add(gitHubDir); + Reviews = new[] + { + new PullRequestReviewSummaryViewModel + { + Id = "id1", + User = new ActorViewModel { Login = "grokys" }, + State = PullRequestReviewState.Pending, + FileCommentCount = 0, + }, + new PullRequestReviewSummaryViewModel + { + Id = "id", + User = new ActorViewModel { Login = "jcansdale" }, + State = PullRequestReviewState.Approved, + FileCommentCount = 5, + }, + new PullRequestReviewSummaryViewModel + { + Id = "id3", + User = new ActorViewModel { Login = "shana" }, + State = PullRequestReviewState.ChangesRequested, + FileCommentCount = 5, + }, + new PullRequestReviewSummaryViewModel + { + }, + }; + + Files = new PullRequestFilesViewModelDesigner(); + + Checks = Array.Empty(); } - public IPullRequestModel Model { get; } + public PullRequestDetailModel Model { get; } + public IPullRequestSession Session { get; } + public LocalRepositoryModel LocalRepository { get; } + public string RemoteRepositoryOwner { get; } + public int Number { get; set; } + public IActorViewModel Author { get; set; } public string SourceBranchDisplayName { get; set; } public string TargetBranchDisplayName { get; set; } + public int CommentCount { get; set; } + public bool IsCheckedOut { get; } public bool IsFromFork { get; } public string Body { get; } - public IReactiveList ChangedFilesTree { get; } + public IReadOnlyList Reviews { get; } + public IPullRequestFilesViewModel Files { get; set; } public IPullRequestCheckoutState CheckoutState { get; set; } public IPullRequestUpdateState UpdateState { get; set; } public string OperationError { get; set; } + public string ErrorMessage { get; set; } + public Uri WebUrl { get; set; } - public ReactiveCommand Checkout { get; } - public ReactiveCommand Pull { get; } - public ReactiveCommand Push { get; } - public ReactiveCommand OpenOnGitHub { get; } - public ReactiveCommand OpenFile { get; } - public ReactiveCommand DiffFile { get; } + public ReactiveCommand Checkout { get; } + public ReactiveCommand Pull { get; } + public ReactiveCommand Push { get; } + public ReactiveCommand SyncSubmodules { get; } + public ReactiveCommand OpenConversation { get; } + public ReactiveCommand OpenOnGitHub { get; } + public ReactiveCommand ShowReview { get; } + public ReactiveCommand ShowAnnotations { get; } - public Task ExtractFile(IPullRequestFileNode file) - { - return null; - } + public IReadOnlyList Checks { get; } + + public Task InitializeAsync(LocalRepositoryModel localRepository, IConnection connection, string owner, string repo, int number) => Task.CompletedTask; - public Task> ExtractDiffFiles(IPullRequestFileNode file) + public string GetLocalFilePath(IPullRequestFileNode file) { return null; } diff --git a/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs new file mode 100644 index 0000000000..5dda31c86d --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class PullRequestFilesViewModelDesigner : PanePageViewModelBase, IPullRequestFilesViewModel + { + public PullRequestFilesViewModelDesigner() + { + Items = new[] + { + new PullRequestDirectoryNode("src") + { + Files = + { + new PullRequestFileNode("x", "src/File1.cs", "x", PullRequestFileStatus.Added, null), + new PullRequestFileNode("x", "src/File2.cs", "x", PullRequestFileStatus.Modified, null), + new PullRequestFileNode("x", "src/File3.cs", "x", PullRequestFileStatus.Removed, null), + new PullRequestFileNode("x", "src/File4.cs", "x", PullRequestFileStatus.Renamed, "src/Old.cs"), + } + } + }; + ChangedFilesCount = 4; + } + + public int ChangedFilesCount { get; set; } + public IReadOnlyList Items { get; } + public ReactiveCommand DiffFile { get; } + public ReactiveCommand ViewFile { get; } + public ReactiveCommand DiffFileWithWorkingDirectory { get; } + public ReactiveCommand OpenFileInWorkingDirectory { get; } + public ReactiveCommand OpenFirstComment { get; } + public ReactiveCommand OpenFirstAnnotationNotice { get; } + public ReactiveCommand OpenFirstAnnotationWarning { get; } + public ReactiveCommand OpenFirstAnnotationFailure { get; } + + public Task InitializeAsync( + IPullRequestSession session, + Func commentFilter = null) + { + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs new file mode 100644 index 0000000000..38c6b5b740 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public class PullRequestListItemViewModelDesigner : ViewModelBase, IPullRequestListItemViewModel + { + public string Id { get; set; } + public IActorViewModel Author { get; set; } + public int CommentCount { get; set; } + public bool IsCurrent { get; set; } + public int Number { get; set; } + public string Title { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public PullRequestChecksSummaryState ChecksSummary { get; set; } + public int ChecksPendingCount { get; set; } + public int ChecksSuccessCount { get; set; } + public int ChecksErrorCount { get; set; } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs index 65ad601e8a..146d35ed46 100644 --- a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs @@ -1,70 +1,72 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using System.Reactive.Linq; -using System.Windows.Input; -using GitHub.Collections; +using System.Reactive; +using System.Threading.Tasks; +using System.Windows.Data; using GitHub.Models; using GitHub.ViewModels; -using System.Collections.Generic; +using GitHub.ViewModels.GitHubPane; using ReactiveUI; -using System.Collections.ObjectModel; -using System.Linq; namespace GitHub.SampleData { [ExcludeFromCodeCoverage] - public class PullRequestListViewModelDesigner : BaseViewModel, IPullRequestListViewModel + public class PullRequestListViewModelDesigner : PanePageViewModelBase, IPullRequestListViewModel { public PullRequestListViewModelDesigner() { - var prs = new TrackingCollection(Observable.Empty()); - prs.Add(new PullRequestModel(399, "Let's try doing this differently", - new AccountDesigner { Login = "shana", IsUser = true }, - DateTimeOffset.Now - TimeSpan.FromDays(1)) - { - Assignee = new AccountDesigner { Login = "shana", IsUser = true }, - }); - prs.Add(new PullRequestModel(389, "Build system upgrade", - new AccountDesigner { Login = "shana", IsUser = true }, - DateTimeOffset.Now - TimeSpan.FromMinutes(2)) - { - CommentCount = 4, - HasNewComments = false, - Assignee = new AccountDesigner { Login = "haacked", IsUser = true }, - }); - prs.Add(new PullRequestModel(409, "Fix publish button style and a really, really long name for this thing... OMG look how long this name is yusssss", - new AccountDesigner { Login = "shana", IsUser = true }, - DateTimeOffset.Now - TimeSpan.FromHours(5)) + Items = new[] { - CommentCount = 27, - HasNewComments = true, - Assignee = new AccountDesigner { Login = "Haacked", IsUser = true }, - }); - PullRequests = prs; - - States = new List { - new PullRequestState { IsOpen = true, Name = "Open" }, - new PullRequestState { IsOpen = false, Name = "Closed" }, - new PullRequestState { Name = "All" } + new PullRequestListItemViewModelDesigner + { + Number = 399, + IsCurrent = true, + Title = "Let's try doing this differently", + Author = new ActorViewModelDesigner("shana"), + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromDays(1), + }, + new PullRequestListItemViewModelDesigner + { + Number = 389, + Title = "Build system upgrade", + Author = new ActorViewModelDesigner("haacked"), + CommentCount = 4, + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromMinutes(2), + }, + new PullRequestListItemViewModelDesigner + { + Number = 409, + Title = "Fix publish button style and a really, really long name for this thing... OMG look how long this name is yusssss", + Author = new ActorViewModelDesigner("shana"), + CommentCount = 27, + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromHours(5), + }, }; - SelectedState = States[0]; - Assignees = new ObservableCollection(prs.Select(x => x.Assignee)); - Authors = new ObservableCollection(prs.Select(x => x.Author)); - SelectedAssignee = Assignees.ElementAt(1); - SelectedAuthor = Authors.ElementAt(1); - } - public ITrackingCollection PullRequests { get; set; } - public IPullRequestModel SelectedPullRequest { get; set; } - public ICommand OpenPullRequest { get; set; } - - public IReadOnlyList States { get; set; } - public PullRequestState SelectedState { get; set; } + ItemsView = CollectionViewSource.GetDefaultView(Items); + States = new[] { "Open", "Closed", "All" }; + SelectedState = "Open"; + } - public ObservableCollection Authors { get; set; } - public IAccount SelectedAuthor { get; set; } + public IUserFilterViewModel AuthorFilter { get; set; } + public IReadOnlyList Items { get; } + public ICollectionView ItemsView { get; } + public LocalRepositoryModel LocalRepository { get; set; } + public IssueListMessage Message { get; set; } + public RepositoryModel RemoteRepository { get; set; } + public IReadOnlyList Forks { get; } + public string SearchQuery { get; set; } + public string SelectedState { get; set; } + public string StateCaption { get; set; } + public IReadOnlyList States { get; } + public Uri WebUrl => null; + public ReactiveCommand CreatePullRequest { get; } + public ReactiveCommand OpenConversation { get; } + public ReactiveCommand OpenItem { get; } + public ReactiveCommand OpenItemInBrowser { get; } - public ObservableCollection Assignees { get; set; } - public IAccount SelectedAssignee { get; set; } + public Task InitializeAsync(LocalRepositoryModel repository, IConnection connection) => Task.CompletedTask; } } \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs new file mode 100644 index 0000000000..9d0c118cbb --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class PullRequestReviewAuthoringViewModelDesigner : PanePageViewModelBase, IPullRequestReviewAuthoringViewModel + { + public PullRequestReviewAuthoringViewModelDesigner() + { + PullRequestModel = new PullRequestDetailModel + { + Number = 419, + Title = "Fix a ton of potential crashers, odd code and redundant calls in ModelService", + Author = new ActorModel { Login = "Haacked" }, + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromDays(2), + }; + + Files = new PullRequestFilesViewModelDesigner(); + + FileComments = new[] + { + new PullRequestReviewFileCommentViewModelDesigner + { + Body = @"These should probably be properties. Most likely they should be readonly properties. I know that makes creating instances of these not look as nice as using property initializers when constructing an instance, but if these properties should never be mutated after construction, then it guides future consumers to the right behavior. + +However, if you're two-way binding these properties to a UI, then ignore the readonly part and make them properties. But in that case they should probably be reactive properties (or implement INPC).", + RelativePath = "src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs", + }, + new PullRequestReviewFileCommentViewModelDesigner + { + Body = "While I have no problems with naming a variable ass I think we should probably avoid swear words in case Microsoft runs their Policheck tool against this code.", + RelativePath = "src/GitHub.App/ViewModels/PullRequestListViewModel.cs", + }, + }; + } + + public string Body { get; set; } + public bool CanApproveRequestChanges { get; set; } + public IReadOnlyList FileComments { get; } + public IPullRequestFilesViewModel Files { get; } + public LocalRepositoryModel LocalRepository { get; set; } + public PullRequestReviewModel Model { get; set; } + public ReactiveCommand NavigateToPullRequest { get; } + public string OperationError { get; set; } + public PullRequestDetailModel PullRequestModel { get; set; } + public string RemoteRepositoryOwner { get; set; } + public ReactiveCommand Approve { get; } + public ReactiveCommand Comment { get; } + public ReactiveCommand RequestChanges { get; } + public ReactiveCommand Cancel { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + + public Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestReviewFileCommentViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewFileCommentViewModelDesigner.cs new file mode 100644 index 0000000000..9d48d6ae41 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestReviewFileCommentViewModelDesigner.cs @@ -0,0 +1,13 @@ +using System.Reactive; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class PullRequestReviewFileCommentViewModelDesigner : IPullRequestReviewFileCommentViewModel + { + public string Body { get; set; } + public string RelativePath { get; set; } + public ReactiveCommand Open { get; } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestReviewViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewViewModelDesigner.cs new file mode 100644 index 0000000000..ca4ce82404 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestReviewViewModelDesigner.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public class PullRequestReviewViewModelDesigner : PanePageViewModelBase, IPullRequestReviewViewModel + { + public PullRequestReviewViewModelDesigner() + { + PullRequestModel = new PullRequestDetailModel + { + Number = 419, + Title = "Fix a ton of potential crashers, odd code and redundant calls in ModelService", + Author = new ActorModel { Login = "Haacked" }, + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromDays(2), + }; + + Model = new PullRequestReviewModel + { + + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(1), + Author = new ActorModel { Login = "Haacked" }, + }; + + Body = @"Just a few comments. I don't feel too strongly about them though. + +Otherwise, very nice work here! ✨"; + + StateDisplay = "approved"; + + FileComments = new[] + { + new PullRequestReviewFileCommentViewModelDesigner + { + Body = @"These should probably be properties. Most likely they should be readonly properties. I know that makes creating instances of these not look as nice as using property initializers when constructing an instance, but if these properties should never be mutated after construction, then it guides future consumers to the right behavior. + +However, if you're two-way binding these properties to a UI, then ignore the readonly part and make them properties. But in that case they should probably be reactive properties (or implement INPC).", + RelativePath = "src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs", + }, + new PullRequestReviewFileCommentViewModelDesigner + { + Body = "While I have no problems with naming a variable ass I think we should probably avoid swear words in case Microsoft runs their Policheck tool against this code.", + RelativePath = "src/GitHub.App/ViewModels/PullRequestListViewModel.cs", + }, + }; + + OutdatedFileComments = new[] + { + new PullRequestReviewFileCommentViewModelDesigner + { + Body = @"So this is just casting a mutable list to an IReadOnlyList which can be cast back to List. I know we probably won't do that, but I'm thinking of the next person to come along. The safe thing to do is to wrap List with a ReadOnlyList. We have an extension method ToReadOnlyList for observables. Wouldn't be hard to write one for IEnumerable.", + RelativePath = "src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs", + }, + }; + } + + public string Body { get; } + public IReadOnlyList FileComments { get; set; } + public bool IsExpanded { get; set; } + public bool HasDetails { get; set; } + public LocalRepositoryModel LocalRepository { get; set; } + public PullRequestReviewModel Model { get; set; } + public ReactiveCommand NavigateToPullRequest { get; } + public IReadOnlyList OutdatedFileComments { get; set; } + public PullRequestDetailModel PullRequestModel { get; set; } + public string RemoteRepositoryOwner { get; set; } + public string StateDisplay { get; set; } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestUserReviewsViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestUserReviewsViewModelDesigner.cs new file mode 100644 index 0000000000..a1aec8c4c3 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestUserReviewsViewModelDesigner.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public class PullRequestUserReviewsViewModelDesigner : PanePageViewModelBase, IPullRequestUserReviewsViewModel + { + public PullRequestUserReviewsViewModelDesigner() + { + var userModel = new ActorModel { Login = "Haacked" }; + + User = new ActorViewModel(userModel); + PullRequestNumber = 123; + PullRequestTitle = "Error handling/bubbling from viewmodels to views to viewhosts"; + + Reviews = new[] + { + new PullRequestReviewViewModelDesigner() + { + IsExpanded = true, + HasDetails = true, + FileComments = Array.Empty(), + StateDisplay = "approved", + Model = new PullRequestReviewModel + { + State = PullRequestReviewState.Approved, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(1), + Author = userModel, + }, + }, + new PullRequestReviewViewModelDesigner() + { + IsExpanded = true, + HasDetails = true, + StateDisplay = "requested changes", + Model = new PullRequestReviewModel + { + State = PullRequestReviewState.ChangesRequested, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(2), + Author = userModel, + }, + }, + new PullRequestReviewViewModelDesigner() + { + IsExpanded = false, + HasDetails = false, + StateDisplay = "commented", + Model = new PullRequestReviewModel + { + State = PullRequestReviewState.Commented, + SubmittedAt = DateTimeOffset.Now - TimeSpan.FromDays(2), + Author = userModel, + }, + } + }; + } + + public LocalRepositoryModel LocalRepository { get; set; } + public string RemoteRepositoryOwner { get; set; } + public int PullRequestNumber { get; set; } + public IActorViewModel User { get; set; } + public IReadOnlyList Reviews { get; set; } + public string PullRequestTitle { get; set; } + public ReactiveCommand NavigateToPullRequest { get; } + + public Task InitializeAsync(LocalRepositoryModel localRepository, IConnection connection, string owner, string repo, int pullRequestNumber, string login) + { + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/SampleData/RemoteRepositoryModelDesigner.cs b/src/GitHub.App/SampleData/RemoteRepositoryModelDesigner.cs index d9179f1cb5..a87fe8d8db 100644 --- a/src/GitHub.App/SampleData/RemoteRepositoryModelDesigner.cs +++ b/src/GitHub.App/SampleData/RemoteRepositoryModelDesigner.cs @@ -6,37 +6,18 @@ namespace GitHub.SampleData { [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1036:OverrideMethodsOnComparableTypes")] - public class RemoteRepositoryModelDesigner : IRemoteRepositoryModel + public class RemoteRepositoryModelDesigner : RemoteRepositoryModel { - public UriString CloneUrl { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public IBranch DefaultBranch { get; set; } - public Octicon Icon { get; set; } - public long Id { get; set; } - public bool IsFork { get; set; } - public string Name { get; set; } - public string Owner { get; set; } - public IAccount OwnerAccount { get; set; } - public IRemoteRepositoryModel Parent { get; set; } - public DateTimeOffset UpdatedAt { get; set; } - - public int CompareTo(IRemoteRepositoryModel other) - { - return 0; - } - - public void CopyFrom(IRemoteRepositoryModel other) - { - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1065:DoNotRaiseExceptionsInUnexpectedLocations")] - public bool Equals(IRemoteRepositoryModel other) - { - return false; - } - - public void SetIcon(bool isPrivate, bool isFork) - { - } + public new UriString CloneUrl { get; set; } + public new DateTimeOffset CreatedAt { get; set; } + public new BranchModel DefaultBranch { get; set; } + public new Octicon Icon { get; set; } + public new long Id { get; set; } + public new bool IsFork { get; set; } + public new string Name { get; set; } + public new string Owner { get; set; } + public new IAccount OwnerAccount { get; set; } + public new RemoteRepositoryModel Parent { get; set; } + public new DateTimeOffset UpdatedAt { get; set; } } } diff --git a/src/GitHub.App/SampleData/SampleViewModels.cs b/src/GitHub.App/SampleData/SampleViewModels.cs index 95856c5b8e..77b36dc9c0 100644 --- a/src/GitHub.App/SampleData/SampleViewModels.cs +++ b/src/GitHub.App/SampleData/SampleViewModels.cs @@ -1,36 +1,34 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Reactive; +using System.Threading.Tasks; using System.Windows.Input; -using GitHub.Api; -using GitHub.Authentication; using GitHub.Extensions; using GitHub.Models; using GitHub.Primitives; -using GitHub.Services; using GitHub.UI; using GitHub.Validation; using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using GitHub.ViewModels.Dialog.Clone; +using GitHub.ViewModels.TeamExplorer; +using GitHub.VisualStudio.TeamExplorer.Connect; using GitHub.VisualStudio.TeamExplorer.Home; using ReactiveUI; -using GitHub.VisualStudio.TeamExplorer.Connect; -using System.Collections.ObjectModel; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; namespace GitHub.SampleData { [ExcludeFromCodeCoverage] - public class RepositoryCreationViewModelDesigner : BaseViewModel, IRepositoryCreationViewModel + public class RepositoryCreationViewModelDesigner : ViewModelBase, IRepositoryCreationViewModel { public RepositoryCreationViewModelDesigner() { RepositoryName = "Hello-World"; Description = "A description"; KeepPrivate = true; - CanKeepPrivate = true; Accounts = new ReactiveList { new AccountDesigner { Login = "shana" }, @@ -55,7 +53,7 @@ public RepositoryCreationViewModelDesigner() SelectedLicense = Licenses[0]; } - public new string Title { get { return "Create a GitHub Repository"; } } // TODO: this needs to be contextual + public string Title { get { return "Create a GitHub Repository"; } } // TODO: this needs to be contextual public IReadOnlyList Accounts { @@ -75,19 +73,13 @@ public ReactivePropertyValidator BaseRepositoryPathValidator private set; } - public ICommand BrowseForDirectory + public ReactiveCommand BrowseForDirectory { get; private set; } - public bool CanKeepPrivate - { - get; - private set; - } - - public IReactiveCommand CreateRepository + public ReactiveCommand CreateRepository { get; private set; @@ -147,24 +139,6 @@ public IAccount SelectedAccount set; } - public bool ShowUpgradePlanWarning - { - get; - private set; - } - - public bool ShowUpgradeToMicroPlanWarning - { - get; - private set; - } - - public ICommand UpgradeAccountPlan - { - get; - private set; - } - public IReadOnlyList GitIgnoreTemplates { get; private set; @@ -186,6 +160,10 @@ public LicenseItem SelectedLicense get; set; } + + public IObservable Done { get; } + + public Task InitializeAsync(IConnection connection) => Task.CompletedTask; } [ExcludeFromCodeCoverage] @@ -196,25 +174,23 @@ class Conn : IConnection public HostAddress HostAddress { get; set; } public string Username { get; set; } - public ObservableCollection Repositories { get; set; } + public Octokit.User User => null; + public ScopesCollection Scopes => null; + public bool IsLoggedIn => true; + public bool IsLoggingIn => false; - public IObservable Login() - { - return null; - } - - public void Logout() - { - } + public Exception ConnectionError => null; - public void Dispose() + event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged { + add { } + remove { } } } public RepositoryPublishViewModelDesigner() { - Connections = new ObservableCollection + Connections = new ObservableCollectionEx { new Conn() { HostAddress = new HostAddress() }, new Conn() { HostAddress = HostAddress.Create("ghe.io") } @@ -222,6 +198,8 @@ public RepositoryPublishViewModelDesigner() SelectedConnection = Connections[0]; } + public bool IsBusy { get; set; } + public bool IsHostComboBoxVisible { get @@ -230,19 +208,19 @@ public bool IsHostComboBoxVisible } } - public bool IsPublishing + public ReactiveCommand PublishRepository { get; private set; } - public IReactiveCommand PublishRepository + public ReactiveCommand LoginAsDifferentUser { get; private set; } - public ObservableCollection Connections + public IReadOnlyObservableCollection Connections { get; private set; @@ -254,261 +232,14 @@ public IConnection SelectedConnection } } - [ExcludeFromCodeCoverage] - public sealed class RepositoryHostDesigner : ReactiveObject, IRepositoryHost - { - public RepositoryHostDesigner(string title) - { - this.Title = title; - } - - public HostAddress Address - { - get; - private set; - } - - public IApiClient ApiClient - { - get; - private set; - } - - public bool IsLoggedIn - { - get; - private set; - } - - public bool SupportsGist - { - get; - private set; - } - - public IModelService ModelService - { - get; - private set; - } - - public string Title - { - get; - private set; - } - - public void Dispose() - { - } - - public IObservable LogIn(string usernameOrEmail, string password) - { - throw new NotImplementedException(); - } - - public IObservable LogInFromCache() - { - throw new NotImplementedException(); - } - - public IObservable LogOut() - { - throw new NotImplementedException(); - } - } - [ExcludeFromCodeCoverage] public static class RepositoryModelDesigner { - public static IRemoteRepositoryModel Create(string name = null, string owner = null) + public static RemoteRepositoryModel Create(string name = null, string owner = null) { name = name ?? "octocat"; owner = owner ?? "github"; - return new RemoteRepositoryModel(0, name, new UriString("http://github.com/" + name + "/" + owner), false, false, new AccountDesigner() { Login = owner }); - } - } - - public class RepositoryCloneViewModelDesigner : BaseViewModel, IRepositoryCloneViewModel - { - public RepositoryCloneViewModelDesigner() - { - Repositories = new ObservableCollection - { - RepositoryModelDesigner.Create("encourage", "haacked"), - RepositoryModelDesigner.Create("haacked.com", "haacked"), - RepositoryModelDesigner.Create("octokit.net", "octokit"), - RepositoryModelDesigner.Create("octokit.rb", "octokit"), - RepositoryModelDesigner.Create("octokit.objc", "octokit"), - RepositoryModelDesigner.Create("windows", "github"), - RepositoryModelDesigner.Create("mac", "github"), - RepositoryModelDesigner.Create("github", "github") - }; - - BrowseForDirectory = ReactiveCommand.Create(); - - BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(this.WhenAny(x => x.BaseRepositoryPath, x => x.Value)) - .IfNullOrEmpty("Please enter a repository path") - .IfTrue(x => x.Length > 200, "Path too long") - .IfContainsInvalidPathChars("Path contains invalid characters") - .IfPathNotRooted("Please enter a valid path"); - } - - public IReactiveCommand CloneCommand - { - get; - private set; - } - - public IRepositoryModel SelectedRepository { get; set; } - - public ObservableCollection Repositories - { - get; - private set; - } - - public bool FilterTextIsEnabled - { - get; - private set; - } - - public string FilterText { get; set; } - - public new string Title { get { return "Clone a GitHub Repository"; } } - - public bool IsLoading - { - get { return false; } - } - - public IReactiveCommand> LoadRepositoriesCommand - { - get; - private set; - } - - public bool LoadingFailed - { - get { return false; } - } - - public bool NoRepositoriesFound - { - get; - set; - } - - public ICommand BrowseForDirectory - { - get; - private set; - } - - public string BaseRepositoryPath - { - get; - set; - } - - public bool CanClone - { - get; - private set; - } - - public ReactivePropertyValidator BaseRepositoryPathValidator - { - get; - private set; - } - } - - public class GitHubHomeSectionDesigner : IGitHubHomeSection - { - public GitHubHomeSectionDesigner() - { - Icon = Octicon.repo; - RepoName = "octokit"; - RepoUrl = "https://github.com/octokit/something-really-long-here-to-check-for-trimming"; - IsLoggedIn = false; + return new RemoteRepositoryModel(0, name, new UriString("http://github.com/" + name + "/" + owner), false, false, new AccountDesigner() { Login = owner }, null); } - - public Octicon Icon - { - get; - private set; - } - - public bool IsLoggedIn - { - get; - private set; - } - - public string RepoName - { - get; - set; - } - - public string RepoUrl - { - get; - set; - } - - public void Login() - { - - } - - public ICommand OpenOnGitHub { get; } - } - - public class GitHubConnectSectionDesigner : IGitHubConnectSection - { - public GitHubConnectSectionDesigner() - { - Repositories = new ObservableCollection(); - Repositories.Add(new LocalRepositoryModel("octokit", new UriString("https://github.com/octokit/octokit.net"), @"C:\Users\user\Source\Repos\octokit.net")); - Repositories.Add(new LocalRepositoryModel("cefsharp", new UriString("https://github.com/cefsharp/cefsharp"), @"C:\Users\user\Source\Repos\cefsharp")); - Repositories.Add(new LocalRepositoryModel("git-lfs", new UriString("https://github.com/github/git-lfs"), @"C:\Users\user\Source\Repos\git-lfs")); - Repositories.Add(new LocalRepositoryModel("another octokit", new UriString("https://github.com/octokit/octokit.net"), @"C:\Users\user\Source\Repos\another-octokit.net")); - Repositories.Add(new LocalRepositoryModel("some cefsharp", new UriString("https://github.com/cefsharp/cefsharp"), @"C:\Users\user\Source\Repos\something-else")); - Repositories.Add(new LocalRepositoryModel("even more git-lfs", new UriString("https://github.com/github/git-lfs"), @"C:\Users\user\Source\Repos\A different path")); - } - - public ObservableCollection Repositories - { - get; set; - } - - public void DoCreate() - { - } - - public void SignOut() - { - } - - public void Login() - { - } - - public bool OpenRepository() - { - return true; - } - - public IConnection SectionConnection { get; } - public ICommand Clone { get; } - } - - public class InfoPanelDesigner - { - public string Message => "This is an informational message for the [info panel](link) to test things in design mode."; - public MessageType MessageType => MessageType.Information; } } diff --git a/src/GitHub.App/SampleData/StartPageCloneViewModelDesigner.cs b/src/GitHub.App/SampleData/StartPageCloneViewModelDesigner.cs deleted file mode 100644 index 87e1d5a376..0000000000 --- a/src/GitHub.App/SampleData/StartPageCloneViewModelDesigner.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Reactive; -using System.Windows.Input; -using GitHub.Models; -using GitHub.Validation; -using GitHub.ViewModels; -using ReactiveUI; - -namespace GitHub.SampleData -{ - public class StartPageCloneViewModelDesigner : BaseViewModel, IBaseCloneViewModel - { - public string BaseRepositoryPath { get; set; } - public ReactivePropertyValidator BaseRepositoryPathValidator { get; } - public ICommand BrowseForDirectory { get; } - public IReactiveCommand CloneCommand { get; } - public IRepositoryModel SelectedRepository { get; set; } - } -} diff --git a/src/GitHub.App/SampleData/UserFilterViewModelDesigner.cs b/src/GitHub.App/SampleData/UserFilterViewModelDesigner.cs new file mode 100644 index 0000000000..54c0db56b8 --- /dev/null +++ b/src/GitHub.App/SampleData/UserFilterViewModelDesigner.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Windows.Data; +using GitHub.ViewModels; + +namespace GitHub.SampleData +{ + public class UserFilterViewModelDesigner : ViewModelBase, IUserFilterViewModel + { + public UserFilterViewModelDesigner() + { + Users = new[] + { + new ActorViewModelDesigner("grokys"), + new ActorViewModelDesigner("jcansdale"), + new ActorViewModelDesigner("meaghanlewis"), + new ActorViewModelDesigner("sguthals"), + new ActorViewModelDesigner("shana"), + new ActorViewModelDesigner("StanleyGoldman"), + }; + + UsersView = CollectionViewSource.GetDefaultView(Users); + } + + public string Filter { get; set; } + public IActorViewModel Selected { get; set; } + public IReadOnlyList Users { get; } + public ICollectionView UsersView { get; } + } +} diff --git a/src/GitHub.App/Services/AutoCompleteAdvisor.cs b/src/GitHub.App/Services/AutoCompleteAdvisor.cs new file mode 100644 index 0000000000..569a87b526 --- /dev/null +++ b/src/GitHub.App/Services/AutoCompleteAdvisor.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel.Composition; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reactive.Linq; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using Serilog; + +namespace GitHub.Services +{ + [Export(typeof(IAutoCompleteAdvisor))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class AutoCompleteAdvisor : IAutoCompleteAdvisor + { + const int SuggestionCount = 5; // The number of suggestions we'll provide. github.com does 5. + + static readonly ILogger log = LogManager.ForContext(); + readonly Lazy> prefixSourceMap; + + [ImportingConstructor] + public AutoCompleteAdvisor([ImportMany(typeof(IAutoCompleteSource))]IEnumerable autocompleteSources) + { + prefixSourceMap = new Lazy>( + () => autocompleteSources.ToDictionary(s => s.Prefix, s => s)); + } + + public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) + { + Guard.ArgumentNotNull("text", text); + + if (caretPosition < 0 || caretPosition > text.Length) + { + string error = String.Format(CultureInfo.InvariantCulture, + "The CaretPosition '{0}', is not in the range of '0' and the text length '{1}' for the text '{2}'", + caretPosition, + text.Length, + text); + + // We need to be alerted when this happens because it should never happen. + // But it apparently did happen in production. + Debug.Fail(error); + log.Error(error); + return Observable.Empty(); + } + var tokenAndSource = PrefixSourceMap + .Select(kvp => new {Source = kvp.Value, Token = ParseAutoCompletionToken(text, caretPosition, kvp.Key)}) + .FirstOrDefault(s => s.Token != null); + + if (tokenAndSource == null) + { + return Observable.Return(AutoCompleteResult.Empty); + } + + return tokenAndSource.Source.GetSuggestions() + .Select(suggestion => new + { + suggestion, + rank = suggestion.GetSortRank(tokenAndSource.Token.SearchSearchPrefix) + }) + .Where(suggestion => suggestion.rank > -1) + .ToList() + .Select(suggestions => suggestions + .OrderByDescending(s => s.rank) + .ThenBy(s => s.suggestion.Name) + .Take(SuggestionCount) + .Select(s => s.suggestion) + .ToList()) + .Select(suggestions => new AutoCompleteResult(tokenAndSource.Token.Offset, + new ReadOnlyCollection(suggestions))) + .Catch(e => + { + log.Error(e, "Error Getting AutoCompleteResult"); + return Observable.Return(AutoCompleteResult.Empty); + }); + } + + [SuppressMessage("Microsoft.Usage", "CA2233:OperationsShouldNotOverflow", MessageId = "caretPosition-1" + , Justification = "We ensure the argument is greater than -1 so it can't overflow")] + public static AutoCompletionToken ParseAutoCompletionToken(string text, int caretPosition, string triggerPrefix) + { + Guard.ArgumentNotNull("text", text); + Guard.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition"); + if (caretPosition == 0 || text.Length == 0) return null; + + // :th : 1 + //:th : 0 + //Hi :th : 3 + int beginningOfWord = text.LastIndexOfAny(new[] { ' ', '\n' }, caretPosition - 1) + 1; + string word = text.Substring(beginningOfWord, caretPosition - beginningOfWord); + if (!word.StartsWith(triggerPrefix, StringComparison.Ordinal)) return null; + + return new AutoCompletionToken(word.Substring(1), beginningOfWord); + } + + Dictionary PrefixSourceMap { get { return prefixSourceMap.Value; } } + } + + public class AutoCompletionToken + { + public AutoCompletionToken(string searchPrefix, int offset) + { + Guard.ArgumentNotNull(searchPrefix, "searchPrefix"); + Guard.ArgumentNonNegative(offset, "offset"); + + SearchSearchPrefix = searchPrefix; + Offset = offset; + } + + /// + /// Used to filter the list of auto complete suggestions to what the user has typed in. + /// + public string SearchSearchPrefix { get; private set; } + public int Offset { get; private set; } + } +} diff --git a/src/GitHub.App/Services/AvatarProvider.cs b/src/GitHub.App/Services/AvatarProvider.cs index 8b8410a1a8..1a99e815f6 100644 --- a/src/GitHub.App/Services/AvatarProvider.cs +++ b/src/GitHub.App/Services/AvatarProvider.cs @@ -1,14 +1,16 @@ using System; +using System.IO.Packaging; using System.ComponentModel.Composition; using System.Diagnostics; using System.Reactive; using System.Reactive.Linq; -using System.Windows; using System.Windows.Media.Imaging; using Akavache; using GitHub.Caches; +using GitHub.Logging; +using GitHub.Extensions; using GitHub.Models; -using NullGuard; +using System.Windows; namespace GitHub.Services { @@ -24,18 +26,17 @@ public class AvatarProvider : IAvatarProvider static AvatarProvider() { - // NB: If this isn't explicitly set, WPF will try to guess it via - // GetEntryAssembly, which in a unit test runner will be null - // This is needed for the pack:// URL format to be understood. - if (Application.ResourceAssembly == null) - { - Application.ResourceAssembly = typeof(AvatarProvider).Assembly; - } + // Calling `Application.Current` will install pack URI scheme via Application.cctor. + // This is needed when unit testing for the pack:// URL format to be understood. + if (Application.Current != null) { } } [ImportingConstructor] public AvatarProvider(ISharedCache sharedCache, IImageCache imageCache) { + Guard.ArgumentNotNull(sharedCache, nameof(sharedCache)); + Guard.ArgumentNotNull(imageCache, nameof(imageCache)); + cache = sharedCache.LocalMachine; this.imageCache = imageCache; @@ -45,6 +46,8 @@ public AvatarProvider(ISharedCache sharedCache, IImageCache imageCache) public static BitmapImage CreateBitmapImage(string packUrl) { + Guard.ArgumentNotEmptyString(packUrl, nameof(packUrl)); + var bitmap = new BitmapImage(new Uri(packUrl)); bitmap.Freeze(); return bitmap; @@ -52,6 +55,8 @@ public static BitmapImage CreateBitmapImage(string packUrl) public IObservable GetAvatar(IAvatarContainer apiAccount) { + Guard.ArgumentNotNull(apiAccount, nameof(apiAccount)); + if (apiAccount.AvatarUrl == null) { return Observable.Return(DefaultAvatar(apiAccount)); @@ -59,13 +64,28 @@ public IObservable GetAvatar(IAvatarContainer apiAccount) Uri avatarUrl; Uri.TryCreate(apiAccount.AvatarUrl, UriKind.Absolute, out avatarUrl); - Debug.Assert(avatarUrl != null, "Cannot have a null avatar url"); + Log.Assert(avatarUrl != null, "Cannot have a null avatar url"); return imageCache.GetImage(avatarUrl) .Catch(_ => Observable.Return(DefaultAvatar(apiAccount))); } - - public IObservable InvalidateAvatar([AllowNull] IAvatarContainer apiAccount) + + public IObservable GetAvatar(string url) + { + if (url == null) + { + return Observable.Return(DefaultUserBitmapImage); + } + + Uri avatarUrl; + Uri.TryCreate(url, UriKind.Absolute, out avatarUrl); + Log.Assert(avatarUrl != null, "Cannot have a null avatar url"); + + return imageCache.GetImage(avatarUrl) + .Catch(_ => Observable.Return(DefaultUserBitmapImage)); + } + + public IObservable InvalidateAvatar(IAvatarContainer apiAccount) { return String.IsNullOrWhiteSpace(apiAccount?.Login) ? Observable.Return(Unit.Default) @@ -74,11 +94,13 @@ public IObservable InvalidateAvatar([AllowNull] IAvatarContainer apiAccoun BitmapImage DefaultAvatar(IAvatarContainer apiAccount) { + Guard.ArgumentNotNull(apiAccount, nameof(apiAccount)); + return apiAccount.IsUser ? DefaultUserBitmapImage : DefaultOrgBitmapImage; } protected virtual void Dispose(bool disposing) - {} + { } public void Dispose() { diff --git a/src/GitHub.App/Services/Browser.cs b/src/GitHub.App/Services/Browser.cs deleted file mode 100644 index 14fbcb760a..0000000000 --- a/src/GitHub.App/Services/Browser.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.IO; -using GitHub.Services; -using Rothko; -using NLog; - -namespace GitHub -{ - [Export(typeof(IVisualStudioBrowser))] - public class Browser : IVisualStudioBrowser - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly IProcessStarter processManager; - readonly IEnvironment environment; - - [ImportingConstructor] - public Browser(IProcessStarter processManager, IEnvironment environment) - { - this.processManager = processManager; - this.environment = environment; - } - - public void OpenUrl(Uri url) - { - if (url == null) - { - log.Warn("Attempted to open a null URL"); - return; - } - - try - { - processManager.Start(url.ToString(), string.Empty); - return; - } - catch (Exception ex) - { - log.Warn("Opening URL in default browser failed", ex); - } - - try - { - processManager.Start( - Path.Combine(environment.GetProgramFilesPath(), @"Internet Explorer", "iexplore.exe"), - url.ToString()); - } - catch (Exception ex) - { - log.Error("Really can't open the URL, even in IE", ex); - } - } - } -} diff --git a/src/GitHub.App/Services/CommentService.cs b/src/GitHub.App/Services/CommentService.cs new file mode 100644 index 0000000000..0b03bab799 --- /dev/null +++ b/src/GitHub.App/Services/CommentService.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.Composition; +using System.Globalization; +using System.Windows.Forms; + +namespace GitHub.Services +{ + [Export(typeof(ICommentService))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class CommentService:ICommentService + { + public bool ConfirmCommentDelete() + { + var options = CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? + MessageBoxOptions.RtlReading | MessageBoxOptions.RightAlign : + 0; + + return MessageBox.Show( + Resources.DeleteCommentConfirmation, + Resources.DeleteCommentConfirmationCaption, + MessageBoxButtons.YesNo, + MessageBoxIcon.Question, + MessageBoxDefaultButton.Button1, + options) == DialogResult.Yes; + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Services/DialogService.cs b/src/GitHub.App/Services/DialogService.cs index 9d937c4e54..5eeae61c74 100644 --- a/src/GitHub.App/Services/DialogService.cs +++ b/src/GitHub.App/Services/DialogService.cs @@ -1,10 +1,13 @@ using System; using System.ComponentModel.Composition; using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Exports; +using GitHub.Extensions; +using GitHub.Factories; using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; -using NullGuard; +using GitHub.ViewModels.Dialog; +using GitHub.ViewModels.Dialog.Clone; namespace GitHub.Services { @@ -12,61 +15,98 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.NonShared)] public class DialogService : IDialogService { - readonly IUIProvider uiProvider; + readonly IViewViewModelFactory factory; + readonly IShowDialogService showDialog; + readonly IGitHubContextService gitHubContextService; [ImportingConstructor] - public DialogService(IUIProvider uiProvider) + public DialogService( + IViewViewModelFactory factory, + IShowDialogService showDialog, + IGitHubContextService gitHubContextService) { - this.uiProvider = uiProvider; + Guard.ArgumentNotNull(factory, nameof(factory)); + Guard.ArgumentNotNull(showDialog, nameof(showDialog)); + Guard.ArgumentNotNull(showDialog, nameof(gitHubContextService)); + + this.factory = factory; + this.showDialog = showDialog; + this.gitHubContextService = gitHubContextService; } - public Task ShowCloneDialog([AllowNull] IConnection connection) + public async Task ShowCloneDialog(IConnection connection, string url = null) { - var controller = uiProvider.Configure(UIControllerFlow.Clone, connection); - var basePath = default(string); - var repository = default(IRepositoryModel); - - controller.TransitionSignal.Subscribe(x => + if (string.IsNullOrEmpty(url)) { - var vm = x.View.ViewModel as IBaseCloneViewModel; - - x.View.Done.Subscribe(_ => + var clipboardContext = gitHubContextService.FindContextFromClipboard(); + switch (clipboardContext?.LinkType) { - basePath = vm?.BaseRepositoryPath; - repository = vm?.SelectedRepository; - }); - }); + case LinkType.Blob: + case LinkType.Repository: + url = clipboardContext?.Url; + break; + } + } - uiProvider.RunInDialog(controller); + var viewModel = factory.CreateViewModel(); + if (url != null) + { + viewModel.Url = url; + } - var result = repository != null && basePath != null ? - new CloneDialogResult(basePath, repository) : null; - return Task.FromResult(result); + if (connection != null) + { + return (CloneDialogResult)await showDialog.Show( + viewModel, + connection, + ApiClientConfiguration.RequestedScopes) + .ConfigureAwait(false); + } + else + { + return (CloneDialogResult)await showDialog.ShowWithFirstConnection(viewModel) + .ConfigureAwait(false); + } } - public Task ShowReCloneDialog(IRepositoryModel repository) + public async Task ShowCreateGist(IConnection connection) { - var controller = uiProvider.Configure(UIControllerFlow.ReClone); - var basePath = default(string); + var viewModel = factory.CreateViewModel(); - controller.TransitionSignal.Subscribe(x => + if (connection != null) { - var vm = x.View.ViewModel as IBaseCloneViewModel; + await viewModel.InitializeAsync(connection); + await showDialog.Show(viewModel); + } + else + { + await showDialog.ShowWithFirstConnection(viewModel); + } + } - if (vm != null) - { - vm.SelectedRepository = repository; - } + public async Task ShowCreateRepositoryDialog(IConnection connection) + { + Guard.ArgumentNotNull(connection, nameof(connection)); - x.View.Done.Subscribe(_ => - { - basePath = vm?.BaseRepositoryPath; - }); - }); + var viewModel = factory.CreateViewModel(); + await viewModel.InitializeAsync(connection); + await showDialog.Show(viewModel); + } - uiProvider.RunInDialog(controller); + public async Task ShowLoginDialog() + { + var viewModel = factory.CreateViewModel(); + return (IConnection)await showDialog.Show(viewModel); + } + + public async Task ShowForkDialog(LocalRepositoryModel repository, IConnection connection) + { + Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentNotNull(connection, nameof(connection)); - return Task.FromResult(basePath); + var viewModel = factory.CreateViewModel(); + await viewModel.InitializeAsync(repository, connection); + await showDialog.Show(viewModel); } } } diff --git a/src/GitHub.App/Services/EnterpriseCapabilitiesService.cs b/src/GitHub.App/Services/EnterpriseCapabilitiesService.cs new file mode 100644 index 0000000000..5731a1f927 --- /dev/null +++ b/src/GitHub.App/Services/EnterpriseCapabilitiesService.cs @@ -0,0 +1,72 @@ +using System; +using System.ComponentModel.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Primitives; +using Octokit; +using IProgram = GitHub.Models.IProgram; + +namespace GitHub.Services +{ + [Export(typeof(IEnterpriseCapabilitiesService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class EnterpriseCapabilitiesService : IEnterpriseCapabilitiesService + { + static readonly Version MinimumOAuthVersion = new Version("2.12.2"); + readonly IEnterpriseProbe probe; + readonly IProgram program; + + [ImportingConstructor] + public EnterpriseCapabilitiesService( + IEnterpriseProbe probe, + IProgram program) + { + this.probe = probe; + this.program = program; + } + + public Task Probe(Uri enterpriseBaseUrl) => probe.Probe(enterpriseBaseUrl); + + public async Task ProbeLoginMethods(Uri enterpriseBaseUrl) + { + try + { + // It's important that we don't use our cached credentials on this connection, as they + // may be wrong - we're trying to log in after all. + var hostAddress = HostAddress.Create(enterpriseBaseUrl); + var connection = new Octokit.Connection(program.ProductHeader, hostAddress.ApiUri); + var meta = await GetMetadata(connection).ConfigureAwait(false); + var result = EnterpriseLoginMethods.Token; + + if (meta.VerifiablePasswordAuthentication != false) result |= EnterpriseLoginMethods.UsernameAndPassword; + + if (meta.InstalledVersion != null) + { + var version = new Version(meta.InstalledVersion); + if (version >= MinimumOAuthVersion) result |= EnterpriseLoginMethods.OAuth; + } + + return result; + } + catch + { + return EnterpriseLoginMethods.Token | EnterpriseLoginMethods.UsernameAndPassword; + } + } + + private static async Task GetMetadata(IConnection connection) + { + var endpoint = new Uri("meta", UriKind.Relative); + var response = await connection.Get(endpoint, null, null).ConfigureAwait(false); + return response.Body; + } + + [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Created via Octokit reflection")] + class EnterpriseMeta + { + public string InstalledVersion { get; private set; } + public bool? VerifiablePasswordAuthentication { get; private set; } + } + } +} diff --git a/src/GitHub.App/Services/EnterpriseProbe.cs b/src/GitHub.App/Services/EnterpriseProbe.cs deleted file mode 100644 index a478e9ea0c..0000000000 --- a/src/GitHub.App/Services/EnterpriseProbe.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Reactive.Threading.Tasks; -using GitHub.Models; -using Octokit.Internal; - -namespace GitHub.Services -{ - [Export(typeof(IEnterpriseProbe))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class EnterpriseProbe : EnterpriseProbeTask, IEnterpriseProbe - { - [ImportingConstructor] - public EnterpriseProbe(IProgram program, IHttpClient httpClient) - : base(program, httpClient) - { - } - - public IObservable Probe(Uri enterpriseBaseUrl) - { - return ProbeAsync(enterpriseBaseUrl).ToObservable(); - } - } -} diff --git a/src/GitHub.App/Services/ErrorMap.cs b/src/GitHub.App/Services/ErrorMap.cs index a60cd53ae8..49e29f70cd 100644 --- a/src/GitHub.App/Services/ErrorMap.cs +++ b/src/GitHub.App/Services/ErrorMap.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; -using NullGuard; using ReactiveUI; +using ReactiveUI.Legacy; + +#pragma warning disable CS0618 // Type or member is obsolete + namespace GitHub.Services { @@ -12,25 +15,22 @@ public class ErrorMap readonly IEnumerable translations; public ErrorMap( - [AllowNull] ErrorMessage defaultMessage, - [AllowNull] IEnumerable translations, - [AllowNull] IEnumerable recoveryCommands) + ErrorMessage defaultMessage, + IEnumerable translations, + IEnumerable recoveryCommands) { this.defaultMessage = defaultMessage; this.translations = translations; RecoveryCommands = recoveryCommands; } - [AllowNull] public IEnumerable RecoveryCommands { - [return: AllowNull] get; private set; } - [return: AllowNull] - public ErrorMessage GetErrorInfo([AllowNull] Exception exception) + public ErrorMessage GetErrorInfo(Exception exception) { if (exception != null && translations != null) { diff --git a/src/GitHub.App/Services/ErrorMessageTranslator.cs b/src/GitHub.App/Services/ErrorMessageTranslator.cs index 85303c0066..91742b2fe1 100644 --- a/src/GitHub.App/Services/ErrorMessageTranslator.cs +++ b/src/GitHub.App/Services/ErrorMessageTranslator.cs @@ -2,8 +2,12 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using NullGuard; +using GitHub.Extensions; using ReactiveUI; +using ReactiveUI.Legacy; + +#pragma warning disable CS0618 // Type or member is obsolete +#pragma warning disable CA1034 // Nested types should not be visible namespace GitHub.Services { @@ -13,10 +17,12 @@ public class ErrorMessageTranslator public ErrorMessageTranslator(IDictionary userErrors) { + Guard.ArgumentNotNull(userErrors, nameof(userErrors)); + userErrorMappings = userErrors; } - public UserError GetUserError(ErrorType errorType, [AllowNull] Exception exception, params object[] context) + public UserError GetUserError(ErrorType errorType, Exception exception, params object[] context) { var translation = GetUserErrorTranslation(errorType, exception, context); return new UserError(translation.ErrorMessage, translation.CauseOrResolution, translation.RecoveryCommands, null, exception) @@ -27,7 +33,7 @@ public UserError GetUserError(ErrorType errorType, [AllowNull] Exception excepti public UserErrorTranslation GetUserErrorTranslation( ErrorType errorType, - [AllowNull] Exception exception, + Exception exception, params object[] context) { ErrorMessage errorMessage = null; @@ -58,35 +64,29 @@ public UserErrorTranslation GetUserErrorTranslation( public class UserErrorTranslation { public UserErrorTranslation( - [AllowNull] string errorMessage, - [AllowNull] string causeOrResolution, - [AllowNull] IEnumerable recoveryCommands) + string errorMessage, + string causeOrResolution, + IEnumerable recoveryCommands) { ErrorMessage = errorMessage; CauseOrResolution = causeOrResolution; RecoveryCommands = recoveryCommands; } - [AllowNull] public string ErrorMessage { - [return: AllowNull] get; private set; } - [AllowNull] public string CauseOrResolution { - [return: AllowNull] get; private set; } - [AllowNull] public IEnumerable RecoveryCommands { - [return: AllowNull] get; private set; } diff --git a/src/GitHub.App/Services/FromGraphQlExtensions.cs b/src/GitHub.App/Services/FromGraphQlExtensions.cs new file mode 100644 index 0000000000..06a1c46e2e --- /dev/null +++ b/src/GitHub.App/Services/FromGraphQlExtensions.cs @@ -0,0 +1,125 @@ +using System; +using GitHub.Models; +using Octokit.GraphQL.Model; +using CheckAnnotationLevel = GitHub.Models.CheckAnnotationLevel; +using CheckConclusionState = GitHub.Models.CheckConclusionState; +using CheckStatusState = GitHub.Models.CheckStatusState; +using PullRequestReviewState = GitHub.Models.PullRequestReviewState; +using StatusState = GitHub.Models.StatusState; + +namespace GitHub.Services +{ + public static class FromGraphQlExtensions + { + public static CheckConclusionState? FromGraphQl(this Octokit.GraphQL.Model.CheckConclusionState? value) + { + switch (value) + { + case null: + return null; + case Octokit.GraphQL.Model.CheckConclusionState.ActionRequired: + return CheckConclusionState.ActionRequired; + case Octokit.GraphQL.Model.CheckConclusionState.TimedOut: + return CheckConclusionState.TimedOut; + case Octokit.GraphQL.Model.CheckConclusionState.Cancelled: + return CheckConclusionState.Cancelled; + case Octokit.GraphQL.Model.CheckConclusionState.Failure: + return CheckConclusionState.Failure; + case Octokit.GraphQL.Model.CheckConclusionState.Success: + return CheckConclusionState.Success; + case Octokit.GraphQL.Model.CheckConclusionState.Neutral: + return CheckConclusionState.Neutral; + case Octokit.GraphQL.Model.CheckConclusionState.Skipped: + return CheckConclusionState.Skipped; + case Octokit.GraphQL.Model.CheckConclusionState.Stale: + return CheckConclusionState.Stale; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + + public static Models.PullRequestState FromGraphQl(this Octokit.GraphQL.Model.PullRequestState value) + { + switch (value) + { + case Octokit.GraphQL.Model.PullRequestState.Open: + return Models.PullRequestState.Open; + case Octokit.GraphQL.Model.PullRequestState.Closed: + return Models.PullRequestState.Closed; + case Octokit.GraphQL.Model.PullRequestState.Merged: + return Models.PullRequestState.Merged; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + + public static StatusState FromGraphQl(this Octokit.GraphQL.Model.StatusState value) + { + switch (value) + { + case Octokit.GraphQL.Model.StatusState.Expected: + return StatusState.Expected; + case Octokit.GraphQL.Model.StatusState.Error: + return StatusState.Error; + case Octokit.GraphQL.Model.StatusState.Failure: + return StatusState.Failure; + case Octokit.GraphQL.Model.StatusState.Pending: + return StatusState.Pending; + case Octokit.GraphQL.Model.StatusState.Success: + return StatusState.Success; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + + public static CheckStatusState FromGraphQl(this Octokit.GraphQL.Model.CheckStatusState value) + { + switch (value) + { + case Octokit.GraphQL.Model.CheckStatusState.Queued: + return CheckStatusState.Queued; + case Octokit.GraphQL.Model.CheckStatusState.InProgress: + return CheckStatusState.InProgress; + case Octokit.GraphQL.Model.CheckStatusState.Completed: + return CheckStatusState.Completed; + case Octokit.GraphQL.Model.CheckStatusState.Requested: + return CheckStatusState.Requested; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + + public static PullRequestReviewState FromGraphQl(this Octokit.GraphQL.Model.PullRequestReviewState value) + { + switch (value) { + case Octokit.GraphQL.Model.PullRequestReviewState.Pending: + return PullRequestReviewState.Pending; + case Octokit.GraphQL.Model.PullRequestReviewState.Commented: + return PullRequestReviewState.Commented; + case Octokit.GraphQL.Model.PullRequestReviewState.Approved: + return PullRequestReviewState.Approved; + case Octokit.GraphQL.Model.PullRequestReviewState.ChangesRequested: + return PullRequestReviewState.ChangesRequested; + case Octokit.GraphQL.Model.PullRequestReviewState.Dismissed: + return PullRequestReviewState.Dismissed; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + + public static CheckAnnotationLevel FromGraphQl(this Octokit.GraphQL.Model.CheckAnnotationLevel value) + { + switch (value) + { + case Octokit.GraphQL.Model.CheckAnnotationLevel.Failure: + return CheckAnnotationLevel.Failure; + case Octokit.GraphQL.Model.CheckAnnotationLevel.Notice: + return CheckAnnotationLevel.Notice; + case Octokit.GraphQL.Model.CheckAnnotationLevel.Warning: + return CheckAnnotationLevel.Warning; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Services/GitClient.cs b/src/GitHub.App/Services/GitClient.cs index 1ccc1dc5de..96d22e6d97 100644 --- a/src/GitHub.App/Services/GitClient.cs +++ b/src/GitHub.App/Services/GitClient.cs @@ -1,15 +1,15 @@ using System; +using System.Collections.Generic; using System.ComponentModel.Composition; using System.IO; using System.Linq; -using System.Reactive.Linq; using System.Threading.Tasks; using GitHub.Extensions; +using GitHub.Models; using GitHub.Primitives; using LibGit2Sharp; -using NullGuard; -using System.Diagnostics; -using NLog; +using GitHub.Logging; +using Serilog; namespace GitHub.Services { @@ -17,14 +17,20 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.Shared)] public class GitClient : IGitClient { - static readonly Logger log = LogManager.GetCurrentClassLogger(); + static readonly ILogger log = LogManager.ForContext(); + readonly IGitService gitService; readonly PullOptions pullOptions; readonly PushOptions pushOptions; readonly FetchOptions fetchOptions; [ImportingConstructor] - public GitClient(IGitHubCredentialProvider credentialProvider) + public GitClient(IGitHubCredentialProvider credentialProvider, IGitService gitService) { + Guard.ArgumentNotNull(credentialProvider, nameof(credentialProvider)); + Guard.ArgumentNotNull(gitService, nameof(gitService)); + + this.gitService = gitService; + pushOptions = new PushOptions { CredentialsProvider = credentialProvider.HandleCredentials }; fetchOptions = new FetchOptions { CredentialsProvider = credentialProvider.HandleCredentials }; pullOptions = new PullOptions @@ -37,11 +43,17 @@ public GitClient(IGitHubCredentialProvider credentialProvider) public Task Pull(IRepository repository) { Guard.ArgumentNotNull(repository, nameof(repository)); - - return Task.Factory.StartNew(() => + return Task.Run(() => { var signature = repository.Config.BuildSignature(DateTimeOffset.UtcNow); - repository.Network.Pull(signature, pullOptions); + if (repository is Repository repo) + { + LibGit2Sharp.Commands.Pull(repo, signature, pullOptions); + } + else + { + log.Error("Couldn't pull because {Variable} isn't an instance of {Type}", nameof(repository), typeof(Repository)); + } }); } @@ -51,7 +63,7 @@ public Task Push(IRepository repository, string branchName, string remoteName) Guard.ArgumentNotEmptyString(branchName, nameof(branchName)); Guard.ArgumentNotEmptyString(remoteName, nameof(remoteName)); - return Task.Factory.StartNew(() => + return Task.Run(() => { if (repository.Head?.Commits != null && repository.Head.Commits.Any()) { @@ -62,21 +74,47 @@ public Task Push(IRepository repository, string branchName, string remoteName) }); } - public Task Fetch(IRepository repository, string remoteName) + public Task Fetch(IRepository repo, UriString cloneUrl, params string[] refspecs) { - Guard.ArgumentNotNull(repository, nameof(repository)); - Guard.ArgumentNotEmptyString(remoteName, nameof(remoteName)); + foreach (var remote in repo.Network.Remotes) + { + if (UriString.RepositoryUrlsAreEqual(new UriString(remote.Url), cloneUrl)) + { + return Fetch(repo, remote.Name, refspecs); + } + } - return Task.Factory.StartNew(() => + return Task.Run(() => { try { - var remote = repository.Network.Remotes[remoteName]; - repository.Network.Fetch(remote, fetchOptions); + var remoteName = cloneUrl.Owner; + var remoteUri = cloneUrl.ToRepositoryUrl(); + + var removeRemote = false; + if (repo.Network.Remotes[remoteName] != null) + { + // If a remote with this name already exists, use a unique name and remove remote afterwards + remoteName = cloneUrl.Owner + "-" + Guid.NewGuid(); + removeRemote = true; + } + + repo.Network.Remotes.Add(remoteName, remoteUri.ToString()); + try + { + repo.Network.Fetch(remoteName, refspecs, fetchOptions); + } + finally + { + if (removeRemote) + { + repo.Network.Remotes.Remove(remoteName); + } + } } catch (Exception ex) { - log.Error("Failed to fetch", ex); + log.Error(ex, "Failed to fetch"); #if DEBUG throw; #endif @@ -89,16 +127,15 @@ public Task Fetch(IRepository repository, string remoteName, params string[] ref Guard.ArgumentNotNull(repository, nameof(repository)); Guard.ArgumentNotEmptyString(remoteName, nameof(remoteName)); - return Task.Factory.StartNew(() => + return Task.Run(() => { try { - var remote = repository.Network.Remotes[remoteName]; - repository.Network.Fetch(remote, refspecs, fetchOptions); + repository.Network.Fetch(remoteName, refspecs, fetchOptions); } - catch(Exception ex) + catch (Exception ex) { - log.Error("Failed to fetch", ex); + log.Error(ex, "Failed to fetch"); #if DEBUG throw; #endif @@ -111,51 +148,32 @@ public Task Checkout(IRepository repository, string branchName) Guard.ArgumentNotNull(repository, nameof(repository)); Guard.ArgumentNotEmptyString(branchName, nameof(branchName)); - return Task.Factory.StartNew(() => + return Task.Run(() => { - repository.Checkout(branchName); + if (repository is Repository repo) + { + LibGit2Sharp.Commands.Checkout(repo, branchName); + } + else + { + log.Error("Couldn't checkout because {Variable} isn't an instance of {Type}", nameof(repository), typeof(Repository)); + } }); } - public Task CreateBranch(IRepository repository, string branchName) + public async Task CommitExists(IRepository repository, string sha) { - Guard.ArgumentNotNull(repository, nameof(repository)); - Guard.ArgumentNotEmptyString(branchName, nameof(branchName)); - - return Task.Factory.StartNew(() => - { - repository.CreateBranch(branchName); - }); + return await Task.Run(() => repository.Lookup(sha) != null).ConfigureAwait(false); } - public Task Compare( - IRepository repository, - string sha1, - string sha2, - bool detectRenames) + public Task CreateBranch(IRepository repository, string branchName) { Guard.ArgumentNotNull(repository, nameof(repository)); - Guard.ArgumentNotEmptyString(sha1, nameof(sha1)); - Guard.ArgumentNotEmptyString(sha2, nameof(sha2)); + Guard.ArgumentNotEmptyString(branchName, nameof(branchName)); - return Task.Factory.StartNew(() => + return Task.Run(() => { - var options = new CompareOptions - { - Similarity = detectRenames ? SimilarityOptions.Renames : SimilarityOptions.None - }; - - var commit1 = repository.Lookup(sha1); - var commit2 = repository.Lookup(sha2); - - if (commit1 != null && commit2 != null) - { - return repository.Diff.Compare(commit1.Tree, commit2.Tree, options); - } - else - { - return null; - } + repository.CreateBranch(branchName); }); } @@ -164,7 +182,7 @@ public Task GetConfig(IRepository repository, string key) Guard.ArgumentNotNull(repository, nameof(repository)); Guard.ArgumentNotEmptyString(key, nameof(key)); - return Task.Factory.StartNew(() => + return Task.Run(() => { var result = repository.Config.Get(key); return result != null ? result.Value : default(T); @@ -177,7 +195,7 @@ public Task SetConfig(IRepository repository, string key, string value) Guard.ArgumentNotEmptyString(key, nameof(key)); Guard.ArgumentNotEmptyString(value, nameof(value)); - return Task.Factory.StartNew(() => + return Task.Run(() => { repository.Config.Set(key, value); }); @@ -188,7 +206,7 @@ public Task SetRemote(IRepository repository, string remoteName, Uri url) Guard.ArgumentNotNull(repository, nameof(repository)); Guard.ArgumentNotEmptyString(remoteName, nameof(remoteName)); - return Task.Factory.StartNew(() => + return Task.Run(() => { repository.Config.Set("remote." + remoteName + ".url", url.ToString()); repository.Config.Set("remote." + remoteName + ".fetch", "+refs/heads/*:refs/remotes/" + remoteName + "/*"); @@ -201,7 +219,7 @@ public Task SetTrackingBranch(IRepository repository, string branchName, string Guard.ArgumentNotEmptyString(branchName, nameof(branchName)); Guard.ArgumentNotEmptyString(remoteName, nameof(remoteName)); - return Task.Factory.StartNew(() => + return Task.Run(() => { var remoteBranchName = IsCanonical(remoteName) ? remoteName : "refs/remotes/" + remoteName + "/" + branchName; var remoteBranch = repository.Branches[remoteBranchName]; @@ -219,7 +237,7 @@ public Task UnsetConfig(IRepository repository, string key) { Guard.ArgumentNotEmptyString(key, nameof(key)); - return Task.Factory.StartNew(() => + return Task.Run(() => { repository.Config.Unset(key); }); @@ -227,9 +245,12 @@ public Task UnsetConfig(IRepository repository, string key) public Task GetHttpRemote(IRepository repo, string remote) { - return Task.Factory.StartNew(() => + Guard.ArgumentNotNull(repo, nameof(repo)); + Guard.ArgumentNotEmptyString(remote, nameof(remote)); + + return Task.Run(() => { - var uri = GitService.GitServiceHelper.GetRemoteUri(repo, remote); + var uri = gitService.GetRemoteUri(repo, remote); var remoteName = uri.IsHypertextTransferProtocol ? remote : remote + "-http"; var ret = repo.Network.Remotes[remoteName]; if (ret == null) @@ -238,41 +259,174 @@ public Task GetHttpRemote(IRepository repo, string remote) }); } - [return: AllowNull] - public async Task ExtractFile(IRepository repository, string commitSha, string fileName) + public Task ExtractFile(IRepository repository, string commitSha, string relativePath) + { + Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentNotEmptyString(commitSha, nameof(commitSha)); + Guard.ArgumentIsRelativePath(relativePath, nameof(relativePath)); + + var gitPath = Paths.ToGitPath(relativePath); + return Task.Run(() => + { + var commit = repository.Lookup(commitSha); + if (commit == null) + { + throw new FileNotFoundException("Couldn't find '" + gitPath + "' at commit " + commitSha + "."); + } + + var blob = commit[gitPath]?.Target as Blob; + return blob?.GetContentText(); + }); + } + + public Task ExtractFileBinary(IRepository repository, string commitSha, string relativePath) { - var commit = repository.Lookup(commitSha); + Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentNotEmptyString(commitSha, nameof(commitSha)); + Guard.ArgumentIsRelativePath(relativePath, nameof(relativePath)); - if (commit == null) + var gitPath = Paths.ToGitPath(relativePath); + return Task.Run(() => { + var commit = repository.Lookup(commitSha); + if (commit == null) + { + throw new FileNotFoundException("Couldn't find '" + gitPath + "' at commit " + commitSha + "."); + } + + var blob = commit[gitPath]?.Target as Blob; + + if (blob != null) + { + using (var m = new MemoryStream()) + { + var content = blob.GetContentStream(); + content.CopyTo(m); + return m.ToArray(); + } + } + return null; - } + }); + } + + public Task IsModified(IRepository repository, string relativePath, byte[] contents) + { + Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentIsRelativePath(relativePath, nameof(relativePath)); + + var gitPath = Paths.ToGitPath(relativePath); + return Task.Run(() => + { + if (repository.RetrieveStatus(gitPath) == FileStatus.Unaltered) + { + var treeEntry = repository.Head[gitPath]; + if (treeEntry?.TargetType != TreeEntryTargetType.Blob) + { + return false; + } + + var blob1 = (Blob)treeEntry.Target; + using (var s = contents != null ? new MemoryStream(contents) : new MemoryStream()) + { + var blob2 = repository.ObjectDatabase.CreateBlob(s, gitPath); + var diff = repository.Diff.Compare(blob1, blob2); + return diff.LinesAdded != 0 || diff.LinesDeleted != 0; + } + } - var blob = commit[fileName]?.Target as Blob; - var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var tempFileName = $"{Path.GetFileNameWithoutExtension(fileName)}@{commitSha}{Path.GetExtension(fileName)}"; - var tempFile = Path.Combine(tempDir, tempFileName); + return true; + }); + } - Directory.CreateDirectory(tempDir); + public async Task GetPullRequestMergeBase(IRepository repo, + UriString targetCloneUrl, string baseSha, string headSha, string baseRef, int pullNumber) + { + Guard.ArgumentNotNull(repo, nameof(repo)); + Guard.ArgumentNotNull(targetCloneUrl, nameof(targetCloneUrl)); + Guard.ArgumentNotEmptyString(baseRef, nameof(baseRef)); - if (blob != null) + var headCommit = repo.Lookup(headSha); + if (headCommit == null) { - using (var source = blob.GetContentStream(new FilteringOptions(fileName))) - using (var destination = File.OpenWrite(tempFile)) + // The PR base branch might no longer exist, so we fetch using `refs/pull//head` first. + // This will often fetch the base commits, even when the base branch no longer exists. + var headRef = $"refs/pull/{pullNumber}/head"; + await Fetch(repo, targetCloneUrl, headRef); + headCommit = repo.Lookup(headSha); + if (headCommit == null) { - await source.CopyToAsync(destination); + throw new NotFoundException($"Couldn't find {headSha} after fetching from {targetCloneUrl}:{headRef}."); } } - else + + var baseCommit = repo.Lookup(baseSha); + if (baseCommit == null) { - File.Create(tempFile).Dispose(); + await Fetch(repo, targetCloneUrl, baseRef); + baseCommit = repo.Lookup(baseSha); + if (baseCommit == null) + { + throw new NotFoundException($"Couldn't find {baseSha} after fetching from {targetCloneUrl}:{baseRef}."); + } + } + + var mergeBaseCommit = repo.ObjectDatabase.FindMergeBase(baseCommit, headCommit); + if (mergeBaseCommit == null) + { + throw new NotFoundException($"Couldn't find merge base between {baseCommit} and {headCommit}."); } - return tempFile; + return mergeBaseCommit.Sha; + } + + public Task IsHeadPushed(IRepository repo) + { + Guard.ArgumentNotNull(repo, nameof(repo)); + + return Task.Run(() => + { + return repo.Head.TrackingDetails.AheadBy == 0; + }); + } + + public Task> GetMessagesForUniqueCommits( + IRepository repo, + string baseBranch, + string compareBranch, + int maxCommits) + { + return Task.Run(() => + { + var baseCommit = repo.Lookup(baseBranch); + var compareCommit = repo.Lookup(compareBranch); + if (baseCommit == null || compareCommit == null) + { + var missingBranch = baseCommit == null ? baseBranch : compareBranch; + throw new NotFoundException(missingBranch); + } + + var mergeCommit = repo.ObjectDatabase.FindMergeBase(baseCommit, compareCommit); + var commitFilter = new CommitFilter + { + IncludeReachableFrom = baseCommit, + ExcludeReachableFrom = mergeCommit, + }; + + var commits = repo.Commits + .QueryBy(commitFilter) + .Take(maxCommits) + .Select(c => new CommitMessage(c.Message)) + .ToList(); + + return (IReadOnlyList)commits; + }); } static bool IsCanonical(string s) { + Guard.ArgumentNotEmptyString(s, nameof(s)); + return s.StartsWith("refs/", StringComparison.Ordinal); } } diff --git a/src/GitHub.App/Services/GitHubContextService.cs b/src/GitHub.App/Services/GitHubContextService.cs new file mode 100644 index 0000000000..5d8944bbb4 --- /dev/null +++ b/src/GitHub.App/Services/GitHubContextService.cs @@ -0,0 +1,613 @@ +using System; +using System.IO; +using System.Text; +using System.Linq; +using System.Windows; +using System.Globalization; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Text.RegularExpressions; +using System.Runtime.InteropServices; +using GitHub.Exports; +using GitHub.Extensions; +using GitHub.Primitives; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.TextManager.Interop; +using LibGit2Sharp; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.Services +{ + [Export(typeof(IGitHubContextService))] + public class GitHubContextService : IGitHubContextService + { + readonly IGitHubServiceProvider serviceProvider; + readonly IGitService gitService; + readonly IVSServices vsServices; + readonly Lazy textManager; + + // USERID_REGEX = /[a-z0-9][a-z0-9\-\_]*/i + const string owner = "(?[a-zA-Z0-9][a-zA-Z0-9-_]*)"; + + // REPO_REGEX = /(?:\w|\.|\-)+/i + // This supports "_" for legacy superfans with logins that still contain "_". + const string repo = @"(?(?:\w|\.|\-)+)"; + + //BRANCH_REGEX = /[^\/]+(\/[^\/]+)?/ + const string branch = @"(?[^./ ~^:?*\[\\][^/ ~^:?*\[\\]*(/[^./ ~^:?*\[\\][^/ ~^:?*\[\\]*)*)"; + + const string pull = "(?[0-9]+)"; + + const string issue = "(?[0-9]+)"; + + static readonly string tree = $"^{repo}/(?[^ ]+)"; + static readonly string blobName = $"^{repo}/(?[^ /]+)"; + + static readonly Regex windowTitleRepositoryRegex = new Regex($"^(GitHub - )?{owner}/{repo}(: .*)? - ", RegexOptions.Compiled); + static readonly Regex windowTitleBranchRegex = new Regex($"^(GitHub - )?{owner}/{repo} at {branch} ", RegexOptions.Compiled); + static readonly Regex windowTitlePullRequestRegex = new Regex($" · Pull Request #{pull} · {owner}/{repo}( · GitHub)? - ", RegexOptions.Compiled); + static readonly Regex windowTitleIssueRegex = new Regex($" · Issue #{issue} · {owner}/{repo}( · GitHub)? - ", RegexOptions.Compiled); + static readonly Regex windowTitleBlobRegex = new Regex($"{blobName} at {branch} · {owner}/{repo}( · GitHub)? - ", RegexOptions.Compiled); + static readonly Regex windowTitleTreeRegex = new Regex($"{tree} at {branch} · {owner}/{repo}( · GitHub)? - ", RegexOptions.Compiled); + static readonly Regex windowTitleBranchesRegex = new Regex($"Branches · {owner}/{repo}( · GitHub)? - ", RegexOptions.Compiled); + + static readonly Regex urlLineRegex = new Regex($"#L(?[0-9]+)(-L(?[0-9]+))?$", RegexOptions.Compiled); + static readonly Regex urlBlobRegex = new Regex($"blob/(?[^/]+(/[^/]+)*)/(?[^/#]+)", RegexOptions.Compiled); + + static readonly Regex treeishCommitRegex = new Regex($"(?[a-z0-9]{{40}})(/(?.+))?", RegexOptions.Compiled); + static readonly Regex treeishBranchRegex = new Regex($"(?master)(/(?.+))?", RegexOptions.Compiled); + + static readonly Regex tempFileObjectishRegex = new Regex(@"\\TFSTemp\\[^\\]*[.](?[a-z0-9]{8})[.][^.\\]*$", RegexOptions.Compiled); + + [ImportingConstructor] + public GitHubContextService(IGitHubServiceProvider serviceProvider, IGitService gitService, IVSServices vsServices) + { + this.serviceProvider = serviceProvider; + this.gitService = gitService; + this.vsServices = vsServices; + textManager = new Lazy(() => serviceProvider.GetService()); + } + + /// + public void TryNavigateToContext(string repositoryDir, GitHubContext context) + { + if (context?.LinkType == LinkType.Blob) + { + var (commitish, path, commitSha) = ResolveBlob(repositoryDir, context); + if (commitish == null && path == null) + { + var message = string.Format(CultureInfo.CurrentCulture, Resources.CouldntFindCorrespondingFile, context.Url); + vsServices.ShowMessageBoxInfo(message); + return; + } + + var hasChanges = HasChangesInWorkingDirectory(repositoryDir, commitish, path); + if (hasChanges) + { + var message = string.Format(CultureInfo.CurrentCulture, Resources.ChangesInWorkingDirectoryMessage, commitish); + vsServices.ShowMessageBoxInfo(message); + } + + TryOpenFile(repositoryDir, context); + } + } + + /// + public GitHubContext FindContextFromClipboard() + { + var text = Clipboard.GetText(TextDataFormat.Text); + return FindContextFromUrl(text); + } + + /// + public GitHubContext FindContextFromUrl(string url) + { + var uri = new UriString(url); + if (!uri.IsValidUri) + { + return null; + } + + if (!uri.IsHypertextTransferProtocol) + { + return null; + } + + var context = new GitHubContext + { + Host = uri.Host, + Owner = uri.Owner, + RepositoryName = uri.RepositoryName, + Url = uri + }; + + if (uri.Owner == null) + { + context.LinkType = LinkType.Unknown; + return context; + } + + if (uri.RepositoryName == null) + { + context.LinkType = LinkType.Unknown; + return context; + } + + var repositoryUrl = uri.ToRepositoryUrl().ToString(); + if (string.Equals(url, repositoryUrl, StringComparison.OrdinalIgnoreCase) || + string.Equals(url, repositoryUrl + ".git", StringComparison.OrdinalIgnoreCase)) + { + context.LinkType = LinkType.Repository; + return context; + } + + var repositoryPrefix = repositoryUrl + "/"; + if (!url.StartsWith(repositoryPrefix, StringComparison.OrdinalIgnoreCase)) + { + return context; + } + + var subpath = url.Substring(repositoryPrefix.Length); + + (context.Line, context.LineEnd) = FindLine(subpath); + + context.PullRequest = FindPullRequest(url); + + var match = urlBlobRegex.Match(subpath); + if (match.Success) + { + context.TreeishPath = match.Groups["treeish"].Value; + context.BlobName = match.Groups["blobName"].Value; + context.LinkType = LinkType.Blob; + return context; + } + + return context; + } + + /// + public GitHubContext FindContextFromBrowser() + { + return + FindWindowTitlesForClass("Chrome_WidgetWin_1") // Chrome + .Concat(FindWindowTitlesForClass("MozillaWindowClass")) // Firefox + .Select(FindContextFromWindowTitle).Where(x => x != null) + .FirstOrDefault(); + } + + /// + public Uri ToRepositoryUrl(GitHubContext context) + { + var builder = new UriBuilder("https", context.Host ?? "github.com"); + builder.Path = $"{context.Owner}/{context.RepositoryName}"; + return builder.Uri; + } + + /// + public GitHubContext FindContextFromWindowTitle(string windowTitle) + { + var match = windowTitleBlobRegex.Match(windowTitle); + if (match.Success) + { + return new GitHubContext + { + Owner = match.Groups["owner"].Value, + RepositoryName = match.Groups["repo"].Value, + BranchName = match.Groups["branch"].Value, + BlobName = match.Groups["blobName"].Value + }; + } + + match = windowTitleTreeRegex.Match(windowTitle); + if (match.Success) + { + return new GitHubContext + { + Owner = match.Groups["owner"].Value, + RepositoryName = match.Groups["repo"].Value, + BranchName = match.Groups["branch"].Value, + TreeishPath = $"{match.Groups["branch"].Value}/{match.Groups["tree"].Value}" + }; + } + + match = windowTitleRepositoryRegex.Match(windowTitle); + if (match.Success) + { + return new GitHubContext + { + Owner = match.Groups["owner"].Value, + RepositoryName = match.Groups["repo"].Value, + }; + } + + match = windowTitleBranchRegex.Match(windowTitle); + if (match.Success) + { + return new GitHubContext + { + Owner = match.Groups["owner"].Value, + RepositoryName = match.Groups["repo"].Value, + BranchName = match.Groups["branch"].Value, + }; + } + + match = windowTitleBranchesRegex.Match(windowTitle); + if (match.Success) + { + return new GitHubContext + { + Owner = match.Groups["owner"].Value, + RepositoryName = match.Groups["repo"].Value + }; + } + + match = windowTitlePullRequestRegex.Match(windowTitle); + if (match.Success) + { + int.TryParse(match.Groups["pull"].Value, out int pullRequest); + + return new GitHubContext + { + Owner = match.Groups["owner"].Value, + RepositoryName = match.Groups["repo"].Value, + PullRequest = pullRequest + }; + } + + match = windowTitleIssueRegex.Match(windowTitle); + if (match.Success) + { + int.TryParse(match.Groups["issue"].Value, out int issue); + + return new GitHubContext + { + Owner = match.Groups["owner"].Value, + RepositoryName = match.Groups["repo"].Value, + Issue = issue + }; + } + + return null; + } + + /// + public bool TryOpenFile(string repositoryDir, GitHubContext context) + { + var (commitish, path, isSha) = ResolveBlob(repositoryDir, context); + if (path == null) + { + return false; + } + + var relativePath = Paths.ToWindowsPath(path); + var fullPath = Path.Combine(repositoryDir, relativePath); + var textView = OpenDocument(fullPath); + SetSelection(textView, context); + return true; + } + + /// + public (string commitish, string path, string commitSha) ResolveBlob(string repositoryDir, GitHubContext context, string remoteName = "origin") + { + Guard.ArgumentNotNull(repositoryDir, nameof(repositoryDir)); + Guard.ArgumentNotNull(context, nameof(context)); + + using (var repository = gitService.GetRepository(repositoryDir)) + { + if (context.TreeishPath == null) + { + // Blobs without a TreeishPath aren't currently supported + return (null, null, null); + } + + if (context.BlobName == null) + { + // Not a blob + return (null, null, null); + } + + var objectishPath = $"{context.TreeishPath}/{context.BlobName}"; + var objectish = ToObjectish(objectishPath); + var (commitSha, pathSha) = objectish.First(); + if (ObjectId.TryParse(commitSha, out ObjectId objectId) && repository.Lookup(objectId) != null) + { + if (repository.Lookup($"{commitSha}:{pathSha}") != null) + { + return (commitSha, pathSha, commitSha); + } + } + + foreach (var (commitish, path) in objectish) + { + var resolveRefs = new[] { $"refs/remotes/{remoteName}/{commitish}", $"refs/tags/{commitish}" }; + foreach (var resolveRef in resolveRefs) + { + var commit = repository.Lookup(resolveRef); + if (commit != null) + { + var blob = repository.Lookup($"{resolveRef}:{path}"); + if (blob != null) + { + return (resolveRef, path, commit.Sha); + } + + // Resolved commitish but not path + return (resolveRef, null, commit.Sha); + } + } + } + + return (null, null, null); + } + + IEnumerable<(string commitish, string path)> ToObjectish(string treeishPath) + { + var index = 0; + while ((index = treeishPath.IndexOf('/', index + 1)) != -1) + { + var commitish = treeishPath.Substring(0, index); + var path = treeishPath.Substring(index + 1); + yield return (commitish, path); + } + } + } + + /// + public string FindObjectishForTFSTempFile(string tempFile) + { + var match = tempFileObjectishRegex.Match(tempFile); + if (match.Success) + { + return match.Groups["objectish"].Value; + } + + return null; + } + + /// + public (string commitSha, string blobPath) ResolveBlobFromHistory(string repositoryDir, string objectish) + { + using (var repo = gitService.GetRepository(repositoryDir)) + { + var blob = repo.Lookup(objectish); + if (blob == null) + { + return (null, null); + } + + foreach (var commit in repo.Commits) + { + var trees = new Stack(); + trees.Push(commit.Tree); + + while (trees.Count > 0) + { + foreach (var treeEntry in trees.Pop()) + { + if (treeEntry.Target == blob) + { + return (commit.Sha, treeEntry.Path); + } + + if (treeEntry.TargetType == TreeEntryTargetType.Tree) + { + trees.Push((Tree)treeEntry.Target); + } + } + } + } + + return (null, null); + } + } + + /// + public bool HasChangesInWorkingDirectory(string repositoryDir, string commitish, string relativePath) + { + Guard.ArgumentNotNull(repositoryDir, nameof(repositoryDir)); + Guard.ArgumentNotNull(commitish, nameof(commitish)); + Guard.ArgumentIsRelativePath(relativePath, nameof(relativePath)); + + var gitPath = Paths.ToGitPath(relativePath); + using (var repo = gitService.GetRepository(repositoryDir)) + { + var commit = repo.Lookup(commitish); + var paths = new[] { gitPath }; + + return repo.Diff.Compare(commit.Tree, DiffTargets.WorkingDirectory, paths).Count() > 0; + } + } + + /// + public async Task TryAnnotateFile(string repositoryDir, string currentBranch, GitHubContext context) + { + var (commitish, path, commitSha) = ResolveBlob(repositoryDir, context); + if (path == null) + { + return false; + } + + if (!AnnotateFile(repositoryDir, currentBranch, path, commitSha)) + { + return false; + } + + if (context.Line != null) + { + await Task.Delay(1000); + var activeView = FindActiveView(); + SetSelection(activeView, context); + } + + return true; + } + + IVsTextView FindActiveView() + { + if (!ErrorHandler.Succeeded(textManager.Value.GetActiveView2(1, null, (uint)_VIEWFRAMETYPE.vftToolWindow, out IVsTextView textView))) + { + return null; + } + + return textView; + } + + /// + /// Call AnnotateFile of the IGitExt2 service if it can be found. + /// + /// + /// The IGitExt2 interface was introduced in an update of Visual Studio 2017. + /// The must exist but doesn't appear to be used in the UI. + /// + /// Path of the target repository + /// A branch of the target repository + /// A path the the target blob + /// The commit version of the blob + /// True if AnnotateFile functionality is available. + bool AnnotateFile(string repositoryPath, string branchName, string relativePath, string versionSha) + { + var serviceType = Type.GetType("Microsoft.VisualStudio.TeamFoundation.Git.Extensibility.IGitExt2, Microsoft.TeamFoundation.Git.Provider", false); + if (serviceType == null) + { + return false; + } + + var service = serviceProvider.GetService(serviceType); + if (service == null) + { + return false; + } + + try + { + Invoke(service, "AnnotateFile", repositoryPath, branchName, relativePath, versionSha); + return true; + } + catch (Exception) + { + return false; + } + + void Invoke(object target, string method, T1 arg1, T2 arg2, T3 arg3, T4 arg4) + { + var action = (Action)Delegate.CreateDelegate(typeof(Action), target, method); + action.Invoke(arg1, arg2, arg3, arg4); + } + } + + static void SetSelection(IVsTextView textView, GitHubContext context) + { + var line = context.Line; + var lineEnd = context.LineEnd ?? line; + + if (line != null) + { + ErrorHandler.ThrowOnFailure(textView.GetBuffer(out IVsTextLines buffer)); + buffer.GetLengthOfLine(lineEnd.Value - 1, out int lineEndLength); + ErrorHandler.ThrowOnFailure(textView.SetSelection(line.Value - 1, 0, lineEnd.Value - 1, lineEndLength)); + ErrorHandler.ThrowOnFailure(textView.CenterLines(line.Value - 1, lineEnd.Value - line.Value + 1)); + } + } + + IVsTextView OpenDocument(string fullPath) + { + var logicalView = VSConstants.LOGVIEWID.TextView_guid; + IVsUIHierarchy hierarchy; + uint itemID; + IVsWindowFrame windowFrame; + IVsTextView view; + VsShellUtilities.OpenDocument(serviceProvider, fullPath, logicalView, out hierarchy, out itemID, out windowFrame, out view); + return view; + } + + static (int? lineStart, int? lineEnd) FindLine(UriString gitHubUrl) + { + var url = gitHubUrl.ToString(); + + var match = urlLineRegex.Match(url); + if (match.Success) + { + int.TryParse(match.Groups["line"].Value, out int line); + + var lineEndGroup = match.Groups["lineEnd"]; + if (string.IsNullOrEmpty(lineEndGroup.Value)) + { + return (line, null); + } + + int.TryParse(lineEndGroup.Value, out int lineEnd); + return (line, lineEnd); + } + + return (null, null); + } + + static int? FindPullRequest(UriString gitHubUrl) + { + var pullRequest = FindSubPath(gitHubUrl, "/pull/")?.Split('/').First(); + if (pullRequest == null) + { + return null; + } + + if (!int.TryParse(pullRequest, out int number)) + { + return null; + } + + return number; + } + + static string FindSubPath(UriString gitHubUrl, string matchPath) + { + var url = gitHubUrl.ToString(); + var prefix = gitHubUrl.ToRepositoryUrl() + matchPath; + if (!url.StartsWith(prefix)) + { + return null; + } + + var endIndex = url.IndexOf('#'); + if (endIndex == -1) + { + endIndex = gitHubUrl.Length; + } + + var path = url.Substring(prefix.Length, endIndex - prefix.Length); + return path; + } + + static IEnumerable FindWindowTitlesForClass(string className) + { + IntPtr handleWin = IntPtr.Zero; + while (IntPtr.Zero != (handleWin = User32.FindWindowEx(IntPtr.Zero, handleWin, className, IntPtr.Zero))) + { + // Allocate correct string length first + int length = User32.GetWindowTextLength(handleWin); + if (length == 0) + { + continue; + } + + var titleBuilder = new StringBuilder(length + 1); + User32.GetWindowText(handleWin, titleBuilder, titleBuilder.Capacity); + yield return titleBuilder.ToString(); + } + } + + static class User32 + { + [DllImport("user32.dll", SetLastError = true)] + internal static extern IntPtr FindWindowEx(IntPtr parentHandle, IntPtr childAfter, string className, IntPtr windowTitle); + + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + internal static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + internal static extern int GetWindowTextLength(IntPtr hWnd); + } + } +} diff --git a/src/GitHub.App/Services/GitHubCredentialProvider.cs b/src/GitHub.App/Services/GitHubCredentialProvider.cs index 1300d4346b..0f6459aaca 100644 --- a/src/GitHub.App/Services/GitHubCredentialProvider.cs +++ b/src/GitHub.App/Services/GitHubCredentialProvider.cs @@ -1,12 +1,13 @@ using System; using System.ComponentModel.Composition; -using System.Reactive.Linq; -using System.Security; -using Akavache; -using GitHub.Caches; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Logging; using GitHub.Primitives; using LibGit2Sharp; -using NullGuard; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using Serilog; namespace GitHub.Services { @@ -14,37 +15,45 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.Shared)] class GitHubCredentialProvider : IGitHubCredentialProvider { - readonly ISecureBlobCache secureCache = null; + static readonly ILogger log = LogManager.ForContext(); + readonly IKeychain keychain; [ImportingConstructor] - public GitHubCredentialProvider(ISharedCache sharedCache) + public GitHubCredentialProvider(IKeychain keychain, [Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) { - secureCache = sharedCache.Secure; + Guard.ArgumentNotNull(keychain, nameof(keychain)); + + this.keychain = keychain; + JoinableTaskContext = joinableTaskContext ?? ThreadHelper.JoinableTaskContext; } /// /// This is a callback from libgit2 /// /// - public Credentials HandleCredentials([AllowNull]string url, [AllowNull]string username, SupportedCredentialTypes types) + public Credentials HandleCredentials(string url, string username, SupportedCredentialTypes types) { if (url == null) return null; // wondering if we should return DefaultCredentials instead var host = HostAddress.Create(url); - return secureCache.GetObject>("login:" + host.CredentialCacheKeyHost) - .Select(CreateCredentials) - .Catch(Observable.Return(null)) - .Wait(); - } - static Credentials CreateCredentials(Tuple data) - { - return new SecureUsernamePasswordCredentials + try { - Username = data.Item1, - Password = data.Item2 - }; + var credentials = JoinableTaskContext.Factory.Run(async () => await keychain.Load(host)); + return new UsernamePasswordCredentials + { + Username = credentials.Item1, + Password = credentials.Item2, + }; + } + catch (Exception e) + { + log.Error(e, "Error loading credentials in GitHubCredentialProvider"); + return null; + } } + + JoinableTaskContext JoinableTaskContext { get; } } } \ No newline at end of file diff --git a/src/GitHub.App/Services/GitHubCredentialStore.cs b/src/GitHub.App/Services/GitHubCredentialStore.cs deleted file mode 100644 index 4bee84389f..0000000000 --- a/src/GitHub.App/Services/GitHubCredentialStore.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Globalization; -using System.Reactive.Linq; -using System.Threading.Tasks; -using Akavache; -using GitHub.Caches; -using GitHub.Extensions.Reactive; -using GitHub.Primitives; -using NLog; -using Octokit; - -namespace GitHub.Services -{ - public class GitHubCredentialStore : ICredentialStore - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - readonly HostAddress hostAddress; - readonly ILoginCache loginCache; - - public GitHubCredentialStore(HostAddress hostAddress, ILoginCache loginCache) - { - this.hostAddress = hostAddress; - this.loginCache = loginCache; - } - - public async Task GetCredentials() - { - return await loginCache.GetLoginAsync(hostAddress) - .CatchNonCritical(Observable.Return(LoginCache.EmptyLoginInfo)) - .Select(CreateFromLoginInfo); - } - - Credentials CreateFromLoginInfo(LoginInfo loginInfo) - { - if (loginInfo == LoginCache.EmptyLoginInfo) - { - log.Debug(CultureInfo.InvariantCulture, "Could not retrieve login info from the secure cache '{0}'", - hostAddress.CredentialCacheKeyHost); - return Credentials.Anonymous; - } - - return new Credentials(loginInfo.UserName, loginInfo.Password); - } - } -} diff --git a/src/GitHub.App/Services/GlobalConnection.cs b/src/GitHub.App/Services/GlobalConnection.cs new file mode 100644 index 0000000000..01fb68fd75 --- /dev/null +++ b/src/GitHub.App/Services/GlobalConnection.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.Composition; +using GitHub.Models; +using GitHub.Services; + +namespace GitHub.App.Services +{ + [Export(typeof(IGlobalConnection))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class GlobalConnection : IGlobalConnection + { + readonly IGitHubServiceProvider serviceProvider; + + [ImportingConstructor] + public GlobalConnection(IGitHubServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public IConnection Get() => serviceProvider.TryGetService(); + } +} diff --git a/src/GitHub.App/Services/IAutoCompleteSource.cs b/src/GitHub.App/Services/IAutoCompleteSource.cs new file mode 100644 index 0000000000..09b77c4cc3 --- /dev/null +++ b/src/GitHub.App/Services/IAutoCompleteSource.cs @@ -0,0 +1,13 @@ +using System; +using GitHub.Models; + +namespace GitHub.Services +{ + public interface IAutoCompleteSource + { + IObservable GetSuggestions(); + + // The prefix used to trigger auto completion. + string Prefix { get; } + } +} diff --git a/src/GitHub.App/Services/IEnterpriseProbe.cs b/src/GitHub.App/Services/IEnterpriseProbe.cs deleted file mode 100644 index 5bffde4fa5..0000000000 --- a/src/GitHub.App/Services/IEnterpriseProbe.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace GitHub.Services -{ - public interface IEnterpriseProbe - { - IObservable Probe(Uri enterpriseBaseUrl); - } -} diff --git a/src/GitHub.App/Services/ImageDownloader.cs b/src/GitHub.App/Services/ImageDownloader.cs index 47751c9a77..1c911bc37c 100644 --- a/src/GitHub.App/Services/ImageDownloader.cs +++ b/src/GitHub.App/Services/ImageDownloader.cs @@ -1,11 +1,15 @@ using System; using System.ComponentModel.Composition; -using System.Diagnostics; using System.Globalization; using System.Net; using System.Net.Http; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; +using System.Runtime.Serialization; +using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Logging; using Octokit; using Octokit.Internal; @@ -16,14 +20,61 @@ namespace GitHub.Services public class ImageDownloader : IImageDownloader { readonly Lazy httpClient; + readonly IDictionary exceptionCache; [ImportingConstructor] public ImageDownloader(Lazy httpClient) { this.httpClient = httpClient; + exceptionCache = new Dictionary(); } + public static string CachedExceptionMessage(string host) => + string.Format(CultureInfo.InvariantCulture, "Host '{0}' previously returned a non-image content type", host); + public static string CouldNotDownloadExceptionMessage(Uri imageUri) => + string.Format(CultureInfo.InvariantCulture, "Could not download image from '{0}'", imageUri); + public static string NonImageContentExceptionMessage(string contentType) => + string.Format(CultureInfo.InvariantCulture, "Server responded with a non-image content type '{0}'", contentType); + + /// + /// Get the bytes for a given image URI. + /// + /// + /// If a host returns a non-image content type, this will be remembered and subsequent download requests + /// to the same host will automatically throw a . This prevents a + /// barrage of download requests when authentication is required (but not currently supported). + /// + /// The URI of an image. + /// The bytes for a given image URI. + /// When the URI returns a status code that isn't OK/200. + /// When the URI returns a non-image content type. public IObservable DownloadImageBytes(Uri imageUri) + { + return ExceptionCachingDownloadImageBytesAsync(imageUri).ToObservable(); + } + + async Task ExceptionCachingDownloadImageBytesAsync(Uri imageUri) + { + var host = imageUri.Host; + + NonImageContentException exception; + if (exceptionCache.TryGetValue(host, out exception)) + { + throw new NonImageContentException(CachedExceptionMessage(host), exception); + } + + try + { + return await DownloadImageBytesAsync(imageUri); + } + catch (NonImageContentException e) + { + exceptionCache[host] = e; + throw; + } + } + + async Task DownloadImageBytesAsync(Uri imageUri) { var request = new Request { @@ -32,26 +83,23 @@ public IObservable DownloadImageBytes(Uri imageUri) Method = HttpMethod.Get, }; - return HttpClient.Send(request) - .ToObservable() - .Select(response => GetSuccessfulBytes(imageUri, response)); + var response = await HttpClient.Send(request); + return GetSuccessfulBytes(imageUri, response); } static byte[] GetSuccessfulBytes(Uri imageUri, IResponse response) { - Debug.Assert(imageUri != null, "The imageUri cannot be null"); - Debug.Assert(response != null, "The response cannot be null"); + Log.Assert(imageUri != null, "The imageUri cannot be null"); + Log.Assert(response != null, "The response cannot be null"); if (response.StatusCode != HttpStatusCode.OK) { - throw new HttpRequestException(string.Format(CultureInfo.InvariantCulture, "Could not download image from {0}", imageUri)); + throw new HttpRequestException(CouldNotDownloadExceptionMessage(imageUri)); } if (response.ContentType == null || !response.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) { - throw new HttpRequestException( - string.Format(CultureInfo.InvariantCulture, - "Server responded with a non-image content type: {0}", response.ContentType)); + throw new NonImageContentException(NonImageContentExceptionMessage(response.ContentType)); } return response.Body as byte[]; @@ -59,4 +107,16 @@ static byte[] GetSuccessfulBytes(Uri imageUri, IResponse response) IHttpClient HttpClient { get { return httpClient.Value; } } } -} \ No newline at end of file + + [Serializable] + public class NonImageContentException : HttpRequestException + { + public NonImageContentException() { } + public NonImageContentException(string message) : base(message) { } + public NonImageContentException(string message, Exception inner) : base(message, inner) { } + + [SuppressMessage("Microsoft.Usage", "CA2236:CallBaseClassMethodsOnISerializableTypes", + Justification = "HttpRequestException doesn't have required constructor")] + protected NonImageContentException(SerializationInfo info, StreamingContext context) { } + } +} diff --git a/src/GitHub.App/Services/InlineCommentPeekService.cs b/src/GitHub.App/Services/InlineCommentPeekService.cs new file mode 100644 index 0000000000..463bfdef5e --- /dev/null +++ b/src/GitHub.App/Services/InlineCommentPeekService.cs @@ -0,0 +1,147 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.ViewModels; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Differencing; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Outlining; +using Microsoft.VisualStudio.Text.Projection; + +namespace GitHub.Services +{ + /// + /// Shows inline comments in a peek view. + /// + [Export(typeof(IInlineCommentPeekService))] + class InlineCommentPeekService : IInlineCommentPeekService + { + const string relationship = "GitHubCodeReview"; + readonly IOutliningManagerService outliningService; + readonly IPeekBroker peekBroker; + readonly IUsageTracker usageTracker; + + [ImportingConstructor] + public InlineCommentPeekService( + IOutliningManagerService outliningManager, + IPeekBroker peekBroker, + IUsageTracker usageTracker) + { + this.outliningService = outliningManager; + this.peekBroker = peekBroker; + this.usageTracker = usageTracker; + } + + /// + public Tuple GetLineNumber(IPeekSession session, ITrackingPoint point) + { + var diffModel = (session.TextView as IWpfTextView)?.TextViewModel as IDifferenceTextViewModel; + var leftBuffer = false; + ITextSnapshotLine line = null; + + if (diffModel != null) + { + if (diffModel.ViewType == DifferenceViewType.InlineView) + { + // If we're displaying a diff in inline mode, then we need to map the point down + // to the left or right buffer. + var snapshotPoint = point.GetPoint(point.TextBuffer.CurrentSnapshot); + var mappedPoint = session.TextView.BufferGraph.MapDownToFirstMatch( + snapshotPoint, + PointTrackingMode.Negative, + x => !(x is IProjectionSnapshot), + PositionAffinity.Successor); + + if (mappedPoint != null) + { + leftBuffer = mappedPoint.Value.Snapshot == diffModel.Viewer.DifferenceBuffer.LeftBuffer.CurrentSnapshot; + line = mappedPoint.Value.GetContainingLine(); + } + } + else + { + // If we're displaying a diff in any other mode than inline, then we're in the + // left buffer if the session's text view is the diff's left view. + leftBuffer = session.TextView == diffModel.Viewer.LeftView; + } + } + + if (line == null) + { + line = point.GetPoint(point.TextBuffer.CurrentSnapshot).GetContainingLine(); + } + + return Tuple.Create(line.LineNumber, leftBuffer); + } + + /// + public void Hide(ITextView textView) + { + peekBroker.DismissPeekSession(textView); + } + + /// + public ITrackingPoint Show(ITextView textView, DiffSide side, int lineNumber) + { + var lineAndtrackingPoint = GetLineAndTrackingPoint(textView, side, lineNumber); + var line = lineAndtrackingPoint.Item1; + var trackingPoint = lineAndtrackingPoint.Item2; + var options = new PeekSessionCreationOptions( + textView, + relationship, + trackingPoint, + defaultHeight: 0); + + ExpandCollapsedRegions(textView, line.Extent); + + var session = peekBroker.TriggerPeekSession(options); + var item = session.PeekableItems.OfType().FirstOrDefault(); + item?.Closed.Take(1).Subscribe(_ => session.Dismiss()); + + return trackingPoint; + } + + Tuple GetLineAndTrackingPoint( + ITextView textView, + DiffSide side, + int lineNumber) + { + var diffModel = (textView as IWpfTextView)?.TextViewModel as IDifferenceTextViewModel; + var snapshot = textView.TextSnapshot; + + if (diffModel?.ViewType == DifferenceViewType.InlineView) + { + snapshot = side == DiffSide.Left ? + diffModel.Viewer.DifferenceBuffer.LeftBuffer.CurrentSnapshot : + diffModel.Viewer.DifferenceBuffer.RightBuffer.CurrentSnapshot; + } + + var line = snapshot.GetLineFromLineNumber(lineNumber); + var trackingPoint = snapshot.CreateTrackingPoint(line.Start.Position, PointTrackingMode.Positive); + + ExpandCollapsedRegions(textView, line.Extent); + peekBroker.TriggerPeekSession(textView, trackingPoint, relationship); + + usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentOpen).Forget(); + + return Tuple.Create(line, trackingPoint); + } + + void ExpandCollapsedRegions(ITextView textView, SnapshotSpan span) + { + var outlining = outliningService.GetOutliningManager(textView); + + if (outlining != null) + { + foreach (var collapsed in outlining.GetCollapsedRegions(span)) + { + outlining.Expand(collapsed); + } + } + } + } +} diff --git a/src/GitHub.App/Services/IssueishService.cs b/src/GitHub.App/Services/IssueishService.cs new file mode 100644 index 0000000000..481d9f7f3c --- /dev/null +++ b/src/GitHub.App/Services/IssueishService.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Primitives; +using Octokit; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using static Octokit.GraphQL.Variable; + +namespace GitHub.Services +{ + /// + /// Base class for issue and pull request services. + /// + public abstract class IssueishService : IIssueishService + { + static ICompiledQuery postComment; + readonly IApiClientFactory apiClientFactory; + readonly IGraphQLClientFactory graphqlFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The API client factory. + /// The GraphQL client factory. + public IssueishService( + IApiClientFactory apiClientFactory, + IGraphQLClientFactory graphqlFactory) + { + this.apiClientFactory = apiClientFactory; + this.graphqlFactory = graphqlFactory; + } + + /// + public async Task CloseIssueish(HostAddress address, string owner, string repository, int number) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + var update = new IssueUpdate { State = ItemState.Closed }; + await client.Issue.Update(owner, repository, number, update).ConfigureAwait(false); + } + + /// + public async Task ReopenIssueish(HostAddress address, string owner, string repository, int number) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + var update = new IssueUpdate { State = ItemState.Open }; + await client.Issue.Update(owner, repository, number, update).ConfigureAwait(false); + } + + /// + public async Task PostComment(HostAddress address, string issueishId, string body) + { + var input = new AddCommentInput + { + Body = body, + SubjectId = new ID(issueishId), + }; + + if (postComment == null) + { + postComment = new Mutation() + .AddComment(Var(nameof(input))) + .CommentEdge + .Node + .Select(comment => new CommentModel + { + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Id = comment.Id.Value, + Url = comment.Url, + }).Compile(); + } + + var vars = new Dictionary + { + { nameof(input), input }, + }; + + var graphql = await graphqlFactory.CreateConnection(address).ConfigureAwait(false); + return await graphql.Run(postComment, vars).ConfigureAwait(false); + } + + public async Task DeleteComment( + HostAddress address, + string owner, + string repository, + int commentId) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + await client.Issue.Comment.Delete(owner, repository, commentId).ConfigureAwait(false); + } + + public async Task EditComment( + HostAddress address, + string owner, + string repository, + int commentId, + string body) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + await client.Issue.Comment.Update(owner, repository, commentId, body).ConfigureAwait(false); + } + } +} diff --git a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs new file mode 100644 index 0000000000..d2c64e671c --- /dev/null +++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using static Octokit.GraphQL.Variable; + +namespace GitHub.Services +{ + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class IssuesAutoCompleteSource : IAutoCompleteSource + { + readonly ITeamExplorerContext teamExplorerContext; + readonly IGraphQLClientFactory graphqlFactory; + ICompiledQuery> query; + + [ImportingConstructor] + public IssuesAutoCompleteSource(ITeamExplorerContext teamExplorerContext, IGraphQLClientFactory graphqlFactory) + { + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); + + this.teamExplorerContext = teamExplorerContext; + this.graphqlFactory = graphqlFactory; + } + + public IObservable GetSuggestions() + { + var localRepositoryModel = teamExplorerContext.ActiveRepository; + + var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host); + var owner = localRepositoryModel.Owner; + var name = localRepositoryModel.Name; + + string filter; + string after; + + if (query == null) + { + query = new Query().Search(query: Var(nameof(filter)), SearchType.Issue, 100, after: Var(nameof(after))) + .Select(item => new Page + { + Items = item.Nodes.Select(searchResultItem => + searchResultItem.Switch(selector => selector + .Issue(i => new SuggestionItem("#" + i.Number, i.Title) { LastModifiedDate = i.LastEditedAt }) + .PullRequest(p => new SuggestionItem("#" + p.Number, p.Title) { LastModifiedDate = p.LastEditedAt })) + ).ToList(), + EndCursor = item.PageInfo.EndCursor, + HasNextPage = item.PageInfo.HasNextPage, + TotalCount = item.IssueCount + }) + .Compile(); + } + + filter = $"repo:{owner}/{name}"; + + return Observable.FromAsync(async () => + { + var results = new List(); + + var variables = new Dictionary + { + {nameof(filter), filter }, + }; + + var connection = await graphqlFactory.CreateConnection(hostAddress); + var searchResults = await connection.Run(query, variables); + + results.AddRange(searchResults.Items); + + while (searchResults.HasNextPage) + { + variables[nameof(after)] = searchResults.EndCursor; + searchResults = await connection.Run(query, variables); + + results.AddRange(searchResults.Items); + } + + return results.Select(item => new IssueAutoCompleteSuggestion(item, Prefix)); + + }).SelectMany(observable => observable); + } + + class SearchResult + { + public SuggestionItem SuggestionItem { get; set; } + } + + public string Prefix + { + get { return "#"; } + } + + class IssueAutoCompleteSuggestion : AutoCompleteSuggestion + { + // Just needs to be some value before GitHub stored its first issue. + static readonly DateTimeOffset lowerBound = new DateTimeOffset(2000, 1, 1, 12, 0, 0, TimeSpan.FromSeconds(0)); + + readonly SuggestionItem suggestion; + public IssueAutoCompleteSuggestion(SuggestionItem suggestion, string prefix) + : base(suggestion.Name, suggestion.Description, prefix) + { + this.suggestion = suggestion; + } + + public override int GetSortRank(string text) + { + // We need to override the sort rank behavior because when we display issues, we include the prefix + // unlike mentions. So we need to account for that in how we do filtering. + if (text.Length == 0) + { + return (int) ((suggestion.LastModifiedDate ?? lowerBound) - lowerBound).TotalSeconds; + } + // Name is always "#" followed by issue number. + return Name.StartsWith("#" + text, StringComparison.OrdinalIgnoreCase) + ? 1 + : DescriptionWords.Any(word => word.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + ? 0 + : -1; + } + + // This is what gets "completed" when you tab. + public override string ToString() + { + return Name; + } + } + } +} diff --git a/src/GitHub.App/Services/MentionsAutoCompleteSource.cs b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs new file mode 100644 index 0000000000..dab165ce89 --- /dev/null +++ b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using Octokit.GraphQL; +using static Octokit.GraphQL.Variable; + +namespace GitHub.Services +{ + /// + /// Supplies @mentions auto complete suggestions. + /// + [Export(typeof(IAutoCompleteSource))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class MentionsAutoCompleteSource : IAutoCompleteSource + { + const string DefaultAvatar = "pack://application:,,,/GitHub.App;component/Images/default_user_avatar.png"; + + readonly ITeamExplorerContext teamExplorerContext; + readonly IGraphQLClientFactory graphqlFactory; + readonly IAvatarProvider avatarProvider; + ICompiledQuery> query; + + [ImportingConstructor] + public MentionsAutoCompleteSource( + ITeamExplorerContext teamExplorerContext, + IGraphQLClientFactory graphqlFactory, + IAvatarProvider avatarProvider) + { + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); + Guard.ArgumentNotNull(avatarProvider, nameof(avatarProvider)); + + this.teamExplorerContext = teamExplorerContext; + this.graphqlFactory = graphqlFactory; + this.avatarProvider = avatarProvider; + } + + public IObservable GetSuggestions() + { + var localRepositoryModel = teamExplorerContext.ActiveRepository; + + var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host); + var owner = localRepositoryModel.Owner; + var name = localRepositoryModel.Name; + + if (query == null) + { + query = new Query().Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .Select(repository => + repository.MentionableUsers(null, null, null, null, null) + .AllPages() + .Select(sourceItem => + new SuggestionItem(sourceItem.Login, + sourceItem.Name ?? "(unknown)", + sourceItem.AvatarUrl(null))) + .ToList()) + .Compile(); + } + + var variables = new Dictionary + { + {nameof(owner), owner }, + {nameof(name), name }, + }; + + return Observable.FromAsync(async () => + { + var connection = await graphqlFactory.CreateConnection(hostAddress); + var suggestions = await connection.Run(query, variables); + return suggestions.Select(suggestion => new AutoCompleteSuggestion(suggestion.Name, + suggestion.Description, + ResolveImage(suggestion), + Prefix)); + }).SelectMany(enumerable => enumerable); + } + + IObservable ResolveImage(SuggestionItem uri) + { + if (uri.ImageUrl != null) + { + return avatarProvider.GetAvatar(uri.ImageUrl); + } + + return Observable.Return(AvatarProvider.CreateBitmapImage(DefaultAvatar)); + } + + public string Prefix => "@"; + } +} diff --git a/src/GitHub.App/Services/ModelService.cs b/src/GitHub.App/Services/ModelService.cs index bdacc8c6ba..0a9f7e03a6 100644 --- a/src/GitHub.App/Services/ModelService.cs +++ b/src/GitHub.App/Services/ModelService.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reactive; using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Threading.Tasks; using Akavache; using GitHub.Api; @@ -13,11 +14,15 @@ using GitHub.Collections; using GitHub.Extensions; using GitHub.Extensions.Reactive; +using GitHub.Logging; using GitHub.Models; using GitHub.Primitives; -using NLog; -using NullGuard; using Octokit; +using Octokit.GraphQL; +using Serilog; +using static Octokit.GraphQL.Variable; + +#pragma warning disable CA1034 // Nested types should not be visible namespace GitHub.Services { @@ -25,22 +30,39 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.NonShared)] public class ModelService : IModelService { + static readonly ILogger log = LogManager.ForContext(); + public const string PRPrefix = "pr"; const string TempFilesDirectory = Info.ApplicationInfo.ApplicationName; const string CachedFilesDirectory = "CachedFiles"; - static readonly Logger log = LogManager.GetCurrentClassLogger(); - readonly IApiClient apiClient; readonly IBlobCache hostCache; readonly IAvatarProvider avatarProvider; - public ModelService(IApiClient apiClient, IBlobCache hostCache, IAvatarProvider avatarProvider) + public ModelService( + IApiClient apiClient, + IBlobCache hostCache, + IAvatarProvider avatarProvider) { - this.apiClient = apiClient; + this.ApiClient = apiClient; this.hostCache = hostCache; this.avatarProvider = avatarProvider; } + public IApiClient ApiClient { get; } + + public IObservable GetCurrentUser() + { + return GetUserFromCache().Select(Create); + } + + public IObservable GetUser(string login) + { + return hostCache.GetAndRefreshObject("user|" + login, + () => ApiClient.GetUser(login).Select(AccountCacheItem.Create), TimeSpan.FromMinutes(5), TimeSpan.FromDays(7)) + .Select(Create); + } + public IObservable GetGitIgnoreTemplates() { return Observable.Defer(() => @@ -53,7 +75,7 @@ public IObservable GetGitIgnoreTemplates() .Select(Create) .Catch(e => { - log.Info("Failed to retrieve GitIgnoreTemplates", e); + log.Error(e, "Failed to retrieve GitIgnoreTemplates"); return Observable.Empty(); }); } @@ -70,7 +92,7 @@ public IObservable GetLicenses() .Select(Create) .Catch(e => { - log.Info("Failed to retrieve licenses", e); + log.Error(e, "Failed to retrieve licenses"); return Observable.Empty(); }); } @@ -84,16 +106,37 @@ public IObservable> GetAccounts() .ToReadOnlyList(Create); } + public IObservable GetForks(RepositoryModel repository) + { + return ApiClient.GetForks(repository.Owner, repository.Name) + .Select(x => CreateRemoteRepositoryModel(x)); + } + + static RemoteRepositoryModel CreateRemoteRepositoryModel(Repository repository) + { + var ownerAccount = new Models.Account(repository.Owner); + var parent = repository.Parent != null ? CreateRemoteRepositoryModel(repository.Parent) : null; + var model = new RemoteRepositoryModel(repository.Id, repository.Name, repository.CloneUrl, + repository.Private, repository.Fork, ownerAccount, parent, repository.DefaultBranch); + + if (parent != null) + { + parent.DefaultBranch.DisplayName = parent.DefaultBranch.Id; + } + + return model; + } + IObservable GetLicensesFromApi() { - return apiClient.GetLicenses() + return ApiClient.GetLicenses() .WhereNotNull() .Select(LicenseCacheItem.Create); } IObservable GetGitIgnoreTemplatesFromApi() { - return apiClient.GetGitIgnoreTemplates() + return ApiClient.GetGitIgnoreTemplates() .WhereNotNull() .Select(GitIgnoreCacheItem.Create); } @@ -101,7 +144,7 @@ IObservable GetGitIgnoreTemplatesFromApi() IObservable> GetUser() { return hostCache.GetAndRefreshObject("user", - () => apiClient.GetUser().Select(AccountCacheItem.Create), TimeSpan.FromMinutes(5), TimeSpan.FromDays(7)) + () => ApiClient.GetUser().Select(AccountCacheItem.Create), TimeSpan.FromMinutes(5), TimeSpan.FromDays(7)) .TakeLast(1) .ToList(); } @@ -110,7 +153,7 @@ IObservable> GetUserOrganizations() { return GetUserFromCache().SelectMany(user => hostCache.GetAndRefreshObject(user.Login + "|orgs", - () => apiClient.GetOrganizations().Select(AccountCacheItem.Create).ToList(), + () => ApiClient.GetOrganizations().Select(AccountCacheItem.Create).ToList(), TimeSpan.FromMinutes(2), TimeSpan.FromDays(7))) // TODO: Akavache returns the cached version followed by the fresh version if > 2 // minutes have expired from the last request. Here we make sure the latest value is @@ -120,17 +163,17 @@ IObservable> GetUserOrganizations() // This could in theory happen if we try to call this before the user is logged in. e => { - log.Error("Retrieve user organizations failed because user is not stored in the cache.", (Exception)e); + log.Error(e, "Retrieve user organizations failed because user is not stored in the cache"); return Observable.Return(Enumerable.Empty()); }) .Catch, Exception>(e => { - log.Error("Retrieve user organizations failed.", e); + log.Error(e, "Retrieve user organizations failed"); return Observable.Return(Enumerable.Empty()); }); } - public IObservable> GetRepositories() + public IObservable> GetRepositories() { return GetUserRepositories(RepositoryType.Owner) .TakeLast(1) @@ -138,98 +181,31 @@ public IObservable> GetRepositories() .Concat(GetAllRepositoriesForAllOrganizations()); } - public IObservable GetUserFromCache() + IObservable GetUserFromCache() { return Observable.Defer(() => hostCache.GetObject("user")); } - /// - /// Gets a collection of Pull Requests. If you want to refresh existing data, pass a collection in - /// - /// - /// - /// - public ITrackingCollection GetPullRequests(ILocalRepositoryModel repo, - ITrackingCollection collection) - { - // Since the api to list pull requests returns all the data for each pr, cache each pr in its own entry - // and also cache an index that contains all the keys for each pr. This way we can fetch prs in bulk - // but also individually without duplicating information. We store things in a custom observable collection - // that checks whether an item is being updated (coming from the live stream after being retrieved from cache) - // and replaces it instead of appending, so items get refreshed in-place as they come in. - - var keyobs = GetUserFromCache() - .Select(user => string.Format(CultureInfo.InvariantCulture, "{0}|{1}:{2}", CacheIndex.PRPrefix, user.Login, repo.Name)); - - var source = Observable.Defer(() => keyobs - .SelectMany(key => - hostCache.GetAndFetchLatestFromIndex(key, () => - apiClient.GetPullRequestsForRepository(repo.CloneUrl.Owner, repo.CloneUrl.RepositoryName) - .Select(PullRequestCacheItem.Create), - item => - { - if (collection.Disposed) return; - - // this could blow up due to the collection being disposed somewhere else - try { collection.RemoveItem(Create(item)); } - catch (ObjectDisposedException) { } - }, - TimeSpan.Zero, - TimeSpan.FromDays(7)) - ) - .Select(Create) - ); - - collection.Listen(source); - return collection; - } - - public IObservable GetPullRequest(ILocalRepositoryModel repo, int number) + public IObservable GetPullRequest(string owner, string name, int number) { - return Observable.Defer(() => - { - return hostCache.GetAndRefreshObject(PRPrefix + '|' + number, () => - Observable.CombineLatest( - apiClient.GetPullRequest(repo.CloneUrl.Owner, repo.CloneUrl.RepositoryName, number), - apiClient.GetPullRequestFiles(repo.CloneUrl.Owner, repo.CloneUrl.RepositoryName, number).ToList(), - (pr, files) => new { PullRequest = pr, Files = files }) - .Select(x => PullRequestCacheItem.Create(x.PullRequest, (IReadOnlyList)x.Files)), - TimeSpan.Zero, - TimeSpan.FromDays(7)) - .Select(Create); - }); + throw new NotImplementedException(); } - public ITrackingCollection GetRepositories(ITrackingCollection collection) + public IObservable GetRepository(string owner, string repo) { var keyobs = GetUserFromCache() - .Select(user => string.Format(CultureInfo.InvariantCulture, "{0}|{1}", CacheIndex.RepoPrefix, user.Login)); + .Select(user => string.Format(CultureInfo.InvariantCulture, "{0}|{1}|{2}/{3}", CacheIndex.RepoPrefix, user.Login, owner, repo)); - var source = Observable.Defer(() => keyobs + return Observable.Defer(() => keyobs .SelectMany(key => - hostCache.GetAndFetchLatestFromIndex(key, () => - apiClient.GetRepositories() - .Select(RepositoryCacheItem.Create), - item => - { - if (collection.Disposed) return; - - // this could blow up due to the collection being disposed somewhere else - try { collection.RemoveItem(Create(item)); } - catch (ObjectDisposedException) { } - }, - TimeSpan.FromMinutes(5), - TimeSpan.FromDays(1)) - ) - .Select(Create) - ); - - collection.Listen(source); - return collection; + hostCache.GetAndFetchLatest( + key, + () => ApiClient.GetRepository(owner, repo).Select(RepositoryCacheItem.Create)) + .Select(Create))); } - public IObservable CreatePullRequest(ILocalRepositoryModel sourceRepository, IRepositoryModel targetRepository, - IBranch sourceBranch, IBranch targetBranch, + public IObservable CreatePullRequest(LocalRepositoryModel sourceRepository, RepositoryModel targetRepository, + BranchModel sourceBranch, BranchModel targetBranch, string title, string body) { var keyobs = GetUserFromCache() @@ -238,11 +214,11 @@ public IObservable CreatePullRequest(ILocalRepositoryModel so return Observable.Defer(() => keyobs .SelectMany(key => hostCache.PutAndUpdateIndex(key, () => - apiClient.CreatePullRequest( + ApiClient.CreatePullRequest( new NewPullRequest(title, string.Format(CultureInfo.InvariantCulture, "{0}:{1}", sourceRepository.Owner, sourceBranch.Name), targetBranch.Name) - { Body = body }, + { Body = body }, targetRepository.Owner, targetRepository.Name) .Select(PullRequestCacheItem.Create) @@ -258,7 +234,7 @@ public IObservable InvalidateAll() return hostCache.InvalidateAll().ContinueAfter(() => hostCache.Vacuum()); } - public IObservable GetFileContents(IRepositoryModel repo, string commitSha, string path, string fileSha) + public IObservable GetFileContents(RepositoryModel repo, string commitSha, string path, string fileSha) { return Observable.Defer(() => Task.Run(async () => { @@ -268,7 +244,7 @@ public IObservable GetFileContents(IRepositoryModel repo, string commitS if (!File.Exists(tempFile)) { - var contents = await apiClient.GetFileContents(repo.Owner, repo.Name, commitSha, path); + var contents = await ApiClient.GetFileContents(repo.Owner, repo.Name, commitSha, path); Directory.CreateDirectory(tempDir); File.WriteAllBytes(tempFile, Convert.FromBase64String(contents.EncodedContent)); } @@ -277,7 +253,7 @@ public IObservable GetFileContents(IRepositoryModel repo, string commitS })); } - IObservable> GetUserRepositories(RepositoryType repositoryType) + IObservable> GetUserRepositories(RepositoryType repositoryType) { return Observable.Defer(() => GetUserFromCache().SelectMany(user => hostCache.GetAndRefreshObject(string.Format(CultureInfo.InvariantCulture, "{0}|{1}:repos", user.Login, repositoryType), @@ -285,64 +261,60 @@ IObservable> GetUserRepositories(Repositor TimeSpan.FromMinutes(2), TimeSpan.FromDays(7))) .ToReadOnlyList(Create)) - .Catch, KeyNotFoundException>( + .Catch, KeyNotFoundException>( // This could in theory happen if we try to call this before the user is logged in. e => { - string message = string.Format(CultureInfo.InvariantCulture, - "Retrieving '{0}' user repositories failed because user is not stored in the cache.", + log.Error(e, + "Retrieving {RepositoryType} user repositories failed because user is not stored in the cache", repositoryType); - log.Error(message, e); - return Observable.Return(new IRemoteRepositoryModel[] {}); + return Observable.Return(Array.Empty()); }); } IObservable> GetUserRepositoriesFromApi(RepositoryType repositoryType) { - return apiClient.GetUserRepositories(repositoryType) + return ApiClient.GetUserRepositories(repositoryType) .WhereNotNull() .Select(RepositoryCacheItem.Create) .ToList() .Catch, Exception>(_ => Observable.Return(Enumerable.Empty())); } - IObservable> GetAllRepositoriesForAllOrganizations() + IObservable> GetAllRepositoriesForAllOrganizations() { return GetUserOrganizations() .SelectMany(org => org.ToObservable()) .SelectMany(org => GetOrganizationRepositories(org.Login).TakeLast(1)); } - IObservable> GetOrganizationRepositories(string organization) + IObservable> GetOrganizationRepositories(string organization) { return Observable.Defer(() => GetUserFromCache().SelectMany(user => hostCache.GetAndRefreshObject(string.Format(CultureInfo.InvariantCulture, "{0}|{1}|repos", user.Login, organization), - () => apiClient.GetRepositoriesForOrganization(organization).Select( + () => ApiClient.GetRepositoriesForOrganization(organization).Select( RepositoryCacheItem.Create).ToList(), TimeSpan.FromMinutes(2), TimeSpan.FromDays(7))) .ToReadOnlyList(Create)) - .Catch, KeyNotFoundException>( + .Catch, KeyNotFoundException>( // This could in theory happen if we try to call this before the user is logged in. e => { - string message = string.Format( - CultureInfo.InvariantCulture, - "Retrieveing '{0}' org repositories failed because user is not stored in the cache.", + log.Error(e, "Retrieveing {Organization} org repositories failed because user is not stored in the cache", organization); - log.Error(message, e); - return Observable.Return(new IRemoteRepositoryModel[] {}); + return Observable.Return(Array.Empty()); }); } - public IObservable GetBranches(IRepositoryModel repo) + public IObservable GetBranches(RepositoryModel repo) { var keyobs = GetUserFromCache() .Select(user => string.Format(CultureInfo.InvariantCulture, "{0}|{1}|branch", user.Login, repo.Name)); return Observable.Defer(() => keyobs - .SelectMany(key => apiClient.GetBranches(repo.CloneUrl.Owner, repo.CloneUrl.RepositoryName))) - .Select(x => new BranchModel(x, repo)); + .SelectMany(key => ApiClient.GetBranches(repo.CloneUrl.Owner, repo.CloneUrl.RepositoryName))) + .Select(x => new BranchModel(x.Name, repo)); } static GitIgnoreItem Create(GitIgnoreCacheItem item) @@ -363,10 +335,23 @@ IAccount Create(AccountCacheItem accountCacheItem) accountCacheItem.IsEnterprise, accountCacheItem.OwnedPrivateRepositoriesCount, accountCacheItem.PrivateRepositoriesInPlanCount, + accountCacheItem.AvatarUrl, avatarProvider.GetAvatar(accountCacheItem)); } - IRemoteRepositoryModel Create(RepositoryCacheItem item) + IAccount Create(string login, string avatarUrl) + { + return new Models.Account( + login, + true, + false, + 0, + 0, + avatarUrl, + avatarProvider.GetAvatar(avatarUrl)); + } + + RemoteRepositoryModel Create(RepositoryCacheItem item) { return new RemoteRepositoryModel( item.Id, @@ -374,14 +359,15 @@ IRemoteRepositoryModel Create(RepositoryCacheItem item) new UriString(item.CloneUrl), item.Private, item.Fork, - Create(item.Owner)) + Create(item.Owner), + item.Parent != null ? Create(item.Parent) : null) { CreatedAt = item.CreatedAt, UpdatedAt = item.UpdatedAt }; } - GitReferenceModel Create(GitReferenceCacheItem item) + static GitReferenceModel Create(GitReferenceCacheItem item) { return new GitReferenceModel(item.Ref, item.Label, item.Sha, item.RepositoryCloneUrl); } @@ -398,15 +384,13 @@ IPullRequestModel Create(PullRequestCacheItem prCacheItem) Assignee = prCacheItem.Assignee != null ? Create(prCacheItem.Assignee) : null, Base = Create(prCacheItem.Base), Body = prCacheItem.Body ?? string.Empty, - ChangedFiles = prCacheItem.ChangedFiles.Select(x => - (IPullRequestFileModel)new PullRequestFileModel(x.FileName, x.Sha, x.Status)).ToList(), CommentCount = prCacheItem.CommentCount, CommitCount = prCacheItem.CommitCount, CreatedAt = prCacheItem.CreatedAt, Head = Create(prCacheItem.Head), - State = prCacheItem.State.HasValue ? - prCacheItem.State.Value : - prCacheItem.IsOpen.Value ? PullRequestStateEnum.Open : PullRequestStateEnum.Closed, + State = prCacheItem.State.HasValue ? + prCacheItem.State.Value : + prCacheItem.IsOpen.Value ? PullRequestState.Open : PullRequestState.Closed, }; } @@ -416,7 +400,7 @@ public IObservable InsertUser(AccountCacheItem user) } protected virtual void Dispose(bool disposing) - {} + { } public void Dispose() { @@ -424,6 +408,11 @@ public void Dispose() GC.SuppressFinalize(this); } + static GitHub.Models.PullRequestReviewState FromGraphQL(Octokit.GraphQL.Model.PullRequestReviewState s) + { + return (GitHub.Models.PullRequestReviewState)s; + } + public class GitIgnoreCacheItem : CacheItem { public static GitIgnoreCacheItem Create(string ignore) @@ -452,7 +441,7 @@ public static RepositoryCacheItem Create(Repository apiRepository) return new RepositoryCacheItem(apiRepository); } - public RepositoryCacheItem() {} + public RepositoryCacheItem() { } public RepositoryCacheItem(Repository apiRepository) { @@ -466,45 +455,31 @@ public RepositoryCacheItem(Repository apiRepository) CreatedAt = apiRepository.CreatedAt; UpdatedAt = apiRepository.UpdatedAt; Timestamp = apiRepository.UpdatedAt; + Parent = apiRepository.Parent != null ? new RepositoryCacheItem(apiRepository.Parent) : null; } public long Id { get; set; } public string Name { get; set; } - [AllowNull] - public AccountCacheItem Owner - { - [return: AllowNull] - get; set; - } + public AccountCacheItem Owner { get; set; } public string CloneUrl { get; set; } public bool Private { get; set; } public bool Fork { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } + public RepositoryCacheItem Parent { get; set; } } - [NullGuard(ValidationFlags.None)] public class PullRequestCacheItem : CacheItem { public static PullRequestCacheItem Create(PullRequest pr) { - return new PullRequestCacheItem(pr, new PullRequestFile[0]); + return new PullRequestCacheItem(pr); } - public static PullRequestCacheItem Create(PullRequest pr, IReadOnlyList files) - { - return new PullRequestCacheItem(pr, files); - } - - public PullRequestCacheItem() {} + public PullRequestCacheItem() { } public PullRequestCacheItem(PullRequest pr) - : this(pr, new PullRequestFile[0]) - { - } - - public PullRequestCacheItem(PullRequest pr, IReadOnlyList files) { Title = pr.Title; Number = pr.Number; @@ -522,14 +497,13 @@ public PullRequestCacheItem(PullRequest pr, IReadOnlyList files Sha = pr.Head.Sha, RepositoryCloneUrl = pr.Head.Repository?.CloneUrl }; - CommentCount = pr.Comments + pr.ReviewComments; + CommentCount = pr.Comments; CommitCount = pr.Commits; Author = new AccountCacheItem(pr.User); Assignee = pr.Assignee != null ? new AccountCacheItem(pr.Assignee) : null; CreatedAt = pr.CreatedAt; UpdatedAt = pr.UpdatedAt; Body = pr.Body; - ChangedFiles = files.Select(x => new PullRequestFileCacheItem(x)).ToList(); State = GetState(pr); IsOpen = pr.State == ItemState.Open; Merged = pr.Merged; @@ -537,7 +511,7 @@ public PullRequestCacheItem(PullRequest pr, IReadOnlyList files Timestamp = UpdatedAt; } - public string Title {get; set; } + public string Title { get; set; } public int Number { get; set; } public GitReferenceCacheItem Base { get; set; } public GitReferenceCacheItem Head { get; set; } @@ -548,52 +522,31 @@ public PullRequestCacheItem(PullRequest pr, IReadOnlyList files public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public string Body { get; set; } - public IList ChangedFiles { get; set; } = new PullRequestFileCacheItem[0]; // Nullable for compatibility with old caches. - public PullRequestStateEnum? State { get; set; } + public PullRequestState? State { get; set; } // This fields exists only for compatibility with old caches. The State property should be used. public bool? IsOpen { get; set; } public bool? Merged { get; set; } - static PullRequestStateEnum GetState(PullRequest pullRequest) + static PullRequestState GetState(PullRequest pullRequest) { if (pullRequest.State == ItemState.Open) { - return PullRequestStateEnum.Open; + return PullRequestState.Open; } else if (pullRequest.Merged) { - return PullRequestStateEnum.Merged; + return PullRequestState.Merged; } else { - return PullRequestStateEnum.Closed; + return PullRequestState.Closed; } } } - [NullGuard(ValidationFlags.None)] - public class PullRequestFileCacheItem - { - public PullRequestFileCacheItem() - { - } - - public PullRequestFileCacheItem(PullRequestFile file) - { - FileName = file.FileName; - Sha = file.Sha; - Status = (PullRequestFileStatus)Enum.Parse(typeof(PullRequestFileStatus), file.Status, true); - } - - public string FileName { get; set; } - public string Sha { get; set; } - public PullRequestFileStatus Status { get; set; } - } - - [NullGuard(ValidationFlags.None)] public class GitReferenceCacheItem { public string Ref { get; set; } diff --git a/src/GitHub.App/Services/OAuthCallbackListener.cs b/src/GitHub.App/Services/OAuthCallbackListener.cs new file mode 100644 index 0000000000..34cd3cde23 --- /dev/null +++ b/src/GitHub.App/Services/OAuthCallbackListener.cs @@ -0,0 +1,82 @@ +using System; +using System.ComponentModel.Composition; +using System.Diagnostics; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using GitHub.Api; +using GitHub.Extensions; +using Rothko; +using static System.FormattableString; + +namespace GitHub.Services +{ + /// + /// Listens for a callback from the OAuth endpoint on "http://localhost:42549". + /// + /// + /// The GitHub for Visual Studio OAUTH application on GitHub is configured to call back to + /// http://localhost:42549 on successful login. This class listens on that port and returns + /// the temporary code received from the callback to the caller. + /// + /// Note that as implemented this class can only listen for one OAUTH callback at a time. If + /// is called when already listening for a callback, the original listen + /// operation will throw an . + /// + [Export(typeof(IOAuthCallbackListener))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class OAuthCallbackListener : IOAuthCallbackListener + { + const int CallbackPort = 42549; + readonly IHttpListener httpListener; + + [ImportingConstructor] + public OAuthCallbackListener(IHttpListener httpListener) + { + Guard.ArgumentNotNull(httpListener, nameof(httpListener)); + + this.httpListener = httpListener; + httpListener.Prefixes.Add(CallbackUrl); + } + + public readonly static string CallbackUrl = Invariant($"http://localhost:{CallbackPort}/"); + private IHttpListenerContext lastContext; + + public async Task Listen(string id, CancellationToken cancel) + { + if (httpListener.IsListening) httpListener.Stop(); + httpListener.Start(); + + try + { + using (cancel.Register(httpListener.Stop)) + { + while (true) + { + lastContext = await httpListener.GetContextAsync().ConfigureAwait(false); + var queryParts = HttpUtility.ParseQueryString(lastContext.Request.Url.Query); + + if (queryParts["state"] == id) + { + return queryParts["code"]; + } + } + } + } + catch(Exception) + { + httpListener.Stop(); + throw; + } + } + + public void RedirectLastContext(Uri url) + { + lastContext.Response.Redirect(url); + lastContext.Response.Close(); + + httpListener.Stop(); + } + } +} diff --git a/src/GitHub.App/Services/PullRequestEditorService.cs b/src/GitHub.App/Services/PullRequestEditorService.cs new file mode 100644 index 0000000000..50bf091988 --- /dev/null +++ b/src/GitHub.App/Services/PullRequestEditorService.cs @@ -0,0 +1,708 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using EnvDTE; +using GitHub.Commands; +using GitHub.Primitives; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Models.Drafts; +using GitHub.ViewModels; +using GitHub.VisualStudio; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Editor; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Differencing; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Projection; +using Microsoft.VisualStudio.TextManager.Interop; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.Services +{ + /// + /// Services for opening views of pull request files in Visual Studio. + /// + [Export(typeof(IPullRequestEditorService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class PullRequestEditorService : IPullRequestEditorService + { + // If the target line doesn't have a unique match, search this number of lines above looking for a match. + public const int MatchLinesAboveTarget = 4; + + readonly IGitHubServiceProvider serviceProvider; + readonly IPullRequestService pullRequestService; + readonly IVsEditorAdaptersFactoryService vsEditorAdaptersFactory; + readonly IStatusBarNotificationService statusBar; + readonly IGoToSolutionOrPullRequestFileCommand goToSolutionOrPullRequestFileCommand; + readonly IEditorOptionsFactoryService editorOptionsFactoryService; + readonly IMessageDraftStore draftStore; + readonly IInlineCommentPeekService peekService; + readonly IUsageTracker usageTracker; + + [ImportingConstructor] + public PullRequestEditorService( + IGitHubServiceProvider serviceProvider, + IPullRequestService pullRequestService, + IVsEditorAdaptersFactoryService vsEditorAdaptersFactory, + IStatusBarNotificationService statusBar, + IGoToSolutionOrPullRequestFileCommand goToSolutionOrPullRequestFileCommand, + IEditorOptionsFactoryService editorOptionsFactoryService, + IMessageDraftStore draftStore, + IInlineCommentPeekService peekService, + IUsageTracker usageTracker) + { + Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); + Guard.ArgumentNotNull(pullRequestService, nameof(pullRequestService)); + Guard.ArgumentNotNull(vsEditorAdaptersFactory, nameof(vsEditorAdaptersFactory)); + Guard.ArgumentNotNull(statusBar, nameof(statusBar)); + Guard.ArgumentNotNull(goToSolutionOrPullRequestFileCommand, nameof(goToSolutionOrPullRequestFileCommand)); + Guard.ArgumentNotNull(goToSolutionOrPullRequestFileCommand, nameof(editorOptionsFactoryService)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + Guard.ArgumentNotNull(peekService, nameof(peekService)); + Guard.ArgumentNotNull(draftStore, nameof(draftStore)); + + this.serviceProvider = serviceProvider; + this.pullRequestService = pullRequestService; + this.vsEditorAdaptersFactory = vsEditorAdaptersFactory; + this.statusBar = statusBar; + this.goToSolutionOrPullRequestFileCommand = goToSolutionOrPullRequestFileCommand; + this.editorOptionsFactoryService = editorOptionsFactoryService; + this.draftStore = draftStore; + this.peekService = peekService; + this.usageTracker = usageTracker; + } + + /// + public async Task OpenFile( + IPullRequestSession session, + string relativePath, + bool workingDirectory) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + + try + { + string fileName; + string commitSha; + + if (workingDirectory) + { + fileName = Path.Combine(session.LocalRepository.LocalPath, relativePath); + commitSha = null; + } + else + { + var file = await session.GetFile(relativePath); + fileName = await pullRequestService.ExtractToTempFile( + session.LocalRepository, + session.PullRequest, + file.RelativePath, + file.CommitSha, + pullRequestService.GetEncoding(session.LocalRepository, file.RelativePath)); + commitSha = file.CommitSha; + } + + IVsTextView textView; + IWpfTextView wpfTextView; + using (workingDirectory ? null : OpenInProvisionalTab()) + { + var readOnly = !workingDirectory; + textView = OpenDocument(fileName, readOnly, out wpfTextView); + + if (!workingDirectory) + { + AddBufferTag(wpfTextView.TextBuffer, session, relativePath, commitSha, null); + EnableNavigateToEditor(textView, session); + } + } + + if (workingDirectory) + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsOpenFileInSolution); + else + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewFile); + + return wpfTextView; + } + catch (Exception e) + { + ShowErrorInStatusBar("Error opening file", e); + return null; + } + } + + /// + public async Task OpenDiff(IPullRequestSession session, string relativePath, string headSha, bool scrollToFirstDraftOrDiff) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + + try + { + var workingDirectory = headSha == null; + var file = await session.GetFile(relativePath, headSha ?? "HEAD"); + var mergeBase = await pullRequestService.GetMergeBase(session.LocalRepository, session.PullRequest); + var encoding = pullRequestService.GetEncoding(session.LocalRepository, file.RelativePath); + var rightFile = workingDirectory ? + Path.Combine(session.LocalRepository.LocalPath, relativePath) : + await pullRequestService.ExtractToTempFile( + session.LocalRepository, + session.PullRequest, + relativePath, + file.CommitSha, + encoding); + + var diffViewer = FocusExistingDiffViewer(session, mergeBase, rightFile); + if (diffViewer != null) + { + return diffViewer; + } + + var leftFile = await pullRequestService.ExtractToTempFile( + session.LocalRepository, + session.PullRequest, + relativePath, + mergeBase, + encoding); + var leftPath = await GetBaseFileName(session, file); + var rightPath = file.RelativePath; + var leftLabel = $"{leftPath};{session.GetBaseBranchDisplay()}"; + var rightLabel = workingDirectory ? rightPath : $"{rightPath};PR {session.PullRequest.Number}"; + var caption = $"Diff - {Path.GetFileName(file.RelativePath)}"; + var options = __VSDIFFSERVICEOPTIONS.VSDIFFOPT_DetectBinaryFiles | + __VSDIFFSERVICEOPTIONS.VSDIFFOPT_LeftFileIsTemporary; + var openThread = (line: -1, side: DiffSide.Left); + var scrollToFirstDiff = false; + + if (!workingDirectory) + { + options |= __VSDIFFSERVICEOPTIONS.VSDIFFOPT_RightFileIsTemporary; + } + + if (scrollToFirstDraftOrDiff) + { + var (key, _) = PullRequestReviewCommentThreadViewModel.GetDraftKeys( + session.LocalRepository.CloneUrl.WithOwner(session.RepositoryOwner), + session.PullRequest.Number, + relativePath, + 0); + var drafts = (await draftStore.GetDrafts(key) + .ConfigureAwait(true)) + .OrderByDescending(x => x.data.UpdatedAt) + .ToList(); + + if (drafts.Count > 0 && int.TryParse(drafts[0].secondaryKey, out var line)) + { + openThread = (line, drafts[0].data.Side); + scrollToFirstDiff = false; + } + else + { + scrollToFirstDiff = true; + } + } + + IVsWindowFrame frame; + using (OpenWithOption(DifferenceViewerOptions.ScrollToFirstDiffName, scrollToFirstDiff)) + using (OpenInProvisionalTab()) + { + var tooltip = $"{leftLabel}\nvs.\n{rightLabel}"; + + // Diff window will open in provisional (right hand) tab until document is touched. + frame = VisualStudio.Services.DifferenceService.OpenComparisonWindow2( + leftFile, + rightFile, + SanitizeForDisplay(caption), + SanitizeForDisplay(tooltip), + SanitizeForDisplay(leftLabel), + SanitizeForDisplay(rightLabel), + string.Empty, + string.Empty, + (uint)options); + } + + diffViewer = GetDiffViewer(frame); + + var leftText = diffViewer.LeftView.TextBuffer.CurrentSnapshot.GetText(); + var rightText = diffViewer.RightView.TextBuffer.CurrentSnapshot.GetText(); + if (leftText.Length == 0) + { + // Don't show LeftView when empty. + diffViewer.ViewMode = DifferenceViewMode.RightViewOnly; + } + else if (rightText.Length == 0) + { + // Don't show RightView when empty. + diffViewer.ViewMode = DifferenceViewMode.LeftViewOnly; + } + else if (leftText == rightText) + { + // Don't show LeftView when no changes. + diffViewer.ViewMode = DifferenceViewMode.RightViewOnly; + } + + AddBufferTag(diffViewer.LeftView.TextBuffer, session, leftPath, mergeBase, DiffSide.Left); + + if (!workingDirectory) + { + AddBufferTag(diffViewer.RightView.TextBuffer, session, rightPath, file.CommitSha, DiffSide.Right); + EnableNavigateToEditor(diffViewer.LeftView, session); + EnableNavigateToEditor(diffViewer.RightView, session); + EnableNavigateToEditor(diffViewer.InlineView, session); + } + + if (workingDirectory) + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsCompareWithSolution); + else + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewChanges); + + if (openThread.line != -1) + { + var view = diffViewer.ViewMode == DifferenceViewMode.Inline ? + diffViewer.InlineView : + openThread.side == DiffSide.Left ? diffViewer.LeftView : diffViewer.RightView; + + // HACK: We need to wait here for the view to initialize or the peek session won't appear. + // There must be a better way of doing this. + await Task.Delay(1500).ConfigureAwait(true); + peekService.Show(view, openThread.side, openThread.line); + } + + return diffViewer; + } + catch (Exception e) + { + ShowErrorInStatusBar("Error opening file", e); + return null; + } + } + + private static string SanitizeForDisplay(string caption) + { + // The diff window passes captions and tooltips through string.Format, with {0} and {1} being the left and right file respectively, but we already + // nicely format the file names with extra info we know, so we have to escape braces to prevent unwanted formatting, or invalid format errors. + return caption.Replace("{", "{{").Replace("}", "}}"); + } + + /// + public Task OpenDiff( + IPullRequestSession session, + string relativePath, + IInlineCommentThreadModel thread) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + Guard.ArgumentNotNull(thread, nameof(thread)); + + return OpenDiff(session, relativePath, thread.CommitSha, thread.LineNumber - 1); + } + + /// + public async Task OpenDiff(IPullRequestSession session, string relativePath, string headSha, int nextInlineTagFromLine) + { + var diffViewer = await OpenDiff(session, relativePath, headSha, scrollToFirstDraftOrDiff: false); + + var param = (object) new InlineCommentNavigationParams + { + FromLine = nextInlineTagFromLine, + }; + + // HACK: We need to wait here for the inline comment tags to initialize so we can find the next inline comment. + // There must be a better way of doing this. + await Task.Delay(1500); + RaiseWhenAvailable(Guids.CommandSetString, PkgCmdIDList.NextInlineCommentId, param); + + return diffViewer; + } + + static bool RaiseWhenAvailable(string guid, int id, object param) + { + var commands = VisualStudio.Services.Dte.Commands; + var command = commands.Item(guid, id); + + if (command.IsAvailable) + { + commands.Raise(command.Guid, command.ID, ref param, null); + return true; + } + + return false; + } + + public void OpenActiveDocumentInCodeView(IVsTextView sourceView) + { + var dte = serviceProvider.GetService(); + // Not sure how to get a file name directly from IVsTextView. Using DTE.ActiveDocument.FullName. + var fullPath = dte.ActiveDocument.FullName; + // VsShellUtilities.OpenDocument with VSConstants.LOGVIEWID.Code_guid always open a new Code view. + // Using DTE.ItemOperations.OpenFile with Constants.vsViewKindCode instead, + dte.ItemOperations.OpenFile(fullPath, EnvDTE.Constants.vsViewKindCode); + var codeView = FindActiveView(); + NavigateToEquivalentPosition(sourceView, codeView); + } + + public bool IsEditableDiff(ITextView textView) + { + var readOnly = textView.Options.GetOptionValue(DefaultTextViewOptions.ViewProhibitUserInputId); + var isDiff = IsDiff(textView); + return !readOnly && isDiff; + } + + public void NavigateToEquivalentPosition(IVsTextView sourceView, IVsTextView targetView) + { + int line; + int column; + ErrorHandler.ThrowOnFailure(sourceView.GetCaretPos(out line, out column)); + var text1 = GetText(sourceView); + var text2 = GetText(targetView); + + var fromLines = ReadLines(text1); + var toLines = ReadLines(text2); + var matchingLine = FindMatchingLine(fromLines, toLines, line, matchLinesAbove: MatchLinesAboveTarget); + if (matchingLine == -1) + { + // If we can't match line use orignal as best guess. + matchingLine = line < toLines.Count ? line : toLines.Count - 1; + column = 0; + } + + ErrorHandler.ThrowOnFailure(targetView.SetCaretPos(matchingLine, column)); + ErrorHandler.ThrowOnFailure(targetView.CenterLines(matchingLine, 1)); + } + + public IVsTextView FindActiveView() + { + var textManager = serviceProvider.GetService(); + IVsTextView view; + var hresult = textManager.GetActiveView2(1, null, (uint)_VIEWFRAMETYPE.vftCodeWindow, out view); + return hresult == VSConstants.S_OK ? view : null; + } + + /// + /// Find the closest matching line in . + /// + /// + /// When matching we prioritize unique matching lines in . If the target line isn't + /// unique, continue searching the lines above for a better match and use this as anchor with an offset. + /// The closest match to with the fewest duplicate matches will be used for the matching line. + /// + /// The document we're navigating from. + /// The document we're navigating to. + /// The 0-based line we're navigating from. + /// + /// The best matching line in + public int FindMatchingLine(IList fromLines, IList toLines, int line, int matchLinesAbove = 0) + { + var matchingLine = -1; + var minMatchedLines = -1; + for (var offset = 0; offset <= matchLinesAbove; offset++) + { + var targetLine = line - offset; + if (targetLine < 0) + { + break; + } + + int matchedLines; + var nearestLine = FindNearestMatchingLine(fromLines, toLines, targetLine, out matchedLines); + if (nearestLine != -1) + { + if (matchingLine == -1 || minMatchedLines >= matchedLines) + { + matchingLine = nearestLine + offset; + minMatchedLines = matchedLines; + } + + if (minMatchedLines == 1) + { + break; // We've found a unique matching line! + } + } + } + + if (matchingLine >= toLines.Count) + { + matchingLine = toLines.Count - 1; + } + + return matchingLine; + } + + /// + /// Find the nearest matching line to and the number of similar matched lines in the text. + /// + /// The document we're navigating from. + /// The document we're navigating to. + /// The 0-based line we're navigating from. + /// The number of similar matched lines in + /// Find the nearest matching line in . + public static int FindNearestMatchingLine(IList fromLines, IList toLines, int line, out int matchedLines) + { + line = line < fromLines.Count ? line : fromLines.Count - 1; // VS shows one extra line at end + var fromLine = fromLines[line]; + + matchedLines = 0; + var matchingLine = -1; + for (var offset = 0; true; offset++) + { + var lineAbove = line + offset; + var checkAbove = lineAbove < toLines.Count; + if (checkAbove && toLines[lineAbove] == fromLine) + { + if (matchedLines == 0) + { + matchingLine = lineAbove; + } + + matchedLines++; + } + + var lineBelow = line - offset; + var checkBelow = lineBelow >= 0; + if (checkBelow && offset > 0 && lineBelow < toLines.Count && toLines[lineBelow] == fromLine) + { + if (matchedLines == 0) + { + matchingLine = lineBelow; + } + + matchedLines++; + } + + if (!checkAbove && !checkBelow) + { + break; + } + } + + return matchingLine; + } + + string GetText(IVsTextView textView) + { + IVsTextLines buffer; + ErrorHandler.ThrowOnFailure(textView.GetBuffer(out buffer)); + + int line; + int index; + ErrorHandler.ThrowOnFailure(buffer.GetLastLineIndex(out line, out index)); + + string text; + ErrorHandler.ThrowOnFailure(buffer.GetLineText(0, 0, line, index, out text)); + return text; + } + + IVsTextView OpenDocument(string fullPath, bool readOnly, out IWpfTextView wpfTextView) + { + var logicalView = VSConstants.LOGVIEWID.TextView_guid; + IVsUIHierarchy hierarchy; + uint itemID; + IVsWindowFrame windowFrame; + IVsTextView view; + VsShellUtilities.OpenDocument(serviceProvider, fullPath, logicalView, out hierarchy, out itemID, out windowFrame, out view); + + wpfTextView = vsEditorAdaptersFactory.GetWpfTextView(view); + wpfTextView?.Options?.SetOptionValue(DefaultTextViewOptions.ViewProhibitUserInputId, readOnly); + + return view; + } + + IDifferenceViewer FocusExistingDiffViewer( + IPullRequestSession session, + string mergeBase, + string rightPath) + { + IVsUIHierarchy uiHierarchy; + uint itemID; + IVsWindowFrame windowFrame; + + // Diff documents are indexed by the path on the right hand side of the comparison. + if (VsShellUtilities.IsDocumentOpen( + serviceProvider, + rightPath, + Guid.Empty, + out uiHierarchy, + out itemID, + out windowFrame)) + { + var diffViewer = GetDiffViewer(windowFrame); + + if (diffViewer != null) + { + PullRequestTextBufferInfo leftBufferInfo; + + if (diffViewer.LeftView.TextBuffer.Properties.TryGetProperty( + typeof(PullRequestTextBufferInfo), + out leftBufferInfo) && + leftBufferInfo.Session.PullRequest.Number == session.PullRequest.Number && + leftBufferInfo.CommitSha == mergeBase) + { + ErrorHandler.ThrowOnFailure(windowFrame.Show()); + return diffViewer; + } + } + } + + return null; + } + + void ShowErrorInStatusBar(string message, Exception e) + { + statusBar.ShowMessage(message + ": " + e.Message); + } + + void AddBufferTag( + ITextBuffer buffer, + IPullRequestSession session, + string relativePath, + string commitSha, + DiffSide? side) + { + buffer.Properties.GetOrCreateSingletonProperty( + typeof(PullRequestTextBufferInfo), + () => new PullRequestTextBufferInfo(session, relativePath, commitSha, side)); + + var projection = buffer as IProjectionBuffer; + + if (projection != null) + { + foreach (var source in projection.SourceBuffers) + { + AddBufferTag(source, session, relativePath, commitSha, side); + } + } + } + + void EnableNavigateToEditor(ITextView textView, IPullRequestSession session) + { + var vsTextView = vsEditorAdaptersFactory.GetViewAdapter(textView); + EnableNavigateToEditor(vsTextView, session); + } + + void EnableNavigateToEditor(IVsTextView vsTextView, IPullRequestSession session) + { + var commandGroup = VSConstants.CMDSETID.StandardCommandSet2K_guid; + var commandId = (int)VSConstants.VSStd2KCmdID.RETURN; + TextViewCommandDispatcher.AddCommandFilter(vsTextView, commandGroup, commandId, goToSolutionOrPullRequestFileCommand); + + EnableNavigateStatusBarMessage(vsTextView, session); + } + + void EnableNavigateStatusBarMessage(IVsTextView vsTextView, IPullRequestSession session) + { + var textView = vsEditorAdaptersFactory.GetWpfTextView(vsTextView); + + var statusMessage = session.IsCheckedOut ? + Resources.NavigateToEditorStatusMessage : Resources.NavigateToEditorNotCheckedOutStatusMessage; + + textView.GotAggregateFocus += (s, e) => + statusBar.ShowMessage(statusMessage); + + textView.LostAggregateFocus += (s, e) => + statusBar.ShowMessage(string.Empty); + } + + ITextBuffer GetBufferAt(string filePath) + { + IVsUIHierarchy uiHierarchy; + uint itemID; + IVsWindowFrame windowFrame; + + if (VsShellUtilities.IsDocumentOpen( + serviceProvider, + filePath, + Guid.Empty, + out uiHierarchy, + out itemID, + out windowFrame)) + { + IVsTextView view = VsShellUtilities.GetTextView(windowFrame); + IVsTextLines lines; + if (view.GetBuffer(out lines) == 0) + { + var buffer = lines as IVsTextBuffer; + if (buffer != null) + return vsEditorAdaptersFactory.GetDataBuffer(buffer); + } + } + + return null; + } + + async Task GetBaseFileName(IPullRequestSession session, IPullRequestSessionFile file) + { + using (var changes = await pullRequestService.GetTreeChanges( + session.LocalRepository, + session.PullRequest)) + { + var gitPath = Paths.ToGitPath(file.RelativePath); + var fileChange = changes.FirstOrDefault(x => x.Path == gitPath); + return fileChange?.Status == LibGit2Sharp.ChangeKind.Renamed ? + Paths.ToWindowsPath(fileChange.OldPath) : file.RelativePath; + } + } + + static bool IsDiff(ITextView textView) => textView.Roles.Contains("DIFF"); + + static IDifferenceViewer GetDiffViewer(IVsWindowFrame frame) + { + object docView; + + if (ErrorHandler.Succeeded(frame.GetProperty((int)__VSFPROPID.VSFPROPID_DocView, out docView))) + { + return (docView as IVsDifferenceCodeWindow)?.DifferenceViewer; + } + + return null; + } + + static IDisposable OpenInProvisionalTab() + { + return new NewDocumentStateScope( + __VSNEWDOCUMENTSTATE.NDS_Provisional, + VSConstants.NewDocumentStateReason.SolutionExplorer); + } + + IDisposable OpenWithOption(string optionId, object value) => new OpenWithOptionScope(editorOptionsFactoryService, optionId, value); + + class OpenWithOptionScope : IDisposable + { + readonly IEditorOptionsFactoryService editorOptionsFactoryService; + readonly string optionId; + readonly object savedValue; + + internal OpenWithOptionScope(IEditorOptionsFactoryService editorOptionsFactoryService, string optionId, object value) + { + this.editorOptionsFactoryService = editorOptionsFactoryService; + this.optionId = optionId; + savedValue = editorOptionsFactoryService.GlobalOptions.GetOptionValue(optionId); + editorOptionsFactoryService.GlobalOptions.SetOptionValue(optionId, value); + } + + public void Dispose() + { + editorOptionsFactoryService.GlobalOptions.SetOptionValue(optionId, savedValue); + } + } + + static IList ReadLines(string text) + { + var lines = new List(); + var reader = new DiffUtilities.LineReader(text); + string line; + while ((line = reader.ReadLine()) != null) + { + lines.Add(line); + } + + return lines; + } + } +} diff --git a/src/GitHub.App/Services/PullRequestService.cs b/src/GitHub.App/Services/PullRequestService.cs index c054ddbc18..54f98852eb 100644 --- a/src/GitHub.App/Services/PullRequestService.cs +++ b/src/GitHub.App/Services/PullRequestService.cs @@ -1,34 +1,51 @@ using System; +using System.Collections.Generic; using System.ComponentModel.Composition; +using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; -using GitHub.Models; +using System.Reactive; using System.Reactive.Linq; -using Rothko; +using System.Reactive.Threading.Tasks; +using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; -using System.Reactive.Threading.Tasks; +using System.Windows.Forms; +using GitHub.Api; +using GitHub.App.Services; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; using GitHub.Primitives; -using System.Text.RegularExpressions; -using System.Globalization; -using System.Reactive; -using System.Collections.Generic; using LibGit2Sharp; -using PullRequest = Octokit.PullRequest; -using System.Diagnostics; +using Microsoft.VisualStudio.StaticReviews.Contracts; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using Rothko; +using static System.FormattableString; +using static Octokit.GraphQL.Variable; +using CheckConclusionState = GitHub.Models.CheckConclusionState; +using CheckStatusState = GitHub.Models.CheckStatusState; +using StatusState = GitHub.Models.StatusState; namespace GitHub.Services { - [NullGuard.NullGuard(NullGuard.ValidationFlags.None)] [Export(typeof(IPullRequestService))] [PartCreationPolicy(CreationPolicy.Shared)] - public class PullRequestService : IPullRequestService + public class PullRequestService : IssueishService, IPullRequestService, IStaticReviewFileMap { const string SettingCreatedByGHfVS = "created-by-ghfvs"; - const string SettingGHfVSPullRequest = "ghfvs-pr"; + const string SettingGHfVSPullRequest = "ghfvs-pr-owner-number"; static readonly Regex InvalidBranchCharsRegex = new Regex(@"[^0-9A-Za-z\-]", RegexOptions.ECMAScript); static readonly Regex BranchCapture = new Regex(@"branch\.(?.+)\.ghfvs-pr", RegexOptions.ECMAScript); + static ICompiledQuery> readAssignableUsers; + static ICompiledQuery> readPullRequests; + static ICompiledQuery> readPullRequestsEnterprise; static readonly string[] TemplatePaths = new[] { @@ -40,25 +57,293 @@ public class PullRequestService : IPullRequestService readonly IGitClient gitClient; readonly IGitService gitService; + readonly IVSGitExt gitExt; + readonly IGraphQLClientFactory graphqlFactory; readonly IOperatingSystem os; readonly IUsageTracker usageTracker; + readonly IDictionary tempFileMappings; + [ImportingConstructor] - public PullRequestService(IGitClient gitClient, IGitService gitService, IOperatingSystem os, IUsageTracker usageTracker) + public PullRequestService( + IGitClient gitClient, + IGitService gitService, + IVSGitExt gitExt, + IApiClientFactory apiClientFactory, + IGraphQLClientFactory graphqlFactory, + IOperatingSystem os, + IUsageTracker usageTracker) + : base(apiClientFactory, graphqlFactory) { this.gitClient = gitClient; this.gitService = gitService; + this.gitExt = gitExt; + this.graphqlFactory = graphqlFactory; this.os = os; this.usageTracker = usageTracker; + this.tempFileMappings = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public async Task> ReadPullRequests( + HostAddress address, + string owner, + string name, + string after, + Models.PullRequestState[] states) + { + + ICompiledQuery> query; + + if (address.IsGitHubDotCom()) + { + if (readPullRequests == null) + { + readPullRequests = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .PullRequests( + first: 100, + after: Var(nameof(after)), + orderBy: new IssueOrder { Direction = OrderDirection.Desc, Field = IssueOrderField.CreatedAt }, + states: Var(nameof(states))) + .Select(page => new Page + { + EndCursor = page.PageInfo.EndCursor, + HasNextPage = page.PageInfo.HasNextPage, + TotalCount = page.TotalCount, + Items = page.Nodes.Select(pr => new ListItemAdapter + { + Id = pr.Id.Value, + LastCommit = pr.Commits(null, null, 1, null).Nodes.Select(commit => + new LastCommitSummaryAdapter + { + CheckSuites = commit.Commit.CheckSuites(null, null, null, null, null).AllPages(10) + .Select(suite => new CheckSuiteSummaryModel + { + CheckRuns = suite.CheckRuns(null, null, null, null, null).AllPages(10) + .Select(run => new CheckRunSummaryModel + { + Conclusion = run.Conclusion.FromGraphQl(), + Status = run.Status.FromGraphQl() + }).ToList(), + }).ToList(), + Statuses = commit.Commit.Status + .Select(context => + context.Contexts.Select(statusContext => new StatusSummaryModel + { + State = statusContext.State.FromGraphQl(), + }).ToList() + ).SingleOrDefault() + }).ToList().FirstOrDefault(), + Author = new ActorModel + { + Login = pr.Author.Login, + AvatarUrl = pr.Author.AvatarUrl(null), + }, + CommentCount = pr.Comments(0, null, null, null).TotalCount, + Number = pr.Number, + Reviews = pr.Reviews(null, null, null, null, null, null).AllPages().Select(review => new ReviewAdapter + { + Body = review.Body, + CommentCount = review.Comments(null, null, null, null).TotalCount, + }).ToList(), + State = pr.State.FromGraphQl(), + Title = pr.Title, + UpdatedAt = pr.UpdatedAt, + }).ToList(), + }).Compile(); + } + + query = readPullRequests; + } + else + { + if (readPullRequestsEnterprise == null) + { + readPullRequestsEnterprise = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .PullRequests( + first: 100, + after: Var(nameof(after)), + orderBy: new IssueOrder { Direction = OrderDirection.Desc, Field = IssueOrderField.CreatedAt }, + states: Var(nameof(states))) + .Select(page => new Page + { + EndCursor = page.PageInfo.EndCursor, + HasNextPage = page.PageInfo.HasNextPage, + TotalCount = page.TotalCount, + Items = page.Nodes.Select(pr => new ListItemAdapter + { + Id = pr.Id.Value, + LastCommit = pr.Commits(null, null, 1, null).Nodes.Select(commit => + new LastCommitSummaryAdapter + { + Statuses = commit.Commit.Status.Select(context => + context == null + ? null + : context.Contexts + .Select(statusContext => new StatusSummaryModel + { + State = statusContext.State.FromGraphQl() + }).ToList() + ).SingleOrDefault() + }).ToList().FirstOrDefault(), + Author = new ActorModel + { + Login = pr.Author.Login, + AvatarUrl = pr.Author.AvatarUrl(null), + }, + CommentCount = pr.Comments(0, null, null, null).TotalCount, + Number = pr.Number, + Reviews = pr.Reviews(null, null, null, null, null, null).AllPages().Select(review => new ReviewAdapter + { + Body = review.Body, + CommentCount = review.Comments(null, null, null, null).TotalCount, + }).ToList(), + State = pr.State.FromGraphQl(), + Title = pr.Title, + UpdatedAt = pr.UpdatedAt, + }).ToList(), + }).Compile(); + } + + query = readPullRequestsEnterprise; + } + + var graphql = await graphqlFactory.CreateConnection(address); + var vars = new Dictionary + { + { nameof(owner), owner }, + { nameof(name), name }, + { nameof(after), after }, + { nameof(states), states.Select(x => (Octokit.GraphQL.Model.PullRequestState)x).ToList() }, + }; + + var region = owner + '/' + name + "/pr-list"; + var result = await graphql.Run(query, vars, regionName: region); + + foreach (var item in result.Items.Cast()) + { + item.CommentCount += item.Reviews.Sum(x => x.Count); + item.Reviews = null; + + var checkRuns = item.LastCommit?.CheckSuites?.SelectMany(model => model.CheckRuns).ToArray(); + var statuses = item.LastCommit?.Statuses; + + var totalCount = 0; + var pendingCount = 0; + var successCount = 0; + var errorCount = 0; + + if (checkRuns != null) + { + totalCount += checkRuns.Length; + + pendingCount += checkRuns.Count(model => model.Status != CheckStatusState.Completed); + + successCount += checkRuns.Count(model => model.Status == CheckStatusState.Completed && + model.Conclusion.HasValue && + (model.Conclusion == CheckConclusionState.Success || + model.Conclusion == CheckConclusionState.Neutral)); + errorCount += checkRuns.Count(model => model.Status == CheckStatusState.Completed && + model.Conclusion.HasValue && + !(model.Conclusion == CheckConclusionState.Success || + model.Conclusion == CheckConclusionState.Neutral)); + } + + if (statuses != null) + { + totalCount += statuses.Count; + + pendingCount += statuses.Count(model => + model.State == StatusState.Pending || model.State == StatusState.Expected); + + successCount += statuses.Count(model => model.State == StatusState.Success); + + errorCount += statuses.Count(model => + model.State == StatusState.Error || model.State == StatusState.Failure); + } + + item.ChecksPendingCount = pendingCount; + item.ChecksSuccessCount = successCount; + item.ChecksErrorCount = errorCount; + + if (totalCount == 0) + { + item.ChecksSummary = PullRequestChecksSummaryState.None; + } + else if (totalCount == pendingCount) + { + item.ChecksSummary = PullRequestChecksSummaryState.Pending; + } + else if (totalCount == successCount) + { + item.ChecksSummary = PullRequestChecksSummaryState.Success; + } + else if (totalCount == errorCount) + { + item.ChecksSummary = PullRequestChecksSummaryState.Failure; + } + else + { + item.ChecksSummary = PullRequestChecksSummaryState.Mixed; + } + + item.LastCommit = null; + } + + return result; + } + + public async Task ClearPullRequestsCache(HostAddress address, string owner, string name) + { + var region = owner + '/' + name + "/pr-list"; + var graphql = await graphqlFactory.CreateConnection(address); + + await graphql.ClearCache(region); } - public IObservable CreatePullRequest(IRepositoryHost host, - ILocalRepositoryModel sourceRepository, IRepositoryModel targetRepository, - IBranch sourceBranch, IBranch targetBranch, + public async Task> ReadAssignableUsers( + HostAddress address, + string owner, + string name, + string after) + { + if (readAssignableUsers == null) + { + readAssignableUsers = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .AssignableUsers(first: 100, after: Var(nameof(after))) + .Select(connection => new Page + { + EndCursor = connection.PageInfo.EndCursor, + HasNextPage = connection.PageInfo.HasNextPage, + TotalCount = connection.TotalCount, + Items = connection.Nodes.Select(user => new ActorModel + { + AvatarUrl = user.AvatarUrl(30), + Login = user.Login, + }).ToList(), + }).Compile(); + } + + var graphql = await graphqlFactory.CreateConnection(address); + var vars = new Dictionary + { + { nameof(owner), owner }, + { nameof(name), name }, + { nameof(after), after }, + }; + + return await graphql.Run(readAssignableUsers, vars, cacheDuration: TimeSpan.FromHours(1)); + } + + public IObservable CreatePullRequest(IModelService modelService, + LocalRepositoryModel sourceRepository, RepositoryModel targetRepository, + BranchModel sourceBranch, BranchModel targetBranch, string title, string body ) { - Extensions.Guard.ArgumentNotNull(host, nameof(host)); + Extensions.Guard.ArgumentNotNull(modelService, nameof(modelService)); Extensions.Guard.ArgumentNotNull(sourceRepository, nameof(sourceRepository)); Extensions.Guard.ArgumentNotNull(targetRepository, nameof(targetRepository)); Extensions.Guard.ArgumentNotNull(sourceBranch, nameof(sourceBranch)); @@ -66,10 +351,10 @@ public IObservable CreatePullRequest(IRepositoryHost host, Extensions.Guard.ArgumentNotNull(title, nameof(title)); Extensions.Guard.ArgumentNotNull(body, nameof(body)); - return PushAndCreatePR(host, sourceRepository, targetRepository, sourceBranch, targetBranch, title, body).ToObservable(); + return PushAndCreatePR(modelService, sourceRepository, targetRepository, sourceBranch, targetBranch, title, body).ToObservable(); } - public IObservable GetPullRequestTemplate(ILocalRepositoryModel repository) + public IObservable GetPullRequestTemplate(LocalRepositoryModel repository) { Extensions.Guard.ArgumentNotNull(repository, nameof(repository)); @@ -88,276 +373,527 @@ public IObservable GetPullRequestTemplate(ILocalRepositoryModel reposito }); } - public IObservable IsWorkingDirectoryClean(ILocalRepositoryModel repository) + public IObservable> GetMessagesForUniqueCommits( + LocalRepositoryModel repository, + string baseBranch, + string compareBranch, + int maxCommits) { - var repo = gitService.GetRepository(repository.LocalPath); - return Observable.Return(!repo.RetrieveStatus().IsDirty); + return Observable.Defer(async () => + { + // CommitMessage doesn't keep a reference to Repository + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + var messages = await gitClient.GetMessagesForUniqueCommits(repo, baseBranch, compareBranch, maxCommits); + return Observable.Return(messages); + } + }); } - public IObservable Pull(ILocalRepositoryModel repository) + public IObservable CountSubmodulesToSync(LocalRepositoryModel repository) { - return Observable.Defer(() => + using (var repo = gitService.GetRepository(repository.LocalPath)) { - var repo = gitService.GetRepository(repository.LocalPath); - return gitClient.Pull(repo).ToObservable(); - }); + var count = 0; + foreach (var submodule in repo.Submodules) + { + var status = submodule.RetrieveStatus(); + if ((status & SubmoduleStatus.WorkDirAdded) != 0) + { + count++; + } + else if ((status & SubmoduleStatus.WorkDirDeleted) != 0) + { + count++; + } + else if ((status & SubmoduleStatus.WorkDirModified) != 0) + { + count++; + } + else if ((status & SubmoduleStatus.WorkDirUninitialized) != 0) + { + count++; + } + } + + return Observable.Return(count); + } } - public IObservable Push(ILocalRepositoryModel repository) + public IObservable IsWorkingDirectoryClean(LocalRepositoryModel repository) { - return Observable.Defer(() => + // The `using` appears to resolve this issue: + // https://github.com/github/VisualStudio/issues/1306 + using (var repo = gitService.GetRepository(repository.LocalPath)) { - var repo = gitService.GetRepository(repository.LocalPath); - return gitClient.Push(repo, repo.Head.TrackedBranch.UpstreamBranchCanonicalName, repo.Head.Remote.Name).ToObservable(); - }); + var statusOptions = new StatusOptions { ExcludeSubmodules = true }; + var status = repo.RetrieveStatus(statusOptions); + var isClean = !IsCheckoutBlockingDirty(status); + return Observable.Return(isClean); + } } - public IObservable Checkout(ILocalRepositoryModel repository, IPullRequestModel pullRequest, string localBranchName) + static bool IsCheckoutBlockingDirty(RepositoryStatus status) + { + if (status.IsDirty) + { + return status.Any(entry => IsCheckoutBlockingChange(entry)); + } + + return false; + } + + // This is similar to IsDirty, but also allows NewInWorkdir and DeletedFromWorkdir files + static bool IsCheckoutBlockingChange(StatusEntry entry) + { + switch (entry.State) + { + case FileStatus.Ignored: + return false; + case FileStatus.Unaltered: + return false; + case FileStatus.NewInWorkdir: + return false; + case FileStatus.DeletedFromWorkdir: + return false; + default: + return true; + } + } + + public IObservable Pull(LocalRepositoryModel repository) { return Observable.Defer(async () => { - var repo = gitService.GetRepository(repository.LocalPath); - var existing = repo.Branches[localBranchName]; + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + await gitClient.Pull(repo); + return Observable.Return(Unit.Default); + } + }); + } - if (existing != null) + public IObservable Push(LocalRepositoryModel repository) + { + return Observable.Defer(async () => + { + using (var repo = gitService.GetRepository(repository.LocalPath)) { - await gitClient.Checkout(repo, localBranchName); + var remoteName = repo.Head.RemoteName; + var remote = await gitClient.GetHttpRemote(repo, remoteName); + await gitClient.Push(repo, repo.Head.TrackedBranch.UpstreamBranchCanonicalName, remote.Name); + return Observable.Return(Unit.Default); } - else if (repository.CloneUrl.ToRepositoryUrl() == pullRequest.Head.RepositoryCloneUrl.ToRepositoryUrl()) + }); + } + + public async Task SyncSubmodules(LocalRepositoryModel repository, Action progress) + { + var exitCode = await Where("git"); + if (exitCode != 0) + { + progress(Resources.CouldntFindGitOnPath); + return false; + } + + return await SyncSubmodules(repository.LocalPath, progress) == 0; + } + + // LibGit2Sharp has limited submodule support so shelling out Git.exe for submodule commands. + async Task SyncSubmodules(string workingDir, Action progress) + { + var cmdArguments = "/C git submodule init & git submodule sync --recursive & git submodule update --recursive"; + var startInfo = new ProcessStartInfo("cmd", cmdArguments) + { + WorkingDirectory = workingDir, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using (var process = Process.Start(startInfo)) + { + await Task.WhenAll( + ReadLinesAsync(process.StandardOutput, progress), + ReadLinesAsync(process.StandardError, progress), + Task.Run(() => process.WaitForExit())); + return process.ExitCode; + } + } + + static Task Where(string fileName) + { + return Task.Run(() => + { + var cmdArguments = "/C WHERE /Q " + fileName; + var startInfo = new ProcessStartInfo("cmd", cmdArguments) { - var remote = await gitClient.GetHttpRemote(repo, "origin"); - await gitClient.Fetch(repo, remote.Name); - await gitClient.Checkout(repo, localBranchName); + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var process = Process.Start(startInfo)) + { + process.WaitForExit(); + return process.ExitCode; } - else + }); + } + + static async Task ReadLinesAsync(TextReader reader, Action progress) + { + string line; + while ((line = await reader.ReadLineAsync()) != null) + { + progress(line); + } + } + + public IObservable Checkout(LocalRepositoryModel repository, PullRequestDetailModel pullRequest, string localBranchName) + { + return Observable.Defer(async () => + { + using (var repo = gitService.GetRepository(repository.LocalPath)) { - var refSpec = $"{pullRequest.Head.Ref}:{localBranchName}"; + var existing = repo.Branches[localBranchName]; + + if (existing != null) + { + await gitClient.Checkout(repo, localBranchName); + } + else if (string.Equals(repository.CloneUrl.Owner, pullRequest.HeadRepositoryOwner, StringComparison.OrdinalIgnoreCase)) + { + var remote = await gitClient.GetHttpRemote(repo, "origin"); + await gitClient.Fetch(repo, remote.Name); + await gitClient.Checkout(repo, localBranchName); + } + else + { + var refSpec = $"{pullRequest.HeadRefName}:{localBranchName}"; + var remoteName = await CreateRemote(repo, repository.CloneUrl.WithOwner(pullRequest.HeadRepositoryOwner)); + + await gitClient.Fetch(repo, remoteName); + await gitClient.Fetch(repo, remoteName, new[] { refSpec }); + await gitClient.Checkout(repo, localBranchName); + await gitClient.SetTrackingBranch(repo, localBranchName, $"refs/remotes/{remoteName}/{pullRequest.HeadRefName}"); + } + + // Store the PR number in the branch config with the key "ghfvs-pr". var prConfigKey = $"branch.{localBranchName}.{SettingGHfVSPullRequest}"; - var remoteName = await CreateRemote(repo, pullRequest.Head.RepositoryCloneUrl); + await gitClient.SetConfig(repo, prConfigKey, BuildGHfVSConfigKeyValue(pullRequest.BaseRepositoryOwner, pullRequest.Number)); - await gitClient.Fetch(repo, remoteName); - await gitClient.Fetch(repo, remoteName, new[] { refSpec }); - await gitClient.Checkout(repo, localBranchName); - await gitClient.SetTrackingBranch(repo, localBranchName, $"refs/remotes/{remoteName}/{pullRequest.Head.Ref}"); - await gitClient.SetConfig(repo, prConfigKey, pullRequest.Number.ToString()); + return Observable.Return(Unit.Default); } - - return Observable.Return(Unit.Default); }); } - public IObservable GetDefaultLocalBranchName(ILocalRepositoryModel repository, int pullRequestNumber, string pullRequestTitle) + public async Task FetchCommit(LocalRepositoryModel localRepository, RepositoryModel remoteRepository, string sha) + { + using (var repo = gitService.GetRepository(localRepository.LocalPath)) + { + if (!await gitClient.CommitExists(repo, sha).ConfigureAwait(false)) + { + var remote = await CreateRemote(repo, remoteRepository.CloneUrl).ConfigureAwait(false); + await gitClient.Fetch(repo, remote).ConfigureAwait(false); + return await gitClient.CommitExists(repo, sha).ConfigureAwait(false); + } + + return true; + } + } + + public IObservable GetDefaultLocalBranchName(LocalRepositoryModel repository, int pullRequestNumber, string pullRequestTitle) { return Observable.Defer(() => { var initial = "pr/" + pullRequestNumber + "-" + GetSafeBranchName(pullRequestTitle); var current = initial; - var repo = gitService.GetRepository(repository.LocalPath); - var index = 2; - - while (repo.Branches[current] != null) + using (var repo = gitService.GetRepository(repository.LocalPath)) { - current = initial + '-' + index++; + var index = 2; + + while (repo.Branches[current] != null) + { + current = initial + '-' + index++; + } } return Observable.Return(current.TrimEnd('-')); }); } - public IObservable CalculateHistoryDivergence(ILocalRepositoryModel repository, int pullRequestNumber) + public IObservable CalculateHistoryDivergence(LocalRepositoryModel repository, int pullRequestNumber) { return Observable.Defer(async () => { - var repo = gitService.GetRepository(repository.LocalPath); - if (repo.Head.Remote != null) - await gitClient.Fetch(repo, repo.Head.Remote.Name); - return Observable.Return(repo.Head.TrackingDetails); + // BranchTrackingDetails doesn't keep a reference to Repository + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + var remoteName = repo.Head.RemoteName; + if (remoteName != null) + { + var remote = await gitClient.GetHttpRemote(repo, remoteName); + await gitClient.Fetch(repo, remote.Name); + } + + return Observable.Return(repo.Head.TrackingDetails); + } }); } - public IObservable GetTreeChanges(ILocalRepositoryModel repository, IPullRequestModel pullRequest) + public async Task GetMergeBase(LocalRepositoryModel repository, PullRequestDetailModel pullRequest) + { + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + return await gitClient.GetPullRequestMergeBase( + repo, + repository.CloneUrl.WithOwner(pullRequest.BaseRepositoryOwner), + pullRequest.BaseRefSha, + pullRequest.HeadRefSha, + pullRequest.BaseRefName, + pullRequest.Number); + } + } + + public IObservable GetTreeChanges(LocalRepositoryModel repository, PullRequestDetailModel pullRequest) { return Observable.Defer(async () => { - var repo = gitService.GetRepository(repository.LocalPath); - var remote = await gitClient.GetHttpRemote(repo, "origin"); - await gitClient.Fetch(repo, remote.Name); - var changes = await gitClient.Compare(repo, pullRequest.Base.Sha, pullRequest.Head.Sha, detectRenames: true); - return Observable.Return(changes); + // TreeChanges doesn't keep a reference to Repository + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + var remote = await gitClient.GetHttpRemote(repo, "origin"); + await gitClient.Fetch(repo, remote.Name); + var changes = await gitService.Compare(repo, pullRequest.BaseRefSha, pullRequest.HeadRefSha, detectRenames: true); + return Observable.Return(changes); + } }); } - public IObservable GetLocalBranches(ILocalRepositoryModel repository, IPullRequestModel pullRequest) + public IObservable GetLocalBranches(LocalRepositoryModel repository, PullRequestDetailModel pullRequest) { return Observable.Defer(() => { - var repo = gitService.GetRepository(repository.LocalPath); - var result = GetLocalBranchesInternal(repository, repo, pullRequest).Select(x => new BranchModel(x, repository)); - return result.ToObservable(); + // BranchModel doesn't keep a reference to rep + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + var result = GetLocalBranchesInternal(repository, repo, pullRequest).Select(x => new BranchModel(x, repository)); + return result.ToList().ToObservable(); + } }); } - public bool IsPullRequestFromFork(ILocalRepositoryModel repository, IPullRequestModel pullRequest) + public IObservable EnsureLocalBranchesAreMarkedAsPullRequests(LocalRepositoryModel repository, PullRequestDetailModel pullRequest) { - if (pullRequest.Head?.Label != null && pullRequest.Base?.Label != null) + return Observable.Defer(async () => { - var headOwner = pullRequest.Head.Label.Split(':')[0]; - var baseOwner = pullRequest.Base.Label.Split(':')[0]; - return headOwner != baseOwner; - } + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + var branches = GetLocalBranchesInternal(repository, repo, pullRequest).Select(x => new BranchModel(x, repository)); + var result = false; - return false; + foreach (var branch in branches) + { + if (!await IsBranchMarkedAsPullRequest(repo, branch.Name, pullRequest)) + { + await MarkBranchAsPullRequest(repo, branch.Name, pullRequest.BaseRepositoryOwner, pullRequest.Number); + result = true; + } + } + + return Observable.Return(result); + } + }); + } + + public bool IsPullRequestFromRepository(LocalRepositoryModel repository, PullRequestDetailModel pullRequest) + { + return string.Equals(repository.CloneUrl?.Owner, pullRequest.HeadRepositoryOwner, StringComparison.OrdinalIgnoreCase); } - public IObservable SwitchToBranch(ILocalRepositoryModel repository, IPullRequestModel pullRequest) + public IObservable SwitchToBranch(LocalRepositoryModel repository, PullRequestDetailModel pullRequest) { return Observable.Defer(async () => { - var repo = gitService.GetRepository(repository.LocalPath); - var branchName = GetLocalBranchesInternal(repository, repo, pullRequest).FirstOrDefault(); - - Debug.Assert(branchName != null, "PullRequestService.SwitchToBranch called but no local branch found."); - - if (branchName != null) + using (var repo = gitService.GetRepository(repository.LocalPath)) { - var remote = await gitClient.GetHttpRemote(repo, "origin"); - await gitClient.Fetch(repo, remote.Name); + var branchName = GetLocalBranchesInternal(repository, repo, pullRequest).FirstOrDefault(); - var branch = repo.Branches[branchName]; + Log.Assert(branchName != null, "PullRequestService.SwitchToBranch called but no local branch found"); - if (branch == null) + if (branchName != null) { - var trackedBranchName = $"refs/remotes/{remote.Name}/" + branchName; - var trackedBranch = repo.Branches[trackedBranchName]; + var remote = await gitClient.GetHttpRemote(repo, "origin"); + await gitClient.Fetch(repo, remote.Name); - if (trackedBranch != null) - { - branch = repo.CreateBranch(branchName, trackedBranch.Tip); - await gitClient.SetTrackingBranch(repo, branchName, trackedBranchName); - } - else + var branch = repo.Branches[branchName]; + + if (branch == null) { - throw new InvalidOperationException($"Could not find branch '{trackedBranchName}'."); + var trackedBranchName = $"refs/remotes/{remote.Name}/" + branchName; + var trackedBranch = repo.Branches[trackedBranchName]; + + if (trackedBranch != null) + { + branch = repo.CreateBranch(branchName, trackedBranch.Tip); + await gitClient.SetTrackingBranch(repo, branchName, trackedBranchName); + } + else + { + throw new InvalidOperationException($"Could not find branch '{trackedBranchName}'."); + } } - } - await gitClient.Checkout(repo, branchName); + await gitClient.Checkout(repo, branchName); + await MarkBranchAsPullRequest(repo, branchName, pullRequest.BaseRepositoryOwner, pullRequest.Number); + } } return Observable.Return(Unit.Default); }); } - public IObservable UnmarkLocalBranch(ILocalRepositoryModel repository) + public IObservable<(string owner, int number)> GetPullRequestForCurrentBranch(LocalRepositoryModel repository) { return Observable.Defer(async () => { - var repo = gitService.GetRepository(repository.LocalPath); - var configKey = $"branch.{repo.Head.FriendlyName}.ghfvs-pr"; - await gitClient.UnsetConfig(repo, configKey); - return Observable.Return(Unit.Default); + using (var repo = gitService.GetRepository(repository.LocalPath)) + { + var configKey = string.Format( + CultureInfo.InvariantCulture, + "branch.{0}.{1}", + repo.Head.FriendlyName, + SettingGHfVSPullRequest); + var value = await gitClient.GetConfig(repo, configKey); + return Observable.Return(ParseGHfVSConfigKeyValue(value)); + } }); } - public IObservable ExtractFile( - ILocalRepositoryModel repository, - IModelService modelService, + public async Task ExtractToTempFile( + LocalRepositoryModel repository, + PullRequestDetailModel pullRequest, + string relativePath, string commitSha, - string fileName, - string fileSha) + Encoding encoding) { - return Observable.Defer(async () => - { - var repo = gitService.GetRepository(repository.LocalPath); - var remote = await gitClient.GetHttpRemote(repo, "origin"); - await gitClient.Fetch(repo, remote.Name); - var result = await GetFileFromRepositoryOrApi(repository, repo, modelService, commitSha, fileName, fileSha); + var tempFilePath = CalculateTempFileName(relativePath, commitSha, encoding); + var gitPath = Paths.ToGitPath(relativePath); - if (result == null) + if (!File.Exists(tempFilePath)) + { + using (var repo = gitService.GetRepository(repository.LocalPath)) { - throw new FileNotFoundException($"Could not retrieve {fileName}@{commitSha}"); + var remote = await gitClient.GetHttpRemote(repo, "origin"); + await ExtractToTempFile(repo, pullRequest.Number, commitSha, gitPath, encoding, tempFilePath); } + } - return Observable.Return(result); - }); + lock (tempFileMappings) + { + tempFileMappings[CanonicalizeLocalFilePath(tempFilePath)] = (commitSha, gitPath); + } + + return tempFilePath; } - public IObservable> ExtractDiffFiles( - ILocalRepositoryModel repository, - IModelService modelService, - IPullRequestModel pullRequest, - string fileName, - string fileSha) + public Encoding GetEncoding(LocalRepositoryModel repository, string relativePath) { - return Observable.Defer(async () => - { - var repo = gitService.GetRepository(repository.LocalPath); - var remote = await gitClient.GetHttpRemote(repo, "origin"); - await gitClient.Fetch(repo, remote.Name); + var fullPath = Path.Combine(repository.LocalPath, relativePath); - // The left file is the target of the PR so this should already be fetched. - var left = await gitClient.ExtractFile(repo, pullRequest.Base.Sha, fileName); + if (File.Exists(fullPath)) + { + var encoding = Encoding.UTF8; + if (HasPreamble(fullPath, encoding)) + { + return encoding; + } + } - // The right file - if it comes from a fork - may not be fetched so fall back to - // getting the file contents from the model service. - var right = await GetFileFromRepositoryOrApi(repository, repo, modelService, pullRequest.Head.Sha, fileName, fileSha); + return null; + } - if (left == null) + static bool HasPreamble(string file, Encoding encoding) + { + using (var stream = File.OpenRead(file)) + { + foreach (var b in encoding.GetPreamble()) { - throw new FileNotFoundException($"Could not retrieve {fileName}@{pullRequest.Base.Sha}"); + if (b != stream.ReadByte()) + { + return false; + } } + } - if (right == null) + return true; + } + + public IObservable RemoveUnusedRemotes(LocalRepositoryModel repository) + { + return Observable.Defer(async () => + { + using (var repo = gitService.GetRepository(repository.LocalPath)) { - throw new FileNotFoundException($"Could not retrieve {fileName}@{pullRequest.Head.Sha}"); - } + var usedRemotes = new HashSet( + repo.Branches + .Where(x => !x.IsRemote && x.RemoteName != null) + .Select(x => x.RemoteName)); - return Observable.Return(Tuple.Create(left, right)); + foreach (var remote in repo.Network.Remotes) + { + var key = $"remote.{remote.Name}.{SettingCreatedByGHfVS}"; + var createdByUs = await gitClient.GetConfig(repo, key); + + if (createdByUs && !usedRemotes.Contains(remote.Name)) + { + repo.Network.Remotes.Remove(remote.Name); + } + } + + return Observable.Return(Unit.Default); + } }); } - async Task GetFileFromRepositoryOrApi( - ILocalRepositoryModel repository, - IRepository repo, - IModelService modelService, - string commitSha, - string fileName, - string fileSha) + /// + public bool ConfirmCancelPendingReview() { - return await gitClient.ExtractFile(repo, commitSha, fileName) ?? - await modelService.GetFileContents(repository, commitSha, fileName, fileSha); + return MessageBox.Show( + Resources.CancelPendingReviewConfirmation, + Resources.CancelPendingReviewConfirmationCaption, + MessageBoxButtons.YesNo, + MessageBoxIcon.Question) == DialogResult.Yes; } - public IObservable RemoveUnusedRemotes(ILocalRepositoryModel repository) + /// + public Task GetObjectishFromLocalPathAsync(string localPath, CancellationToken cancellationToken) { - return Observable.Defer(async () => + lock (this.tempFileMappings) { - var repo = gitService.GetRepository(repository.LocalPath); - var usedRemotes = new HashSet( - repo.Branches - .Where(x => !x.IsRemote && x.Remote != null) - .Select(x => x.Remote?.Name)); - - foreach (var remote in repo.Network.Remotes) + var canonicalizedPath = CanonicalizeLocalFilePath(localPath); + if (this.tempFileMappings.TryGetValue(canonicalizedPath, out (string commitId, string repoPath) result)) { - var key = $"remote.{remote.Name}.{SettingCreatedByGHfVS}"; - var createdByUs = await gitClient.GetConfig(repo, key); - - if (createdByUs && !usedRemotes.Contains(remote.Name)) - { - repo.Network.Remotes.Remove(remote.Name); - } + return Task.FromResult($"{result.commitId}:{result.repoPath}"); } + } - return Observable.Return(Unit.Default); - }); + return Task.FromResult(null); + } + + /// + public Task GetLocalPathFromObjectishAsync(string objectish, CancellationToken cancellationToken) + { + throw new NotImplementedException(); } + async Task CreateRemote(IRepository repo, UriString cloneUri) { foreach (var remote in repo.Network.Remotes) { - if (remote.Url == cloneUri) + if (UriString.RepositoryUrlsAreEqual(new UriString(remote.Url), cloneUri)) { return remote.Name; } @@ -382,51 +918,107 @@ string CreateUniqueRemoteName(IRepository repo, string name) return uniqueName; } + async Task ExtractToTempFile( + IRepository repo, + int pullRequestNumber, + string commitSha, + string relativePath, + Encoding encoding, + string tempFilePath) + { + string contents; + + try + { + contents = await gitClient.ExtractFile(repo, commitSha, relativePath) ?? string.Empty; + } + catch (FileNotFoundException) + { + var pullHeadRef = $"refs/pull/{pullRequestNumber}/head"; + var remote = await gitClient.GetHttpRemote(repo, "origin"); + await gitClient.Fetch(repo, remote.Name, commitSha, pullHeadRef); + contents = await gitClient.ExtractFile(repo, commitSha, relativePath) ?? string.Empty; + } + + Directory.CreateDirectory(Path.GetDirectoryName(tempFilePath)); + + if (encoding != null) + { + File.WriteAllText(tempFilePath, contents, encoding); + } + else + { + File.WriteAllText(tempFilePath, contents); + } + } + IEnumerable GetLocalBranchesInternal( - ILocalRepositoryModel localRepository, + LocalRepositoryModel localRepository, IRepository repository, - IPullRequestModel pullRequest) + PullRequestDetailModel pullRequest) { - if (!IsPullRequestFromFork(localRepository, pullRequest)) + if (IsPullRequestFromRepository(localRepository, pullRequest)) { - return new[] { pullRequest.Head.Ref }; + return new[] { pullRequest.HeadRefName }; } else { - var pr = pullRequest.Number.ToString(CultureInfo.InvariantCulture); + var key = BuildGHfVSConfigKeyValue(pullRequest.BaseRepositoryOwner, pullRequest.Number); + return repository.Config .Select(x => new { Branch = BranchCapture.Match(x.Key).Groups["branch"].Value, Value = x.Value }) - .Where(x => !string.IsNullOrWhiteSpace(x.Branch) && x.Value == pr) + .Where(x => !string.IsNullOrWhiteSpace(x.Branch) && x.Value == key) .Select(x => x.Branch); } } - async Task PushAndCreatePR(IRepositoryHost host, - ILocalRepositoryModel sourceRepository, IRepositoryModel targetRepository, - IBranch sourceBranch, IBranch targetBranch, + async Task IsBranchMarkedAsPullRequest(IRepository repo, string branchName, PullRequestDetailModel pullRequest) + { + var prConfigKey = $"branch.{branchName}.{SettingGHfVSPullRequest}"; + var value = ParseGHfVSConfigKeyValue(await gitClient.GetConfig(repo, prConfigKey)); + return value != default && + value.Item1 == pullRequest.BaseRepositoryOwner && + value.Item2 == pullRequest.Number; + } + + async Task MarkBranchAsPullRequest(IRepository repo, string branchName, string owner, int number) + { + // Store the PR number in the branch config with the key "ghfvs-pr". + var prConfigKey = $"branch.{branchName}.{SettingGHfVSPullRequest}"; + await gitClient.SetConfig(repo, prConfigKey, BuildGHfVSConfigKeyValue(owner, number)); + } + + async Task PushAndCreatePR(IModelService modelService, + LocalRepositoryModel sourceRepository, RepositoryModel targetRepository, + BranchModel sourceBranch, BranchModel targetBranch, string title, string body) { - var repo = await Task.Run(() => gitService.GetRepository(sourceRepository.LocalPath)); - var remote = await gitClient.GetHttpRemote(repo, "origin"); - await gitClient.Push(repo, sourceBranch.Name, remote.Name); + // PullRequestModel doesn't keep a reference to repo + using (var repo = await Task.Run(() => gitService.GetRepository(sourceRepository.LocalPath))) + { + var remote = await gitClient.GetHttpRemote(repo, "origin"); + await gitClient.Push(repo, sourceBranch.Name, remote.Name); - if (!repo.Branches[sourceBranch.Name].IsTracking) - await gitClient.SetTrackingBranch(repo, sourceBranch.Name, remote.Name); + if (!repo.Branches[sourceBranch.Name].IsTracking) + await gitClient.SetTrackingBranch(repo, sourceBranch.Name, remote.Name); - // delay things a bit to avoid a race between pushing a new branch and creating a PR on it - if (!Splat.ModeDetector.Current.InUnitTestRunner().GetValueOrDefault()) - await Task.Delay(TimeSpan.FromSeconds(5)); + // delay things a bit to avoid a race between pushing a new branch and creating a PR on it + if (!Splat.ModeDetector.InUnitTestRunner()) + await Task.Delay(TimeSpan.FromSeconds(5)); - var ret = await host.ModelService.CreatePullRequest(sourceRepository, targetRepository, sourceBranch, targetBranch, title, body); - await usageTracker.IncrementUpstreamPullRequestCount(); - return ret; + var ret = await modelService.CreatePullRequest(sourceRepository, targetRepository, sourceBranch, targetBranch, title, body); + await MarkBranchAsPullRequest(repo, sourceBranch.Name, targetRepository.CloneUrl.Owner, ret.Number); + gitExt.RefreshActiveRepositories(); + await usageTracker.IncrementCounter(x => x.NumberOfUpstreamPullRequests); + return ret; + } } static string GetSafeBranchName(string name) { var before = InvalidBranchCharsRegex.Replace(name, "-").TrimEnd('-'); - for (;;) + for (; ; ) { string after = before.Replace("--", "-"); @@ -438,5 +1030,85 @@ static string GetSafeBranchName(string name) before = after; } } + + static string CalculateTempFileName(string relativePath, string commitSha, Encoding encoding) + { + // The combination of relative path, commit SHA and encoding should be sufficient to uniquely identify a file. + var relativeDir = Path.GetDirectoryName(relativePath) ?? string.Empty; + var key = relativeDir + '|' + (encoding?.WebName ?? "unknown"); + var relativePathHash = key.GetSha256Hash(); + var tempDir = Path.Combine(Path.GetTempPath(), "GitHubVisualStudio", "FileContents", relativePathHash); + var tempFileName = Invariant($"{Path.GetFileNameWithoutExtension(relativePath)}@{commitSha}{Path.GetExtension(relativePath)}"); + return Path.Combine(tempDir, tempFileName); + } + + static string BuildGHfVSConfigKeyValue(string owner, int number) + { + return owner + '#' + number.ToString(CultureInfo.InvariantCulture); + } + + static (string owner, int number) ParseGHfVSConfigKeyValue(string value) + { + if (value != null) + { + var separator = value.IndexOf('#'); + + if (separator != -1) + { + var owner = value.Substring(0, separator); + int number; + + if (int.TryParse(value.Substring(separator + 1), NumberStyles.None, CultureInfo.InvariantCulture, out number)) + { + return (owner, number); + } + } + } + + return default; + } + + static string CanonicalizeLocalFilePath(string localPath) + { + localPath = localPath.Replace("\\\\", "\\"); + return Path.GetFullPath(localPath); + } + + class ListItemAdapter : PullRequestListItemModel + { + public IList Reviews { get; set; } + + public LastCommitSummaryAdapter LastCommit { get; set; } + } + + class ReviewAdapter + { + public string Body { get; set; } + public int CommentCount { get; set; } + public int Count => CommentCount + (!string.IsNullOrWhiteSpace(Body) ? 1 : 0); + } + + class LastCommitSummaryAdapter + { + public List CheckSuites { get; set; } + + public List Statuses { get; set; } + } + + class CheckSuiteSummaryModel + { + public List CheckRuns { get; set; } + } + + class CheckRunSummaryModel + { + public CheckConclusionState? Conclusion { get; set; } + public CheckStatusState Status { get; set; } + } + + class StatusSummaryModel + { + public StatusState State { get; set; } + } } -} \ No newline at end of file +} diff --git a/src/GitHub.App/Services/RepositoryCloneService.cs b/src/GitHub.App/Services/RepositoryCloneService.cs index 7b8d30d37e..86177cda7d 100644 --- a/src/GitHub.App/Services/RepositoryCloneService.cs +++ b/src/GitHub.App/Services/RepositoryCloneService.cs @@ -1,13 +1,22 @@ using System; +using System.Collections.Generic; using System.ComponentModel.Composition; using System.IO; -using System.Reactive; +using System.Linq; using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Api; using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; using Microsoft.VisualStudio.Shell; -using NLog; +using Microsoft.VisualStudio.Threading; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; using Rothko; -using GitHub.Helpers; +using Serilog; using Task = System.Threading.Tasks.Task; namespace GitHub.Services @@ -21,57 +30,219 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.NonShared)] public class RepositoryCloneService : IRepositoryCloneService { - static readonly Logger log = LogManager.GetCurrentClassLogger(); + static readonly ILogger log = LogManager.ForContext(); readonly IOperatingSystem operatingSystem; readonly string defaultClonePath; readonly IVSGitServices vsGitServices; + readonly ITeamExplorerServices teamExplorerServices; + readonly IGraphQLClientFactory graphqlFactory; + readonly IGitHubContextService gitHubContextService; readonly IUsageTracker usageTracker; + readonly Lazy dte; + ICompiledQuery readViewerRepositories; [ImportingConstructor] public RepositoryCloneService( IOperatingSystem operatingSystem, IVSGitServices vsGitServices, - IUsageTracker usageTracker) + ITeamExplorerServices teamExplorerServices, + IGraphQLClientFactory graphqlFactory, + IGitHubContextService gitHubContextService, + IUsageTracker usageTracker, + IGitHubServiceProvider sp, + [Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) { this.operatingSystem = operatingSystem; this.vsGitServices = vsGitServices; + this.teamExplorerServices = teamExplorerServices; + this.graphqlFactory = graphqlFactory; + this.gitHubContextService = gitHubContextService; this.usageTracker = usageTracker; + dte = new Lazy(() => sp.GetService()); + JoinableTaskContext = joinableTaskContext ?? ThreadHelper.JoinableTaskContext; defaultClonePath = GetLocalClonePathFromGitProvider(operatingSystem.Environment.GetUserRepositoriesPath()); } + /// + public async Task ReadViewerRepositories(HostAddress address, bool refresh = false) + { + if (readViewerRepositories == null) + { + var order = new RepositoryOrder + { + Field = RepositoryOrderField.PushedAt, + Direction = OrderDirection.Desc + }; + + var repositorySelection = new Fragment( + "repository", + repo => new RepositoryListItemModel + { + IsFork = repo.IsFork, + IsPrivate = repo.IsPrivate, + Name = repo.Name, + Owner = repo.Owner.Login, + Url = new Uri(repo.Url), + }); + + readViewerRepositories = new Query() + .Viewer + .Select(viewer => new ViewerRepositoriesModel + { + Owner = viewer.Login, + Repositories = viewer.Repositories(null, null, null, null, null, null, null, order, null, null) + .AllPages() + .Select(repositorySelection).ToList(), + ContributedToRepositories = viewer.RepositoriesContributedTo(100, null, null, null, null, null, null, order, null) + .Nodes + .Select(repositorySelection).ToList(), + Organizations = viewer.Organizations(null, null, null, null).AllPages().Select(org => new + { + org.Login, + Repositories = org.Repositories(100, null, null, null, null, null, null, order, null, null) + .Nodes + .Select(repositorySelection).ToList() + }).ToDictionary(x => x.Login, x => (IReadOnlyList)x.Repositories), + }).Compile(); + } + + var graphql = await graphqlFactory.CreateConnection(address).ConfigureAwait(false); + var result = await graphql.Run(readViewerRepositories, cacheDuration: TimeSpan.FromHours(1), refresh: refresh).ConfigureAwait(false); + return result; + } + + /// + public async Task CloneOrOpenRepository( + CloneDialogResult cloneDialogResult, + object progress = null, + CancellationToken? cancellationToken = null) + { + Guard.ArgumentNotNull(cloneDialogResult, nameof(cloneDialogResult)); + + var repositoryPath = cloneDialogResult.Path; + var url = cloneDialogResult.Url; + + if (DestinationFileExists(repositoryPath)) + { + throw new InvalidOperationException("Can't clone or open a repository because a file exists at: " + repositoryPath); + } + + var repositoryUrl = url.ToRepositoryUrl(); + var isDotCom = HostAddress.IsGitHubDotComUri(repositoryUrl); + if (DestinationDirectoryExists(repositoryPath) && !DestinationDirectoryEmpty(repositoryPath)) + { + if (!IsSolutionInRepository(repositoryPath)) + { + teamExplorerServices.OpenRepository(repositoryPath); + } + + if (isDotCom) + { + await usageTracker.IncrementCounter(x => x.NumberOfGitHubOpens); + } + else + { + await usageTracker.IncrementCounter(x => x.NumberOfEnterpriseOpens); + } + } + else + { + var cloneUrl = repositoryUrl.ToString(); + await CloneRepository(cloneUrl, repositoryPath, progress, cancellationToken).ConfigureAwait(true); + + if (isDotCom) + { + await usageTracker.IncrementCounter(x => x.NumberOfGitHubClones); + } + else + { + await usageTracker.IncrementCounter(x => x.NumberOfEnterpriseClones); + } + } + + // Give user a chance to choose a solution + teamExplorerServices.ShowHomePage(); + + // Navigate to context for supported URL types (e.g. /blob/ URLs) + var context = gitHubContextService.FindContextFromUrl(url); + if (context != null) + { + gitHubContextService.TryNavigateToContext(repositoryPath, context); + } + } + + bool IsSolutionInRepository(string repositoryPath) + { + var solutionPath = dte.Value.Solution.FileName; + if (string.IsNullOrEmpty(solutionPath)) + { + return false; + } + + var isFolder = operatingSystem.Directory.DirectoryExists(solutionPath); + var solutionDir = isFolder ? solutionPath : Path.GetDirectoryName(solutionPath); + if (string.Equals(repositoryPath, solutionDir, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (solutionDir.StartsWith(repositoryPath + '\\', StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + /// public async Task CloneRepository( string cloneUrl, - string repositoryName, string repositoryPath, - object progress = null) + object progress = null, + CancellationToken? cancellationToken = null) { Guard.ArgumentNotEmptyString(cloneUrl, nameof(cloneUrl)); - Guard.ArgumentNotEmptyString(repositoryName, nameof(repositoryName)); Guard.ArgumentNotEmptyString(repositoryPath, nameof(repositoryPath)); - string path = Path.Combine(repositoryPath, repositoryName); - // Switch to a thread pool thread for IO then back to the main thread to call // vsGitServices.Clone() as this must be called on the main thread. - await ThreadingHelper.SwitchToPoolThreadAsync(); - operatingSystem.Directory.CreateDirectory(path); - await ThreadingHelper.SwitchToMainThreadAsync(); + if (!DestinationDirectoryExists(repositoryPath)) + { + await TaskScheduler.Default; + operatingSystem.Directory.CreateDirectory(repositoryPath); + await JoinableTaskContext.Factory.SwitchToMainThreadAsync(); + } try { - await vsGitServices.Clone(cloneUrl, path, true, progress); - await usageTracker.IncrementCloneCount(); + await vsGitServices.Clone(cloneUrl, repositoryPath, true, progress, cancellationToken); + await usageTracker.IncrementCounter(x => x.NumberOfClones); + + if (repositoryPath.StartsWith(DefaultClonePath, StringComparison.OrdinalIgnoreCase)) + { + // Count the number of times users clone into the Default Repository Location + await usageTracker.IncrementCounter(x => x.NumberOfClonesToDefaultClonePath); + } } catch (Exception ex) { - log.Error("Could not clone {0} to {1}. {2}", cloneUrl, path, ex); + log.Error(ex, "Could not clone {CloneUrl} to {Path}", cloneUrl, repositoryPath); + operatingSystem.Directory.DeleteDirectory(repositoryPath); throw; } } + /// + public bool DestinationDirectoryExists(string path) => operatingSystem.Directory.DirectoryExists(path); + + /// + public bool DestinationDirectoryEmpty(string path) => operatingSystem.Directory.GetDirectory(path).IsEmpty; + + /// + public bool DestinationFileExists(string path) => operatingSystem.File.Exists(path); + string GetLocalClonePathFromGitProvider(string fallbackPath) { var ret = vsGitServices.GetLocalClonePathFromGitProvider(); @@ -81,5 +252,12 @@ string GetLocalClonePathFromGitProvider(string fallbackPath) } public string DefaultClonePath { get { return defaultClonePath; } } + + JoinableTaskContext JoinableTaskContext { get; } + + class OrganizationAdapter + { + public IReadOnlyList Repositories { get; set; } + } } } diff --git a/src/GitHub.App/Services/RepositoryCreationService.cs b/src/GitHub.App/Services/RepositoryCreationService.cs index 64dba3729f..7c41ba4d81 100644 --- a/src/GitHub.App/Services/RepositoryCreationService.cs +++ b/src/GitHub.App/Services/RepositoryCreationService.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.ComponentModel.Composition; using System.Reactive; using System.Reactive.Linq; @@ -36,7 +37,7 @@ public IObservable CreateRepository( Guard.ArgumentNotEmptyString(directory, nameof(directory)); return apiClient.CreateRepository(newRepository, account.Login, account.IsUser) - .Select(repository => cloneService.CloneRepository(repository.CloneUrl, repository.Name, directory)) + .Select(repository => cloneService.CloneRepository(repository.CloneUrl, Path.Combine(directory, repository.Name))) .SelectUnit(); } } diff --git a/src/GitHub.App/Services/RepositoryForkService.cs b/src/GitHub.App/Services/RepositoryForkService.cs new file mode 100644 index 0000000000..b6f1a00ab5 --- /dev/null +++ b/src/GitHub.App/Services/RepositoryForkService.cs @@ -0,0 +1,126 @@ +using System; +using System.Linq; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.ViewModels.Dialog; +using LibGit2Sharp; +using Octokit; +using ReactiveUI; +using Serilog; +using Repository = Octokit.Repository; +using System.Reactive; + +namespace GitHub.Services +{ + [Export(typeof(IRepositoryForkService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class RepositoryForkService : IRepositoryForkService + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IGitClient gitClient; + readonly IVSGitServices vsGitServices; + readonly IVSGitExt vsGitExt; + readonly IUsageTracker usageTracker; + + [ImportingConstructor] + public RepositoryForkService(IGitClient gitClient, IVSGitServices vsGitServices, IVSGitExt vsGitExt, IUsageTracker usageTracker) + { + this.gitClient = gitClient; + this.vsGitServices = vsGitServices; + this.vsGitExt = vsGitExt; + this.usageTracker = usageTracker; + } + + public IObservable ForkRepository(IApiClient apiClient, RepositoryModel sourceRepository, NewRepositoryFork repositoryFork, bool updateOrigin, bool addUpstream, bool trackMasterUpstream) + { + log.Verbose("ForkRepository Source:{SourceOwner}/{SourceName} To:{DestinationOwner}", sourceRepository.Owner, sourceRepository.Name, repositoryFork.Organization ?? "[Current User]"); + log.Verbose("ForkRepository updateOrigin:{UpdateOrigin} addUpstream:{AddUpstream} trackMasterUpstream:{TrackMasterUpstream}", updateOrigin, addUpstream, trackMasterUpstream); + + usageTracker.IncrementCounter(model => model.NumberOfReposForked).Forget(); + + return Observable.Defer(() => apiClient.ForkRepository(sourceRepository.Owner, sourceRepository.Name, repositoryFork) + .ObserveOn(RxApp.MainThreadScheduler) + .Select(remoteRepo => new { RemoteRepo = remoteRepo, ActiveRepo = updateOrigin ? vsGitServices.GetActiveRepo() : null })) + .SelectMany(async repo => + { + if (repo.ActiveRepo != null) + { + using (repo.ActiveRepo) + { + var originUri = repo.RemoteRepo != null ? new Uri(repo.RemoteRepo.CloneUrl) : null; + var upstreamUri = addUpstream ? sourceRepository.CloneUrl.ToUri() : null; + + await SwitchRemotes(repo.ActiveRepo, originUri, upstreamUri, trackMasterUpstream); + } + } + + return repo.RemoteRepo; + }); + } + + public IObservable SwitchRemotes(RepositoryModel destinationRepository, bool updateOrigin, bool addUpstream, bool trackMasterUpstream) + { + return Observable.Defer(() => Observable.Return(new object()) + .ObserveOn(RxApp.MainThreadScheduler) + .Select(_ => vsGitServices.GetActiveRepo())) + .SelectMany(async activeRepo => + { + using (activeRepo) + { + Uri currentOrigin = null; + + if (addUpstream) + { + var remote = await gitClient.GetHttpRemote(activeRepo, "origin"); + currentOrigin = new Uri(remote.Url); + } + + await SwitchRemotes(activeRepo, updateOrigin ? destinationRepository.CloneUrl.ToUri() : null, + currentOrigin, trackMasterUpstream); + } + + if (updateOrigin) + { + vsGitExt.RefreshActiveRepositories(); + + var updatedRepository = vsGitExt.ActiveRepositories.FirstOrDefault(); + log.Assert(updatedRepository?.CloneUrl == destinationRepository.CloneUrl, + "CloneUrl is {UpdatedRepository} not {DestinationRepository}", updatedRepository?.CloneUrl ?? "[NULL]", destinationRepository.CloneUrl); + } + + return Unit.Default; + }); + } + + private async Task SwitchRemotes(IRepository repository, Uri originUri, Uri upstreamUri = null, bool trackMasterUpstream = false) + { + Guard.ArgumentNotNull(originUri, nameof(originUri)); + + log.Verbose("Set remote origin to {OriginUri}", originUri); + + await gitClient.SetRemote(repository, "origin", originUri); + + if (upstreamUri != null) + { + log.Verbose("Set remote upstream to {UpstreamUri}", upstreamUri); + + await gitClient.SetRemote(repository, "upstream", upstreamUri); + + await gitClient.Fetch(repository, "upstream"); + + if (trackMasterUpstream) + { + log.Verbose("set master tracking to upstream"); + + await gitClient.SetTrackingBranch(repository, "master", "upstream"); + } + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Services/RepositoryPublishService.cs b/src/GitHub.App/Services/RepositoryPublishService.cs index 2bddb565e7..48d4a21fc4 100644 --- a/src/GitHub.App/Services/RepositoryPublishService.cs +++ b/src/GitHub.App/Services/RepositoryPublishService.cs @@ -26,10 +26,12 @@ public string LocalRepositoryName { get { - var activeRepo = vsGitServices.GetActiveRepo(); - if (!string.IsNullOrEmpty(activeRepo?.Info?.WorkingDirectory)) - return new DirectoryInfo(activeRepo.Info.WorkingDirectory).Name ?? ""; - return string.Empty; + using (var activeRepo = vsGitServices.GetActiveRepo()) + { + if (!string.IsNullOrEmpty(activeRepo?.Info?.WorkingDirectory)) + return new DirectoryInfo(activeRepo.Info.WorkingDirectory).Name ?? ""; + return string.Empty; + } } } @@ -43,11 +45,14 @@ public string LocalRepositoryName .Select(remoteRepo => new { RemoteRepo = remoteRepo, LocalRepo = vsGitServices.GetActiveRepo() })) .SelectMany(async repo => { - await gitClient.SetRemote(repo.LocalRepo, "origin", new Uri(repo.RemoteRepo.CloneUrl)); - await gitClient.Push(repo.LocalRepo, "master", "origin"); - await gitClient.Fetch(repo.LocalRepo, "origin"); - await gitClient.SetTrackingBranch(repo.LocalRepo, "master", "origin"); - return repo.RemoteRepo; + using (repo.LocalRepo) + { + await gitClient.SetRemote(repo.LocalRepo, "origin", new Uri(repo.RemoteRepo.CloneUrl)); + await gitClient.Push(repo.LocalRepo, "master", "origin"); + await gitClient.Fetch(repo.LocalRepo, "origin"); + await gitClient.SetTrackingBranch(repo.LocalRepo, "master", "origin"); + return repo.RemoteRepo; + } }); } } diff --git a/src/GitHub.App/Services/RepositoryService.cs b/src/GitHub.App/Services/RepositoryService.cs new file mode 100644 index 0000000000..f713cabbb7 --- /dev/null +++ b/src/GitHub.App/Services/RepositoryService.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Primitives; +using Octokit.GraphQL; +using static Octokit.GraphQL.Variable; + +namespace GitHub.Services +{ + [Export(typeof(IRepositoryService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class RepositoryService : IRepositoryService + { + static ICompiledQuery> readParentOwnerLogin; + readonly IGraphQLClientFactory graphqlFactory; + + [ImportingConstructor] + public RepositoryService(IGraphQLClientFactory graphqlFactory) + { + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); + + this.graphqlFactory = graphqlFactory; + } + + public async Task<(string owner, string name)?> FindParent(HostAddress address, string owner, string name) + { + Guard.ArgumentNotNull(address, nameof(address)); + Guard.ArgumentNotEmptyString(owner, nameof(owner)); + Guard.ArgumentNotEmptyString(name, nameof(name)); + + if (readParentOwnerLogin == null) + { + readParentOwnerLogin = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .Select(r => r.Parent != null ? Tuple.Create(r.Parent.Owner.Login, r.Parent.Name) : null) + .Compile(); + } + + var vars = new Dictionary + { + { nameof(owner), owner }, + { nameof(name), name }, + }; + + var graphql = await graphqlFactory.CreateConnection(address); + var result = await graphql.Run(readParentOwnerLogin, vars); + return result != null ? (result.Item1, result.Item2) : ((string, string)?)null; + } + } +} diff --git a/src/GitHub.App/Services/SqliteMessageDraftStore.cs b/src/GitHub.App/Services/SqliteMessageDraftStore.cs new file mode 100644 index 0000000000..08463e0d3b --- /dev/null +++ b/src/GitHub.App/Services/SqliteMessageDraftStore.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using Newtonsoft.Json; +using Rothko; +using Serilog; +using SQLite; + +namespace GitHub.Services +{ + /// + /// Stores drafts of messages in an SQL database. + /// + [Export(typeof(IMessageDraftStore))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class SqliteMessageDraftStore : IMessageDraftStore + { + static readonly ILogger log = LogManager.ForContext(); + readonly IOperatingSystem os; + SQLiteAsyncConnection connection; + bool initialized; + + [ImportingConstructor] + public SqliteMessageDraftStore(IOperatingSystem os) + { + this.os = os; + } + + public async Task GetDraft(string key, string secondaryKey) where T : class + { + Guard.ArgumentNotEmptyString(key, nameof(key)); + Guard.ArgumentNotNull(secondaryKey, nameof(secondaryKey)); + + if (await Initialize().ConfigureAwait(false)) + { + try + { + var result = await connection.Table().Where( + x => x.Key == key && x.SecondaryKey == secondaryKey) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + if (result != null) + { + return JsonConvert.DeserializeObject(result.Data); + } + } + catch (Exception ex) + { + log.Error(ex, "Failed to load message draft into {Type}", typeof(T)); + } + } + + return null; + } + + public async Task> GetDrafts(string key) where T : class + { + Guard.ArgumentNotEmptyString(key, nameof(key)); + + if (await Initialize().ConfigureAwait(false)) + { + try + { + var result = await connection.Table().Where(x => x.Key == key) + .ToListAsync() + .ConfigureAwait(false); + + return result.Select(x => (x.SecondaryKey, JsonConvert.DeserializeObject(x.Data))); + } + catch (Exception ex) + { + log.Error(ex, "Failed to load message drafts into {Type}", typeof(T)); + } + } + + return null; + } + + public async Task UpdateDraft(string key, string secondaryKey, T data) where T : class + { + Guard.ArgumentNotEmptyString(key, nameof(key)); + Guard.ArgumentNotNull(secondaryKey, nameof(secondaryKey)); + + if (!await Initialize().ConfigureAwait(false)) + { + return; + } + + try + { + var row = new Draft + { + Key = key, + SecondaryKey = secondaryKey, + Data = JsonConvert.SerializeObject(data), + }; + + await connection.InsertOrReplaceAsync(row).ConfigureAwait(false); + } + catch (Exception ex) + { + log.Error(ex, "Failed to update message draft"); + } + } + + public async Task DeleteDraft(string key, string secondaryKey) + { + Guard.ArgumentNotEmptyString(key, nameof(key)); + Guard.ArgumentNotNull(secondaryKey, nameof(secondaryKey)); + + if (!await Initialize().ConfigureAwait(false)) + { + return; + } + + try + { + await connection.ExecuteAsync( + "DELETE FROM Drafts WHERE Key=? AND SecondaryKey=?", + key, + secondaryKey).ConfigureAwait(false); + } + catch (Exception ex) + { + log.Error(ex, "Failed to update message draft"); + } + } + + async Task Initialize() + { + if (!initialized) + { + var path = Path.Combine(os.Environment.GetApplicationDataPath(), "drafts.db"); + + try + { + connection = new SQLiteAsyncConnection(path); + + var draftsTable = await connection.GetTableInfoAsync("Drafts").ConfigureAwait(false); + + if (draftsTable.Count == 0) + { + await connection.ExecuteAsync(@" + CREATE TABLE `Drafts` ( + `Key` TEXT, + `SecondaryKey` TEXT, + `Data` TEXT, + UNIQUE(`Key`,`SecondaryKey`) + );").ConfigureAwait(false); + } + } + catch (Exception ex) + { + log.Error(ex, "Error opening drafts from {Path}.", path); + } + finally + { + initialized = true; + } + } + + return connection != null; + } + + [Table("Drafts")] + private class Draft + { + public string Key { get; set; } + public string SecondaryKey { get; set; } + public string Data { get; set; } + } + } +} diff --git a/src/GitHub.App/Services/StandardUserErrors.cs b/src/GitHub.App/Services/StandardUserErrors.cs index b15cdbf492..40bfff09e9 100644 --- a/src/GitHub.App/Services/StandardUserErrors.cs +++ b/src/GitHub.App/Services/StandardUserErrors.cs @@ -11,6 +11,9 @@ using GitHub.Primitives; using Octokit; using ReactiveUI; +using ReactiveUI.Legacy; + +#pragma warning disable CS0618 // Type or member is obsolete namespace GitHub.Services { @@ -25,7 +28,7 @@ public enum ErrorType CannotDropFolder, CannotDropFolderUnauthorizedAccess, ClipboardFailed, - ClonedFailed, + CloneOrOpenFailed, CloneFailedNotLoggedIn, CommitCreateFailed, CommitRevertFailed, @@ -65,6 +68,7 @@ public enum ErrorType Global, RefreshFailed, GistCreateFailed, + RepoForkFailed } public static class StandardUserErrors @@ -122,7 +126,7 @@ public static class StandardUserErrors }, { ErrorType.ClipboardFailed, Map(Defaults("Failed to copy text to the clipboard.")) }, { - ErrorType.ClonedFailed, Map(Defaults("Failed to clone the repository '{0}'", "Email support@github.com if you continue to have problems."), + ErrorType.CloneOrOpenFailed, Map(Defaults("Failed to clone or open the repository '{0}'", "Email support@github.com if you continue to have problems."), new[] { new Translation(@"fatal: bad config file line (\d+) in (.+)", "Failed to clone the repository '{0}'", @"The config file '$2' is corrupted at line $1. You may need to open the file and try to fix any errors."), @@ -137,8 +141,8 @@ public static class StandardUserErrors { ErrorType.EnterpriseConnectFailed, Map(Defaults("Connecting to GitHub Enterprise instance failed", "Could not find a GitHub Enterprise instance at '{0}'. Double check the URL and your internet/intranet connection.")) }, { ErrorType.LaunchEnterpriseConnectionFailed, Map(Defaults("Failed to launch the enterprise connection.")) }, { ErrorType.LogFileError, Map(Defaults("Could not open the log file", "Could not find or open the log file.")) }, - { ErrorType.LoginFailed, Map(Defaults("login failed", "Unable to retrieve your user info from the server. A proxy server might be interfering with the request.")) }, - { ErrorType.LogoutFailed, Map(Defaults("logout failed", "Logout failed. A proxy server might be interfering with the request.")) }, + { ErrorType.LoginFailed, Map(Defaults("Login failed", "Unable to retrieve your user info from the server. A proxy server might be interfering with the request.")) }, + { ErrorType.LogoutFailed, Map(Defaults("Logout failed", "Logout failed. A proxy server might be interfering with the request.")) }, { ErrorType.RepoCreationAsPrivateNotAvailableForFreePlan, Map(Defaults("Failed to create private repository", "You are currently on a free plan and unable to create private repositories. Either make the repository public or upgrade your account on the website to a plan that allows for private repositories.")) }, { ErrorType.RepoCreationFailed, Map(Defaults("Failed to create repository", "An error occurred while creating the repository. You might need to open a shell and debug the state of this repo.")) }, { ErrorType.RepoExistsOnDisk, Map(Defaults("Failed to create repository", "A repository named '{0}' exists in the directory\n'{1}'.")) }, @@ -150,6 +154,7 @@ public static class StandardUserErrors ErrorType.RefreshFailed, Map(Defaults("Refresh failed", "Refresh failed unexpectedly. Please email support@github.com if this error persists."), new Translation("Refresh failed", "Could not connect to the remote server. The server or your internect connection could be down")) }, { ErrorType.GistCreateFailed, Map(Defaults("Failed to create gist", "Creating a gist failed unexpectedly. Try logging back in.")) }, + { ErrorType.RepoForkFailed, Map(Defaults("Failed to create fork")) }, })); public static string GetUserFriendlyErrorMessage(this Exception exception, ErrorType errorType, params object[] messageArgs) @@ -174,14 +179,6 @@ public static IObservable ShowUserThatRepoAlreadyExists(st return DisplayErrorMessage(ErrorType.RepoExistsOnDisk, new object[] { repositoryName, fullPath }, new[] { OpenPathInExplorer(fullPath), Cancel }); } - public static IObservable ShowCloneError(this Exception exception, - ErrorType errorType, - string displayName, - string repositoryLocalWorkingDirectory) - { - return exception.DisplayErrorMessage(errorType, new object[] { displayName }, null); - } - public static IObservable ShowUserErrorThatRequiresNavigatingToBilling( this Exception exception, IAccount account) @@ -192,7 +189,7 @@ public static IObservable ShowUserErrorThatRequiresNavigat return exception.DisplayErrorMessage( errorType, - new object[] { }, + Array.Empty(), new[] { OpenBrowser("View Plans", account.Billing()), Cancel }); } @@ -215,7 +212,7 @@ static IObservable DisplayErrorMessage(this Exception exce return userError.Throw(); } - static UserError GetUserFriendlyError(this Exception exception, ErrorType errorType, params object[] messageArgs) + public static UserError GetUserFriendlyError(this Exception exception, ErrorType errorType, params object[] messageArgs) { return Translator.Value.GetUserError(errorType, exception, messageArgs); } diff --git a/src/GitHub.App/Services/TeamExplorerContext.cs b/src/GitHub.App/Services/TeamExplorerContext.cs new file mode 100644 index 0000000000..1a9c1560f3 --- /dev/null +++ b/src/GitHub.App/Services/TeamExplorerContext.cs @@ -0,0 +1,190 @@ +using System; +using System.Linq; +using System.ComponentModel; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Logging; +using GitHub.Primitives; +using GitHub.Extensions; +using Serilog; +using EnvDTE; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.Services +{ + /// + /// This implementation listenes to IGitExt for ActiveRepositories property change events and fires + /// and events when appropriate. + /// + /// + /// A is fired when a solution is opened in a new repository (or not in a repository). + /// A event is only fired when the current branch, head SHA or tracked SHA changes (not + /// on every IGitExt property change event). contains the active repository or null + /// if a solution is opened that isn't in a repository. No events are fired when the same solution is unloaded then + /// reloaded (e.g. when a .sln file is touched). + /// + [Export(typeof(ITeamExplorerContext))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class TeamExplorerContext : ITeamExplorerContext + { + static ILogger log = LogManager.ForContext(); + + readonly AsyncLazy dteAsync; + readonly IVSGitExt gitExt; + readonly IPullRequestService pullRequestService; + + string solutionPath; + string repositoryPath; + UriString cloneUrl; + (string owner, int number) pullRequest; + + LocalRepositoryModel repositoryModel; + JoinableTask refreshJoinableTask; + + [ImportingConstructor] + TeamExplorerContext( + IVSGitExt gitExt, + [Import(typeof(SVsServiceProvider))] IServiceProvider sp, + IPullRequestService pullRequestService, + [Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) : this( + gitExt, + new AsyncLazy(async () => + { + await (joinableTaskContext ?? ThreadHelper.JoinableTaskContext).Factory.SwitchToMainThreadAsync(); + return (DTE)sp.GetService(typeof(DTE)); + }), + pullRequestService, + joinableTaskContext ?? ThreadHelper.JoinableTaskContext) + { + } + + public TeamExplorerContext( + IVSGitExt gitExt, + AsyncLazy dteAsync, + IPullRequestService pullRequestService, + JoinableTaskContext joinableTaskContext) + { + JoinableTaskCollection = joinableTaskContext.CreateCollection(); + JoinableTaskCollection.DisplayName = nameof(TeamExplorerContext); + JoinableTaskFactory = joinableTaskContext.CreateFactory(JoinableTaskCollection); + + this.gitExt = gitExt; + this.dteAsync = dteAsync; + this.pullRequestService = pullRequestService; + + StartRefresh(); + gitExt.ActiveRepositoriesChanged += Refresh; + } + + void StartRefresh() => JoinableTaskFactory.RunAsync(QueueRefreshAsync).Task.Forget(log); + void Refresh() => JoinableTaskFactory.Run(QueueRefreshAsync); + + async Task QueueRefreshAsync() + { + if (refreshJoinableTask != null) + { + await refreshJoinableTask.JoinAsync(); // make sure StartRefresh has completed + } + + await (refreshJoinableTask = JoinableTaskFactory.RunAsync(RefreshAsync)); + } + + async Task RefreshAsync() + { + try + { + await TaskScheduler.Default; // switch to threadpool + + var repo = gitExt.ActiveRepositories?.FirstOrDefault(); + string newSolutionPath = await GetSolutionPath(); + if (repo == null && newSolutionPath == solutionPath) + { + // Ignore when ActiveRepositories is empty and solution hasn't changed. + // https://github.com/github/VisualStudio/issues/1421 + log.Debug("Ignoring no ActiveRepository when solution hasn't changed"); + } + else + { + var newRepositoryPath = repo?.LocalPath; + var newCloneUrl = repo?.CloneUrl; + var newPullRequest = repo != null ? await pullRequestService.GetPullRequestForCurrentBranch(repo) : default; + + if (newRepositoryPath != repositoryPath) + { + log.Debug("ActiveRepository changed to {CloneUrl} @ {Path}", repo?.CloneUrl, newRepositoryPath); + ActiveRepository = repo; + } + else if (newCloneUrl != cloneUrl) + { + log.Debug("ActiveRepository changed to {CloneUrl} @ {Path}", repo?.CloneUrl, newRepositoryPath); + ActiveRepository = repo; + } + else if (newPullRequest != pullRequest) + { + log.Debug("Fire StatusChanged event when PullRequest changes for ActiveRepository"); + StatusChanged?.Invoke(this, EventArgs.Empty); + } + else if (newRepositoryPath != null) + { + // For example, this will fire when the HEAD commit changes + log.Debug("Fire StatusChanged event if anything about an active repository has changed"); + StatusChanged?.Invoke(this, EventArgs.Empty); + } + + repositoryPath = newRepositoryPath; + cloneUrl = newCloneUrl; + solutionPath = newSolutionPath; + pullRequest = newPullRequest; + } + } + catch (Exception e) + { + log.Error(e, "Refreshing active repository"); + } + } + + async Task GetSolutionPath() + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await dteAsync.GetValueAsync(); + return dte.Solution?.FullName; + } + + /// + /// The active repository or null if not in a repository. + /// + public LocalRepositoryModel ActiveRepository + { + get + { + return repositoryModel; + } + + private set + { + if (value != repositoryModel) + { + repositoryModel = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ActiveRepository))); + } + } + } + + /// + /// Fired when a solution is opened in a new repository (or that isn't in a repository). + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Fired when the current branch, head SHA or tracked SHA changes. + /// + public event EventHandler StatusChanged; + + public JoinableTaskCollection JoinableTaskCollection { get; } + JoinableTaskFactory JoinableTaskFactory { get; } + } +} diff --git a/src/GitHub.App/Services/TextViewCommandDispatcher.cs b/src/GitHub.App/Services/TextViewCommandDispatcher.cs new file mode 100644 index 0000000000..f8e8290824 --- /dev/null +++ b/src/GitHub.App/Services/TextViewCommandDispatcher.cs @@ -0,0 +1,82 @@ +using System; +using System.Windows.Input; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.OLE.Interop; +using Microsoft.VisualStudio.TextManager.Interop; + +namespace GitHub.Services +{ + /// + /// Intercepts all commands sent to a and fires when a specified command is encountered. + /// + /// + /// Intercepting commands like this is necessary if we want to capture commands in context of a . + /// Well known commands like Copy, Paste and Enter can be captured as well as custom commands. + /// + class TextViewCommandDispatcher : IOleCommandTarget, IDisposable + { + readonly IVsTextView textView; + readonly Guid commandGroup; + readonly int commandId; + readonly IOleCommandTarget next; + readonly ICommand targetCommand; + + public static IDisposable AddCommandFilter(IVsTextView textView, Guid commandGroup, int commandId, ICommand targetCommand) + { + return new TextViewCommandDispatcher(textView, commandGroup, commandId, targetCommand); + } + + /// + /// Add a command filter to . + /// + /// The text view to filter commands from. + /// The group of the command to listen for. + /// The ID of the command to listen for. + /// The command to dispatch to. + TextViewCommandDispatcher(IVsTextView textView, Guid commandGroup, int commandId, ICommand targetCommand) + { + this.textView = textView; + this.commandGroup = commandGroup; + this.commandId = commandId; + this.targetCommand = targetCommand; + + ErrorHandler.ThrowOnFailure(textView.AddCommandFilter(this, out next)); + } + + /// + /// Remove the command filter. + /// + public void Dispose() + { + ErrorHandler.ThrowOnFailure(textView.RemoveCommandFilter(this)); + } + + int IOleCommandTarget.Exec(ref Guid pguidCmdGroup, uint nCmdID, uint nCmdexecopt, IntPtr pvaIn, IntPtr pvaOut) + { + if (pguidCmdGroup == commandGroup && nCmdID == commandId) + { + targetCommand.Execute(null); + return VSConstants.S_OK; + } + + return next?.Exec(ref pguidCmdGroup, nCmdID, nCmdexecopt, pvaIn, pvaOut) ?? 0; + } + + int IOleCommandTarget.QueryStatus(ref Guid pguidCmdGroup, uint cCmds, OLECMD[] prgCmds, IntPtr pCmdText) + { + if (pguidCmdGroup == commandGroup) + { + if (prgCmds != null && cCmds == 1) + { + if (prgCmds[0].cmdID == commandId) + { + prgCmds[0].cmdf = (uint)OLECMDF.OLECMDF_SUPPORTED | (uint)OLECMDF.OLECMDF_ENABLED; + return VSConstants.S_OK; + } + } + } + + return next?.QueryStatus(ref pguidCmdGroup, cCmds, prgCmds, pCmdText) ?? 0; + } + } +} diff --git a/src/GitHub.App/Services/Translation.cs b/src/GitHub.App/Services/Translation.cs index 9f08a6ec6b..8235ffc8a1 100644 --- a/src/GitHub.App/Services/Translation.cs +++ b/src/GitHub.App/Services/Translation.cs @@ -4,7 +4,6 @@ using System.Text.RegularExpressions; using GitHub.Extensions; using Newtonsoft.Json; -using NullGuard; using Octokit; namespace GitHub.Services @@ -14,29 +13,39 @@ public class Translation readonly ErrorMessage defaultMessage; readonly Func translator; - public Translation([AllowNull]string original, string heading, string message) + public Translation(string original, string heading, string message) { + Guard.ArgumentNotNull(heading, nameof(heading)); + Guard.ArgumentNotNull(message, nameof(message)); + Original = original; defaultMessage = new ErrorMessage(heading, message); } - public Translation([AllowNull]string original, Func translator) + public Translation(string original, Func translator) { + Guard.ArgumentNotNull(translator, nameof(translator)); + Original = original; this.translator = translator; } - public Translation([AllowNull]string original, string heading, string messageFormatString, Func translator) + public Translation(string original, string heading, string messageFormatString, Func translator) : this(original, heading, messageFormatString) { + Guard.ArgumentNotNull(heading, nameof(heading)); + Guard.ArgumentNotNull(messageFormatString, nameof(messageFormatString)); + Guard.ArgumentNotNull(translator, nameof(translator)); + this.translator = e => new ErrorMessage(heading, String.Format(CultureInfo.InvariantCulture, messageFormatString, translator(e))); } public string Original { get; private set; } - [return: AllowNull] public ErrorMessage Translate(Exception exception) { + Guard.ArgumentNotNull(exception, nameof(exception)); + var match = Match(exception); if (match == null) return null; @@ -64,6 +73,8 @@ public ErrorMessage Translate(Exception exception) // Returns a tuple indicating whether this translation is a match for the exception and the regex line if existing. protected virtual Tuple Match(Exception exception) { + Guard.ArgumentNotNull(exception, nameof(exception)); + string exceptionMessage = exception.Message; var apiException = exception as ApiValidationException; @@ -105,14 +116,19 @@ public class Translation : Translation where TException : Exception { public Translation(string heading, string message) : base(null, heading, message) { + Guard.ArgumentNotNull(heading, nameof(heading)); + Guard.ArgumentNotNull(message, nameof(message)); } public Translation(string heading) : base(null, e => new ErrorMessage(heading, e.Message)) { + Guard.ArgumentNotNull(heading, nameof(heading)); } protected override Tuple Match(Exception exception) { + Guard.ArgumentNotNull(exception, nameof(exception)); + if (exception is TException) return new Tuple(null, null); return null; } diff --git a/src/GitHub.App/UserErrors/PrivateRepositoryOnFreeAccountUserError.cs b/src/GitHub.App/UserErrors/PrivateRepositoryOnFreeAccountUserError.cs index cb83fe264a..e0ff9363f5 100644 --- a/src/GitHub.App/UserErrors/PrivateRepositoryOnFreeAccountUserError.cs +++ b/src/GitHub.App/UserErrors/PrivateRepositoryOnFreeAccountUserError.cs @@ -1,4 +1,7 @@ using ReactiveUI; +using ReactiveUI.Legacy; + +#pragma warning disable CS0618 // Type or member is obsolete namespace GitHub.UserErrors { diff --git a/src/GitHub.App/UserErrors/PrivateRepositoryQuotaExceededUserError.cs b/src/GitHub.App/UserErrors/PrivateRepositoryQuotaExceededUserError.cs index 12d63be521..ef6b59b52e 100644 --- a/src/GitHub.App/UserErrors/PrivateRepositoryQuotaExceededUserError.cs +++ b/src/GitHub.App/UserErrors/PrivateRepositoryQuotaExceededUserError.cs @@ -2,6 +2,9 @@ using System.Globalization; using GitHub.Models; using ReactiveUI; +using ReactiveUI.Legacy; + +#pragma warning disable CS0618 // Type or member is obsolete namespace GitHub.UserErrors { diff --git a/src/GitHub.App/UserErrors/PublishRepositoryUserError.cs b/src/GitHub.App/UserErrors/PublishRepositoryUserError.cs index 8f3b0d1451..935d051d8f 100644 --- a/src/GitHub.App/UserErrors/PublishRepositoryUserError.cs +++ b/src/GitHub.App/UserErrors/PublishRepositoryUserError.cs @@ -4,6 +4,9 @@ using GitHub.Services; using Octokit; using ReactiveUI; +using ReactiveUI.Legacy; + +#pragma warning disable CS0618 // Type or member is obsolete namespace GitHub.UserErrors { diff --git a/src/GitHub.App/ViewModels/ActorViewModel.cs b/src/GitHub.App/ViewModels/ActorViewModel.cs new file mode 100644 index 0000000000..a266b43e84 --- /dev/null +++ b/src/GitHub.App/ViewModels/ActorViewModel.cs @@ -0,0 +1,56 @@ +using System; +using System.Windows.Media.Imaging; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using Serilog; + +namespace GitHub.ViewModels +{ + public class ActorViewModel : ViewModelBase, IActorViewModel + { + const string DefaultAvatar = "pack://application:,,,/GitHub.App;component/Images/default_user_avatar.png"; + static readonly ILogger log = LogManager.ForContext(); + + public ActorViewModel() + { + } + + public ActorViewModel(ActorModel model) + { + Login = model?.Login ?? "[unknown]"; + + if (model?.AvatarUrl != null) + { + try + { + var uri = new Uri(model.AvatarUrl); + + // Image requests to enterprise hosts over https always fail currently, + // so just display the default avatar. See #1547. + if (uri.Scheme != "https" || + uri.Host.EndsWith("githubusercontent.com", StringComparison.OrdinalIgnoreCase)) + { + AvatarUrl = model.AvatarUrl; + Avatar = new BitmapImage(uri); + } + } + catch (Exception ex) + { + log.Error(ex, "Invalid avatar URL"); + } + } + + if (AvatarUrl == null) + { + Avatar = AvatarProvider.CreateBitmapImage(DefaultAvatar); + AvatarUrl = DefaultAvatar; + } + } + + public BitmapSource Avatar { get; set; } + public string AvatarUrl { get; set; } + public string Login { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/BaseViewModel.cs b/src/GitHub.App/ViewModels/BaseViewModel.cs deleted file mode 100644 index d3fe140946..0000000000 --- a/src/GitHub.App/ViewModels/BaseViewModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Windows.Input; -using ReactiveUI; -using NullGuard; -using GitHub.UI; - -namespace GitHub.ViewModels -{ - public class BaseViewModel : ReactiveObject, IReactiveViewModel, IHasBusy - { - protected ObservableAsPropertyHelper isShowing; - string title; - bool isBusy; - - public BaseViewModel() - { - CancelCommand = ReactiveCommand.Create(); - } - - public IReactiveCommand CancelCommand { get; protected set; } - public ICommand Cancel { get { return CancelCommand; } } - - public string Title - { - [return: AllowNull] - get { return title; } - protected set { this.RaiseAndSetIfChanged(ref title, value); } - } - - public bool IsShowing { get { return isShowing?.Value ?? true; } } - public bool IsBusy - { - get { return isBusy; } - set { this.RaiseAndSetIfChanged(ref isBusy, value); } - } - - public virtual void Initialize([AllowNull] ViewWithData data) - { - } - } -} diff --git a/src/GitHub.App/ViewModels/CommentThreadViewModel.cs b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs new file mode 100644 index 0000000000..ce3caf74a2 --- /dev/null +++ b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Models.Drafts; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// Base view model for a thread of comments. + /// + public abstract class CommentThreadViewModel : ReactiveObject, ICommentThreadViewModel + { + readonly Dictionary> draftThrottles = + new Dictionary>(); + readonly IScheduler timerScheduler; + + /// + /// Initializes a new instance of the class. + /// + /// The message draft store. + [ImportingConstructor] + public CommentThreadViewModel(IMessageDraftStore draftStore) + : this(draftStore, DefaultScheduler.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message draft store. + /// + /// The scheduler to use to apply a throttle to message drafts. + /// + [ImportingConstructor] + public CommentThreadViewModel( + IMessageDraftStore draftStore, + IScheduler timerScheduler) + { + Guard.ArgumentNotNull(draftStore, nameof(draftStore)); + + DraftStore = draftStore; + this.timerScheduler = timerScheduler; + } + + /// + public IActorViewModel CurrentUser { get; private set; } + + protected IMessageDraftStore DraftStore { get; } + + /// + public abstract Task PostComment(ICommentViewModel comment); + + /// + public abstract Task EditComment(ICommentViewModel comment); + + /// + public abstract Task DeleteComment(ICommentViewModel comment); + + /// + /// Initializes a placeholder comment that will allow the user to enter a reply, and wires up + /// event listeners for saving drafts. + /// + /// The placeholder comment view model. + /// An object which when disposed will remove the event listeners. + protected IDisposable InitializePlaceholder(ICommentViewModel placeholder) + { + return placeholder.WhenAnyValue( + x => x.EditState, + x => x.Body, + (state, body) => (state, body)) + .Subscribe(x => PlaceholderChanged(placeholder, x.state)); + } + + /// + /// Intializes a new instance of the class. + /// + /// The current user. + protected Task InitializeAsync(ActorModel currentUser) + { + Guard.ArgumentNotNull(currentUser, nameof(currentUser)); + CurrentUser = new ActorViewModel(currentUser); + return Task.CompletedTask; + } + + protected virtual CommentDraft BuildDraft(ICommentViewModel comment) + { + return !string.IsNullOrEmpty(comment.Body) ? + new CommentDraft { Body = comment.Body } : + null; + } + + protected async Task DeleteDraft(ICommentViewModel comment) + { + if (draftThrottles.TryGetValue(comment, out var throttle)) + { + throttle.OnCompleted(); + draftThrottles.Remove(comment); + } + + var (key, secondaryKey) = GetDraftKeys(comment); + await DraftStore.DeleteDraft(key, secondaryKey).ConfigureAwait(false); + } + + protected abstract (string key, string secondaryKey) GetDraftKeys(ICommentViewModel comment); + + void PlaceholderChanged(ICommentViewModel placeholder, CommentEditState state) + { + if (state == CommentEditState.Editing) + { + if (!draftThrottles.TryGetValue(placeholder, out var throttle)) + { + var subject = new Subject(); + subject.Throttle(TimeSpan.FromSeconds(1), timerScheduler).Subscribe(UpdateDraft); + draftThrottles.Add(placeholder, subject); + throttle = subject; + } + + throttle.OnNext(placeholder); + } + else if (state != CommentEditState.Editing) + { + DeleteDraft(placeholder).Forget(); + } + } + + void UpdateDraft(ICommentViewModel comment) + { + if (comment.EditState == CommentEditState.Editing) + { + var draft = BuildDraft(comment); + var (key, secondaryKey) = GetDraftKeys(comment); + + if (draft != null) + { + DraftStore.UpdateDraft(key, secondaryKey, draft).Forget(); + } + else + { + DraftStore.DeleteDraft(key, secondaryKey).Forget(); + } + } + } + } +} diff --git a/src/GitHub.App/ViewModels/CommentViewModel.cs b/src/GitHub.App/ViewModels/CommentViewModel.cs new file mode 100644 index 0000000000..0a750f08a4 --- /dev/null +++ b/src/GitHub.App/ViewModels/CommentViewModel.cs @@ -0,0 +1,311 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels +{ + /// + /// An issue or pull request comment. + /// + [Export(typeof(ICommentViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class CommentViewModel : ViewModelBase, ICommentViewModel + { + static readonly ILogger log = LogManager.ForContext(); + readonly ICommentService commentService; + readonly ObservableAsPropertyHelper canCancel; + readonly ObservableAsPropertyHelper canDelete; + ObservableAsPropertyHelper commitCaption; + string id; + IActorViewModel author; + IActorViewModel currentUser; + string body; + string errorMessage; + bool isReadOnly; + bool isSubmitting; + CommentEditState state; + DateTimeOffset createdAt; + ICommentThreadViewModel thread; + string undoBody; + + /// + /// Initializes a new instance of the class. + /// + /// The comment service. + /// The auto complete advisor. + [ImportingConstructor] + public CommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor) + { + Guard.ArgumentNotNull(commentService, nameof(commentService)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); + + AutoCompleteAdvisor = autoCompleteAdvisor; + this.commentService = commentService; + + var canDeleteObservable = this.WhenAnyValue( + x => x.EditState, + x => x.Author, + x => x.CurrentUser, + (editState, author, currentUser) => editState == CommentEditState.None && author?.Login == currentUser?.Login); + + canDelete = canDeleteObservable.ToProperty(this, x => x.CanDelete); + + Delete = ReactiveCommand.CreateFromTask(DoDelete, canDeleteObservable); + + var canEdit = this.WhenAnyValue( + x => x.EditState, + x => x.Author, + x => x.CurrentUser, + (editState, author, currentUser) => editState == CommentEditState.Placeholder || + (editState == CommentEditState.None && author?.Login == currentUser?.Login)); + + BeginEdit = ReactiveCommand.Create(DoBeginEdit, canEdit); + AddErrorHandler(BeginEdit); + + CommitEdit = ReactiveCommand.CreateFromTask( + DoCommitEdit, + this.WhenAnyValue( + x => x.IsReadOnly, + x => x.Body, + (ro, body) => !ro && !string.IsNullOrWhiteSpace(body))); + AddErrorHandler(CommitEdit); + + canCancel = this.WhenAnyValue(x => x.Id) + .Select(id => id != null) + .ToProperty(this, x => x.CanCancel); + CancelEdit = ReactiveCommand.Create(DoCancelEdit, CommitEdit.IsExecuting.Select(x => !x)); + AddErrorHandler(CancelEdit); + + OpenOnGitHub = ReactiveCommand.Create( + () => { }, + this.WhenAnyValue(x => x.Id).Select(x => x != null)); + } + + /// + public string Id + { + get => id; + private set => this.RaiseAndSetIfChanged(ref id, value); + } + + /// + public int DatabaseId { get; private set; } + + /// + public int PullRequestId { get; private set; } + + /// + public IActorViewModel Author + { + get => author; + private set => this.RaiseAndSetIfChanged(ref author, value); + } + + /// + public IActorViewModel CurrentUser + { + get => currentUser; + private set => this.RaiseAndSetIfChanged(ref currentUser, value); + } + + /// + public string Body + { + get => body; + set => this.RaiseAndSetIfChanged(ref body, value); + } + + /// + public string ErrorMessage + { + get => errorMessage; + private set => this.RaiseAndSetIfChanged(ref errorMessage, value); + } + + /// + public CommentEditState EditState + { + get => state; + private set => this.RaiseAndSetIfChanged(ref state, value); + } + + /// + public bool IsReadOnly + { + get => isReadOnly; + set => this.RaiseAndSetIfChanged(ref isReadOnly, value); + } + + /// + public bool IsSubmitting + { + get => isSubmitting; + protected set => this.RaiseAndSetIfChanged(ref isSubmitting, value); + } + + /// + public bool CanCancel => canCancel.Value; + + /// + public bool CanDelete => canDelete.Value; + + /// + public DateTimeOffset CreatedAt + { + get => createdAt; + private set => this.RaiseAndSetIfChanged(ref createdAt, value); + } + + /// + public string CommitCaption => commitCaption.Value; + + /// + public ICommentThreadViewModel Thread + { + get => thread; + private set => this.RaiseAndSetIfChanged(ref thread, value); + } + + /// + public Uri WebUrl { get; private set; } + + /// + public ReactiveCommand BeginEdit { get; } + + /// + public ReactiveCommand CancelEdit { get; } + + /// + public ReactiveCommand CommitEdit { get; } + + /// + public ReactiveCommand OpenOnGitHub { get; } + + /// + public ReactiveCommand Delete { get; } + + /// + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + + /// + public Task InitializeAsync( + ICommentThreadViewModel thread, + ActorModel currentUser, + CommentModel comment, + CommentEditState state) + { + Guard.ArgumentNotNull(thread, nameof(thread)); + Guard.ArgumentNotNull(currentUser, nameof(currentUser)); + + Thread = thread; + CurrentUser = new ActorViewModel(currentUser); + Id = comment?.Id; + DatabaseId = comment?.DatabaseId ?? 0; + PullRequestId = (comment as PullRequestReviewCommentModel)?.PullRequestId ?? 0; + Body = comment?.Body; + EditState = state; + Author = comment != null ? new ActorViewModel(comment.Author) : CurrentUser; + CreatedAt = comment?.CreatedAt ?? DateTimeOffset.MinValue; + WebUrl = comment?.Url != null ? new Uri(comment.Url) : null; + + commitCaption = GetCommitCaptionObservable().ToProperty(this, x => x.CommitCaption); + + return Task.CompletedTask; + } + + protected void AddErrorHandler(ReactiveCommand command) + { + command.ThrownExceptions.Subscribe(x => ErrorMessage = x.Message); + } + + protected virtual IObservable GetCommitCaptionObservable() + { + return this.WhenAnyValue(x => x.Id) + .Select(x => x == null ? Resources.Comment : Resources.UpdateComment); + } + + async Task DoDelete() + { + if (commentService.ConfirmCommentDelete()) + { + try + { + ErrorMessage = null; + IsSubmitting = true; + + await Thread.DeleteComment(this).ConfigureAwait(true); + } + catch (Exception e) + { + var message = e.Message; + ErrorMessage = message; + log.Error(e, "Error Deleting comment"); + } + finally + { + IsSubmitting = false; + } + } + } + + void DoBeginEdit() + { + if (state != CommentEditState.Editing) + { + ErrorMessage = null; + undoBody = Body; + EditState = CommentEditState.Editing; + } + } + + void DoCancelEdit() + { + if (EditState == CommentEditState.Editing) + { + EditState = string.IsNullOrWhiteSpace(undoBody) ? CommentEditState.Placeholder : CommentEditState.None; + Body = undoBody; + ErrorMessage = null; + undoBody = null; + } + } + + async Task DoCommitEdit() + { + try + { + ErrorMessage = null; + IsSubmitting = true; + + if (Id == null) + { + await Thread.PostComment(this).ConfigureAwait(true); + } + else + { + await Thread.EditComment(this).ConfigureAwait(true); + } + + EditState = CommentEditState.None; + } + catch (Exception e) + { + var message = e.Message; + ErrorMessage = message; + log.Error(e, "Error posting comment"); + } + finally + { + IsSubmitting = false; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/CommitActorViewModel.cs b/src/GitHub.App/ViewModels/CommitActorViewModel.cs new file mode 100644 index 0000000000..3f40c05089 --- /dev/null +++ b/src/GitHub.App/ViewModels/CommitActorViewModel.cs @@ -0,0 +1,19 @@ +using GitHub.Models; + +namespace GitHub.ViewModels +{ + public class CommitActorViewModel: ActorViewModel, ICommitActorViewModel + { + public CommitActorViewModel(CommitActorModel model) + :base(model.User) + { + Name = model.Name; + Email = model.Email; + HasLogin = model.User != null; + } + + public string Email { get; } + public string Name { get; } + public bool HasLogin { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs new file mode 100644 index 0000000000..1971914a4f --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Globalization; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; +using Rothko; +using Serilog; + +namespace GitHub.ViewModels.Dialog.Clone +{ + [Export(typeof(IRepositoryCloneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class RepositoryCloneViewModel : ViewModelBase, IRepositoryCloneViewModel + { + readonly IOperatingSystem os; + readonly IConnectionManager connectionManager; + readonly IRepositoryCloneService service; + readonly IGitService gitService; + readonly IUsageTracker usageTracker; + readonly IDialogService dialogService; + readonly IReadOnlyList tabs; + string path; + UriString url; + RepositoryModel previousRepository; + ObservableAsPropertyHelper pathWarning; + int selectedTabIndex; + + [ImportingConstructor] + public RepositoryCloneViewModel( + IOperatingSystem os, + IConnectionManager connectionManager, + IRepositoryCloneService service, + IGitService gitService, + IUsageTracker usageTracker, + IDialogService dialogService, + IRepositorySelectViewModel gitHubTab, + IRepositorySelectViewModel enterpriseTab) + { + this.os = os; + this.connectionManager = connectionManager; + this.service = service; + this.gitService = gitService; + this.usageTracker = usageTracker; + this.dialogService = dialogService; + + GitHubTab = gitHubTab; + EnterpriseTab = enterpriseTab; + tabs = new IRepositoryCloneTabViewModel[] { GitHubTab, EnterpriseTab }; + + var repository = this.WhenAnyValue(x => x.SelectedTabIndex) + .SelectMany(x => tabs[x].WhenAnyValue(tab => tab.Repository)); + + Path = service.DefaultClonePath; + repository.Subscribe(x => UpdatePath(x)); + + pathWarning = Observable.CombineLatest( + repository, + this.WhenAnyValue(x => x.Path), + ValidatePathWarning) + .ToProperty(this, x => x.PathWarning); + + var canClone = Observable.CombineLatest( + repository, this.WhenAnyValue(x => x.Path), + (repo, path) => repo != null && !service.DestinationFileExists(path) && + (!service.DestinationDirectoryExists(path) || service.DestinationDirectoryEmpty(path))); + + var canOpen = Observable.CombineLatest( + repository, this.WhenAnyValue(x => x.Path), + (repo, path) => repo != null && !service.DestinationFileExists(path) && service.DestinationDirectoryExists(path) + && !service.DestinationDirectoryEmpty(path)); + + Browse = ReactiveCommand.Create(() => BrowseForDirectory()); + Clone = ReactiveCommand.CreateFromObservable( + () => repository.Select(x => new CloneDialogResult(Path, x?.CloneUrl)), + canClone); + Open = ReactiveCommand.CreateFromObservable( + () => repository.Select(x => new CloneDialogResult(Path, x?.CloneUrl)), + canOpen); + + LoginAsDifferentUser = ReactiveCommand.CreateFromTask(LoginAsDifferentUserAsync); + } + + public IRepositorySelectViewModel GitHubTab { get; } + public IRepositorySelectViewModel EnterpriseTab { get; } + + public string Path + { + get => path; + set => this.RaiseAndSetIfChanged(ref path, value); + } + + public UriString Url + { + get => url; + set => this.RaiseAndSetIfChanged(ref url, value); + } + + public string PathWarning => pathWarning.Value; + + public int SelectedTabIndex + { + get => selectedTabIndex; + set => this.RaiseAndSetIfChanged(ref selectedTabIndex, value); + } + + public string Title => Resources.OpenFromGitHubTitle; + + public IObservable Done => Observable.Merge(Clone, Open); + + public ReactiveCommand LoginAsDifferentUser { get; } + + public ReactiveCommand Browse { get; } + + public ReactiveCommand Clone { get; } + + public ReactiveCommand Open { get; } + + public async Task InitializeAsync(IConnection connection) + { + var connections = await connectionManager.GetLoadedConnections().ConfigureAwait(false); + var gitHubConnection = connections.FirstOrDefault(x => x.HostAddress.IsGitHubDotCom()); + var enterpriseConnection = connections.FirstOrDefault(x => !x.HostAddress.IsGitHubDotCom()); + + if (gitHubConnection?.IsLoggedIn == true) + { + GitHubTab.Initialize(gitHubConnection); + } + + if (enterpriseConnection?.IsLoggedIn == true) + { + EnterpriseTab.Initialize(enterpriseConnection); + } + + if (connection == gitHubConnection) + { + SelectedTabIndex = 0; + } + else if (connection == enterpriseConnection) + { + SelectedTabIndex = 1; + } + + if (Url?.Host is string host && HostAddress.Create(host) is HostAddress hostAddress) + { + if (hostAddress == gitHubConnection?.HostAddress) + { + GitHubTab.Filter = Url; + SelectedTabIndex = 0; + } + else if (hostAddress == enterpriseConnection?.HostAddress) + { + EnterpriseTab.Filter = Url; + SelectedTabIndex = 1; + } + } + + this.WhenAnyValue(x => x.SelectedTabIndex).Subscribe(x => tabs[x].Activate().Forget()); + } + + async Task LoginAsDifferentUserAsync() + { + if (await dialogService.ShowLoginDialog() is IConnection connection) + { + var connections = await connectionManager.GetLoadedConnections(); + var gitHubConnection = connections.FirstOrDefault(x => x.HostAddress.IsGitHubDotCom()); + + if (connection == gitHubConnection) + { + SelectedTabIndex = 0; + GitHubTab.Initialize(connection); + GitHubTab.Activate().Forget(); + } + else + { + SelectedTabIndex = 1; + EnterpriseTab.Initialize(connection); + EnterpriseTab.Activate().Forget(); + } + } + } + + void BrowseForDirectory() + { + var result = os.Dialog.BrowseForDirectory(Path, Resources.BrowseForDirectory); + + if (result != BrowseDirectoryResult.Failed) + { + var path = result.DirectoryPath; + var selected = tabs[SelectedTabIndex].Repository; + + if (selected != null) + { + path = System.IO.Path.Combine(path, selected.Name); + } + + Path = path; + } + } + + void UpdatePath(RepositoryModel repository) + { + if (repository != null) + { + var basePath = GetUpdatedBasePath(Path); + previousRepository = repository; + Path = System.IO.Path.Combine(basePath, repository.Owner, repository.Name); + } + } + + string GetUpdatedBasePath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return service.DefaultClonePath; + } + + if (previousRepository == null) + { + return path; + } + + if (FindDirWithout(path, previousRepository?.Owner, 2) is string dirWithoutOwner) + { + return dirWithoutOwner; + } + + if (FindDirWithout(path, previousRepository?.Name, 1) is string dirWithoutRepo) + { + return dirWithoutRepo; + } + + return path; + + string FindDirWithout(string dir, string match, int levels) + { + string dirWithout = null; + for (var i = 0; i < 2; i++) + { + if (string.IsNullOrEmpty(dir)) + { + break; + } + + var name = System.IO.Path.GetFileName(dir); + dir = System.IO.Path.GetDirectoryName(dir); + if (name == match) + { + dirWithout = dir; + } + } + + return dirWithout; + } + } + + string ValidatePathWarning(RepositoryModel repositoryModel, string path) + { + if (repositoryModel != null) + { + if (service.DestinationFileExists(path)) + { + return Resources.DestinationAlreadyExists; + } + + if (service.DestinationDirectoryExists(path) && !service.DestinationDirectoryEmpty(path)) + { + using (var repository = gitService.GetRepository(path)) + { + if (repository == null) + { + return Resources.DirectoryAtDestinationNotEmpty; + } + + var localUrl = gitService.GetRemoteUri(repository)?.ToRepositoryUrl(); + if (localUrl == null) + { + return Resources.LocalRepositoryDoesntHaveARemoteOrigin; + } + + var targetUrl = repositoryModel.CloneUrl?.ToRepositoryUrl(); + if (localUrl != targetUrl) + { + return string.Format(CultureInfo.CurrentCulture, Resources.LocalRepositoryHasARemoteOf, + localUrl); + } + + return Resources.YouHaveAlreadyClonedToThisLocation; + } + } + } + + return null; + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryItemViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryItemViewModel.cs new file mode 100644 index 0000000000..d802b7f30e --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryItemViewModel.cs @@ -0,0 +1,29 @@ +using System; +using GitHub.Models; +using GitHub.UI; + +namespace GitHub.ViewModels.Dialog.Clone +{ + public class RepositoryItemViewModel : ViewModelBase, IRepositoryItemViewModel + { + public RepositoryItemViewModel(RepositoryListItemModel model, string group) + { + Name = model.Name; + Owner = model.Owner; + Icon = model.IsPrivate + ? Octicon.@lock + : model.IsFork + ? Octicon.repo_forked + : Octicon.repo; + Url = model.Url; + Group = group; + } + + public string Caption => Owner + '/' + Name; + public string Name { get; } + public string Owner { get; } + public string Group { get; } + public Octicon Icon { get; } + public Uri Url { get; } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/Clone/RepositorySelectViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Clone/RepositorySelectViewModel.cs new file mode 100644 index 0000000000..e3f391f412 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Clone/RepositorySelectViewModel.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.Composition; +using System.Globalization; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using GitHub.Exports; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.Dialog.Clone +{ + [Export(typeof(IRepositorySelectViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class RepositorySelectViewModel : ViewModelBase, IRepositorySelectViewModel + { + static readonly ILogger log = LogManager.ForContext(); + readonly IRepositoryCloneService service; + readonly IGitHubContextService gitHubContextService; + IConnection connection; + Exception error; + string filter; + bool isEnabled; + bool isLoading; + IReadOnlyList items; + ICollectionView itemsView; + ObservableAsPropertyHelper repository; + IRepositoryItemViewModel selectedItem; + + [ImportingConstructor] + public RepositorySelectViewModel(IRepositoryCloneService service, IGitHubContextService gitHubContextService) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(service, nameof(gitHubContextService)); + + this.service = service; + this.gitHubContextService = gitHubContextService; + + var selectedRepository = this.WhenAnyValue(x => x.SelectedItem) + .Select(CreateRepository); + + var filterRepository = this.WhenAnyValue(x => x.Filter) + .Select(f => gitHubContextService.FindContextFromUrl(f)) + .Select(CreateRepository); + + repository = selectedRepository + .Merge(filterRepository) + .ToProperty(this, x => x.Repository); + + this.WhenAnyValue(x => x.Filter).Subscribe(_ => ItemsView?.Refresh()); + } + + public Exception Error + { + get => error; + private set => this.RaiseAndSetIfChanged(ref error, value); + } + + public string Filter + { + get => filter; + set => this.RaiseAndSetIfChanged(ref filter, value); + } + + public bool IsEnabled + { + get => isEnabled; + private set => this.RaiseAndSetIfChanged(ref isEnabled, value); + } + + public bool IsLoading + { + get => isLoading; + private set => this.RaiseAndSetIfChanged(ref isLoading, value); + } + + public IReadOnlyList Items + { + get => items; + private set => this.RaiseAndSetIfChanged(ref items, value); + } + + public ICollectionView ItemsView + { + get => itemsView; + private set => this.RaiseAndSetIfChanged(ref itemsView, value); + } + + public IRepositoryItemViewModel SelectedItem + { + get => selectedItem; + set => this.RaiseAndSetIfChanged(ref selectedItem, value); + } + + public RepositoryModel Repository => repository.Value; + + public void Initialize(IConnection connection) + { + Guard.ArgumentNotNull(connection, nameof(connection)); + + this.connection = connection; + IsEnabled = true; + } + + public async Task Activate() + { + await this.LoadItems(true); + } + + static string GroupName(KeyValuePair> group, int max) + { + var name = group.Key; + if (group.Value.Count == max) + { + name += $" ({string.Format(CultureInfo.InvariantCulture, Resources.MostRecentlyPushed, max)})"; + } + + return name; + } + + async Task LoadItems(bool refresh) + { + if (connection == null || IsLoading) return; + + Error = null; + IsLoading = true; + + try + { + if (refresh) + { + Items = new List(); + ItemsView = CollectionViewSource.GetDefaultView(Items); + } + + var results = await log.TimeAsync(nameof(service.ReadViewerRepositories), + () => service.ReadViewerRepositories(connection.HostAddress, refresh)); + + var yourRepositories = results.Repositories + .Where(r => r.Owner == results.Owner) + .Select(x => new RepositoryItemViewModel(x, Resources.RepositorySelectYourRepositories)); + var collaboratorRepositories = results.Repositories + .Where(r => r.Owner != results.Owner) + .OrderBy(r => r.Owner) + .Select(x => new RepositoryItemViewModel(x, Resources.RepositorySelectCollaboratorRepositories)); + var repositoriesContributedTo = results.ContributedToRepositories + .Select(x => new RepositoryItemViewModel(x, Resources.RepositorySelectContributedRepositories)); + var orgRepositories = results.Organizations + .OrderBy(x => x.Key) + .SelectMany(x => x.Value.Select(y => new RepositoryItemViewModel(y, GroupName(x, 100)))); + Items = yourRepositories + .Concat(collaboratorRepositories) + .Concat(repositoriesContributedTo) + .Concat(orgRepositories) + .ToList(); + log.Information("Read {Total} viewer repositories", Items.Count); + ItemsView = CollectionViewSource.GetDefaultView(Items); + ItemsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(RepositoryItemViewModel.Group))); + ItemsView.Filter = FilterItem; + } + catch (Exception ex) + { + log.Error(ex, "Error reading repository list from {Address}", connection.HostAddress); + + if (ex is AggregateException aggregate) + { + ex = aggregate.InnerExceptions[0]; + } + + Error = ex; + } + finally + { + IsLoading = false; + } + } + + bool FilterItem(object obj) + { + var trimedFilter = Filter?.Trim(); + if (obj is IRepositoryItemViewModel item && !string.IsNullOrEmpty(trimedFilter)) + { + if (new UriString(trimedFilter).IsHypertextTransferProtocol) + { + var urlString = item.Url.ToString(); + var urlStringWithGit = urlString + ".git"; + var urlStringWithSlash = urlString + "/"; + return + urlString.Contains(trimedFilter, StringComparison.OrdinalIgnoreCase) || + urlStringWithGit.Contains(trimedFilter, StringComparison.OrdinalIgnoreCase) || + urlStringWithSlash.Contains(trimedFilter, StringComparison.OrdinalIgnoreCase); + } + else + { + return + item.Caption.Contains(trimedFilter, StringComparison.CurrentCultureIgnoreCase); + } + } + + return true; + } + + RepositoryModel CreateRepository(IRepositoryItemViewModel item) + { + return item != null ? + new RepositoryModel(item.Name, UriString.ToUriString(item.Url)) : + null; + } + + RepositoryModel CreateRepository(GitHubContext context) + { + switch (context?.LinkType) + { + case LinkType.Repository: + case LinkType.Blob: + return new RepositoryModel(context.RepositoryName, context.Url); + } + + return null; + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/ForkRepositoryExecuteViewModel.cs b/src/GitHub.App/ViewModels/Dialog/ForkRepositoryExecuteViewModel.cs new file mode 100644 index 0000000000..9211b06b4c --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/ForkRepositoryExecuteViewModel.cs @@ -0,0 +1,156 @@ +using System; +using System.ComponentModel.Composition; +using System.Globalization; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.App; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using Octokit; +using ReactiveUI; +using Serilog; +using IConnection = GitHub.Models.IConnection; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IForkRepositoryExecuteViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ForkRepositoryExecuteViewModel : ViewModelBase, IForkRepositoryExecuteViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IModelServiceFactory modelServiceFactory; + readonly IRepositoryForkService repositoryForkService; + + IApiClient apiClient; + + [ImportingConstructor] + public ForkRepositoryExecuteViewModel( + IModelServiceFactory modelServiceFactory, + IRepositoryForkService repositoryForkService + ) + { + this.modelServiceFactory = modelServiceFactory; + this.repositoryForkService = repositoryForkService; + + this.WhenAnyValue(model => model.UpdateOrigin) + .Subscribe(value => CanAddUpstream = value); + + this.WhenAnyValue(model => model.UpdateOrigin, model => model.AddUpstream) + .Subscribe(tuple => CanResetMasterTracking = tuple.Item1 && tuple.Item2); + + CreateFork = ReactiveCommand.CreateFromObservable(OnCreateFork); + BackCommand = ReactiveCommand.Create(() => { }); + } + + public RepositoryModel SourceRepository { get; private set; } + + public IAccount DestinationAccount { get; private set; } + + public RepositoryModel DestinationRepository { get; private set; } + + public ReactiveCommand CreateFork { get; } + + public ReactiveCommand BackCommand { get; } + + public string Title => Resources.ForkRepositoryTitle; + + public IObservable Done => CreateFork.Where(repository => repository != null); + + public IObservable Back => BackCommand; + + public async Task InitializeAsync(LocalRepositoryModel sourceRepository, IAccount destinationAccount, IConnection connection) + { + var modelService = await modelServiceFactory.CreateAsync(connection); + apiClient = modelService.ApiClient; + + DestinationAccount = destinationAccount; + + SourceRepository = sourceRepository; + DestinationRepository = new RemoteRepositoryModel( + 0, + sourceRepository.Name, + CreateForkUri(sourceRepository.CloneUrl, destinationAccount.Login), + false, + true, + destinationAccount, + null); + } + + UriString CreateForkUri(UriString url, string login) + { + var original = url.ToRepositoryUrl(); + var forkUri = string.Format(CultureInfo.CurrentCulture, "{0}://{1}/{2}/{3}", original.Scheme, original.Authority, login, url.RepositoryName); + return new UriString(forkUri); + } + + IObservable OnCreateFork() + { + var newRepositoryFork = new NewRepositoryFork + { + Organization = !DestinationAccount.IsUser ? DestinationAccount.Login : null + }; + + return repositoryForkService + .ForkRepository(apiClient, SourceRepository, newRepositoryFork, UpdateOrigin, CanAddUpstream && AddUpstream, CanResetMasterTracking && ResetMasterTracking) + .Catch(ex => + { + log.Error(ex, "Error Creating Fork"); + + var apiEx = ex as ApiException; + Error = apiEx != null ? apiEx.Message : "An unexpected error occurred."; + + return Observable.Return(null); + }); + } + + bool updateOrigin = true; + public bool UpdateOrigin + { + get { return updateOrigin; } + set { this.RaiseAndSetIfChanged(ref updateOrigin, value); } + } + + bool canAddUpstream = true; + public bool CanAddUpstream + { + get { return canAddUpstream; } + private set { this.RaiseAndSetIfChanged(ref canAddUpstream, value); } + } + + bool addUpstream = true; + public bool AddUpstream + { + get { return addUpstream; } + set { this.RaiseAndSetIfChanged(ref addUpstream, value); } + } + + bool canResetMasterTracking; + public bool CanResetMasterTracking + { + get { return canResetMasterTracking; } + private set { this.RaiseAndSetIfChanged(ref canResetMasterTracking, value); } + } + + bool resetMasterTracking; + public bool ResetMasterTracking + { + get { return resetMasterTracking; } + set { this.RaiseAndSetIfChanged(ref resetMasterTracking, value); } + } + + string error = null; + + public string Error + { + get { return error; } + private set { this.RaiseAndSetIfChanged(ref error, value); } + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/ForkRepositorySelectViewModel.cs b/src/GitHub.App/ViewModels/Dialog/ForkRepositorySelectViewModel.cs new file mode 100644 index 0000000000..083b60532c --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/ForkRepositorySelectViewModel.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.App; +using GitHub.Extensions.Reactive; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IForkRepositorySelectViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ForkRepositorySelectViewModel : ViewModelBase, IForkRepositorySelectViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IModelServiceFactory modelServiceFactory; + IReadOnlyList accounts; + IReadOnlyList existingForks; + bool isLoading; + + [ImportingConstructor] + public ForkRepositorySelectViewModel(IModelServiceFactory modelServiceFactory) + { + this.modelServiceFactory = modelServiceFactory; + SelectedAccount = ReactiveCommand.Create(account => account); + SwitchOrigin = ReactiveCommand.Create(_ => { }); + } + + public string Title => Resources.ForkRepositoryTitle; + + public IReadOnlyList Accounts + { + get { return accounts; } + private set { this.RaiseAndSetIfChanged(ref accounts, value); } + } + + public IReadOnlyList ExistingForks + { + get { return existingForks; } + private set { this.RaiseAndSetIfChanged(ref existingForks, value); } + } + + public bool IsLoading + { + get { return isLoading; } + private set { this.RaiseAndSetIfChanged(ref isLoading, value); } + } + + public ReactiveCommand SelectedAccount { get; } + + public ReactiveCommand SwitchOrigin { get; } + + public IObservable Done => SelectedAccount; + + public async Task InitializeAsync(LocalRepositoryModel repository, IConnection connection) + { + IsLoading = true; + + try + { + var modelService = await modelServiceFactory.CreateAsync(connection); + + Observable.CombineLatest( + modelService.GetAccounts(), + modelService.GetRepository(repository.Owner, repository.Name), + modelService.GetForks(repository).ToList(), + (a, r, f) => new { Accounts = a, Respoitory = r, Forks = f }) + .Finally(() => IsLoading = false) + .Subscribe(x => + { + var forks = x.Forks; + + var parents = new List(); + var current = x.Respoitory; + while (current.Parent != null) + { + parents.Add(current.Parent); + current = current.Parent; + } + + BuildAccounts(x.Accounts, repository, forks, parents); + }); + + } + catch (Exception ex) + { + log.Error(ex, "Error initializing ForkRepositoryViewModel"); + IsLoading = false; + } + } + + void BuildAccounts(IReadOnlyList accessibleAccounts, LocalRepositoryModel currentRepository, IList forks, List parents) + { + log.Verbose("BuildAccounts: {AccessibleAccounts} accessibleAccounts, {Forks} forks, {Parents} parents", accessibleAccounts.Count, forks.Count, parents.Count); + + var existingForksAndParents = forks.Union(parents).ToDictionary(model => model.Owner); + + var readOnlyList = accessibleAccounts + .Where(account => account.Login != currentRepository.Owner) + .Select(account => new { Account = account, Fork = existingForksAndParents.ContainsKey(account.Login) ? existingForksAndParents[account.Login] : null }) + .ToArray(); + + Accounts = readOnlyList.Where(arg => arg.Fork == null).Select(arg => arg.Account).ToList(); + ExistingForks = readOnlyList.Where(arg => arg.Fork != null).Select(arg => arg.Fork).ToList(); + + // HACK: Our avatar cache only provides avatars in a very small size, but we want to + // display them 100x100 in the Fork view. For now, wse the AvatarUrl directly to get + // the avatar, appending "s=100" to the URL to get the correct size. + foreach (Account account in Accounts) + { + account.AvatarUrl += "&s=100"; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/ForkRepositorySwitchViewModel.cs b/src/GitHub.App/ViewModels/Dialog/ForkRepositorySwitchViewModel.cs new file mode 100644 index 0000000000..74777a7a05 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/ForkRepositorySwitchViewModel.cs @@ -0,0 +1,76 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; +using GitHub.App; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using Octokit; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IForkRepositorySwitchViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ForkRepositorySwitchViewModel : ViewModelBase, IForkRepositorySwitchViewModel + { + readonly IRepositoryForkService repositoryForkService; + + [ImportingConstructor] + public ForkRepositorySwitchViewModel(IRepositoryForkService repositoryForkService) + { + this.repositoryForkService = repositoryForkService; + + SwitchFork = ReactiveCommand.CreateFromObservable(OnSwitchFork); + } + + public RepositoryModel SourceRepository { get; private set; } + + public RepositoryModel DestinationRepository { get; private set; } + + public ReactiveCommand SwitchFork { get; } + + public string Title => Resources.SwitchOriginTitle; + + public IObservable Done => SwitchFork.Where(value => value != null).SelectNull(); + + public void Initialize(LocalRepositoryModel sourceRepository, RemoteRepositoryModel remoteRepository) + { + SourceRepository = sourceRepository; + DestinationRepository = remoteRepository; + } + + IObservable OnSwitchFork() + { + return repositoryForkService.SwitchRemotes(DestinationRepository, UpdateOrigin, AddUpstream, ResetMasterTracking); + } + + bool resetMasterTracking = true; + public bool ResetMasterTracking + { + get { return resetMasterTracking; } + set { this.RaiseAndSetIfChanged(ref resetMasterTracking, value); } + } + + bool addUpstream = true; + public bool AddUpstream + { + get { return addUpstream; } + set { this.RaiseAndSetIfChanged(ref addUpstream, value); } + } + + bool updateOrigin = true; + public bool UpdateOrigin + { + get { return updateOrigin; } + set { this.RaiseAndSetIfChanged(ref updateOrigin, value); } + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/ForkRepositoryViewModel.cs b/src/GitHub.App/ViewModels/Dialog/ForkRepositoryViewModel.cs new file mode 100644 index 0000000000..28f0fed260 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/ForkRepositoryViewModel.cs @@ -0,0 +1,73 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IForkRepositoryViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ForkRepositoryViewModel : PagedDialogViewModelBase, IForkRepositoryViewModel + { + readonly IForkRepositorySelectViewModel selectPage; + readonly IForkRepositorySwitchViewModel switchPage; + readonly IForkRepositoryExecuteViewModel executePage; + + [ImportingConstructor] + public ForkRepositoryViewModel( + IForkRepositorySelectViewModel selectPage, + IForkRepositorySwitchViewModel switchPage, + IForkRepositoryExecuteViewModel executePage) + { + this.selectPage = selectPage; + this.executePage = executePage; + this.switchPage = switchPage; + + Completed = ReactiveCommand.Create(() => { }); + + //selectPage.SwitchOrigin.Subscribe(x => ShowSwitchRepositoryPath((IRemoteRepositoryModel)x)); + selectPage.Done.Subscribe(x => ShowExecutePage((IAccount)x).Forget()); + executePage.Back.Subscribe(x => ShowSelectPage().Forget()); + } + + public LocalRepositoryModel Repository { get; private set; } + + public IConnection Connection { get; private set; } + + private ReactiveCommand Completed { get; } + + public override IObservable Done => executePage.Done; + + public async Task InitializeAsync(LocalRepositoryModel repository, IConnection connection) + { + Repository = repository; + Connection = connection; + await ShowSelectPage(); + } + + async Task ShowSelectPage() + { + await selectPage.InitializeAsync(Repository, Connection); + Content = selectPage; + } + + async Task ShowExecutePage(IAccount account) + { + await executePage.InitializeAsync(Repository, account, Connection); + Content = executePage; + } + + void ShowSwitchRepositoryPath(RemoteRepositoryModel remoteRepository) + { + switchPage.Initialize(Repository, remoteRepository); + Content = switchPage; + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/GistCreationViewModel.cs b/src/GitHub.App/ViewModels/Dialog/GistCreationViewModel.cs new file mode 100644 index 0000000000..c246e0f203 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/GistCreationViewModel.cs @@ -0,0 +1,137 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.App; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using Octokit; +using ReactiveUI; +using Serilog; +using IConnection = GitHub.Models.IConnection; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IGistCreationViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class GistCreationViewModel : ViewModelBase, IGistCreationViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IModelServiceFactory modelServiceFactory; + readonly IGistPublishService gistPublishService; + readonly INotificationService notificationService; + readonly IUsageTracker usageTracker; + IApiClient apiClient; + ObservableAsPropertyHelper account; + + [ImportingConstructor] + public GistCreationViewModel( + IModelServiceFactory modelServiceFactory, + ISelectedTextProvider selectedTextProvider, + IGistPublishService gistPublishService, + INotificationService notificationService, + IUsageTracker usageTracker) + { + Guard.ArgumentNotNull(selectedTextProvider, nameof(selectedTextProvider)); + Guard.ArgumentNotNull(gistPublishService, nameof(gistPublishService)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + + this.modelServiceFactory = modelServiceFactory; + this.gistPublishService = gistPublishService; + this.notificationService = notificationService; + this.usageTracker = usageTracker; + + FileName = VisualStudio.Services.GetFileNameFromActiveDocument() ?? Resources.DefaultGistFileName; + SelectedText = selectedTextProvider.GetSelectedText(); + + var canCreateGist = this.WhenAny( + x => x.FileName, + fileName => !String.IsNullOrEmpty(fileName.Value)); + + CreateGist = ReactiveCommand.CreateFromObservable(OnCreateGist, canCreateGist); + } + + public async Task InitializeAsync(IConnection connection) + { + var modelService = await modelServiceFactory.CreateAsync(connection); + apiClient = modelService.ApiClient; + + // This class is only instantiated after we are logged into to a github account, so we should be safe to grab the first one here as the defaut. + account = modelService.GetAccounts() + .FirstAsync() + .Select(a => a.First()) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, vm => vm.Account); + } + + IObservable OnCreateGist() + { + var newGist = new NewGist + { + Description = Description, + Public = !IsPrivate + }; + + newGist.Files.Add(FileName, SelectedText); + + return gistPublishService.PublishGist(apiClient, newGist) + .Do(_ => usageTracker.IncrementCounter(x => x.NumberOfGists).Forget()) + .Catch(ex => + { + if (!ex.IsCriticalException()) + { + log.Error(ex, "Error Creating Gist"); + var error = StandardUserErrors.GetUserFriendlyErrorMessage(ex, ErrorType.GistCreateFailed); + notificationService.ShowError(error); + } + return Observable.Return(null); + }); + } + + public string Title => Resources.CreateGistTitle; + + public ReactiveCommand CreateGist { get; } + + public IAccount Account + { + get { return account.Value; } + } + + bool isPrivate; + public bool IsPrivate + { + get { return isPrivate; } + set { this.RaiseAndSetIfChanged(ref isPrivate, value); } + } + + string description; + public string Description + { + get { return description; } + set { this.RaiseAndSetIfChanged(ref description, value); } + } + + string selectedText; + public string SelectedText + { + get { return selectedText; } + set { this.RaiseAndSetIfChanged(ref selectedText, value); } + } + + string fileName; + public string FileName + { + get { return fileName; } + set { this.RaiseAndSetIfChanged(ref fileName, value); } + } + + public IObservable Done => CreateGist.Where(x => x != null); + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/GitHubDialogWindowViewModel.cs b/src/GitHub.App/ViewModels/Dialog/GitHubDialogWindowViewModel.cs new file mode 100644 index 0000000000..479e28b38a --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/GitHubDialogWindowViewModel.cs @@ -0,0 +1,114 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the top-level view model for the GitHub dialog. + /// + [Export(typeof(IGitHubDialogWindowViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class GitHubDialogWindowViewModel : ViewModelBase, IGitHubDialogWindowViewModel + { + readonly IViewViewModelFactory factory; + readonly Lazy connectionManager; + IDialogContentViewModel content; + Subject done = new Subject(); + IDisposable subscription; + + [ImportingConstructor] + public GitHubDialogWindowViewModel( + IViewViewModelFactory factory, + Lazy connectionManager) + { + this.factory = factory; + this.connectionManager = connectionManager; + } + + /// + public IDialogContentViewModel Content + { + get { return content; } + private set { this.RaiseAndSetIfChanged(ref content, value); } + } + + /// + public IObservable Done => done; + + /// + public void Dispose() + { + subscription?.Dispose(); + subscription = null; + } + + /// + public void Start(IDialogContentViewModel viewModel) + { + subscription?.Dispose(); + Content = viewModel; + subscription = viewModel.Done?.Subscribe(done); + } + + /// + public async Task StartWithConnection(T viewModel) + where T : IDialogContentViewModel, IConnectionInitializedViewModel + { + var connections = await connectionManager.Value.GetLoadedConnections().ConfigureAwait(true); + var connection = connections.FirstOrDefault(x => x.IsLoggedIn); + + if (connection == null) + { + connection = await ShowLogin().ConfigureAwait(true); + } + + if (connection != null) + { + await viewModel.InitializeAsync(connection).ConfigureAwait(true); + Start(viewModel); + } + else + { + done.OnNext(null); + } + } + + public Task StartWithLogout(T viewModel, IConnection connection) + where T : IDialogContentViewModel, IConnectionInitializedViewModel + { + var logout = factory.CreateViewModel(); + + subscription?.Dispose(); + subscription = logout.Done.Take(1).Subscribe(async _ => + { + await connectionManager.Value.LogOut(connection.HostAddress).ConfigureAwait(true); + + connection = await ShowLogin().ConfigureAwait(true); + + if (connection != null) + { + await viewModel.InitializeAsync(connection).ConfigureAwait(true); + Start(viewModel); + } + }); + + Content = logout; + return Task.CompletedTask; + } + + async Task ShowLogin() + { + var login = factory.CreateViewModel(); + Content = login; + return (IConnection)await login.Done.Take(1); + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/LogOutRequiredViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LogOutRequiredViewModel.cs new file mode 100644 index 0000000000..cf44a7cd8c --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/LogOutRequiredViewModel.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using GitHub.Extensions.Reactive; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using ReactiveUI; + +namespace GitHub.App.ViewModels.Dialog +{ + /// + /// The "Logout required" dialog page. + /// + [Export(typeof(ILogOutRequiredViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LogOutRequiredViewModel : ViewModelBase, ILogOutRequiredViewModel + { + /// + public ReactiveCommand LogOut { get; } = ReactiveCommand.Create(() => { }); + + /// + public string Title => Resources.LogoutRequired; + + /// + public IObservable Done => LogOut.SelectNull(); + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/Login2FaViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Login2FaViewModel.cs new file mode 100644 index 0000000000..8ddc84be30 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Login2FaViewModel.cs @@ -0,0 +1,163 @@ +using System; +using System.ComponentModel.Composition; +using System.Globalization; +using System.Reactive; +using System.Reactive.Linq; +using GitHub.App; +using GitHub.Authentication; +using GitHub.Extensions; +using GitHub.Info; +using GitHub.Services; +using GitHub.Validation; +using Octokit; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(ILogin2FaViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class Login2FaViewModel : ViewModelBase, ILogin2FaViewModel + { + bool isAuthenticationCodeSent; + bool invalidAuthenticationCode; + string authenticationCode; + TwoFactorType twoFactorType; + bool isBusy; + readonly ObservableAsPropertyHelper description; + readonly ObservableAsPropertyHelper isSms; + readonly ObservableAsPropertyHelper showErrorMessage; + + [ImportingConstructor] + public Login2FaViewModel(IVisualStudioBrowser browser) + { + Guard.ArgumentNotNull(browser, nameof(browser)); + + var canVerify = this.WhenAny( + x => x.AuthenticationCode, + x => x.IsBusy, + (code, busy) => !string.IsNullOrEmpty(code.Value) && code.Value.Length == 6 && !busy.Value); + + OkCommand = ReactiveCommand.Create(() => { }, canVerify); + NavigateLearnMore = ReactiveCommand.Create(() => { }); + NavigateLearnMore.Subscribe(x => browser.OpenUrl(GitHubUrls.TwoFactorLearnMore)); + //TODO: ShowHelpCommand.Subscribe(x => browser.OpenUrl(twoFactorHelpUri)); + ResendCodeCommand = ReactiveCommand.Create(() => { }); + + showErrorMessage = this.WhenAny( + x => x.IsAuthenticationCodeSent, + x => x.InvalidAuthenticationCode, + (authSent, invalid) => invalid.Value && !authSent.Value) + .ToProperty(this, x => x.ShowErrorMessage); + + description = this.WhenAny(x => x.TwoFactorType, x => x.Value) + .Select(type => + { + switch (type) + { + case TwoFactorType.Sms: + return Resources.TwoFactorSms; + case TwoFactorType.AuthenticatorApp: + return Resources.TwoFactorApp; + case TwoFactorType.Unknown: + return Resources.TwoFactorUnknown; + + default: + return null; + } + }) + .ToProperty(this, x => x.Description); + + isSms = this.WhenAny(x => x.TwoFactorType, x => x.Value) + .Select(factorType => factorType == TwoFactorType.Sms) + .ToProperty(this, x => x.IsSms); + } + +#pragma warning disable CS0618 // Type or member is obsolete + public IObservable Show(ReactiveUI.Legacy.UserError userError) + { + Guard.ArgumentNotNull(userError, nameof(userError)); + + IsBusy = false; + var error = userError as TwoFactorRequiredUserError; + + if (error == null) + { + throw new GitHubLogicException( + String.Format( + CultureInfo.InvariantCulture, + "The user error is '{0}' not a TwoFactorRequiredUserError", + userError)); + } + + InvalidAuthenticationCode = error.RetryFailed; + IsAuthenticationCodeSent = false; + TwoFactorType = error.TwoFactorType; + var ok = OkCommand + .Do(_ => IsBusy = true) + .Select(_ => AuthenticationCode == null + ? null + : new TwoFactorChallengeResult(AuthenticationCode)); + var resend = ResendCodeCommand.Select(_ => ReactiveUI.Legacy.RecoveryOptionResult.RetryOperation) + .Select(_ => TwoFactorChallengeResult.RequestResendCode) + .Do(_ => IsAuthenticationCodeSent = true); + var cancel = this.WhenAnyValue(x => x.TwoFactorType) + .Skip(1) + .Where(x => x == TwoFactorType.None) + .Select(_ => default(TwoFactorChallengeResult)); + return Observable.Merge(ok, cancel, resend).Take(1); + } +#pragma warning restore CS0618 // Type or member is obsolete + + public string Title => Resources.TwoFactorTitle; + + public TwoFactorType TwoFactorType + { + get { return twoFactorType; } + private set { this.RaiseAndSetIfChanged(ref twoFactorType, value); } + } + + public bool IsBusy + { + get { return isBusy; } + private set { this.RaiseAndSetIfChanged(ref isBusy, value); } + } + + public bool IsSms { get { return isSms.Value; } } + + public bool IsAuthenticationCodeSent + { + get { return isAuthenticationCodeSent; } + private set { this.RaiseAndSetIfChanged(ref isAuthenticationCodeSent, value); } + } + + public string Description + { + get { return description.Value; } + } + + public string AuthenticationCode + { + get { return authenticationCode; } + set { this.RaiseAndSetIfChanged(ref authenticationCode, value); } + } + + public IObservable Done => null; + public ReactiveCommand OkCommand { get; private set; } + public ReactiveCommand NavigateLearnMore { get; private set; } + public ReactiveCommand ResendCodeCommand { get; private set; } + public ReactivePropertyValidator AuthenticationCodeValidator { get; private set; } + + public bool InvalidAuthenticationCode + { + get { return invalidAuthenticationCode; } + private set { this.RaiseAndSetIfChanged(ref invalidAuthenticationCode, value); } + } + + public bool ShowErrorMessage + { + get { return showErrorMessage.Value; } + } + + public void Cancel() => TwoFactorType = TwoFactorType.None; + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/LoginCredentialsViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginCredentialsViewModel.cs new file mode 100644 index 0000000000..fce1a65368 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/LoginCredentialsViewModel.cs @@ -0,0 +1,56 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(ILoginCredentialsViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LoginCredentialsViewModel : ViewModelBase, ILoginCredentialsViewModel + { + [ImportingConstructor] + public LoginCredentialsViewModel( + IConnectionManager connectionManager, + ILoginToGitHubViewModel loginToGitHubViewModel, + ILoginToGitHubForEnterpriseViewModel loginToGitHubEnterpriseViewModel) + { + ConnectionManager = connectionManager; + GitHubLogin = loginToGitHubViewModel; + EnterpriseLogin = loginToGitHubEnterpriseViewModel; + + isLoginInProgress = this.WhenAny( + x => x.GitHubLogin.IsLoggingIn, + x => x.EnterpriseLogin.IsLoggingIn, + (x, y) => x.Value || y.Value + ).ToProperty(this, vm => vm.IsLoginInProgress); + + LoginMode = LoginMode.DotComOrEnterprise; + + Done = Observable.Merge( + loginToGitHubViewModel.Login, + loginToGitHubViewModel.LoginViaOAuth, + loginToGitHubEnterpriseViewModel.Login, + loginToGitHubEnterpriseViewModel.LoginViaOAuth) + .Where(x => x != null); + } + + public string Title => Resources.LoginTitle; + public ILoginToGitHubViewModel GitHubLogin { get; } + public ILoginToGitHubForEnterpriseViewModel EnterpriseLogin { get; } + public IConnectionManager ConnectionManager { get; } + + LoginMode loginMode; + public LoginMode LoginMode + { + get { return loginMode; } + private set { this.RaiseAndSetIfChanged(ref loginMode, value); } + } + + readonly ObservableAsPropertyHelper isLoginInProgress; + public bool IsLoginInProgress { get { return isLoginInProgress.Value; } } + + public IObservable Done { get; } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/LoginTabViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginTabViewModel.cs new file mode 100644 index 0000000000..34062353df --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/LoginTabViewModel.cs @@ -0,0 +1,226 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using GitHub.App; +using GitHub.Authentication; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Info; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using GitHub.Validation; +using ReactiveUI; +using Serilog; +using IRecoveryCommand = ReactiveUI.Legacy.IRecoveryCommand; +using RecoveryCommand = ReactiveUI.Legacy.RecoveryCommand; +using RecoveryOptionResult = ReactiveUI.Legacy.RecoveryOptionResult; +using UserError = ReactiveUI.Legacy.UserError; + +#pragma warning disable CS0618 // Type or member is obsolete + +namespace GitHub.ViewModels.Dialog +{ + [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")] + public abstract class LoginTabViewModel : ReactiveObject + { + static readonly ILogger log = LogManager.ForContext(); + CancellationTokenSource oauthCancel; + + protected LoginTabViewModel( + IConnectionManager connectionManager, + IVisualStudioBrowser browser) + { + Guard.ArgumentNotNull(connectionManager, nameof(connectionManager)); + Guard.ArgumentNotNull(browser, nameof(browser)); + + ConnectionManager = connectionManager; + + UsernameOrEmailValidator = ReactivePropertyValidator.For(this, x => x.UsernameOrEmail) + .IfNullOrEmpty(Resources.UsernameOrEmailValidatorEmpty) + .IfMatch(@"\s", Resources.UsernameOrEmailValidatorSpaces); + + PasswordValidator = ReactivePropertyValidator.For(this, x => x.Password) + .IfNullOrEmpty(Resources.PasswordValidatorEmpty); + + canLogin = this.WhenAny( + x => x.UsernameOrEmailValidator.ValidationResult.IsValid, + x => x.PasswordValidator.ValidationResult.IsValid, + (x, y) => x.Value && y.Value).ToProperty(this, x => x.CanLogin); + + Login = ReactiveCommand.CreateFromTask(LogIn, this.WhenAny(x => x.CanLogin, x => x.Value)); + Login.ThrownExceptions.Subscribe(HandleError); + isLoggingIn = Login.IsExecuting.ToProperty(this, x => x.IsLoggingIn); + + LoginViaOAuth = ReactiveCommand.CreateFromTask( + LogInViaOAuth, + this.WhenAnyValue(x => x.IsLoggingIn, x => !x)); + LoginViaOAuth.ThrownExceptions.Subscribe(HandleError); + + Reset = ReactiveCommand.CreateFromTask(Clear); + + NavigateForgotPassword = new RecoveryCommand(Resources.ForgotPasswordLink, _ => + { + browser.OpenUrl(new Uri(BaseUri, GitHubUrls.ForgotPasswordPath)); + return RecoveryOptionResult.RetryOperation; + }); + + SignUp = ReactiveCommand.CreateFromObservable(() => + { + browser.OpenUrl(GitHubUrls.Plans); + return Observable.Return(Unit.Default); + }); + } + protected IConnectionManager ConnectionManager { get; } + protected abstract Uri BaseUri { get; } + public ReactiveCommand SignUp { get; } + + public ReactiveCommand Login { get; } + public ReactiveCommand LoginViaOAuth { get; } + public ReactiveCommand Reset { get; } +#pragma warning disable CS0618 // Type or member is obsolete + public IRecoveryCommand NavigateForgotPassword { get; } +#pragma warning restore CS0618 // Type or member is obsolete + + string usernameOrEmail; + public string UsernameOrEmail + { + get { return usernameOrEmail; } + set { this.RaiseAndSetIfChanged(ref usernameOrEmail, value); } + } + + ReactivePropertyValidator usernameOrEmailValidator; + public ReactivePropertyValidator UsernameOrEmailValidator + { + get { return usernameOrEmailValidator; } + private set { this.RaiseAndSetIfChanged(ref usernameOrEmailValidator, value); } + } + + string password; + public string Password + { + get { return password; } + set { this.RaiseAndSetIfChanged(ref password, value); } + } + + ReactivePropertyValidator passwordValidator; + public ReactivePropertyValidator PasswordValidator + { + get { return passwordValidator; } + private set { this.RaiseAndSetIfChanged(ref passwordValidator, value); } + } + + readonly ObservableAsPropertyHelper isLoggingIn; + public bool IsLoggingIn + { + get { return isLoggingIn.Value; } + } + + protected ObservableAsPropertyHelper canLogin; + public bool CanLogin + { + get { return canLogin.Value; } + } + + protected ObservableAsPropertyHelper canSsoLogin; + public bool CanSsoLogin + { + get { return canSsoLogin.Value; } + } + +#pragma warning disable CS0618 // Type or member is obsolete + UserError error; + public UserError Error + { + get { return error; } + set { this.RaiseAndSetIfChanged(ref error, value); } + } +#pragma warning restore CS0618 // Type or member is obsolete + + public void Deactivated() => oauthCancel?.Cancel(); + + protected abstract Task LogIn(); + protected abstract Task LogInViaOAuth(); + + protected async Task LogInToHost(HostAddress hostAddress) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + + if (await ConnectionManager.GetConnection(hostAddress) != null) + { + await ConnectionManager.LogOut(hostAddress); + } + + return await ConnectionManager.LogIn(hostAddress, UsernameOrEmail, Password); + } + + protected async Task LoginToHostViaOAuth(HostAddress address) + { + oauthCancel = new CancellationTokenSource(); + + if (await ConnectionManager.GetConnection(address) != null) + { + await ConnectionManager.LogOut(address); + } + + try + { + return await ConnectionManager.LogInViaOAuth(address, oauthCancel.Token); + } + finally + { + oauthCancel.Dispose(); + oauthCancel = null; + } + } + + async Task Clear() + { + UsernameOrEmail = null; + Password = null; + await UsernameOrEmailValidator.ResetAsync(); + await PasswordValidator.ResetAsync(); + await ResetValidation(); + } + + protected virtual Task ResetValidation() + { + // noop + return Task.FromResult(0); + } + +#pragma warning disable CS0618 // Type or member is obsolete + void HandleError(Exception ex) + { + // The Windows ERROR_OPERATION_ABORTED error code. + const int operationAborted = 995; + + if (ex is HttpListenerException && + ((HttpListenerException)ex).ErrorCode == operationAborted) + { + // An Oauth listener was aborted, probably because the user closed the login + // dialog or switched between the GitHub and Enterprise tabs while listening + // for an Oauth callbacl. + return; + } + + if (ex.IsCriticalException()) return; + + log.Error(ex, "Error logging into '{BaseUri}' as '{UsernameOrEmail}'", BaseUri, UsernameOrEmail); + if (ex is Octokit.ForbiddenException) + { + Error = new UserError(Resources.LoginFailedForbiddenMessage, ex.Message); + } + else + { + Error = new UserError(ex.Message); + } + } +#pragma warning restore CS0618 // Type or member is obsolete + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/LoginToGitHubForEnterpriseViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubForEnterpriseViewModel.cs new file mode 100644 index 0000000000..7283164ccd --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubForEnterpriseViewModel.cs @@ -0,0 +1,184 @@ +using System; +using System.ComponentModel.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.App; +using GitHub.Extensions; +using GitHub.Info; +using GitHub.Primitives; +using GitHub.Services; +using GitHub.Validation; +using Octokit; +using ReactiveUI; +using IConnection = GitHub.Models.IConnection; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(ILoginToGitHubForEnterpriseViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LoginToGitHubForEnterpriseViewModel : LoginTabViewModel, ILoginToGitHubForEnterpriseViewModel + { + readonly IEnterpriseCapabilitiesService enterpriseCapabilities; + + [ImportingConstructor] + public LoginToGitHubForEnterpriseViewModel( + IConnectionManager connectionManager, + IEnterpriseCapabilitiesService enterpriseCapabilities, + IVisualStudioBrowser browser) + : this(connectionManager, enterpriseCapabilities, browser, Scheduler.Default) + { + } + + public LoginToGitHubForEnterpriseViewModel( + IConnectionManager connectionManager, + IEnterpriseCapabilitiesService enterpriseCapabilities, + IVisualStudioBrowser browser, + IScheduler scheduler) + : base(connectionManager, browser) + { + Guard.ArgumentNotNull(connectionManager, nameof(connectionManager)); + Guard.ArgumentNotNull(enterpriseCapabilities, nameof(enterpriseCapabilities)); + Guard.ArgumentNotNull(browser, nameof(browser)); + + this.enterpriseCapabilities = enterpriseCapabilities; + + EnterpriseUrlValidator = ReactivePropertyValidator.For(this, x => x.EnterpriseUrl) + .IfNullOrEmpty(Resources.EnterpriseUrlValidatorEmpty) + .IfNotUri(Resources.EnterpriseUrlValidatorInvalid) + .IfGitHubDotComHost(Resources.EnterpriseUrlValidatorNotAGitHubHost); + + canLogin = this.WhenAnyValue( + x => x.UsernameOrEmailValidator.ValidationResult.IsValid, + x => x.PasswordValidator.ValidationResult.IsValid, + x => x.SupportedLoginMethods, + (x, y, z) => (x || (z & EnterpriseLoginMethods.Token) != 0) && y) + .ToProperty(this, x => x.CanLogin); + + canSsoLogin = this.WhenAnyValue( + x => x.EnterpriseUrlValidator.ValidationResult.IsValid) + .ToProperty(this, x => x.CanLogin); + + this.WhenAnyValue(x => x.EnterpriseUrl, x => x.EnterpriseUrlValidator.ValidationResult) + .Throttle(TimeSpan.FromMilliseconds(500), scheduler) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => UpdatingProbeStatus = EnterpriseUrlChanged(x.Item1, x.Item2?.IsValid ?? false)); + + NavigateLearnMore = ReactiveCommand.CreateFromObservable(() => + { + browser.OpenUrl(GitHubUrls.LearnMore); + return Observable.Return(Unit.Default); + }); + } + + protected override Task LogIn() + { + if (string.IsNullOrWhiteSpace(UsernameOrEmail)) + { + return LogInWithToken(HostAddress.Create(EnterpriseUrl), Password); + } + else + { + return LogInToHost(HostAddress.Create(EnterpriseUrl)); + } + } + + protected override Task LogInViaOAuth() + { + return LoginToHostViaOAuth(HostAddress.Create(EnterpriseUrl)); + } + + string enterpriseUrl; + public string EnterpriseUrl + { + get { return enterpriseUrl; } + set { this.RaiseAndSetIfChanged(ref enterpriseUrl, value); } + } + + EnterpriseProbeStatus probeStatus; + public EnterpriseProbeStatus ProbeStatus + { + get { return probeStatus; } + private set { this.RaiseAndSetIfChanged(ref probeStatus, value); } + } + + EnterpriseLoginMethods? supportedLoginMethods; + public EnterpriseLoginMethods? SupportedLoginMethods + { + get { return supportedLoginMethods; } + private set { this.RaiseAndSetIfChanged(ref supportedLoginMethods, value); } + } + + public ReactivePropertyValidator EnterpriseUrlValidator { get; } + + [SuppressMessage("Microsoft.Usage", "CA2234:PassSystemUriObjectsInsteadOfStrings")] + protected override Uri BaseUri => new UriBuilder(EnterpriseUrl).Uri; + + public ReactiveCommand NavigateLearnMore + { + get; + } + + public Task UpdatingProbeStatus + { + get; + private set; + } + + protected override async Task ResetValidation() + { + EnterpriseUrl = null; + await EnterpriseUrlValidator.ResetAsync(); + } + + async Task EnterpriseUrlChanged(string url, bool valid) + { + if (!valid) + { + // The EnterpriseUrlValidator will display an adorner in this case so don't show anything. + ProbeStatus = EnterpriseProbeStatus.None; + SupportedLoginMethods = null; + return; + } + + var enterpriseInstance = false; + var loginMethods = (EnterpriseLoginMethods?)null; + var uri = new UriBuilder(url).Uri; + + ProbeStatus = EnterpriseProbeStatus.Checking; + + if (await enterpriseCapabilities.Probe(uri) == EnterpriseProbeResult.Ok) + { + loginMethods = await enterpriseCapabilities.ProbeLoginMethods(uri); + enterpriseInstance = true; + } + + if (url == EnterpriseUrl) + { + if ((loginMethods & EnterpriseLoginMethods.Token) != 0 && + (loginMethods & EnterpriseLoginMethods.UsernameAndPassword) != 0) + { + loginMethods &= ~EnterpriseLoginMethods.Token; + } + + ProbeStatus = enterpriseInstance ? EnterpriseProbeStatus.Valid : EnterpriseProbeStatus.Invalid; + SupportedLoginMethods = loginMethods; + } + } + + async Task LogInWithToken(HostAddress hostAddress, string token) + { + Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); + + if (await ConnectionManager.GetConnection(hostAddress) != null) + { + await ConnectionManager.LogOut(hostAddress); + } + + return await ConnectionManager.LogInWithToken(hostAddress, token); + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/LoginToGitHubViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubViewModel.cs new file mode 100644 index 0000000000..72a0eb7844 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubViewModel.cs @@ -0,0 +1,49 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Authentication; +using GitHub.Info; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(ILoginToGitHubViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LoginToGitHubViewModel : LoginTabViewModel, ILoginToGitHubViewModel + { + [ImportingConstructor] + public LoginToGitHubViewModel( + IConnectionManager connectionManager, + IVisualStudioBrowser browser) + : base(connectionManager, browser) + { + BaseUri = HostAddress.GitHubDotComHostAddress.WebUri; + + NavigatePricing = ReactiveCommand.CreateFromObservable(() => + { + browser.OpenUrl(GitHubUrls.Pricing); + return Observable.Return(Unit.Default); + + }); + } + + public ReactiveCommand NavigatePricing { get; } + + protected override Uri BaseUri { get; } + + protected override Task LogIn() + { + return LogInToHost(HostAddress.GitHubDotComHostAddress); + } + + protected override Task LogInViaOAuth() + { + return LoginToHostViaOAuth(HostAddress.GitHubDotComHostAddress); + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/Dialog/LoginViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginViewModel.cs new file mode 100644 index 0000000000..2bfaf0d59b --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/LoginViewModel.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using GitHub.Authentication; +using Octokit; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the Login dialog content. + /// + [Export(typeof(ILoginViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LoginViewModel : PagedDialogViewModelBase, ILoginViewModel + { + [ImportingConstructor] + public LoginViewModel( + ILoginCredentialsViewModel credentials, + ILogin2FaViewModel twoFactor, + IDelegatingTwoFactorChallengeHandler twoFactorHandler) + { + twoFactorHandler.SetViewModel(twoFactor); + + Content = credentials; + Done = credentials.Done; + + twoFactor.WhenAnyValue(x => x.TwoFactorType) + .Subscribe(x => + { + Content = x == TwoFactorType.None ? + (IDialogContentViewModel)credentials : + twoFactor; + }); + } + + /// + public override IObservable Done { get; } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/PagedDialogViewModelBase.cs b/src/GitHub.App/ViewModels/Dialog/PagedDialogViewModelBase.cs new file mode 100644 index 0000000000..621d747eab --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/PagedDialogViewModelBase.cs @@ -0,0 +1,38 @@ +using System; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Base class for view models representing a multi-page dialog. + /// + public abstract class PagedDialogViewModelBase : ViewModelBase, IDialogContentViewModel + { + readonly ObservableAsPropertyHelper title; + IDialogContentViewModel content; + + /// + /// Initializes a new instance of the class. + /// + protected PagedDialogViewModelBase() + { + title = this.WhenAny(x => x.Content, x => x.Value?.Title ?? "GitHub") + .ToProperty(this, x => x.Title); + } + + /// + /// Gets the current page being displayed in the dialog. + /// + public IDialogContentViewModel Content + { + get { return content; } + protected set { this.RaiseAndSetIfChanged(ref content, value); } + } + + /// + public abstract IObservable Done { get; } + + /// + public string Title => title.Value; + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/RepositoryCreationViewModel.cs b/src/GitHub.App/ViewModels/Dialog/RepositoryCreationViewModel.cs new file mode 100644 index 0000000000..e31758de75 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/RepositoryCreationViewModel.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel.Composition; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using GitHub.App; +using GitHub.Collections; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using GitHub.UserErrors; +using GitHub.Validation; +using Octokit; +using ReactiveUI; +using Rothko; +using Serilog; +using IConnection = GitHub.Models.IConnection; +using UserError = ReactiveUI.Legacy.UserError; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IRepositoryCreationViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class RepositoryCreationViewModel : RepositoryFormViewModel, IRepositoryCreationViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(() => { }); + readonly IModelServiceFactory modelServiceFactory; + readonly IRepositoryCreationService repositoryCreationService; + readonly ObservableAsPropertyHelper isCreating; + readonly IOperatingSystem operatingSystem; + readonly IUsageTracker usageTracker; + ObservableAsPropertyHelper> accounts; + IModelService modelService; + + [ImportingConstructor] + public RepositoryCreationViewModel( + IModelServiceFactory modelServiceFactory, + IOperatingSystem operatingSystem, + IRepositoryCreationService repositoryCreationService, + IUsageTracker usageTracker) + { + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); + Guard.ArgumentNotNull(operatingSystem, nameof(operatingSystem)); + Guard.ArgumentNotNull(repositoryCreationService, nameof(repositoryCreationService)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + + this.modelServiceFactory = modelServiceFactory; + this.operatingSystem = operatingSystem; + this.repositoryCreationService = repositoryCreationService; + this.usageTracker = usageTracker; + + SelectedGitIgnoreTemplate = GitIgnoreItem.None; + SelectedLicense = LicenseItem.None; + + browseForDirectoryCommand.Subscribe(_ => ShowBrowseForDirectoryDialog()); + + BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(this.WhenAny(x => x.BaseRepositoryPath, x => x.Value)) + .IfNullOrEmpty(Resources.RepositoryCreationClonePathEmpty) + .IfTrue(x => x.Length > 200, Resources.RepositoryCreationClonePathTooLong) + .IfContainsInvalidPathChars(Resources.RepositoryCreationClonePathInvalidCharacters) + .IfPathNotRooted(Resources.RepositoryCreationClonePathInvalid); + + var nonNullRepositoryName = this.WhenAny( + x => x.RepositoryName, + x => x.BaseRepositoryPath, + (x, y) => x.Value) + .WhereNotNull(); + + RepositoryNameValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) + .IfNullOrEmpty(Resources.RepositoryNameValidatorEmpty) + .IfTrue(x => x.Length > 100, Resources.RepositoryNameValidatorTooLong) + .IfTrue(IsAlreadyRepoAtPath, Resources.RepositoryNameValidatorAlreadyExists); + + SafeRepositoryNameWarningValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) + .Add(repoName => + { + var parsedReference = GetSafeRepositoryName(repoName); + return parsedReference != repoName ? String.Format(CultureInfo.CurrentCulture, Resources.SafeRepositoryNameWarning, parsedReference) : null; + }); + + CreateRepository = InitializeCreateRepositoryCommand(); + + isCreating = CreateRepository.IsExecuting + .ToProperty(this, x => x.IsCreating); + + BaseRepositoryPath = repositoryCreationService.DefaultClonePath; + } + + public string Title { get; private set; } + + string baseRepositoryPath; + /// + /// Path to clone repositories into + /// + public string BaseRepositoryPath + { + get { return baseRepositoryPath; } + set { this.RaiseAndSetIfChanged(ref baseRepositoryPath, StripSurroundingQuotes(value)); } + } + + /// + /// Fires up a file dialog to select the directory to clone into + /// + public ReactiveCommand BrowseForDirectory { get { return browseForDirectoryCommand; } } + + /// + /// Is running the creation process + /// + public bool IsCreating { get { return isCreating.Value; } } + + IReadOnlyList gitIgnoreTemplates; + public IReadOnlyList GitIgnoreTemplates + { + get { return gitIgnoreTemplates; } + set { this.RaiseAndSetIfChanged(ref gitIgnoreTemplates, value); } + } + + IReadOnlyList licenses; + public IReadOnlyList Licenses + { + get { return licenses; } + set { this.RaiseAndSetIfChanged(ref licenses, value); } + } + + GitIgnoreItem selectedGitIgnoreTemplate; + public GitIgnoreItem SelectedGitIgnoreTemplate + { + get { return selectedGitIgnoreTemplate; } + set { this.RaiseAndSetIfChanged(ref selectedGitIgnoreTemplate, value ?? GitIgnoreItem.None); } + } + + LicenseItem selectedLicense; + public LicenseItem SelectedLicense + { + get { return selectedLicense; } + set { this.RaiseAndSetIfChanged(ref selectedLicense, value ?? LicenseItem.None); } + } + + /// + /// List of accounts (at least one) + /// + public IReadOnlyList Accounts { get { return accounts.Value; } } + + public ReactivePropertyValidator BaseRepositoryPathValidator { get; private set; } + + /// + /// Fires off the process of creating the repository remotely and then cloning it locally + /// + public ReactiveCommand CreateRepository { get; private set; } + + public IObservable Done => CreateRepository.Select(_ => (object)null); + + public async Task InitializeAsync(IConnection connection) + { + modelService = await modelServiceFactory.CreateAsync(connection).ConfigureAwait(true); + + Title = string.Format(CultureInfo.CurrentCulture, Resources.CreateTitle, connection.HostAddress.Title); + + accounts = modelService.GetAccounts() + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, vm => vm.Accounts, initialValue: new ReadOnlyCollection(Array.Empty())); + + this.WhenAny(x => x.Accounts, x => x.Value) + .Select(accts => accts?.FirstOrDefault()) + .WhereNotNull() + .Subscribe(a => SelectedAccount = a); + + modelService.GetGitIgnoreTemplates() + .Where(x => x != null) + .ToList() + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => + { + var sorted = x + .Distinct() + .OrderByDescending(item => item.Recommended) + .ThenBy(item => item.Name); + GitIgnoreTemplates = new[] { GitIgnoreItem.None }.Concat(sorted).ToList(); + + SelectedGitIgnoreTemplate = GitIgnoreTemplates + .FirstOrDefault(i => i?.Name.Equals("VisualStudio", StringComparison.OrdinalIgnoreCase) == true); + }); + + modelService.GetLicenses() + .Where(x => x != null) + .ToList() + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => + { + var sorted = x + .Distinct() + .OrderByDescending(item => item.Recommended) + .ThenBy(item => item.Key); + Licenses = new[] { LicenseItem.None }.Concat(sorted).ToList(); + }); + } + + protected override NewRepository GatherRepositoryInfo() + { + var gitHubRepository = base.GatherRepositoryInfo(); + + if (SelectedLicense != LicenseItem.None) + { + gitHubRepository.LicenseTemplate = SelectedLicense.Key; + gitHubRepository.AutoInit = true; + } + + if (SelectedGitIgnoreTemplate != GitIgnoreItem.None) + { + gitHubRepository.GitignoreTemplate = SelectedGitIgnoreTemplate.Name; + gitHubRepository.AutoInit = true; + } + return gitHubRepository; + } + + IObservable ShowBrowseForDirectoryDialog() + { + return Observable.Start(() => + { + // We store this in a local variable to prevent it changing underneath us while the + // folder dialog is open. + var localBaseRepositoryPath = BaseRepositoryPath; + var browseResult = operatingSystem.Dialog.BrowseForDirectory(localBaseRepositoryPath, + Resources.BrowseForDirectory); + + if (!browseResult.Success) + return; + + var directory = browseResult.DirectoryPath ?? localBaseRepositoryPath; + + try + { + BaseRepositoryPath = directory; + } + catch (Exception e) + { + // TODO: We really should limit this to exceptions we know how to handle. + log.Error(e, "Failed to set base repository path {@0}", + new { localBaseRepositoryPath, BaseRepositoryPath, directory }); + } + }, RxApp.MainThreadScheduler); + } + + bool IsAlreadyRepoAtPath(string potentialRepositoryName) + { + Guard.ArgumentNotNull(potentialRepositoryName, nameof(potentialRepositoryName)); + + bool isAlreadyRepoAtPath = false; + var validationResult = BaseRepositoryPathValidator.ValidationResult; + string safeRepositoryName = GetSafeRepositoryName(potentialRepositoryName); + if (validationResult != null && validationResult.IsValid) + { + if (Path.GetInvalidPathChars().Any(chr => BaseRepositoryPath.Contains(chr))) + return false; + string potentialPath = Path.Combine(BaseRepositoryPath, safeRepositoryName); + isAlreadyRepoAtPath = operatingSystem.Directory.Exists(potentialPath); + } + return isAlreadyRepoAtPath; + } + + IObservable OnCreateRepository() + { + var newRepository = GatherRepositoryInfo(); + + return repositoryCreationService.CreateRepository( + newRepository, + SelectedAccount, + BaseRepositoryPath, + modelService.ApiClient) + .Do(_ => usageTracker.IncrementCounter(x => x.NumberOfReposCreated).Forget()); + } + + ReactiveCommand InitializeCreateRepositoryCommand() + { + var canCreate = this.WhenAny( + x => x.RepositoryNameValidator.ValidationResult.IsValid, + x => x.BaseRepositoryPathValidator.ValidationResult.IsValid, + (x, y) => x.Value && y.Value); + var createCommand = ReactiveCommand.CreateFromObservable(OnCreateRepository, canCreate); + createCommand.ThrownExceptions.Subscribe(ex => + { + if (!Extensions.ExceptionExtensions.IsCriticalException(ex)) + { + log.Error(ex, "Error creating repository"); +#pragma warning disable CS0618 // Type or member is obsolete + UserError.Throw(TranslateRepositoryCreateException(ex)); +#pragma warning restore CS0618 // Type or member is obsolete + } + }); + + return createCommand; + } + + static string StripSurroundingQuotes(string path) + { + Guard.ArgumentNotNull(path, nameof(path)); + + if (string.IsNullOrEmpty(path) + || path.Length < 2 + || !path.StartsWith("\"", StringComparison.Ordinal) + || !path.EndsWith("\"", StringComparison.Ordinal)) + { + return path; + } + + return path.Substring(1, path.Length - 2); + } + + PublishRepositoryUserError TranslateRepositoryCreateException(Exception ex) + { + Guard.ArgumentNotNull(ex, nameof(ex)); + + var existsException = ex as RepositoryExistsException; + if (existsException != null && SelectedAccount != null) + { + string message = string.Format( + CultureInfo.InvariantCulture, + Resources.RepositoryCreationFailedAlreadyExists, + SelectedAccount.Login, RepositoryName); + return new PublishRepositoryUserError(message, Resources.RepositoryCreationFailedAlreadyExistsMessage); + } + var quotaExceededException = ex as PrivateRepositoryQuotaExceededException; + if (quotaExceededException != null && SelectedAccount != null) + { + return new PublishRepositoryUserError(Resources.RepositoryCreationFailedQuota, quotaExceededException.Message); + } + return new PublishRepositoryUserError(ex.Message); + } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/CommitListViewModel.cs b/src/GitHub.App/ViewModels/Documents/CommitListViewModel.cs new file mode 100644 index 0000000000..982661a556 --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/CommitListViewModel.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Text; + +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a list of commit summaries in a pull request timeline. + /// + [Export(typeof(ICommitListViewModel))] + public class CommitListViewModel : ViewModelBase, ICommitListViewModel + { + /// + /// Initializes a new instance of the class. + /// + /// The commits to display. + public CommitListViewModel(params ICommitSummaryViewModel[] commits) + { + if (commits.Length == 0) + { + throw new NotSupportedException("Cannot create a CommitListViewModel with 0 commits."); + } + + Commits = commits; + Author = Commits[0].Author; + AuthorName = GetAuthorDisplayName(Commits[0].Author); + AuthorCaption = BuildAuthorCaption(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The commits to display. + public CommitListViewModel(IEnumerable commits) + { + Commits = commits.ToList(); + + if (Commits.Count == 0) + { + throw new NotSupportedException("Cannot create a CommitListViewModel with 0 commits."); + } + + Author = Commits[0].Author; + AuthorName = GetAuthorDisplayName(Commits[0].Author); + AuthorCaption = BuildAuthorCaption(); + } + + /// + public ICommitActorViewModel Author { get; } + + /// + public string AuthorName { get; } + + /// + public string AuthorCaption { get; } + + /// + public IReadOnlyList Commits { get; } + + string BuildAuthorCaption() + { + var result = new StringBuilder(); + + if (Commits.Any(x => GetAuthorDisplayName(x.Author) != AuthorName)) + { + result.Append(Resources.AndOthers); + result.Append(' '); + } + + result.Append(Resources.AddedSomeCommits); + return result.ToString(); + } + + string GetAuthorDisplayName(ICommitActorViewModel commitActorViewModel) + { + return commitActorViewModel.HasLogin ? commitActorViewModel.Login : commitActorViewModel.Name; + } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/CommitSummaryViewModel.cs b/src/GitHub.App/ViewModels/Documents/CommitSummaryViewModel.cs new file mode 100644 index 0000000000..7cb9b10e0b --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/CommitSummaryViewModel.cs @@ -0,0 +1,34 @@ +using GitHub.Models; + +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a one-line summary of a commit in a pull request timeline. + /// + public class CommitSummaryViewModel : ViewModelBase, ICommitSummaryViewModel + { + /// + /// Initializes a new instance of the class. + /// + /// The commit model. + public CommitSummaryViewModel(CommitModel commit) + { + AbbreviatedOid = commit.AbbreviatedOid; + Author = new CommitActorViewModel(commit.Author); + Header = commit.MessageHeadline; + Oid = commit.Oid; + } + + /// + public string AbbreviatedOid { get; private set; } + + /// + public ICommitActorViewModel Author { get; private set; } + + /// + public string Header { get; private set; } + + /// + public string Oid { get; private set; } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/IIssueishCommentViewModel.cs b/src/GitHub.App/ViewModels/Documents/IIssueishCommentViewModel.cs new file mode 100644 index 0000000000..08bf9e1f1c --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/IIssueishCommentViewModel.cs @@ -0,0 +1,55 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for comments on an issue or pull request. + /// + public interface IIssueishCommentViewModel : ICommentViewModel, IDisposable + { + /// + /// Gets a value indicating whether the comment will show a button for + /// . + /// + bool CanCloseOrReopen { get; } + + /// + /// Gets a a caption for the command. + /// + string CloseOrReopenCaption { get; } + + /// + /// Gets a command which when executed will close the issue or pull request if it is open, + /// or reopen it if it is closed. + /// + ReactiveCommand CloseOrReopen { get; } + + /// + /// Initializes the view model with data. + /// + /// The thread that the comment is a part of. + /// The current user. + /// The comment model. May be null. + /// + /// true if the comment is on a pull request, false if the comment is on an issue. + /// + /// + /// Whether the user can close or reopen the pull request from this comment. + /// + /// + /// An observable tracking whether the issue or pull request is open. Can be null if + /// is false. + /// + Task InitializeAsync( + IIssueishCommentThreadViewModel thread, + ActorModel currentUser, + CommentModel comment, + bool isPullRequest, + bool canCloseOrReopen, + IObservable isOpen = null); + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs new file mode 100644 index 0000000000..44db887114 --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs @@ -0,0 +1,104 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for comments on an issue or pull request. + /// + [Export(typeof(IIssueishCommentViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class IssueishCommentViewModel : CommentViewModel, IIssueishCommentViewModel + { + bool canCloseOrReopen; + ObservableAsPropertyHelper closeOrReopenCaption; + + /// + /// Initializes a new instance of the class. + /// + /// The comment service. + /// + [ImportingConstructor] + public IssueishCommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor) + : base(commentService, autoCompleteAdvisor) + { + CloseOrReopen = ReactiveCommand.CreateFromTask( + DoCloseOrReopen, + this.WhenAnyValue(x => x.CanCloseOrReopen)); + AddErrorHandler(CloseOrReopen); + } + + /// + public bool CanCloseOrReopen + { + get => canCloseOrReopen; + private set => this.RaiseAndSetIfChanged(ref canCloseOrReopen, value); + } + + /// + public string CloseOrReopenCaption => closeOrReopenCaption?.Value; + + /// + public ReactiveCommand CloseOrReopen { get; } + + /// + public async Task InitializeAsync( + IIssueishCommentThreadViewModel thread, + ActorModel currentUser, + CommentModel comment, + bool isPullRequest, + bool canCloseOrReopen, + IObservable isOpen = null) + { + await base.InitializeAsync( + thread, + currentUser, + comment, + comment == null ? CommentEditState.Editing : CommentEditState.None) + .ConfigureAwait(true); + + CanCloseOrReopen = canCloseOrReopen; + closeOrReopenCaption?.Dispose(); + + if (canCloseOrReopen && isOpen != null) + { + closeOrReopenCaption = + this.WhenAnyValue(x => x.Body) + .CombineLatest(isOpen, (body, open) => GetCloseOrReopenCaption(isPullRequest, open, body)) + .ToProperty(this, x => x.CloseOrReopenCaption); + } + } + + public void Dispose() => closeOrReopenCaption?.Dispose(); + + async Task DoCloseOrReopen() + { + await ((IIssueishCommentThreadViewModel)Thread).CloseOrReopen(this).ConfigureAwait(true); + } + + static string GetCloseOrReopenCaption(bool isPullRequest, bool isOpen, string body) + { + if (string.IsNullOrEmpty(body)) + { + if (isPullRequest) + { + return isOpen ? Resources.ClosePullRequest : Resources.ReopenPullRequest; + } + else + { + return isOpen ? Resources.CloseIssue: Resources.ReopenIssue; + } + } + else + { + return isOpen ? Resources.CloseAndComment : Resources.ReopenAndComment; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/IssueishPaneViewModel.cs b/src/GitHub.App/ViewModels/Documents/IssueishPaneViewModel.cs new file mode 100644 index 0000000000..9e7650c19d --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/IssueishPaneViewModel.cs @@ -0,0 +1,86 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + [Export(typeof(IIssueishPaneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class IssueishPaneViewModel : ViewModelBase, IIssueishPaneViewModel + { + readonly IViewViewModelFactory factory; + readonly IPullRequestSessionManager sessionManager; + IViewModel content; + string paneCaption; + + [ImportingConstructor] + public IssueishPaneViewModel( + IViewViewModelFactory factory, + IPullRequestSessionManager sessionManager) + { + Guard.ArgumentNotNull(factory, nameof(factory)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + + this.factory = factory; + this.sessionManager = sessionManager; + } + + public IViewModel Content + { + get => content; + private set => this.RaiseAndSetIfChanged(ref content, value); + } + + public bool IsInitialized => content != null; + + public string PaneCaption + { + get => paneCaption; + private set => this.RaiseAndSetIfChanged(ref paneCaption, value); + } + + public Task InitializeAsync(IServiceProvider paneServiceProvider) + { + return Task.CompletedTask; + } + + public async Task Load(IConnection connection, string owner, string name, int number) + { + Content = new SpinnerViewModel(); + PaneCaption = "#" + number; + + // TODO: We will eventually support loading issues here as well. + try + { + var session = await sessionManager.GetSession(owner, name, number).ConfigureAwait(true); + var vm = factory.CreateViewModel(); + + var repository = new RemoteRepositoryModel( + 0, + name, + session.LocalRepository.CloneUrl.WithOwner(session.PullRequest.HeadRepositoryOwner), + false, + false, + null, + null); + + await vm.InitializeAsync( + repository, + session.LocalRepository, + session.User, + session.PullRequest).ConfigureAwait(true); + Content = vm; + PaneCaption += " " + vm.Title; + } + catch (Exception ex) + { + // TODO: Show exception. + } + } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/PullRequestPageViewModel.cs b/src/GitHub.App/ViewModels/Documents/PullRequestPageViewModel.cs new file mode 100644 index 0000000000..879779792d --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/PullRequestPageViewModel.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for displaying a pull request in a document window. + /// + [Export(typeof(IPullRequestPageViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestPageViewModel : PullRequestViewModelBase, IPullRequestPageViewModel, IIssueishCommentThreadViewModel + { + readonly IViewViewModelFactory factory; + readonly IPullRequestService service; + readonly IPullRequestSessionManager sessionManager; + readonly ITeamExplorerServices teServices; + readonly IVisualStudioBrowser visualStudioBrowser; + readonly IUsageTracker usageTracker; + ActorModel currentUserModel; + ReactiveList timeline = new ReactiveList(); + + /// + /// Initializes a new instance of the class. + /// + /// The view model factory. + [ImportingConstructor] + public PullRequestPageViewModel( + IViewViewModelFactory factory, + IPullRequestService service, + IPullRequestSessionManager sessionManager, + ITeamExplorerServices teServices, + IVisualStudioBrowser visualStudioBrowser, + IUsageTracker usageTracker) + { + Guard.ArgumentNotNull(factory, nameof(factory)); + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(visualStudioBrowser, nameof(visualStudioBrowser)); + Guard.ArgumentNotNull(teServices, nameof(teServices)); + + this.factory = factory; + this.service = service; + this.sessionManager = sessionManager; + this.teServices = teServices; + this.visualStudioBrowser = visualStudioBrowser; + this.usageTracker = usageTracker; + + timeline.ItemsRemoved.Subscribe(TimelineItemRemoved); + + ShowCommit = ReactiveCommand.CreateFromTask(DoShowCommit); + OpenOnGitHub = ReactiveCommand.Create(DoOpenOnGitHub); + } + + /// + public IActorViewModel CurrentUser { get; private set; } + + /// + public int CommitCount { get; private set; } + + /// + public IReadOnlyList Timeline => timeline; + + /// + public ReactiveCommand ShowCommit { get; } + + /// + public async Task InitializeAsync( + RemoteRepositoryModel repository, + LocalRepositoryModel localRepository, + ActorModel currentUser, + PullRequestDetailModel model) + { + await base.InitializeAsync(repository, localRepository, model).ConfigureAwait(true); + + timeline.Clear(); + CommitCount = 0; + currentUserModel = currentUser; + CurrentUser = new ActorViewModel(currentUser); + + var commits = new List(); + + foreach (var i in model.Timeline) + { + if (!(i is CommitModel) && commits.Count > 0) + { + timeline.Add(new CommitListViewModel(commits)); + commits.Clear(); + } + + switch (i) + { + case CommitModel commit: + commits.Add(new CommitSummaryViewModel(commit)); + ++CommitCount; + break; + case CommentModel comment: + await AddComment(comment).ConfigureAwait(true); + break; + } + } + + if (commits.Count > 0) + { + timeline.Add(new CommitListViewModel(commits)); + } + + await AddPlaceholder().ConfigureAwait(true); + await usageTracker.IncrementCounter(x => x.NumberOfPRConversationsOpened); + } + + /// + public async Task CloseOrReopen(ICommentViewModel comment) + { + var address = HostAddress.Create(Repository.CloneUrl); + + if (State == PullRequestState.Open) + { + await service.CloseIssueish( + address, + Repository.Owner, + Repository.Name, + Number).ConfigureAwait(true); + State = PullRequestState.Closed; + } + else + { + await service.ReopenIssueish( + address, + Repository.Owner, + Repository.Name, + Number).ConfigureAwait(true); + State = PullRequestState.Open; + } + } + + /// + public async Task PostComment(ICommentViewModel comment) + { + var address = HostAddress.Create(Repository.CloneUrl); + var result = await service.PostComment(address, Id, comment.Body).ConfigureAwait(true); + timeline.Remove(comment); + await AddComment(result).ConfigureAwait(true); + await AddPlaceholder().ConfigureAwait(true); + } + + public async Task DeleteComment(ICommentViewModel comment) + { + await service.DeleteComment( + HostAddress.Create(Repository.CloneUrl), + Repository.Owner, + Repository.Name, + comment.DatabaseId).ConfigureAwait(true); + timeline.Remove(comment); + } + + public async Task EditComment(ICommentViewModel comment) + { + await service.EditComment( + HostAddress.Create(Repository.CloneUrl), + Repository.Owner, + Repository.Name, + comment.DatabaseId, + comment.Body).ConfigureAwait(false); + } + + async Task AddComment(CommentModel comment) + { + var vm = factory.CreateViewModel(); + await vm.InitializeAsync( + this, + currentUserModel, + comment, + true, + false).ConfigureAwait(true); + timeline.Add(vm); + } + + async Task AddPlaceholder() + { + var placeholder = factory.CreateViewModel(); + await placeholder.InitializeAsync( + this, + currentUserModel, + null, + true, + true, + this.WhenAnyValue(x => x.State, x => x == PullRequestState.Open)).ConfigureAwait(true); + timeline.Add(placeholder); + } + + async Task DoShowCommit(string oid) + { + await service.FetchCommit(LocalRepository, Repository, oid).ConfigureAwait(true); + teServices.ShowCommitDetails(oid); + } + + void DoOpenOnGitHub() + { + visualStudioBrowser.OpenUrl(WebUrl); + } + + void TimelineItemRemoved(IViewModel item) + { + (item as IDisposable)?.Dispose(); + } + } +} diff --git a/src/GitHub.App/ViewModels/GistCreationViewModel.cs b/src/GitHub.App/ViewModels/GistCreationViewModel.cs deleted file mode 100644 index 203407ee7e..0000000000 --- a/src/GitHub.App/ViewModels/GistCreationViewModel.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Linq; -using GitHub.Api; -using GitHub.App; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using NLog; -using NullGuard; -using Octokit; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType=UIViewType.Gist)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class GistCreationViewModel : BaseViewModel, IGistCreationViewModel - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly IApiClient apiClient; - readonly ObservableAsPropertyHelper account; - readonly IGistPublishService gistPublishService; - readonly INotificationService notificationService; - readonly IUsageTracker usageTracker; - - [ImportingConstructor] - GistCreationViewModel( - IConnectionRepositoryHostMap connectionRepositoryHostMap, - ISelectedTextProvider selectedTextProvider, - IGistPublishService gistPublishService, - INotificationService notificationService, - IUsageTracker usageTracker) - : this(connectionRepositoryHostMap.CurrentRepositoryHost, selectedTextProvider, gistPublishService, usageTracker) - { - this.notificationService = notificationService; - } - - public GistCreationViewModel( - IRepositoryHost repositoryHost, - ISelectedTextProvider selectedTextProvider, - IGistPublishService gistPublishService, - IUsageTracker usageTracker) - { - Title = Resources.CreateGistTitle; - apiClient = repositoryHost.ApiClient; - this.gistPublishService = gistPublishService; - this.usageTracker = usageTracker; - - FileName = VisualStudio.Services.GetFileNameFromActiveDocument() ?? Resources.DefaultGistFileName; - SelectedText = selectedTextProvider.GetSelectedText(); - - // This class is only instantiated after we are logged into to a github account, so we should be safe to grab the first one here as the defaut. - account = repositoryHost.ModelService.GetAccounts() - .FirstAsync() - .Select(a => a.First()) - .ObserveOn(RxApp.MainThreadScheduler) - .ToProperty(this, vm => vm.Account); - - var canCreateGist = this.WhenAny( - x => x.FileName, - fileName => !String.IsNullOrEmpty(fileName.Value)); - - CreateGist = ReactiveCommand.CreateAsyncObservable(canCreateGist, OnCreateGist); - } - - IObservable OnCreateGist(object unused) - { - var newGist = new NewGist - { - Description = Description, - Public = !IsPrivate - }; - - newGist.Files.Add(FileName, SelectedText); - - return gistPublishService.PublishGist(apiClient, newGist) - .Do(_ => usageTracker.IncrementCreateGistCount().Forget()) - .Catch(ex => - { - if (!ex.IsCriticalException()) - { - log.Error(ex); - var error = StandardUserErrors.GetUserFriendlyErrorMessage(ex, ErrorType.GistCreateFailed); - notificationService.ShowError(error); - } - return Observable.Return(null); - }); - } - - public IReactiveCommand CreateGist { get; } - - public IAccount Account - { - [return: AllowNull] - get { return account.Value; } - } - - bool isPrivate; - public bool IsPrivate - { - get { return isPrivate; } - set { this.RaiseAndSetIfChanged(ref isPrivate, value); } - } - - string description; - [AllowNull] - public string Description - { - [return: AllowNull] - get { return description; } - set { this.RaiseAndSetIfChanged(ref description, value); } - } - - string selectedText; - [AllowNull] - public string SelectedText - { - [return: AllowNull] - get { return selectedText; } - set { this.RaiseAndSetIfChanged(ref selectedText, value); } - } - - string fileName; - [AllowNull] - public string FileName - { - [return: AllowNull] - get { return fileName; } - set { this.RaiseAndSetIfChanged(ref fileName, value); } - } - } -} diff --git a/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs new file mode 100644 index 0000000000..fa65012187 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs @@ -0,0 +1,545 @@ +using System; +using System.ComponentModel.Composition; +using System.ComponentModel.Design; +using System.Globalization; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Info; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using GitHub.Services.Vssdk.Commands; +using GitHub.VisualStudio; +using ReactiveUI; +using Serilog; +using OleMenuCommand = Microsoft.VisualStudio.Shell.OleMenuCommand; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// The view model for the GitHub Pane. + /// + [Export(typeof(IGitHubPaneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class GitHubPaneViewModel : ViewModelBase, IGitHubPaneViewModel, IDisposable + { + static readonly ILogger log = LogManager.ForContext(); + static readonly Regex pullUri = CreateRoute("/:owner/:repo/pull/:number"); + static readonly Regex pullNewReviewUri = CreateRoute("/:owner/:repo/pull/:number/review/new"); + static readonly Regex pullUserReviewsUri = CreateRoute("/:owner/:repo/pull/:number/reviews/:login"); + static readonly Regex pullCheckRunsUri = CreateRoute("/:owner/:repo/pull/:number/checkruns/:id"); + + readonly IViewViewModelFactory viewModelFactory; + readonly ISimpleApiClientFactory apiClientFactory; + readonly IConnectionManager connectionManager; + readonly ITeamExplorerContext teamExplorerContext; + readonly INavigationViewModel navigator; + readonly ILoggedOutViewModel loggedOut; + readonly INotAGitHubRepositoryViewModel notAGitHubRepository; + readonly INotAGitRepositoryViewModel notAGitRepository; + readonly INoRemoteOriginViewModel noRemoteOrigin; + readonly ILoginFailedViewModel loginFailed; + readonly SemaphoreSlim navigating = new SemaphoreSlim(1); + readonly ObservableAsPropertyHelper contentOverride; + readonly ObservableAsPropertyHelper isSearchEnabled; + readonly ObservableAsPropertyHelper title; + readonly ReactiveCommand refresh; + readonly ReactiveCommand showPullRequests; + readonly ReactiveCommand openInBrowser; + readonly ReactiveCommand help; + IDisposable connectionSubscription; + Task initializeTask; + IViewModel content; + LocalRepositoryModel localRepository; + string searchQuery; + + [ImportingConstructor] + public GitHubPaneViewModel( + IViewViewModelFactory viewModelFactory, + ISimpleApiClientFactory apiClientFactory, + IConnectionManager connectionManager, + ITeamExplorerContext teamExplorerContext, + IVisualStudioBrowser browser, + IUsageTracker usageTracker, + INavigationViewModel navigator, + ILoggedOutViewModel loggedOut, + INotAGitHubRepositoryViewModel notAGitHubRepository, + INotAGitRepositoryViewModel notAGitRepository, + INoRemoteOriginViewModel noRemoteOrigin, + ILoginFailedViewModel loginFailed) + { + Guard.ArgumentNotNull(viewModelFactory, nameof(viewModelFactory)); + Guard.ArgumentNotNull(apiClientFactory, nameof(apiClientFactory)); + Guard.ArgumentNotNull(connectionManager, nameof(connectionManager)); + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(browser, nameof(browser)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + Guard.ArgumentNotNull(navigator, nameof(navigator)); + Guard.ArgumentNotNull(loggedOut, nameof(loggedOut)); + Guard.ArgumentNotNull(notAGitHubRepository, nameof(notAGitHubRepository)); + Guard.ArgumentNotNull(notAGitRepository, nameof(notAGitRepository)); + Guard.ArgumentNotNull(noRemoteOrigin, nameof(noRemoteOrigin)); + Guard.ArgumentNotNull(loginFailed, nameof(loginFailed)); + + this.viewModelFactory = viewModelFactory; + this.apiClientFactory = apiClientFactory; + this.connectionManager = connectionManager; + this.teamExplorerContext = teamExplorerContext; + this.navigator = navigator; + this.loggedOut = loggedOut; + this.notAGitHubRepository = notAGitHubRepository; + this.notAGitRepository = notAGitRepository; + this.noRemoteOrigin = noRemoteOrigin; + this.loginFailed = loginFailed; + + var contentAndNavigatorContent = Observable.CombineLatest( + this.WhenAnyValue(x => x.Content), + navigator.WhenAnyValue(x => x.Content), + (c, nc) => new { Content = c, NavigatorContent = nc }); + + contentOverride = contentAndNavigatorContent + .SelectMany(x => + { + if (x.Content == null) return Observable.Return(ContentOverride.Spinner); + else if (x.Content == navigator && x.NavigatorContent != null) + { + return x.NavigatorContent.WhenAnyValue( + y => y.IsLoading, + y => y.Error, + (l, e) => + { + if (l) return ContentOverride.Spinner; + if (e != null) return ContentOverride.Error; + else return ContentOverride.None; + }); + } + else return Observable.Return(ContentOverride.None); + }) + .ToProperty(this, x => x.ContentOverride); + + // Returns navigator.Content if Content == navigator, otherwise null. + var currentPage = contentAndNavigatorContent + .Select(x => x.Content == navigator ? x.NavigatorContent : null); + + title = currentPage + .SelectMany(x => x?.WhenAnyValue(y => y.Title) ?? Observable.Return(null)) + .Select(x => x ?? "GitHub") + .ToProperty(this, x => x.Title); + + isSearchEnabled = currentPage + .Select(x => x is ISearchablePageViewModel) + .ToProperty(this, x => x.IsSearchEnabled); + + refresh = ReactiveCommand.CreateFromTask( + () => navigator.Content.Refresh(), + currentPage.SelectMany(x => x?.WhenAnyValue( + y => y.IsLoading, + y => y.IsBusy, + (loading, busy) => !loading && !busy) + ?? Observable.Return(false))); + refresh.ThrownExceptions.Subscribe(); + + showPullRequests = ReactiveCommand.CreateFromTask( + ShowPullRequests, + this.WhenAny(x => x.Content, x => x.Value == navigator)); + + openInBrowser = ReactiveCommand.Create( + () => + { + var url = ((IOpenInBrowser)navigator.Content).WebUrl; + if (url != null) browser.OpenUrl(url); + }, + currentPage.Select(x => x is IOpenInBrowser)); + + BrowseRepository = ReactiveCommand.Create( + () => + { + var url = LocalRepository.CloneUrl.ToRepositoryUrl(); + if (url != null) browser.OpenUrl(url); + }, + currentPage.Select(x => x is IOpenInBrowser)); + + help = ReactiveCommand.Create(() => { }); + help.Subscribe(_ => + { + browser.OpenUrl(new Uri(GitHubUrls.Documentation)); + usageTracker.IncrementCounter(x => x.NumberOfGitHubPaneHelpClicks).Forget(); + }); + + navigator.WhenAnyObservable(x => x.Content.NavigationRequested) + .Subscribe(x => NavigateTo(x).Forget()); + + this.WhenAnyValue(x => x.SearchQuery) + .Where(x => navigator.Content is ISearchablePageViewModel) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => ((ISearchablePageViewModel)navigator.Content).SearchQuery = x); + } + + /// + public IConnection Connection + { + get; + private set; + } + + /// + public IViewModel Content + { + get { return content; } + private set { this.RaiseAndSetIfChanged(ref content, value); } + } + + /// + public ContentOverride ContentOverride => contentOverride.Value; + + /// + public bool IsSearchEnabled => isSearchEnabled.Value; + + /// + public LocalRepositoryModel LocalRepository + { + get { return localRepository; } + private set { this.RaiseAndSetIfChanged(ref localRepository, value); } + } + + /// + public string SearchQuery + { + get { return searchQuery; } + set { this.RaiseAndSetIfChanged(ref searchQuery, value); } + } + + /// + public string Title => title.Value; + + /// + public void Dispose() + { + navigating.Dispose(); + } + + /// + public Task InitializeAsync(IServiceProvider paneServiceProvider) + { + return initializeTask = initializeTask ?? CreateInitializeTask(paneServiceProvider); + } + + /// + public async Task NavigateTo(Uri uri) + { + Guard.ArgumentNotNull(uri, nameof(uri)); + + if (uri.Scheme != "github") + { + throw new NotSupportedException("Invalid URI scheme for GitHub pane: " + uri.Scheme); + } + + if (uri.Authority != "pane") + { + throw new NotSupportedException("Invalid URI authority for GitHub pane: " + uri.Authority); + } + + Match match; + + if (uri.AbsolutePath == "/pulls") + { + await ShowPullRequests(); + } + else if (uri.AbsolutePath == "/pull/new") + { + await ShowCreatePullRequest(); + } + else if ((match = pullUri.Match(uri.AbsolutePath))?.Success == true) + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var number = int.Parse(match.Groups["number"].Value, CultureInfo.InvariantCulture); + await ShowPullRequest(owner, repo, number); + } + else if ((match = pullNewReviewUri.Match(uri.AbsolutePath))?.Success == true) + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var number = int.Parse(match.Groups["number"].Value, CultureInfo.InvariantCulture); + await ShowPullRequestReviewAuthoring(owner, repo, number); + } + else if ((match = pullUserReviewsUri.Match(uri.AbsolutePath))?.Success == true) + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var number = int.Parse(match.Groups["number"].Value, CultureInfo.InvariantCulture); + var login = match.Groups["login"].Value; + await ShowPullRequestReviews(owner, repo, number, login); + } + else if ((match = pullCheckRunsUri.Match(uri.AbsolutePath))?.Success == true) + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var number = int.Parse(match.Groups["number"].Value, CultureInfo.InvariantCulture); + var id = match.Groups["id"].Value; + + await ShowPullRequestCheckRun(owner, repo, number, id); + } + else + { + throw new NotSupportedException("Unrecognised GitHub pane URL: " + uri.AbsolutePath); + } + + var queries = HttpUtility.ParseQueryString(uri.Query); + + if (queries["refresh"] == "true") + { + await navigator.Content.Refresh(); + } + } + + /// + public Task ShowDefaultPage() => ShowPullRequests(); + + /// + public Task ShowCreatePullRequest() + { + return NavigateTo(x => x.InitializeAsync(LocalRepository, Connection)); + } + + /// + public Task ShowPullRequests() + { + return NavigateTo(x => x.InitializeAsync(LocalRepository, Connection)); + } + + /// + public Task ShowPullRequest(string owner, string repo, int number) + { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(repo, nameof(repo)); + + return NavigateTo( + x => x.InitializeAsync(LocalRepository, Connection, owner, repo, number), + x => x.RemoteRepositoryOwner == owner && x.LocalRepository.Name == repo && x.Number == number); + } + + /// + public Task ShowPullRequestReviews(string owner, string repo, int number, string login) + { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(repo, nameof(repo)); + + return NavigateTo( + x => x.InitializeAsync(LocalRepository, Connection, owner, repo, number, login), + x => x.RemoteRepositoryOwner == owner && + x.LocalRepository.Name == repo && + x.PullRequestNumber == number && + x.User.Login == login); + } + + /// + public Task ShowPullRequestCheckRun(string owner, string repo, int number, string checkRunId) + { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(repo, nameof(repo)); + + return NavigateTo( + x => x.InitializeAsync(LocalRepository, Connection, owner, repo, number, checkRunId), + x => x.RemoteRepositoryOwner == owner && + x.LocalRepository.Name == repo && + x.PullRequestNumber == number && + x.CheckRunId == checkRunId); + } + + /// + public Task ShowPullRequestReviewAuthoring(string owner, string repo, int number) + { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(repo, nameof(repo)); + + return NavigateTo( + x => x.InitializeAsync(LocalRepository, Connection, owner, repo, number), + x => x.RemoteRepositoryOwner == owner && + x.LocalRepository.Name == repo && + x.PullRequestModel.Number == number); + } + + async Task CreateInitializeTask(IServiceProvider paneServiceProvider) + { + await UpdateContent(teamExplorerContext.ActiveRepository); + teamExplorerContext.WhenAnyValue(x => x.ActiveRepository) + .Skip(1) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => UpdateContent(x).Forget(log)); + + connectionManager.Connections.CollectionChanged += (_, __) => UpdateContent(LocalRepository).Forget(log); + + var menuService = (IMenuCommandService)paneServiceProvider.GetService(typeof(IMenuCommandService)); + BindNavigatorCommand(menuService, PkgCmdIDList.pullRequestCommand, showPullRequests); + BindNavigatorCommand(menuService, PkgCmdIDList.backCommand, navigator.NavigateBack); + BindNavigatorCommand(menuService, PkgCmdIDList.forwardCommand, navigator.NavigateForward); + BindNavigatorCommand(menuService, PkgCmdIDList.refreshCommand, refresh); + BindNavigatorCommand(menuService, PkgCmdIDList.githubCommand, openInBrowser); + BindNavigatorCommand(menuService, PkgCmdIDList.helpCommand, help); + } + + OleMenuCommand BindNavigatorCommand(IMenuCommandService menu, int commandId, ReactiveCommand command) + { + Guard.ArgumentNotNull(menu, nameof(menu)); + Guard.ArgumentNotNull(command, nameof(command)); + + return menu.BindCommand(new CommandID(Guids.guidGitHubToolbarCmdSet, commandId), command); + } + + async Task NavigateTo(Func initialize, Func match = null) + where TViewModel : class, IPanePageViewModel + { + Guard.ArgumentNotNull(initialize, nameof(initialize)); + + if (Content != navigator) return; + await navigating.WaitAsync(); + + try + { + var viewModel = navigator.History + .OfType() + .FirstOrDefault(x => match?.Invoke(x) ?? true); + + if (viewModel == null) + { + viewModel = viewModelFactory.CreateViewModel(); + navigator.NavigateTo(viewModel); + await initialize(viewModel); + } + else if (navigator.Content != viewModel) + { + navigator.NavigateTo(viewModel); + } + } + finally + { + navigating.Release(); + } + } + + async Task UpdateContent(LocalRepositoryModel repository) + { + log.Debug("UpdateContent called with {CloneUrl}", repository?.CloneUrl); + + LocalRepository = repository; + connectionSubscription?.Dispose(); + connectionSubscription = null; + Connection = null; + Content = null; + navigator.Clear(); + + if (repository == null) + { + log.Debug("Not a git repository: {CloneUrl}", repository?.CloneUrl); + Content = notAGitRepository; + return; + } + else if (string.IsNullOrWhiteSpace(repository.CloneUrl)) + { + if (repository.HasRemotesButNoOrigin) + { + log.Debug("No origin remote"); + Content = noRemoteOrigin; + } + else + { + log.Debug("Not a GitHub repository: {CloneUrl}", repository?.CloneUrl); + Content = notAGitHubRepository; + } + + return; + } + + var repositoryUrl = repository.CloneUrl.ToRepositoryUrl(); + var isDotCom = HostAddress.IsGitHubDotComUri(repositoryUrl); + var client = await apiClientFactory.Create(repository.CloneUrl); + var isEnterprise = isDotCom ? false : await client.IsEnterprise(); + var notGitHubRepo = true; + + if (isDotCom || isEnterprise) + { + var hostAddress = HostAddress.Create(repository.CloneUrl); + + notGitHubRepo = false; + + Connection = await connectionManager.GetConnection(hostAddress); + Connection?.WhenAnyValue( + x => x.IsLoggedIn, + x => x.IsLoggingIn, + (_, __) => Unit.Default) + .Skip(1) + .Throttle(TimeSpan.FromMilliseconds(100)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(_ => UpdateContent(LocalRepository).Forget()); + + if (Connection?.IsLoggedIn == true) + { + if (await IsValidRepository(client) == true) + { + log.Debug("Found a GitHub repository: {CloneUrl}", repository?.CloneUrl); + Content = navigator; + await ShowDefaultPage(); + } + else + { + notGitHubRepo = true; + } + } + else if (Connection?.IsLoggingIn == true) + { + log.Debug("Found a GitHub repository: {CloneUrl} and logging in", repository?.CloneUrl); + Content = null; + } + else if (Connection?.ConnectionError != null) + { + log.Debug("Found a GitHub repository: {CloneUrl} with login error", repository?.CloneUrl); + loginFailed.Initialize(Connection.ConnectionError.GetUserFriendlyError(ErrorType.LoginFailed)); + Content = loginFailed; + } + else + { + log.Debug("Found a a GitHub repository but not logged in: {CloneUrl}", repository?.CloneUrl); + Content = loggedOut; + } + } + + if (notGitHubRepo) + { + log.Debug("Not a GitHub repository: {CloneUrl}", repository?.CloneUrl); + Content = notAGitHubRepository; + } + } + + static async Task IsValidRepository(ISimpleApiClient client) + { + try + { + var repo = await client.GetRepository(); + return repo.Id != 0; + } + catch + { + return false; + } + } + + static Regex CreateRoute(string route) + { + // Build RegEx from route (:foo to named group (?[\w_.\-=]+)). + var routeFormat = "^" + new Regex("(:([a-z]+))\\b").Replace(route, @"(?<$2>[\w_.\-=]+)") + "$"; + return new Regex(routeFormat, RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase); + } + + public ReactiveCommand BrowseRepository { get; } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/IssueListViewModelBase.cs b/src/GitHub.App/ViewModels/GitHubPane/IssueListViewModelBase.cs new file mode 100644 index 0000000000..271b88ab32 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/IssueListViewModelBase.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Collections; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model which implements the functionality common to issue and pull request lists. + /// + public abstract class IssueListViewModelBase : PanePageViewModelBase, IIssueListViewModelBase + { + static readonly ILogger log = LogManager.ForContext(); + readonly IRepositoryService repositoryService; + IReadOnlyList items; + ICollectionView itemsView; + IDisposable subscription; + IssueListMessage message; + RepositoryModel remoteRepository; + IReadOnlyList forks; + string searchQuery; + string selectedState; + ObservableAsPropertyHelper stateCaption; + string stringFilter; + int numberFilter; + IUserFilterViewModel authorFilter; + + /// + /// Initializes a new instance of the class. + /// + /// The repository service. + public IssueListViewModelBase(IRepositoryService repositoryService) + { + this.repositoryService = repositoryService; + OpenItem = ReactiveCommand.CreateFromTask(OpenItemImpl); + stateCaption = this.WhenAnyValue( + x => x.Items.Count, + x => x.SelectedState, + x => x.IsBusy, + x => x.IsLoading, + (count, state, busy, loading) => busy || loading ? state : count + " " + state) + .ToProperty(this, x => x.StateCaption); + } + + /// + public IUserFilterViewModel AuthorFilter + { + get { return authorFilter; } + private set { this.RaiseAndSetIfChanged(ref authorFilter, value); } + } + + /// + public IReadOnlyList Forks + { + get { return forks; } + set { this.RaiseAndSetIfChanged(ref forks, value); } + } + + /// + public IReadOnlyList Items + { + get { return items; } + private set { this.RaiseAndSetIfChanged(ref items, value); } + } + + /// + public ICollectionView ItemsView + { + get { return itemsView; } + private set { this.RaiseAndSetIfChanged(ref itemsView, value); } + } + + /// + public LocalRepositoryModel LocalRepository { get; private set; } + + /// + public IssueListMessage Message + { + get { return message; } + private set { this.RaiseAndSetIfChanged(ref message, value); } + } + + /// + public RepositoryModel RemoteRepository + { + get { return remoteRepository; } + set { this.RaiseAndSetIfChanged(ref remoteRepository, value); } + } + + /// + public string SearchQuery + { + get { return searchQuery; } + set { this.RaiseAndSetIfChanged(ref searchQuery, value); } + } + + /// + public string SelectedState + { + get { return selectedState; } + set { this.RaiseAndSetIfChanged(ref selectedState, value); } + } + + /// + public abstract IReadOnlyList States { get; } + + /// + public string StateCaption => stateCaption.Value; + + /// + public ReactiveCommand OpenItem { get; } + + /// + public async Task InitializeAsync(LocalRepositoryModel repository, IConnection connection) + { + try + { + LocalRepository = repository; + SelectedState = States.FirstOrDefault(); + AuthorFilter = new UserFilterViewModel(LoadAuthors); + IsLoading = true; + + var parent = await repositoryService.FindParent( + HostAddress.Create(repository.CloneUrl), + repository.Owner, + repository.Name); + + if (parent == null) + { + RemoteRepository = repository; + } + else + { + // TODO: Handle forks with different names. + RemoteRepository = new RepositoryModel( + repository.Name, + UriString.ToUriString(repository.CloneUrl.ToRepositoryUrl(parent.Value.owner))); + + Forks = new RepositoryModel[] + { + RemoteRepository, + repository, + }; + } + + this.WhenAnyValue(x => x.SelectedState, x => x.RemoteRepository) + .Skip(1) + .Subscribe(_ => InitializeItemSource(false).Forget()); + + Observable.Merge( + this.WhenAnyValue(x => x.SearchQuery).Skip(1).SelectUnit(), + AuthorFilter.WhenAnyValue(x => x.Selected).Skip(1).SelectUnit()) + .Subscribe(_ => FilterChanged()); + + await InitializeItemSource(true); + } + catch (Exception ex) + { + Error = ex; + IsLoading = false; + log.Error(ex, "Error initializing IssueListViewModelBase"); + } + } + + /// + /// Refreshes the view model. + /// + /// A task tracking the operation. + public override Task Refresh() => InitializeItemSource(true); + + /// + /// When overridden in a derived class, creates the + /// that will act as the source for . + /// + /// + /// Whether the item source is being created due to being called. + /// + protected abstract Task> CreateItemSource(bool refresh); + + /// + /// When overridden in a derived class, navigates to the specified item. + /// + /// The item. + /// A task tracking the operation. + protected abstract Task DoOpenItem(IIssueListItemViewModelBase item); + + /// + /// Loads a page of authors for the . + /// + /// The GraphQL "after" cursor. + /// A task that returns a page of authors. + protected abstract Task> LoadAuthors(string after); + + async Task InitializeItemSource(bool refresh) + { + if (RemoteRepository == null) + { + // If an exception occurred reading the parent repository, do nothing. + return; + } + + subscription?.Dispose(); + + var dispose = new CompositeDisposable(); + var itemSource = await CreateItemSource(refresh); + var items = new VirtualizingList(itemSource, null); + var view = new VirtualizingListCollectionView(items); + + view.Filter = FilterItem; + Items = items; + ItemsView = view; + Error = null; + + dispose.Add(itemSource); + dispose.Add( + Observable.CombineLatest( + itemSource.WhenAnyValue(x => x.IsLoading), + view.WhenAnyValue(x => x.Count), + this.WhenAnyValue(x => x.SearchQuery), + this.WhenAnyValue(x => x.SelectedState), + this.WhenAnyValue(x => x.AuthorFilter.Selected), + (loading, count, _, __, ___) => Tuple.Create(loading, count)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => UpdateState(x.Item1, x.Item2))); + dispose.Add( + Observable.FromEventPattern( + x => items.InitializationError += x, + x => items.InitializationError -= x) + .Subscribe(x => Error = x.EventArgs.GetException())); + subscription = dispose; + } + + void FilterChanged() + { + if (!string.IsNullOrWhiteSpace(SearchQuery)) + { + numberFilter = 0; + + int.TryParse(SearchQuery.Substring(SearchQuery.StartsWith('#') ? 1 : 0), out numberFilter); + + if (numberFilter == 0) + { + stringFilter = SearchQuery.ToUpperInvariant(); + } + } + else + { + stringFilter = null; + numberFilter = 0; + } + + ItemsView?.Refresh(); + } + + bool FilterItem(object o) + { + var item = o as IIssueListItemViewModelBase; + var result = true; + + if (!string.IsNullOrWhiteSpace(SearchQuery)) + { + + if (item != null) + { + if (numberFilter != 0) + { + result = item.Number == numberFilter; + } + else + { + result = item.Title.ToUpperInvariant().Contains(stringFilter); + } + } + } + + if (result && AuthorFilter.Selected != null) + { + result = item.Author.Login.Equals( + AuthorFilter.Selected.Login, + StringComparison.CurrentCultureIgnoreCase); + } + + return result; + } + + async Task OpenItemImpl(IIssueListItemViewModelBase item) + { + if (item != null) await DoOpenItem(item); + } + + void UpdateState(bool loading, int count) + { + var message = IssueListMessage.None; + + if (!loading) + { + if (count == 0) + { + if (SelectedState == States[0] && + string.IsNullOrWhiteSpace(SearchQuery) && + AuthorFilter.Selected == null) + { + message = IssueListMessage.NoOpenItems; + } + else + { + message = IssueListMessage.NoItemsMatchCriteria; + } + } + + IsLoading = false; + } + + IsBusy = loading; + Message = message; + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/LoggedOutViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/LoggedOutViewModel.cs new file mode 100644 index 0000000000..45910163cd --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/LoggedOutViewModel.cs @@ -0,0 +1,60 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Reactive.Linq; +using GitHub.Info; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// The view model for the "Sign in to GitHub" view in the GitHub pane. + /// + [Export(typeof(ILoggedOutViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LoggedOutViewModel : PanePageViewModelBase, ILoggedOutViewModel + { + readonly IDialogService dialogService; + readonly IVisualStudioBrowser browser; + + /// + /// Initializes a new instance of the class. + /// + [ImportingConstructor] + public LoggedOutViewModel(IDialogService dialogService, IVisualStudioBrowser browser) + { + this.dialogService = dialogService; + this.browser = browser; + SignIn = ReactiveCommand.Create(() => { }); + SignIn.Subscribe(_ => OnSignIn()); + Register = ReactiveCommand.Create(() => { }); + Register.Subscribe(_ => OnRegister()); + } + + /// + public ReactiveCommand SignIn { get; } + + /// + public ReactiveCommand Register { get; } + + /// + /// Called when the command is executed. + /// + void OnSignIn() + { + // Show the Sign In dialog. We don't need to listen to the outcome of this: the parent + // GitHubPaneViewModel will listen to the ConnectionManager and close this view when + // the user logs in. + dialogService.ShowLoginDialog(); + } + + /// + /// Called when the command is executed. + /// + void OnRegister() + { + browser.OpenUrl(GitHubUrls.Pricing); + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/LoginFailedViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/LoginFailedViewModel.cs new file mode 100644 index 0000000000..85cec88458 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/LoginFailedViewModel.cs @@ -0,0 +1,47 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using GitHub.Services; +using ReactiveUI; +using UserError = ReactiveUI.Legacy.UserError; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// The view model for the "Login Failed" view in the GitHub pane. + /// + [Export(typeof(ILoginFailedViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LoginFailedViewModel : PanePageViewModelBase, ILoginFailedViewModel + { + readonly ITeamExplorerServices teServices; + UserError loginError; + + /// + /// Initializes a new instance of the class. + /// + [ImportingConstructor] + public LoginFailedViewModel(ITeamExplorerServices teServices) + { + this.teServices = teServices; + OpenTeamExplorer = ReactiveCommand.Create(DoOpenTeamExplorer); + } + + /// + public UserError LoginError + { + get => loginError; + private set => this.RaiseAndSetIfChanged(ref loginError, value); + } + + /// + public ReactiveCommand OpenTeamExplorer { get; } + + public void Initialize(UserError error) + { + LoginError = error; + } + + void DoOpenTeamExplorer() => teServices.ShowConnectPage(); + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/NavigationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/NavigationViewModel.cs new file mode 100644 index 0000000000..10f8835f0c --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/NavigationViewModel.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using GitHub.Extensions; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model that supports back/forward navigation of an inner content page. + /// + [Export(typeof(INavigationViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class NavigationViewModel : ViewModelBase, INavigationViewModel + { + readonly ReactiveList history; + readonly ObservableAsPropertyHelper content; + int index = -1; + + /// + /// Initializes a new instance of the class. + /// + public NavigationViewModel() + { + history = new ReactiveList(); + history.Changing.Subscribe(CollectionChanging); + history.Changed.Subscribe(CollectionChanged); + + var pos = this.WhenAnyValue( + x => x.Index, + x => x.History.Count, + (i, c) => new { Index = i, Count = c }); + + content = pos + .Where(x => x.Index < x.Count) + .Select(x => x.Index != -1 ? history[x.Index] : null) + .StartWith((IPanePageViewModel)null) + .ToProperty(this, x => x.Content); + + this.WhenAnyValue(x => x.Content) + .Buffer(2, 1) + .Subscribe(x => { + if (x[0] != null && history.Contains(x[0])) x[0].Deactivated(); + x[1]?.Activated(); + }); + + NavigateBack = ReactiveCommand.Create(() => { }, pos.Select(x => x.Index > 0)); + NavigateBack.Subscribe(_ => Back()); + NavigateForward = ReactiveCommand.Create(() => { }, pos.Select(x => x.Index < x.Count - 1)); + NavigateForward.Subscribe(_ => Forward()); + } + + /// + public IPanePageViewModel Content => content.Value; + + /// + public int Index + { + get { return index; } + set { this.RaiseAndSetIfChanged(ref index, value); } + } + + /// + public IReadOnlyReactiveList History => history; + + /// + public ReactiveCommand NavigateBack { get; } + + /// + public ReactiveCommand NavigateForward { get; } + + /// + public void NavigateTo(IPanePageViewModel page) + { + Guard.ArgumentNotNull(page, nameof(page)); + + history.Insert(index + 1, page); + ++Index; + + if (index < history.Count - 1) + { + history.RemoveRange(index + 1, history.Count - (index + 1)); + } + } + + /// + public bool Back() + { + if (index == 0) + return false; + --Index; + return true; + } + + /// + public bool Forward() + { + if (index >= history.Count - 1) + return false; + ++Index; + return true; + } + + /// + public void Clear() + { + Index = -1; + history.Clear(); + } + + public int RemoveAll(IPanePageViewModel page) + { + var count = 0; + while (history.Remove(page)) ++count; + return count; + } + + void BeforeItemAdded(IPanePageViewModel page) + { + if (!history.Contains(page)) + { + page.CloseRequested.Subscribe(_ => RemoveAll(page)); + } + } + + void ItemRemoved(int removedIndex, IPanePageViewModel page) + { + if (removedIndex <= Index) + { + --Index; + } + + if (!history.Contains(page)) + { + if (Content == page) page.Deactivated(); + page.Dispose(); + } + } + + void CollectionChanging(NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + foreach (IPanePageViewModel page in e.NewItems) + { + BeforeItemAdded(page); + } + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + foreach (var page in history) + { + page.Dispose(); + } + } + } + + void CollectionChanged(NotifyCollectionChangedEventArgs e) + { + using (DelayChangeNotifications()) + { + if (e.Action == NotifyCollectionChangedAction.Remove) + { + for (var i = 0; i < e.OldItems.Count; ++i) + { + ItemRemoved(e.OldStartingIndex + i, (IPanePageViewModel)e.OldItems[i]); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/NoRemoteOriginViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/NoRemoteOriginViewModel.cs new file mode 100644 index 0000000000..7803d272e2 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/NoRemoteOriginViewModel.cs @@ -0,0 +1,28 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using System.ComponentModel.Composition; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// The view model for the "No Origin Remote" view in the GitHub pane. + /// + [Export(typeof(INoRemoteOriginViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class NoRemoteOriginViewModel : PanePageViewModelBase, INoRemoteOriginViewModel + { + ITeamExplorerServices teamExplorerServices; + + [ImportingConstructor] + public NoRemoteOriginViewModel(ITeamExplorerServices teamExplorerServices) + { + this.teamExplorerServices = teamExplorerServices; + EditRemotes = ReactiveCommand.CreateFromTask(teamExplorerServices.ShowRepositorySettingsRemotesAsync); + } + + public ReactiveCommand EditRemotes { get; } + } +} diff --git a/src/GitHub.App/ViewModels/NotAGitHubRepositoryViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/NotAGitHubRepositoryViewModel.cs similarity index 75% rename from src/GitHub.App/ViewModels/NotAGitHubRepositoryViewModel.cs rename to src/GitHub.App/ViewModels/GitHubPane/NotAGitHubRepositoryViewModel.cs index 4f373d5ac7..05e2d77455 100644 --- a/src/GitHub.App/ViewModels/NotAGitHubRepositoryViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/NotAGitHubRepositoryViewModel.cs @@ -1,19 +1,17 @@ using System; using System.ComponentModel.Composition; -using GitHub.Exports; -using GitHub.Models; +using System.Reactive; using GitHub.Services; -using GitHub.UI; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// The view model for the "Not a GitHub repository" view in the GitHub pane. /// - [ExportViewModel(ViewType = UIViewType.NotAGitHubRepository)] + [Export(typeof(INotAGitHubRepositoryViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class NotAGitHubRepositoryViewModel : BaseViewModel, INotAGitHubRepositoryViewModel + public class NotAGitHubRepositoryViewModel : PanePageViewModelBase, INotAGitHubRepositoryViewModel { ITeamExplorerServices teamExplorerServices; @@ -24,14 +22,14 @@ public class NotAGitHubRepositoryViewModel : BaseViewModel, INotAGitHubRepositor public NotAGitHubRepositoryViewModel(ITeamExplorerServices teamExplorerServices) { this.teamExplorerServices = teamExplorerServices; - Publish = ReactiveCommand.Create(); + Publish = ReactiveCommand.Create(() => { }); Publish.Subscribe(_ => OnPublish()); } /// /// Gets the command executed when the user clicks the "Publish to GitHub" link. /// - public IReactiveCommand Publish { get; } + public ReactiveCommand Publish { get; } /// /// Called when the command is executed. diff --git a/src/GitHub.App/ViewModels/GitHubPane/NotAGitRepositoryViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/NotAGitRepositoryViewModel.cs new file mode 100644 index 0000000000..ee9e046f85 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/NotAGitRepositoryViewModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.Composition; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// The view model for the "Not a Git repository" view in the GitHub pane. + /// + [Export(typeof(INotAGitRepositoryViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class NotAGitRepositoryViewModel : PanePageViewModelBase, INotAGitRepositoryViewModel + { + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PanePageViewModelBase.cs b/src/GitHub.App/ViewModels/GitHubPane/PanePageViewModelBase.cs new file mode 100644 index 0000000000..182506567d --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PanePageViewModelBase.cs @@ -0,0 +1,110 @@ +using System; +using System.Reactive; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Base class for view models that appear as a page in a the GitHub pane. + /// + public abstract class PanePageViewModelBase : ViewModelBase, IPanePageViewModel, IDisposable + { + static readonly Uri paneUri = new Uri("github://pane"); + readonly Subject navigate = new Subject(); + readonly Subject close = new Subject(); + Exception error; + bool isBusy; + bool isLoading; + string title; + + /// + /// Initializes a new instance of the class. + /// + protected PanePageViewModelBase() + { + } + + ~PanePageViewModelBase() + { + Dispose(false); + } + + /// + public Exception Error + { + get { return error; } + protected set { this.RaiseAndSetIfChanged(ref error, value); } + } + + /// + public bool IsBusy + { + get { return isBusy; } + protected set { this.RaiseAndSetIfChanged(ref isBusy, value); } + } + + /// + public bool IsLoading + { + get { return isLoading; } + protected set { this.RaiseAndSetIfChanged(ref isLoading, value); } + } + + /// + public string Title + { + get { return title; } + protected set { this.RaiseAndSetIfChanged(ref title, value); } + } + + /// + public IObservable CloseRequested => close; + + /// + public IObservable NavigationRequested => navigate; + + /// + public virtual void Activated() + { + } + + /// + public virtual void Deactivated() + { + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public virtual Task Refresh() => Task.CompletedTask; + + /// + /// Sends a request to close the page. + /// + protected void Close() => close.OnNext(Unit.Default); + + /// + /// Sends a request to navigate to a new page. + /// + /// + /// The path portion of the URI of the new page, e.g. "pulls". + /// + protected void NavigateTo(string uri) => navigate.OnNext(new Uri(paneUri, new Uri(uri, UriKind.Relative))); + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + close.Dispose(); + navigate.Dispose(); + } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestAnnotationItemViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestAnnotationItemViewModel.cs new file mode 100644 index 0000000000..d07c806f89 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestAnnotationItemViewModel.cs @@ -0,0 +1,56 @@ +using System.Reactive; +using System.Reactive.Linq; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + public class PullRequestAnnotationItemViewModel : ViewModelBase, IPullRequestAnnotationItemViewModel + { + bool isExpanded; + + /// + /// Initializes the . + /// + /// The check run annotation model. + /// A flag that denotes if the annotation is part of the pull request's changes. + /// The check suite model. + /// The pull request session. + /// The pull request editor service. + public PullRequestAnnotationItemViewModel( + CheckRunAnnotationModel annotation, + bool isFileInPullRequest, + CheckSuiteModel checkSuite, + IPullRequestSession session, + IPullRequestEditorService editorService) + { + Annotation = annotation; + IsFileInPullRequest = isFileInPullRequest; + + OpenAnnotation = ReactiveCommand.CreateFromTask( + async _ => await editorService.OpenDiff(session, annotation.Path, checkSuite.HeadSha, annotation.EndLine - 1), + Observable.Return(IsFileInPullRequest)); + } + + /// + public bool IsFileInPullRequest { get; } + + /// + public CheckRunAnnotationModel Annotation { get; } + + /// + public string LineDescription => $"{Annotation.StartLine}:{Annotation.EndLine}"; + + /// + public ReactiveCommand OpenAnnotation { get; } + + /// + public bool IsExpanded + { + get { return isExpanded; } + set { this.RaiseAndSetIfChanged(ref isExpanded, value); } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestAnnotationsViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestAnnotationsViewModel.cs new file mode 100644 index 0000000000..fd9fe3ab98 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestAnnotationsViewModel.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + [Export(typeof(IPullRequestAnnotationsViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestAnnotationsViewModel : PanePageViewModelBase, IPullRequestAnnotationsViewModel + { + readonly IPullRequestSessionManager sessionManager; + readonly IPullRequestEditorService pullRequestEditorService; + readonly IUsageTracker usageTracker; + + IPullRequestSession session; + string title; + string checkSuiteName; + string checkRunName; + IReadOnlyDictionary annotationsDictionary; + string checkRunSummary; + string checkRunText; + + /// + /// Initializes a new instance of the class. + /// + /// The pull request session manager. + /// The pull request editor service. + [ImportingConstructor] + public PullRequestAnnotationsViewModel( + IPullRequestSessionManager sessionManager, + IPullRequestEditorService pullRequestEditorService, + IUsageTracker usageTracker) + { + this.sessionManager = sessionManager; + this.pullRequestEditorService = pullRequestEditorService; + this.usageTracker = usageTracker; + + NavigateToPullRequest = ReactiveCommand.Create(() => { + NavigateTo(FormattableString.Invariant( + $"{LocalRepository.Owner}/{LocalRepository.Name}/pull/{PullRequestNumber}")); + }); + } + + /// + public async Task InitializeAsync(LocalRepositoryModel localRepository, IConnection connection, string owner, + string repo, int pullRequestNumber, string checkRunId) + { + if (repo != localRepository.Name) + { + throw new NotSupportedException(); + } + + IsLoading = true; + + try + { + LocalRepository = localRepository; + RemoteRepositoryOwner = owner; + PullRequestNumber = pullRequestNumber; + CheckRunId = checkRunId; + session = await sessionManager.GetSession(owner, repo, pullRequestNumber); + Load(session.PullRequest); + } + finally + { + IsLoading = false; + } + } + + /// + public LocalRepositoryModel LocalRepository { get; private set; } + + /// + public string RemoteRepositoryOwner { get; private set; } + + /// + public int PullRequestNumber { get; private set; } + + /// + public string CheckRunId { get; private set; } + + /// + public ReactiveCommand NavigateToPullRequest { get; private set; } + + /// + public string PullRequestTitle + { + get { return title; } + private set { this.RaiseAndSetIfChanged(ref title, value); } + } + + /// + public string CheckSuiteName + { + get { return checkSuiteName; } + private set { this.RaiseAndSetIfChanged(ref checkSuiteName, value); } + } + + /// + public string CheckRunName + { + get { return checkRunName; } + private set { this.RaiseAndSetIfChanged(ref checkRunName, value); } + } + + /// + public string CheckRunSummary + { + get { return checkRunSummary; } + private set { this.RaiseAndSetIfChanged(ref checkRunSummary, value); } + } + + /// + public string CheckRunText + { + get { return checkRunText; } + private set { this.RaiseAndSetIfChanged(ref checkRunText, value); } + } + + /// + public IReadOnlyDictionary AnnotationsDictionary + { + get { return annotationsDictionary; } + private set { this.RaiseAndSetIfChanged(ref annotationsDictionary, value); } + } + + void Load(PullRequestDetailModel pullRequest) + { + IsBusy = true; + + try + { + PullRequestTitle = pullRequest.Title; + + var checkSuiteRun = pullRequest + .CheckSuites.SelectMany(checkSuite => checkSuite.CheckRuns + .Select(checkRun => new{checkSuite, checkRun})) + .First(arg => arg.checkRun.Id == CheckRunId); + + CheckSuiteName = checkSuiteRun.checkSuite.ApplicationName; + CheckRunName = checkSuiteRun.checkRun.Name; + CheckRunSummary = checkSuiteRun.checkRun.Summary; + CheckRunText = checkSuiteRun.checkRun.Text; + + var changedFiles = new HashSet(session.PullRequest.ChangedFiles.Select(model => model.FileName)); + + var annotationsLookup = checkSuiteRun.checkRun.Annotations + .ToLookup(annotation => annotation.Path); + + AnnotationsDictionary = annotationsLookup + .Select(models => models.Key) + .OrderBy(s => s) + .ToDictionary( + path => path, + path => annotationsLookup[path] + .Select(annotation => new PullRequestAnnotationItemViewModel(annotation, changedFiles.Contains(path), checkSuiteRun.checkSuite, session, pullRequestEditorService)) + .Cast() + .ToArray() + ); + + usageTracker.IncrementCounter(x => x.NumberOfPullRequestOpenAnnotationsList).Forget(); + } + finally + { + IsBusy = false; + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCheckViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCheckViewModel.cs new file mode 100644 index 0000000000..a5cdc09851 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCheckViewModel.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Linq.Expressions; +using System.Reactive; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + [Export(typeof(IPullRequestCheckViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestCheckViewModel: ViewModelBase, IPullRequestCheckViewModel + { + private readonly IUsageTracker usageTracker; + + /// + /// Factory method to create a . + /// + /// A viewviewmodel factory. + /// The pull request. + public static IEnumerable Build(IViewViewModelFactory viewViewModelFactory, PullRequestDetailModel pullRequest) + { + var statuses = pullRequest.Statuses?.Select(statusModel => + { + PullRequestCheckStatus checkStatus; + switch (statusModel.State) + { + case StatusState.Expected: + case StatusState.Error: + case StatusState.Failure: + checkStatus = PullRequestCheckStatus.Failure; + break; + case StatusState.Pending: + checkStatus = PullRequestCheckStatus.Pending; + break; + case StatusState.Success: + checkStatus = PullRequestCheckStatus.Success; + break; + default: + throw new InvalidOperationException("Unkown PullRequestCheckStatusEnum"); + } + + var pullRequestCheckViewModel = (PullRequestCheckViewModel) viewViewModelFactory.CreateViewModel(); + pullRequestCheckViewModel.CheckType = PullRequestCheckType.StatusApi; + pullRequestCheckViewModel.Title = statusModel.Context; + pullRequestCheckViewModel.Description = statusModel.Description; + pullRequestCheckViewModel.Status = checkStatus; + pullRequestCheckViewModel.DetailsUrl = !string.IsNullOrEmpty(statusModel.TargetUrl) ? new Uri(statusModel.TargetUrl) : null; + + return pullRequestCheckViewModel; + }) ?? Array.Empty(); + + var checks = + pullRequest.CheckSuites? + .SelectMany(checkSuite => checkSuite.CheckRuns + .Select(checkRun => new { checkSuiteModel = checkSuite, checkRun})) + .Select(arg => + { + PullRequestCheckStatus checkStatus; + switch (arg.checkRun.Status) + { + case CheckStatusState.Requested: + case CheckStatusState.Queued: + case CheckStatusState.InProgress: + checkStatus = PullRequestCheckStatus.Pending; + break; + + case CheckStatusState.Completed: + switch (arg.checkRun.Conclusion) + { + case CheckConclusionState.Success: + checkStatus = PullRequestCheckStatus.Success; + break; + + case CheckConclusionState.ActionRequired: + case CheckConclusionState.TimedOut: + case CheckConclusionState.Cancelled: + case CheckConclusionState.Failure: + case CheckConclusionState.Neutral: + checkStatus = PullRequestCheckStatus.Failure; + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + break; + default: + throw new ArgumentOutOfRangeException(); + } + + var pullRequestCheckViewModel = (PullRequestCheckViewModel)viewViewModelFactory.CreateViewModel(); + pullRequestCheckViewModel.CheckType = PullRequestCheckType.ChecksApi; + pullRequestCheckViewModel.CheckRunId = arg.checkRun.Id; + pullRequestCheckViewModel.HasAnnotations = arg.checkRun.Annotations?.Any() ?? false; + pullRequestCheckViewModel.Title = arg.checkRun.Name; + pullRequestCheckViewModel.Description = arg.checkRun.Summary; + pullRequestCheckViewModel.Status = checkStatus; + pullRequestCheckViewModel.DetailsUrl = new Uri(arg.checkRun.DetailsUrl); + return pullRequestCheckViewModel; + }) ?? Array.Empty(); + + return statuses.Concat(checks).OrderBy(model => model.Title); + } + + /// + /// Initializes a new instance of . + /// + /// The usage tracker. + [ImportingConstructor] + public PullRequestCheckViewModel(IUsageTracker usageTracker) + { + this.usageTracker = usageTracker; + OpenDetailsUrl = ReactiveCommand.Create(DoOpenDetailsUrl); + } + + private void DoOpenDetailsUrl() + { + Expression> expression; + if (CheckType == PullRequestCheckType.StatusApi) + { + expression = x => x.NumberOfPRStatusesOpenInGitHub; + } + else + { + expression = x => x.NumberOfPRChecksOpenInGitHub; + } + + usageTracker.IncrementCounter(expression).Forget(); + } + + /// + public string Title { get; private set; } + + /// + public string Description { get; private set; } + + /// + public PullRequestCheckType CheckType { get; private set; } + + /// + public string CheckRunId { get; private set; } + + /// + public bool HasAnnotations { get; private set; } + + /// + public PullRequestCheckStatus Status{ get; private set; } + + /// + public Uri DetailsUrl { get; private set; } + + /// + public ReactiveCommand OpenDetailsUrl { get; } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs new file mode 100644 index 0000000000..bd25c85c52 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.App; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Models.Drafts; +using GitHub.Primitives; +using GitHub.Services; +using GitHub.UI; +using GitHub.Validation; +using Octokit; +using ReactiveUI; +using Serilog; +using IConnection = GitHub.Models.IConnection; +using static System.FormattableString; + +namespace GitHub.ViewModels.GitHubPane +{ + [Export(typeof(IPullRequestCreationViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")] + public class PullRequestCreationViewModel : PanePageViewModelBase, IPullRequestCreationViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly ObservableAsPropertyHelper isExecuting; + readonly IPullRequestService service; + readonly IModelServiceFactory modelServiceFactory; + readonly IMessageDraftStore draftStore; + readonly IGitService gitService; + readonly IScheduler timerScheduler; + readonly CompositeDisposable disposables = new CompositeDisposable(); + LocalRepositoryModel activeLocalRepo; + ObservableAsPropertyHelper githubRepository; + IModelService modelService; + + [ImportingConstructor] + public PullRequestCreationViewModel( + IModelServiceFactory modelServiceFactory, + IPullRequestService service, + INotificationService notifications, + IMessageDraftStore draftStore, + IGitService gitService, + IAutoCompleteAdvisor autoCompleteAdvisor) + : this(modelServiceFactory, service, notifications, draftStore, gitService, autoCompleteAdvisor, DefaultScheduler.Instance) + { + } + + public PullRequestCreationViewModel( + IModelServiceFactory modelServiceFactory, + IPullRequestService service, + INotificationService notifications, + IMessageDraftStore draftStore, + IGitService gitService, + IAutoCompleteAdvisor autoCompleteAdvisor, + IScheduler timerScheduler) + { + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(notifications, nameof(notifications)); + Guard.ArgumentNotNull(draftStore, nameof(draftStore)); + Guard.ArgumentNotNull(gitService, nameof(gitService)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); + Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler)); + + this.service = service; + this.modelServiceFactory = modelServiceFactory; + this.draftStore = draftStore; + this.gitService = gitService; + this.AutoCompleteAdvisor = autoCompleteAdvisor; + this.timerScheduler = timerScheduler; + + this.WhenAnyValue(x => x.Branches) + .WhereNotNull() + .Where(_ => TargetBranch != null) + .Subscribe(x => + { + if (!x.Any(t => t.Equals(TargetBranch))) + { + TargetBranch = GitHubRepository.IsFork ? GitHubRepository.Parent.DefaultBranch : GitHubRepository.DefaultBranch; + } + }); + + SetupValidators(); + + var whenAnyValidationResultChanges = this.WhenAny( + x => x.TitleValidator.ValidationResult, + x => x.BranchValidator.ValidationResult, + x => x.IsBusy, + (x, y, z) => (x.Value?.IsValid ?? false) && (y.Value?.IsValid ?? false) && !z.Value); + + this.WhenAny(x => x.BranchValidator.ValidationResult, x => x.GetValue()) + .WhereNotNull() + .Where(x => !x.IsValid && x.DisplayValidationError) + .Subscribe(x => notifications.ShowError(BranchValidator.ValidationResult.Message)); + + CreatePullRequest = ReactiveCommand.CreateFromObservable( + () => service + .CreatePullRequest(modelService, activeLocalRepo, TargetBranch.Repository, SourceBranch, TargetBranch, PRTitle, Description ?? String.Empty) + .Catch(ex => + { + log.Error(ex, "Error creating pull request"); + + //TODO:Will need a uniform solution to HTTP exception message handling + var apiException = ex as ApiValidationException; + var error = apiException?.ApiError?.Errors?.FirstOrDefault(); + notifications.ShowError(error?.Message ?? ex.Message); + return Observable.Empty(); + }), + whenAnyValidationResultChanges); + CreatePullRequest.Subscribe(pr => + { + notifications.ShowMessage(String.Format(CultureInfo.CurrentCulture, Resources.PRCreatedUpstream, SourceBranch.DisplayName, TargetBranch.Repository.Owner + "/" + TargetBranch.Repository.Name + "#" + pr.Number, + TargetBranch.Repository.CloneUrl.ToRepositoryUrl().Append("pull/" + pr.Number))); + NavigateTo("/pulls?refresh=true"); + Cancel.Execute(); + draftStore.DeleteDraft(GetDraftKey(), string.Empty).Forget(); + Close(); + }); + + Cancel = ReactiveCommand.Create(() => { }); + Cancel.Subscribe(_ => + { + Close(); + draftStore.DeleteDraft(GetDraftKey(), string.Empty).Forget(); + }); + + isExecuting = CreatePullRequest.IsExecuting.ToProperty(this, x => x.IsExecuting); + + this.WhenAnyValue(x => x.Initialized, x => x.GitHubRepository, x => x.IsExecuting) + .Select(x => !(x.Item1 && x.Item2 != null && !x.Item3)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => IsBusy = x); + } + + public async Task InitializeAsync(LocalRepositoryModel repository, IConnection connection) + { + modelService = await modelServiceFactory.CreateAsync(connection); + activeLocalRepo = repository; + SourceBranch = gitService.GetBranch(repository); + + var obs = modelService.ApiClient.GetRepository(repository.Owner, repository.Name) + .Select(r => CreateRemoteRepositoryModel(r)) + .PublishLast(); + disposables.Add(obs.Connect()); + var githubObs = obs; + + githubRepository = githubObs.ToProperty(this, x => x.GitHubRepository); + + this.WhenAnyValue(x => x.GitHubRepository) + .WhereNotNull() + .Subscribe(r => + { + TargetBranch = r.IsFork ? r.Parent.DefaultBranch : r.DefaultBranch; + }); + + githubObs.SelectMany(r => + { + var b = Observable.Empty(); + if (r.IsFork) + { + b = modelService.GetBranches(r.Parent).Select(x => + { + return x; + }); + } + return b.Concat(modelService.GetBranches(r)); + }) + .ToList() + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => + { + Branches = x.ToList(); + Initialized = true; + }); + + var draftKey = GetDraftKey(); + await LoadInitialState(draftKey).ConfigureAwait(true); + + this.WhenAnyValue( + x => x.PRTitle, + x => x.Description, + (t, d) => new PullRequestDraft { Title = t, Body = d }) + .Throttle(TimeSpan.FromSeconds(1), timerScheduler) + .Subscribe(x => draftStore.UpdateDraft(draftKey, string.Empty, x)); + + Initialized = true; + } + + static RemoteRepositoryModel CreateRemoteRepositoryModel(Repository repository) + { + var ownerAccount = new Models.Account(repository.Owner); + var parent = repository.Parent != null ? CreateRemoteRepositoryModel(repository.Parent) : null; + var model = new RemoteRepositoryModel(repository.Id, repository.Name, repository.CloneUrl, + repository.Private, repository.Fork, ownerAccount, parent, repository.DefaultBranch); + + if (parent != null) + { + parent.DefaultBranch.DisplayName = parent.DefaultBranch.Id; + } + + return model; + } + + async Task LoadInitialState(string draftKey) + { + if (activeLocalRepo.CloneUrl == null) + return; + + var draft = await draftStore.GetDraft(draftKey, string.Empty).ConfigureAwait(true); + + if (draft != null) + { + PRTitle = draft.Title; + Description = draft.Body; + } + else + { + LoadDescriptionFromCommits(); + } + } + + void LoadDescriptionFromCommits() + { + SourceBranch = gitService.GetBranch(activeLocalRepo); + + var uniqueCommits = this.WhenAnyValue( + x => x.SourceBranch, + x => x.TargetBranch) + .Where(x => x.Item1 != null && x.Item2 != null) + .Select(branches => + { + var baseBranch = branches.Item1.Name; + var compareBranch = branches.Item2.Name; + + // We only need to get max two commits for what we're trying to achieve here. + // If there's no commits we want to block creation of the PR, if there's one commits + // we wan't to use its commit message as the PR title/body and finally if there's more + // than one we'll use the branch name for the title. + return service.GetMessagesForUniqueCommits(activeLocalRepo, baseBranch, compareBranch, maxCommits: 2) + .Catch, Exception>(ex => + { + log.Warning(ex, "Could not load unique commits"); + return Observable.Empty>(); + }); + }) + .Switch() + .ObserveOn(RxApp.MainThreadScheduler) + .Replay(1) + .RefCount(); + + Observable.CombineLatest( + this.WhenAnyValue(x => x.SourceBranch), + uniqueCommits, + service.GetPullRequestTemplate(activeLocalRepo).DefaultIfEmpty(string.Empty), + (compare, commits, template) => new { compare, commits, template }) + .Subscribe(x => + { + var prTitle = string.Empty; + var prDescription = string.Empty; + + if (x.commits.Count == 1) + { + prTitle = x.commits[0].Summary; + prDescription = x.commits[0].Details; + } + else + { + prTitle = x.compare.Name.Humanize(); + } + + if (!string.IsNullOrWhiteSpace(x.template)) + { + if (!string.IsNullOrEmpty(prDescription)) + prDescription += "\n\n"; + prDescription += x.template; + } + + PRTitle = prTitle; + Description = prDescription; + }); + } + + void SetupValidators() + { + var titleObs = this.WhenAnyValue(x => x.PRTitle); + TitleValidator = ReactivePropertyValidator.ForObservable(titleObs) + .IfNullOrEmpty(Resources.PullRequestCreationTitleValidatorEmpty); + + var branchObs = this.WhenAnyValue( + x => x.Initialized, + x => x.TargetBranch, + x => x.SourceBranch, + (init, target, source) => new { Initialized = init, Source = source, Target = target }) + .Where(x => x.Initialized); + + BranchValidator = ReactivePropertyValidator.ForObservable(branchObs) + .IfTrue(x => x.Source == null, Resources.PullRequestSourceBranchDoesNotExist) + .IfTrue(x => x.Source.Equals(x.Target), Resources.PullRequestSourceAndTargetBranchTheSame); + } + + bool disposed; // To detect redundant calls + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + if (disposed) return; + disposed = true; + + disposables.Dispose(); + } + } + + public static string GetDraftKey( + UriString cloneUri, + string branchName) + { + return Invariant($"pr|{cloneUri}|{branchName}"); + } + + protected string GetDraftKey() + { + return GetDraftKey( + activeLocalRepo.CloneUrl, + SourceBranch.Name); + } + + public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } + bool IsExecuting { get { return isExecuting.Value; } } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + + bool initialized; + bool Initialized + { + get { return initialized; } + set { this.RaiseAndSetIfChanged(ref initialized, value); } + } + + BranchModel sourceBranch; + public BranchModel SourceBranch + { + get { return sourceBranch; } + set { this.RaiseAndSetIfChanged(ref sourceBranch, value); } + } + + BranchModel targetBranch; + public BranchModel TargetBranch + { + get { return targetBranch; } + set { this.RaiseAndSetIfChanged(ref targetBranch, value); } + } + + IReadOnlyList branches; + public IReadOnlyList Branches + { + get { return branches; } + set { this.RaiseAndSetIfChanged(ref branches, value); } + } + + public ReactiveCommand CreatePullRequest { get; } + public ReactiveCommand Cancel { get; } + + string title; + public string PRTitle + { + get { return title; } + set { this.RaiseAndSetIfChanged(ref title, value); } + } + + string description; + public string Description + { + get { return description; } + set { this.RaiseAndSetIfChanged(ref description, value); } + } + + ReactivePropertyValidator titleValidator; + public ReactivePropertyValidator TitleValidator + { + get { return titleValidator; } + set { this.RaiseAndSetIfChanged(ref titleValidator, value); } + } + + ReactivePropertyValidator branchValidator; + ReactivePropertyValidator BranchValidator + { + get { return branchValidator; } + set { this.RaiseAndSetIfChanged(ref branchValidator, value); } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs new file mode 100644 index 0000000000..4c8cb4c620 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs @@ -0,0 +1,744 @@ +using System; +using System.Globalization; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using GitHub.App; +using GitHub.Commands; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using LibGit2Sharp; +using Microsoft.VisualStudio.StaticReviews.Contracts; +using ReactiveUI; +using ReactiveUI.Legacy; +using Serilog; +using static System.FormattableString; +using ReactiveCommand = ReactiveUI.ReactiveCommand; +using GitHub.Primitives; +using Microsoft.VisualStudio.Threading; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + [Export(typeof(IPullRequestDetailViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullRequestDetailViewModel, IStaticReviewFileMap + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IModelServiceFactory modelServiceFactory; + readonly IPullRequestService pullRequestsService; + readonly IPullRequestSessionManager sessionManager; + readonly IUsageTracker usageTracker; + readonly ITeamExplorerContext teamExplorerContext; + readonly ISyncSubmodulesCommand syncSubmodulesCommand; + readonly IViewViewModelFactory viewViewModelFactory; + readonly IGitService gitService; + readonly IOpenIssueishDocumentCommand openDocumentCommand; + + IModelService modelService; + PullRequestDetailModel model; + IActorViewModel author; + string sourceBranchDisplayName; + string targetBranchDisplayName; + string body; + IReadOnlyList reviews; + IPullRequestCheckoutState checkoutState; + IPullRequestUpdateState updateState; + string operationError; + bool isCheckedOut; + bool isFromFork; + bool isInCheckout; + bool active; + bool refreshOnActivate; + Uri webUrl; + IDisposable sessionSubscription; + IReadOnlyList checks = Array.Empty(); + + /// + /// Initializes a new instance of the class. + /// + /// The pull requests service. + /// The pull request session manager. + /// The model service factory + /// The usage tracker. + /// The context for tracking repo changes + /// The view model which will display the changed files + /// A command that will be run when is executed + [ImportingConstructor] + public PullRequestDetailViewModel( + IPullRequestService pullRequestsService, + IPullRequestSessionManager sessionManager, + IModelServiceFactory modelServiceFactory, + IUsageTracker usageTracker, + ITeamExplorerContext teamExplorerContext, + IPullRequestFilesViewModel files, + ISyncSubmodulesCommand syncSubmodulesCommand, + IViewViewModelFactory viewViewModelFactory, + IGitService gitService, + IOpenIssueishDocumentCommand openDocumentCommand, + [Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) + { + Guard.ArgumentNotNull(pullRequestsService, nameof(pullRequestsService)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(syncSubmodulesCommand, nameof(syncSubmodulesCommand)); + Guard.ArgumentNotNull(viewViewModelFactory, nameof(viewViewModelFactory)); + Guard.ArgumentNotNull(gitService, nameof(gitService)); + Guard.ArgumentNotNull(openDocumentCommand, nameof(openDocumentCommand)); + + this.pullRequestsService = pullRequestsService; + this.sessionManager = sessionManager; + this.modelServiceFactory = modelServiceFactory; + this.usageTracker = usageTracker; + this.teamExplorerContext = teamExplorerContext; + this.syncSubmodulesCommand = syncSubmodulesCommand; + this.viewViewModelFactory = viewViewModelFactory; + this.gitService = gitService; + this.openDocumentCommand = openDocumentCommand; + JoinableTaskContext = joinableTaskContext ?? ThreadHelper.JoinableTaskContext; + + Files = files; + + Checkout = ReactiveCommand.CreateFromObservable( + DoCheckout, + this.WhenAnyValue(x => x.CheckoutState) + .Cast() + .Select(x => x != null && x.IsEnabled)); + Checkout.IsExecuting.Subscribe(x => isInCheckout = x); + SubscribeOperationError(Checkout); + + Pull = ReactiveCommand.CreateFromObservable( + DoPull, + this.WhenAnyValue(x => x.UpdateState) + .Cast() + .Select(x => x != null && x.PullEnabled)); + SubscribeOperationError(Pull); + + Push = ReactiveCommand.CreateFromObservable( + DoPush, + this.WhenAnyValue(x => x.UpdateState) + .Cast() + .Select(x => x != null && x.PushEnabled)); + SubscribeOperationError(Push); + + SyncSubmodules = ReactiveCommand.CreateFromTask( + DoSyncSubmodules, + this.WhenAnyValue(x => x.UpdateState) + .Cast() + .Select(x => x != null && x.SyncSubmodulesEnabled)); + SyncSubmodules.Subscribe(_ => Refresh().ToObservable()); + SubscribeOperationError(SyncSubmodules); + + OpenConversation = ReactiveCommand.Create(DoOpenConversation); + + OpenOnGitHub = ReactiveCommand.Create(DoOpenDetailsUrl); + + ShowReview = ReactiveCommand.Create(DoShowReview); + + ShowAnnotations = ReactiveCommand.Create(DoShowAnnotations); + } + + [Import(AllowDefault = true)] + private IStaticReviewFileMapManager StaticReviewFileMapManager { get; set; } + + /// + public PullRequestDetailModel Model + { + get { return model; } + private set + { + // PullRequestModel overrides Equals such that two PRs with the same number are + // considered equal. This was causing the Model not to be updated on refresh: + // we need to use ReferenceEquals. + if (!ReferenceEquals(model, value)) + { + this.RaisePropertyChanging(nameof(Model)); + model = value; + this.RaisePropertyChanged(nameof(Model)); + } + } + } + + /// + public LocalRepositoryModel LocalRepository { get; private set; } + + /// + public string RemoteRepositoryOwner { get; private set; } + + /// + public int Number { get; private set; } + + /// + public IActorViewModel Author + { + get { return author; } + private set { this.RaiseAndSetIfChanged(ref author, value); } + } + + /// + public IPullRequestSession Session { get; private set; } + + /// + public string SourceBranchDisplayName + { + get { return sourceBranchDisplayName; } + private set { this.RaiseAndSetIfChanged(ref sourceBranchDisplayName, value); } + } + + /// + public string TargetBranchDisplayName + { + get { return targetBranchDisplayName; } + private set { this.RaiseAndSetIfChanged(ref targetBranchDisplayName, value); } + } + + /// + public bool IsCheckedOut + { + get { return isCheckedOut; } + private set { this.RaiseAndSetIfChanged(ref isCheckedOut, value); } + } + + /// + public bool IsFromFork + { + get { return isFromFork; } + private set { this.RaiseAndSetIfChanged(ref isFromFork, value); } + } + + /// + public string Body + { + get { return body; } + private set { this.RaiseAndSetIfChanged(ref body, value); } + } + + /// + public IPullRequestCheckoutState CheckoutState + { + get { return checkoutState; } + private set { this.RaiseAndSetIfChanged(ref checkoutState, value); } + } + + /// + public IPullRequestUpdateState UpdateState + { + get { return updateState; } + private set { this.RaiseAndSetIfChanged(ref updateState, value); } + } + + /// + public string OperationError + { + get { return operationError; } + private set { this.RaiseAndSetIfChanged(ref operationError, value); } + } + + /// + public IReadOnlyList Reviews + { + get { return reviews; } + private set { this.RaiseAndSetIfChanged(ref reviews, value); } + } + + /// + public IPullRequestFilesViewModel Files { get; } + + /// + /// Gets the web URL for the pull request. + /// + public Uri WebUrl + { + get { return webUrl; } + private set { this.RaiseAndSetIfChanged(ref webUrl, value); } + } + + /// + public ReactiveCommand Checkout { get; } + + /// + public ReactiveCommand Pull { get; } + + /// + public ReactiveCommand Push { get; } + + /// + public ReactiveCommand SyncSubmodules { get; } + + /// + public ReactiveCommand OpenConversation { get; } + + /// + public ReactiveCommand OpenOnGitHub { get; } + + /// + public ReactiveCommand ShowReview { get; } + + /// + public ReactiveCommand ShowAnnotations { get; } + + /// + public IReadOnlyList Checks + { + get { return checks; } + private set { this.RaiseAndSetIfChanged(ref checks, value); } + } + + /// + public async Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int number) + { + IsLoading = true; + + try + { + if (!string.Equals(repo, localRepository.Name, StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException("Showing pull requests from other repositories not yet supported."); + } + + LocalRepository = localRepository; + RemoteRepositoryOwner = owner; + Number = number; + WebUrl = localRepository.CloneUrl.ToRepositoryUrl(owner).Append("pull/" + number); + modelService = await modelServiceFactory.CreateAsync(connection); + Session = await sessionManager.GetSession(owner, repo, number); + await Load(Session.PullRequest); + teamExplorerContext.StatusChanged += RefreshIfActive; + } + catch (Exception ex) + { + Error = ex; + } + finally + { + IsLoading = false; + } + } + + void RefreshIfActive(object sender, EventArgs e) + { + if (active) + { + Refresh().Forget(); + } + else + { + refreshOnActivate = true; + } + } + + /// + /// Loads the view model from octokit models. + /// + /// The pull request model. + public async Task Load(PullRequestDetailModel pullRequest) + { + try + { + var firstLoad = (Model == null); + Model = pullRequest; + Author = new ActorViewModel(pullRequest.Author); + Title = Resources.PullRequestNavigationItemText + " #" + pullRequest.Number; + + IsBusy = true; + IsFromFork = !pullRequestsService.IsPullRequestFromRepository(LocalRepository, pullRequest); + SourceBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.HeadRepositoryOwner, pullRequest.HeadRefName); + TargetBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.BaseRepositoryOwner, pullRequest.BaseRefName); + Body = !string.IsNullOrWhiteSpace(pullRequest.Body) ? pullRequest.Body : Resources.NoDescriptionProvidedMarkdown; + Reviews = PullRequestReviewSummaryViewModel.BuildByUser(Session.User, pullRequest).ToList(); + + Checks = (IReadOnlyList)PullRequestCheckViewModel.Build(viewViewModelFactory, pullRequest)?.ToList() ?? Array.Empty(); + + // Only show unresolved comments + await Files.InitializeAsync(Session, c => !c.IsResolved); + + var localBranches = await pullRequestsService.GetLocalBranches(LocalRepository, pullRequest).ToList(); + + var currentBranch = gitService.GetBranch(LocalRepository); + IsCheckedOut = localBranches.Contains(currentBranch); + + if (IsCheckedOut) + { + var divergence = await pullRequestsService.CalculateHistoryDivergence(LocalRepository, Model.Number); + var pullEnabled = divergence.BehindBy > 0; + var pushEnabled = divergence.AheadBy > 0 && !pullEnabled; + string pullToolTip; + string pushToolTip; + + if (pullEnabled) + { + pullToolTip = string.Format( + CultureInfo.InvariantCulture, + Resources.PullRequestDetailsPullToolTip, + IsFromFork ? Resources.Fork : Resources.Remote, + SourceBranchDisplayName); + } + else + { + pullToolTip = Resources.NoCommitsToPull; + } + + if (pushEnabled) + { + pushToolTip = string.Format( + CultureInfo.InvariantCulture, + Resources.PullRequestDetailsPushToolTip, + IsFromFork ? Resources.Fork : Resources.Remote, + SourceBranchDisplayName); + } + else if (divergence.AheadBy == 0) + { + pushToolTip = Resources.NoCommitsToPush; + } + else + { + pushToolTip = Resources.MustPullBeforePush; + } + + var submodulesToSync = await pullRequestsService.CountSubmodulesToSync(LocalRepository); + var syncSubmodulesToolTip = string.Format(CultureInfo.InvariantCulture, Resources.SyncSubmodules, submodulesToSync); + + UpdateState = new UpdateCommandState(divergence, pullEnabled, pushEnabled, pullToolTip, pushToolTip, syncSubmodulesToolTip, submodulesToSync); + CheckoutState = null; + } + else + { + var caption = localBranches.Count > 0 ? + string.Format( + CultureInfo.InvariantCulture, + Resources.PullRequestDetailsCheckout, + localBranches.First().DisplayName) : + string.Format( + CultureInfo.InvariantCulture, + Resources.PullRequestDetailsCheckoutTo, + await pullRequestsService.GetDefaultLocalBranchName(LocalRepository, Model.Number, Model.Title)); + var clean = await pullRequestsService.IsWorkingDirectoryClean(LocalRepository); + string disabled = null; + + if (pullRequest.HeadRepositoryOwner == null) + { + disabled = Resources.SourceRepositoryNoLongerAvailable; + } + else if (!clean) + { + disabled = Resources.WorkingDirectoryHasUncommittedCHanges; + } + + CheckoutState = new CheckoutCommandState(caption, disabled); + UpdateState = null; + } + + sessionSubscription?.Dispose(); + sessionSubscription = Session.WhenAnyValue(x => x.HasPendingReview) + .Skip(1) + .Subscribe(x => Reviews = PullRequestReviewSummaryViewModel.BuildByUser(Session.User, Session.PullRequest).ToList()); + + if (firstLoad) + { + usageTracker.IncrementCounter(x => x.NumberOfPullRequestsOpened).Forget(); + } + + if (!isInCheckout) + { + pullRequestsService.RemoveUnusedRemotes(LocalRepository).Subscribe(_ => { }); + } + } + finally + { + IsBusy = false; + } + } + + /// + /// Refreshes the contents of the view model. + /// + public override async Task Refresh() + { + try + { + await JoinableTaskContext.Factory.SwitchToMainThreadAsync(); + + Error = null; + OperationError = null; + IsBusy = true; + await Session.Refresh(); + await Load(Session.PullRequest); + } + catch (Exception ex) + { + log.Error( + ex, + "Error loading pull request {Owner}/{Repo}/{Number} from {Address}", + RemoteRepositoryOwner, + LocalRepository.Name, + Number, + modelService.ApiClient.HostAddress.Title); + Error = ex; + IsBusy = false; + } + } + + /// + public string GetLocalFilePath(IPullRequestFileNode file) + { + return Path.Combine(LocalRepository.LocalPath, file.RelativePath); + } + + /// + public override void Activated() + { + active = true; + this.StaticReviewFileMapManager?.RegisterStaticReviewFileMap(this); + + if (refreshOnActivate) + { + Refresh().Forget(); + refreshOnActivate = false; + } + } + + /// + public override void Deactivated() + { + this.StaticReviewFileMapManager?.UnregisterStaticReviewFileMap(this); + active = false; + } + + /// + public Task GetLocalPathFromObjectishAsync(string objectish, CancellationToken cancellationToken) + { + if (this.pullRequestsService != null) + { + string commitId = objectish.Substring(0, objectish.IndexOf(':')); + string relativePath = objectish.Substring(objectish.IndexOf(':')+1).TrimStart('/'); + + return this.pullRequestsService.ExtractToTempFile( + this.Session.LocalRepository, + this.Session.PullRequest, + relativePath, + commitId, + this.pullRequestsService.GetEncoding(this.Session.LocalRepository, relativePath)); + } + + return Task.FromResult(null); + } + + /// + public Task GetObjectishFromLocalPathAsync(string localPath, CancellationToken cancellationToken) + { + // We rely on pull request service's global map here instead of trying to get it from IPullRequestSessionManager via ITextBuffer + // because it is possible that the file queried wasn't opened by GitHub extension and instead was opened by LSP + if (this.pullRequestsService is IStaticReviewFileMap staticReviewFileMap) + { + return staticReviewFileMap.GetObjectishFromLocalPathAsync(localPath, cancellationToken); + } + + return Task.FromResult(null); + } + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + teamExplorerContext.StatusChanged -= RefreshIfActive; + } + } + + void SubscribeOperationError(ReactiveCommand command) + { + command.ThrownExceptions.Subscribe(x => OperationError = x.Message); + } + + static string GetBranchDisplayName(bool isFromFork, string owner, string label) + { + if (owner != null) + { + return isFromFork ? owner + ':' + label : label; + } + else + { + return Resources.InvalidBranchName; + } + } + + IObservable DoCheckout() + { + OperationError = null; + + return Observable.Defer(async () => + { + var localBranches = await pullRequestsService.GetLocalBranches(LocalRepository, Model).ToList(); + + if (localBranches.Count > 0) + { + return pullRequestsService.SwitchToBranch(LocalRepository, Model); + } + else + { + return pullRequestsService + .GetDefaultLocalBranchName(LocalRepository, Model.Number, Model.Title) + .SelectMany(x => pullRequestsService.Checkout(LocalRepository, Model, x)); + } + }).Do(_ => + { + if (IsFromFork) + usageTracker.IncrementCounter(x => x.NumberOfForkPullRequestsCheckedOut).Forget(); + else + usageTracker.IncrementCounter(x => x.NumberOfLocalPullRequestsCheckedOut).Forget(); + }); + } + + IObservable DoPull() + { + OperationError = null; + + return pullRequestsService.Pull(LocalRepository) + .Do(_ => + { + if (IsFromFork) + usageTracker.IncrementCounter(x => x.NumberOfForkPullRequestPulls).Forget(); + else + usageTracker.IncrementCounter(x => x.NumberOfLocalPullRequestPulls).Forget(); + }); + } + + IObservable DoPush() + { + OperationError = null; + + return pullRequestsService.Push(LocalRepository) + .Do(_ => + { + if (IsFromFork) + usageTracker.IncrementCounter(x => x.NumberOfForkPullRequestPushes).Forget(); + else + usageTracker.IncrementCounter(x => x.NumberOfLocalPullRequestPushes).Forget(); + }); + } + + async Task DoSyncSubmodules() + { + try + { + IsBusy = true; + OperationError = null; + usageTracker.IncrementCounter(x => x.NumberOfSyncSubmodules).Forget(); + + var result = await syncSubmodulesCommand.SyncSubmodules(); + var complete = result.Item1; + var summary = result.Item2; + if (!complete) + { + throw new ApplicationException(summary); + } + } + finally + { + IsBusy = false; + } + } + + void DoOpenConversation() + { + var p = new OpenIssueishParams( + HostAddress.Create(LocalRepository.CloneUrl), + RemoteRepositoryOwner, + LocalRepository.Name, + Number); + openDocumentCommand.Execute(p); + } + + void DoOpenDetailsUrl() + { + usageTracker.IncrementCounter(measuresModel => measuresModel.NumberOfPRDetailsOpenInGitHub).Forget(); + } + + void DoShowReview(IPullRequestReviewSummaryViewModel review) + { + if (review.State == PullRequestReviewState.Pending) + { + NavigateTo(Invariant($"{RemoteRepositoryOwner}/{LocalRepository.Name}/pull/{Number}/review/new")); + } + else + { + NavigateTo(Invariant($"{RemoteRepositoryOwner}/{LocalRepository.Name}/pull/{Number}/reviews/{review.User.Login}")); + } + } + + void DoShowAnnotations(IPullRequestCheckViewModel checkView) + { + NavigateTo(Invariant($"{RemoteRepositoryOwner}/{LocalRepository.Name}/pull/{Number}/checkruns/{checkView.CheckRunId}")); + } + + class CheckoutCommandState : IPullRequestCheckoutState + { + public CheckoutCommandState(string caption, string disabledMessage) + { + Caption = caption; + IsEnabled = disabledMessage == null; + ToolTip = disabledMessage ?? caption; + } + + public string Caption { get; } + public bool IsEnabled { get; } + public string ToolTip { get; } + } + + class UpdateCommandState : IPullRequestUpdateState + { + public UpdateCommandState( + BranchTrackingDetails divergence, + bool pullEnabled, + bool pushEnabled, + string pullToolTip, + string pushToolTip, + string syncSubmodulesToolTip, + int submodulesToSync) + { + CommitsAhead = divergence.AheadBy ?? 0; + CommitsBehind = divergence.BehindBy ?? 0; + PushEnabled = pushEnabled; + PullEnabled = pullEnabled; + PullToolTip = pullToolTip; + PushToolTip = pushToolTip; + SyncSubmodulesToolTip = syncSubmodulesToolTip; + SubmodulesToSync = submodulesToSync; + } + + public int CommitsAhead { get; } + public int CommitsBehind { get; } + public bool UpToDate => CommitsAhead == 0 && CommitsBehind == 0 && !SyncSubmodulesEnabled; + public bool PullEnabled { get; } + public bool PushEnabled { get; } + public bool SyncSubmodulesEnabled => SubmodulesToSync > 0; + public string PullToolTip { get; } + public string PushToolTip { get; } + public string SyncSubmodulesToolTip { get; } + public int SubmodulesToSync { get; } + } + + JoinableTaskContext JoinableTaskContext { get; } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs new file mode 100644 index 0000000000..bd79e0270e --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GitHub.Primitives; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A directory node in a pull request changes tree. + /// + public class PullRequestDirectoryNode : IPullRequestDirectoryNode + { + /// + /// Initializes a new instance of the class. + /// + /// The path to the directory, relative to the repository. + public PullRequestDirectoryNode(string relativeOrGitPath) + { + DirectoryName = System.IO.Path.GetFileName(relativeOrGitPath); + RelativePath = Paths.ToWindowsPath(relativeOrGitPath); + Directories = new List(); + Files = new List(); + } + + /// + /// Gets the name of the directory without path information. + /// + public string DirectoryName { get; } + + /// + /// Gets the path to the directory, relative to the root of the repository. + /// + public string RelativePath { get; } + + /// + /// Gets the directory children of the node. + /// + public IList Directories { get; } + + /// + /// Gets the file children of the node. + /// + public IList Files { get; } + + /// + /// Gets the children of the directory. + /// + public IEnumerable Children + { + get { return Directories.Cast().Concat(Files); } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs new file mode 100644 index 0000000000..faf3a7fdb7 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using GitHub.App; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A file node in a pull request changes tree. + /// + public class PullRequestFileNode : ReactiveObject, IPullRequestFileNode + { + int commentCount; + int annotationNoticeCount; + int annotationWarningCount; + int _annotationFailureCount; + + /// + /// Initializes a new instance of the class. + /// + /// The absolute path to the repository. + /// The path to the file, relative to the repository. + /// The SHA of the file. + /// The way the file was changed. + /// The string to display in the [message] box next to the filename. + /// + /// The old path of a moved/renamed file, relative to the repository. Should be null if the + /// file was not moved/renamed. + /// + public PullRequestFileNode( + string repositoryPath, + string relativeOrGitPath, + string sha, + PullRequestFileStatus status, + string oldPath) + { + Guard.ArgumentNotEmptyString(repositoryPath, nameof(repositoryPath)); + Guard.ArgumentNotEmptyString(relativeOrGitPath, nameof(relativeOrGitPath)); + Guard.ArgumentNotEmptyString(sha, nameof(sha)); + + FileName = Path.GetFileName(relativeOrGitPath); + RelativePath = Paths.ToWindowsPath(relativeOrGitPath); + Sha = sha; + Status = status; + OldPath = oldPath; + + if (status == PullRequestFileStatus.Added) + { + StatusDisplay = Resources.AddedFileStatus; + } + else if (status == PullRequestFileStatus.Renamed) + { + if (oldPath != null) + { + StatusDisplay = Path.GetDirectoryName(oldPath) == Path.GetDirectoryName(relativeOrGitPath) ? + Path.GetFileName(oldPath) : oldPath; + } + else + { + StatusDisplay = Resources.RenamedFileStatus; + } + } + } + + /// + /// Gets the name of the file without path information. + /// + public string FileName { get; } + + /// + /// Gets the path to the file, relative to the root of the repository. + /// + public string RelativePath { get; } + + /// + /// Gets the old path of a moved/renamed file, relative to the root of the repository. + /// + public string OldPath { get; } + + /// + /// Gets the SHA of the file. + /// + public string Sha { get; } + + /// + /// Gets the type of change that was made to the file. + /// + public PullRequestFileStatus Status { get; } + + /// + /// Gets the string to display in the [message] box next to the filename. + /// + public string StatusDisplay { get; } + + /// + /// Gets or sets the number of review comments on the file. + /// + public int CommentCount + { + get { return commentCount; } + set { this.RaiseAndSetIfChanged(ref commentCount, value); } + } + + /// + /// Gets or sets the number of annotation notices on the file. + /// + public int AnnotationNoticeCount + { + get { return annotationNoticeCount; } + set { this.RaiseAndSetIfChanged(ref annotationNoticeCount, value); } + } + + /// + /// Gets or sets the number of annotation errors on the file. + /// + public int AnnotationWarningCount + { + get { return annotationWarningCount; } + set { this.RaiseAndSetIfChanged(ref annotationWarningCount, value); } + } + + /// + /// Gets or sets the number of annotation failures on the file. + /// + public int AnnotationFailureCount + { + get { return _annotationFailureCount; } + set { this.RaiseAndSetIfChanged(ref _annotationFailureCount, value); } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs new file mode 100644 index 0000000000..9be03eb613 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; +using System.Windows.Input; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using LibGit2Sharp; +using ReactiveUI; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// View model displaying a tree of changed files in a pull request. + /// + [Export(typeof(IPullRequestFilesViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class PullRequestFilesViewModel : ViewModelBase, IPullRequestFilesViewModel + { + readonly IPullRequestService service; + readonly BehaviorSubject isBranchCheckedOut = new BehaviorSubject(false); + + IPullRequestSession pullRequestSession; + Func commentFilter; + int changedFilesCount; + IReadOnlyList items; + CompositeDisposable subscriptions; + + [ImportingConstructor] + public PullRequestFilesViewModel( + IPullRequestService service, + IPullRequestEditorService editorService) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(editorService, nameof(editorService)); + + this.service = service; + + DiffFile = ReactiveCommand.CreateFromTask(x => + editorService.OpenDiff(pullRequestSession, x.RelativePath, "HEAD")); + ViewFile = ReactiveCommand.CreateFromTask(x => + editorService.OpenFile(pullRequestSession, x.RelativePath, false)); + DiffFileWithWorkingDirectory = ReactiveCommand.CreateFromTask( + x => editorService.OpenDiff(pullRequestSession, x.RelativePath), + isBranchCheckedOut); + OpenFileInWorkingDirectory = ReactiveCommand.CreateFromTask( + x => editorService.OpenFile(pullRequestSession, x.RelativePath, true), + isBranchCheckedOut); + + OpenFirstComment = ReactiveCommand.CreateFromTask(async file => + { + var thread = await GetFirstCommentThread(file); + + if (thread != null) + { + await editorService.OpenDiff(pullRequestSession, file.RelativePath, thread); + } + }); + + OpenFirstAnnotationNotice = ReactiveCommand.CreateFromTask( + async file => await OpenFirstAnnotation(editorService, file, CheckAnnotationLevel.Notice)); + + OpenFirstAnnotationWarning = ReactiveCommand.CreateFromTask( + async file => await OpenFirstAnnotation(editorService, file, CheckAnnotationLevel.Warning)); + + OpenFirstAnnotationFailure = ReactiveCommand.CreateFromTask( + async file => await OpenFirstAnnotation(editorService, file, CheckAnnotationLevel.Failure)); + } + + private async Task OpenFirstAnnotation(IPullRequestEditorService editorService, IPullRequestFileNode file, + CheckAnnotationLevel checkAnnotationLevel) + { + var annotationModel = await GetFirstAnnotation(file, checkAnnotationLevel); + + if (annotationModel != null) + { + //AnnotationModel.EndLine is a 1-based number + //EditorService.OpenDiff takes a 0-based line number to start searching AFTER and will open the next tag + var nextInlineCommentFromLine = annotationModel.EndLine - 2; + await editorService.OpenDiff(pullRequestSession, file.RelativePath, annotationModel.HeadSha, nextInlineCommentFromLine); + } + } + + /// + public int ChangedFilesCount + { + get { return changedFilesCount; } + private set { this.RaiseAndSetIfChanged(ref changedFilesCount, value); } + } + + /// + public IReadOnlyList Items + { + get { return items; } + private set { this.RaiseAndSetIfChanged(ref items, value); } + } + + /// + public void Dispose() + { + subscriptions?.Dispose(); + subscriptions = null; + } + + /// + public async Task InitializeAsync( + IPullRequestSession session, + Func filter = null) + { + Guard.ArgumentNotNull(session, nameof(session)); + + subscriptions?.Dispose(); + this.pullRequestSession = session; + this.commentFilter = filter; + subscriptions = new CompositeDisposable(); + subscriptions.Add(session.WhenAnyValue(x => x.IsCheckedOut).Subscribe(isBranchCheckedOut)); + + var dirs = new Dictionary + { + { string.Empty, new PullRequestDirectoryNode(string.Empty) } + }; + + using (var changes = await service.GetTreeChanges(session.LocalRepository, session.PullRequest)) + { + foreach (var changedFile in session.PullRequest.ChangedFiles) + { + var node = new PullRequestFileNode( + session.LocalRepository.LocalPath, + changedFile.FileName, + changedFile.Sha, + changedFile.Status, + GetOldFileName(changedFile, changes)); + var file = await session.GetFile(changedFile.FileName); + + if (file != null) + { + subscriptions.Add(file.WhenAnyValue(x => x.InlineCommentThreads) + .Subscribe(x => node.CommentCount = CountComments(x, filter))); + + subscriptions.Add(file.WhenAnyValue(x => x.InlineAnnotations) + .Subscribe(x => + { + var noticeCount = x.Count(model => model.AnnotationLevel == CheckAnnotationLevel.Notice); + var warningCount = x.Count(model => model.AnnotationLevel == CheckAnnotationLevel.Warning); + var failureCount = x.Count(model => model.AnnotationLevel == CheckAnnotationLevel.Failure); + + node.AnnotationNoticeCount = noticeCount; + node.AnnotationWarningCount = warningCount; + node.AnnotationFailureCount = failureCount; + })); + } + + var dir = GetDirectory(Path.GetDirectoryName(node.RelativePath), dirs); + dir.Files.Add(node); + } + } + + ChangedFilesCount = session.PullRequest.ChangedFiles.Count; + Items = dirs[string.Empty].Children.ToList(); + } + + /// + public ReactiveCommand DiffFile { get; } + + /// + public ReactiveCommand ViewFile { get; } + + /// + public ReactiveCommand DiffFileWithWorkingDirectory { get; } + + /// + public ReactiveCommand OpenFileInWorkingDirectory { get; } + + /// + public ReactiveCommand OpenFirstComment { get; } + + /// + public ReactiveCommand OpenFirstAnnotationNotice { get; } + + /// + public ReactiveCommand OpenFirstAnnotationWarning { get; } + + /// + public ReactiveCommand OpenFirstAnnotationFailure { get; } + + static int CountComments( + IEnumerable thread, + Func commentFilter) + { + return thread.Count(x => x.LineNumber != -1 && (commentFilter?.Invoke(x) ?? true)); + } + + static PullRequestDirectoryNode GetDirectory(string path, Dictionary dirs) + { + PullRequestDirectoryNode dir; + + if (!dirs.TryGetValue(path, out dir)) + { + var parentPath = Path.GetDirectoryName(path); + var parentDir = GetDirectory(parentPath, dirs); + + dir = new PullRequestDirectoryNode(path); + + if (!parentDir.Directories.Any(x => x.DirectoryName == dir.DirectoryName)) + { + parentDir.Directories.Add(dir); + dirs.Add(path, dir); + } + } + + return dir; + } + + static string GetOldFileName(PullRequestFileModel file, TreeChanges changes) + { + if (file.Status == PullRequestFileStatus.Renamed) + { + var gitPath = Paths.ToGitPath(file.FileName); + return changes?.Renamed.FirstOrDefault(x => x.Path == gitPath)?.OldPath; + } + + return null; + } + + async Task GetFirstCommentThread(IPullRequestFileNode file) + { + var sessionFile = await pullRequestSession.GetFile(file.RelativePath); + var threads = sessionFile.InlineCommentThreads.AsEnumerable(); + + if (commentFilter != null) + { + threads = threads.Where(commentFilter); + } + + return threads.FirstOrDefault(); + } + + async Task GetFirstAnnotation(IPullRequestFileNode file, + CheckAnnotationLevel annotationLevel) + { + var sessionFile = await pullRequestSession.GetFile(file.RelativePath); + var annotations = sessionFile.InlineAnnotations; + + return annotations.OrderBy(model => model.EndLine).FirstOrDefault(model => model.AnnotationLevel == annotationLevel); + } + + /// + /// Implements the command. + /// + /// + /// We need to "Open File in Solution" when the parameter passed to the command parameter + /// represents a deleted file. ReactiveCommand doesn't allow us to change the CanExecute + /// state depending on the parameter, so we override + /// to do this ourselves. + /// + class NonDeletedFileCommand : ReactiveCommand, ICommand + { + public NonDeletedFileCommand( + IObservable canExecute, + Func executeAsync) + : base(x => executeAsync(x).ToObservable(), canExecute, null) + { + } + + bool ICommand.CanExecute(object parameter) + { + if (parameter is IPullRequestFileNode node) + { + if (node.Status == PullRequestFileStatus.Removed) + { + return false; + } + } + + return true; ////CanExecute(parameter); + } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs new file mode 100644 index 0000000000..30436ed5a0 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs @@ -0,0 +1,69 @@ +using System; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model which displays an item in a . + /// + public class PullRequestListItemViewModel : ViewModelBase, IPullRequestListItemViewModel + { + bool isCurrent; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying pull request item model. + public PullRequestListItemViewModel(PullRequestListItemModel model) + { + Id = model.Id; + Author = new ActorViewModel(model.Author); + ChecksSummary = model.ChecksSummary; + ChecksErrorCount = model.ChecksErrorCount; + ChecksPendingCount = model.ChecksPendingCount; + ChecksSuccessCount = model.ChecksSuccessCount; + CommentCount = model.CommentCount; + Number = model.Number; + Title = model.Title; + UpdatedAt = model.UpdatedAt; + } + + /// + public string Id { get; } + + /// + public IActorViewModel Author { get; } + + /// + public PullRequestChecksSummaryState ChecksSummary { get; } + + /// + public int ChecksSuccessCount { get; } + + /// + public int ChecksPendingCount { get; } + + /// + public int ChecksErrorCount { get; } + + /// + public int CommentCount { get; } + + /// + public bool IsCurrent + { + get { return isCurrent; } + internal set { this.RaiseAndSetIfChanged(ref isCurrent, value); } + } + + /// + public int Number { get; } + + /// + public string Title { get; } + + /// + public DateTimeOffset UpdatedAt { get; } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs new file mode 100644 index 0000000000..23890674c8 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Collections; +using GitHub.Commands; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; +using static System.FormattableString; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model which displays a pull request list. + /// + [Export(typeof(IPullRequestListViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestListViewModel : IssueListViewModelBase, IPullRequestListViewModel + { + static readonly IReadOnlyList states = new[] { "Open", "Closed", "All" }; + readonly IPullRequestSessionManager sessionManager; + readonly IPullRequestService service; + readonly IDisposable subscription; + ObservableAsPropertyHelper webUrl; + + /// + /// Initializes a new instance of the class. + /// + /// The session manager. + /// The repository service. + /// The pull request service. + [ImportingConstructor] + public PullRequestListViewModel( + IPullRequestSessionManager sessionManager, + IRepositoryService repositoryService, + IPullRequestService service) + : base(repositoryService) + { + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(service, nameof(service)); + + this.sessionManager = sessionManager; + this.service = service; + + subscription = sessionManager.WhenAnyValue(x => x.CurrentSession.PullRequest.Number).Subscribe(UpdateCurrent); + webUrl = this.WhenAnyValue(x => x.RemoteRepository) + .Select(x => x?.CloneUrl?.ToRepositoryUrl().Append("pulls")) + .ToProperty(this, x => x.WebUrl); + CreatePullRequest = ReactiveCommand.Create(() => NavigateTo("pull/new")); + OpenItemInBrowser = ReactiveCommand.Create(x => x); + } + + /// + public override IReadOnlyList States => states; + + /// + public Uri WebUrl => webUrl.Value; + + /// + public ReactiveCommand CreatePullRequest { get; } + + /// + public ReactiveCommand OpenItemInBrowser { get; } + + /// + protected override async Task> CreateItemSource(bool refresh) + { + if (refresh) + { + await service.ClearPullRequestsCache( + HostAddress.Create(RemoteRepository.CloneUrl), + RemoteRepository.Owner, + RemoteRepository.Name); + } + + return new ItemSource(this); + } + + /// + protected override Task DoOpenItem(IIssueListItemViewModelBase item) + { + var i = (IPullRequestListItemViewModel)item; + NavigateTo(Invariant($"{RemoteRepository.Owner}/{RemoteRepository.Name}/pull/{i.Number}")); + return Task.CompletedTask; + } + + /// + protected override Task> LoadAuthors(string after) + { + return service.ReadAssignableUsers( + HostAddress.Create(LocalRepository.CloneUrl), + LocalRepository.Owner, + LocalRepository.Name, + after); + } + + void UpdateCurrent(int number) + { + if (Items != null) + { + foreach (var i in Items) + { + var item = i as PullRequestListItemViewModel; + + if (item != null) + { + item.IsCurrent = item.Number == number; + } + } + } + } + + class ItemSource : SequentialListSource + { + readonly PullRequestListViewModel owner; + + public ItemSource(PullRequestListViewModel owner) + { + this.owner = owner; + } + + protected override IIssueListItemViewModelBase CreateViewModel(PullRequestListItemModel model) + { + var result = new PullRequestListItemViewModel(model); + result.IsCurrent = owner.sessionManager.CurrentSession?.PullRequest.Number == model.Number; + return result; + } + + protected override async Task> LoadPage(string after) + { + PullRequestState[] states; + + switch (owner.SelectedState) + { + case "Open": + states = new[] { PullRequestState.Open }; + break; + case "Closed": + states = new[] { PullRequestState.Closed, PullRequestState.Merged }; + break; + default: + states = new[] { PullRequestState.Open, PullRequestState.Closed, PullRequestState.Merged }; + break; + } + + var result = await owner.service.ReadPullRequests( + HostAddress.Create(owner.RemoteRepository.CloneUrl), + owner.RemoteRepository.Owner, + owner.RemoteRepository.Name, + after, + states).ConfigureAwait(false); + return result; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs new file mode 100644 index 0000000000..82ff54359c --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Models.Drafts; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; +using Serilog; +using static System.FormattableString; + +namespace GitHub.ViewModels.GitHubPane +{ + [Export(typeof(IPullRequestReviewAuthoringViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestReviewAuthoringViewModel : PanePageViewModelBase, IPullRequestReviewAuthoringViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IPullRequestEditorService editorService; + readonly IPullRequestSessionManager sessionManager; + readonly IMessageDraftStore draftStore; + readonly IPullRequestService pullRequestService; + readonly IScheduler timerScheduler; + IPullRequestSession session; + IDisposable sessionSubscription; + PullRequestReviewModel model; + PullRequestDetailModel pullRequestModel; + string body; + ObservableAsPropertyHelper canApproveRequestChanges; + IReadOnlyList fileComments; + string operationError; + + [ImportingConstructor] + public PullRequestReviewAuthoringViewModel( + IPullRequestService pullRequestService, + IPullRequestEditorService editorService, + IPullRequestSessionManager sessionManager, + IMessageDraftStore draftStore, + IPullRequestFilesViewModel files, + IAutoCompleteAdvisor autoCompleteAdvisor) + : this(pullRequestService, editorService, sessionManager,draftStore, files, autoCompleteAdvisor, DefaultScheduler.Instance) + { + } + + public PullRequestReviewAuthoringViewModel( + IPullRequestService pullRequestService, + IPullRequestEditorService editorService, + IPullRequestSessionManager sessionManager, + IMessageDraftStore draftStore, + IPullRequestFilesViewModel files, + IAutoCompleteAdvisor autoCompleteAdvisor, + IScheduler timerScheduler) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(draftStore, nameof(draftStore)); + Guard.ArgumentNotNull(files, nameof(files)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); + Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler)); + + this.pullRequestService = pullRequestService; + this.editorService = editorService; + this.sessionManager = sessionManager; + this.draftStore = draftStore; + this.timerScheduler = timerScheduler; + + canApproveRequestChanges = this.WhenAnyValue( + x => x.Model, + x => x.PullRequestModel, + (review, pr) => review != null && pr != null && review.Author.Login != pr.Author.Login) + .ToProperty(this, x => x.CanApproveRequestChanges); + + Files = files; + AutoCompleteAdvisor = autoCompleteAdvisor; + + var hasBodyOrComments = this.WhenAnyValue( + x => x.Body, + x => x.FileComments.Count, + (body, comments) => !string.IsNullOrWhiteSpace(body) || comments > 0); + + Approve = ReactiveCommand.CreateFromTask(() => DoSubmit(Octokit.PullRequestReviewEvent.Approve)); + Comment = ReactiveCommand.CreateFromTask( + () => DoSubmit(Octokit.PullRequestReviewEvent.Comment), + hasBodyOrComments); + RequestChanges = ReactiveCommand.CreateFromTask( + () => DoSubmit(Octokit.PullRequestReviewEvent.RequestChanges), + hasBodyOrComments); + Cancel = ReactiveCommand.CreateFromTask(DoCancel); + NavigateToPullRequest = ReactiveCommand.Create(() => + NavigateTo(Invariant($"{RemoteRepositoryOwner}/{LocalRepository.Name}/pull/{PullRequestModel.Number}"))); + } + + /// + public LocalRepositoryModel LocalRepository { get; private set; } + + /// + public string RemoteRepositoryOwner { get; private set; } + + /// + public PullRequestReviewModel Model + { + get { return model; } + private set { this.RaiseAndSetIfChanged(ref model, value); } + } + + /// + public PullRequestDetailModel PullRequestModel + { + get { return pullRequestModel; } + private set { this.RaiseAndSetIfChanged(ref pullRequestModel, value); } + } + + /// + public IPullRequestFilesViewModel Files { get; } + + /// + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + + /// + public string Body + { + get { return body; } + set { this.RaiseAndSetIfChanged(ref body, value); } + } + + /// + public bool CanApproveRequestChanges => canApproveRequestChanges.Value; + + /// + /// Gets the error message to be displayed in the action area as a result of an error in a + /// git operation. + /// + public string OperationError + { + get { return operationError; } + private set { this.RaiseAndSetIfChanged(ref operationError, value); } + } + + /// + public IReadOnlyList FileComments + { + get { return fileComments; } + private set { this.RaiseAndSetIfChanged(ref fileComments, value); } + } + + public ReactiveCommand NavigateToPullRequest { get; } + public ReactiveCommand Approve { get; } + public ReactiveCommand Comment { get; } + public ReactiveCommand RequestChanges { get; } + public ReactiveCommand Cancel { get; } + + public async Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber) + { + if (repo != localRepository.Name) + { + throw new NotSupportedException(); + } + + IsLoading = true; + + try + { + LocalRepository = localRepository; + RemoteRepositoryOwner = owner; + session = await sessionManager.GetSession(owner, repo, pullRequestNumber).ConfigureAwait(true); + await Load(session.PullRequest).ConfigureAwait(true); + + if (LocalRepository?.CloneUrl != null) + { + var key = GetDraftKey(); + + if (string.IsNullOrEmpty(Body)) + { + var draft = await draftStore.GetDraft(key, string.Empty) + .ConfigureAwait(true); + Body = draft?.Body; + } + + this.WhenAnyValue(x => x.Body) + .Throttle(TimeSpan.FromSeconds(1), timerScheduler) + .Select(x => new PullRequestReviewDraft { Body = x }) + .Subscribe(x => draftStore.UpdateDraft(key, string.Empty, x)); + } + } + finally + { + IsLoading = false; + } + } + + /// + public override async Task Refresh() + { + try + { + Error = null; + IsBusy = true; + await session.Refresh(); + await Load(session.PullRequest); + } + catch (Exception ex) + { + log.Error( + ex, + "Error loading pull request review {Owner}/{Repo}/{Number}/{PullRequestReviewId} from {Address}", + RemoteRepositoryOwner, + LocalRepository.Name, + PullRequestModel.Number, + Model.Id, + session.LocalRepository.CloneUrl.Host); + Error = ex; + IsBusy = false; + } + } + + public static string GetDraftKey( + UriString cloneUri, + int pullRequestNumber) + { + return Invariant($"pr-review|{cloneUri}|{pullRequestNumber}"); + } + + protected string GetDraftKey() + { + return GetDraftKey( + LocalRepository.CloneUrl.WithOwner(RemoteRepositoryOwner), + PullRequestModel.Number); + } + + async Task Load(PullRequestDetailModel pullRequest) + { + try + { + PullRequestModel = pullRequest; + + Model = pullRequest.Reviews.FirstOrDefault(x => + x.State == PullRequestReviewState.Pending && x.Author.Login == session.User.Login) ?? + new PullRequestReviewModel + { + Body = string.Empty, + Author = session.User, + State = PullRequestReviewState.Pending, + }; + + Body = Model.Body; + + sessionSubscription?.Dispose(); + await UpdateFileComments(); + sessionSubscription = session.PullRequestChanged.Subscribe(_ => UpdateFileComments().Forget()); + } + finally + { + IsBusy = false; + } + } + + bool FilterComments(IInlineCommentThreadModel thread) + { + return thread.Comments.Any(x => x.Review.Id == Model.Id); + } + + async Task UpdateFileComments() + { + var result = new List(); + + if (Model.Id == null && session.PendingReviewId != null) + { + Model.Id = session.PendingReviewId; + } + + foreach (var file in await session.GetAllFiles()) + { + foreach (var thread in file.InlineCommentThreads) + { + foreach (var comment in thread.Comments) + { + if (comment.Review.Id == Model.Id) + { + result.Add(new PullRequestReviewCommentViewModel( + editorService, + session, + thread.RelativePath, + comment.Comment)); + } + } + } + } + + FileComments = result; + await Files.InitializeAsync(session, FilterComments); + } + + async Task DoSubmit(Octokit.PullRequestReviewEvent e) + { + OperationError = null; + IsBusy = true; + + try + { + await session.PostReview(Body, e).ConfigureAwait(true); + Close(); + await draftStore.DeleteDraft(GetDraftKey(), string.Empty).ConfigureAwait(true); + } + catch (Exception ex) + { + OperationError = ex.Message; + } + finally + { + IsBusy = false; + } + } + + async Task DoCancel() + { + OperationError = null; + IsBusy = true; + + try + { + if (Model?.Id != null) + { + if (pullRequestService.ConfirmCancelPendingReview()) + { + await session.CancelReview(); + Close(); + } + } + else + { + Close(); + } + + await draftStore.DeleteDraft(GetDraftKey(), string.Empty).ConfigureAwait(true); + } + catch (Exception ex) + { + OperationError = ex.Message; + } + finally + { + IsBusy = false; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewCommentViewModel.cs new file mode 100644 index 0000000000..7c960baaa5 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewCommentViewModel.cs @@ -0,0 +1,90 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model for a file comment in a . + /// + public class PullRequestReviewCommentViewModel : IPullRequestReviewFileCommentViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IPullRequestEditorService editorService; + readonly IPullRequestSession session; + readonly PullRequestReviewCommentModel model; + IInlineCommentThreadModel thread; + + public PullRequestReviewCommentViewModel( + IPullRequestEditorService editorService, + IPullRequestSession session, + string relativePath, + PullRequestReviewCommentModel model) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotNull(model, nameof(model)); + + this.editorService = editorService; + this.session = session; + this.model = model; + RelativePath = relativePath; + + Open = ReactiveCommand.CreateFromTask(DoOpen); + } + + /// + public string Body => model.Body; + + /// + public string RelativePath { get; set; } + + /// + public ReactiveCommand Open { get; } + + async Task DoOpen() + { + try + { + if (thread == null) + { + if(model.Thread.IsOutdated) + { + var file = await session.GetFile(RelativePath, model.Thread.OriginalCommitSha); + thread = file.InlineCommentThreads.FirstOrDefault(t => t.Comments.Any(c => c.Comment.Id == model.Id)); + } + else + { + var file = await session.GetFile(RelativePath, model.Thread.CommitSha); + thread = file.InlineCommentThreads.FirstOrDefault(t => t.Comments.Any(c => c.Comment.Id == model.Id)); + + if(thread?.LineNumber == -1) + { + log.Warning("Couldn't find line number for comment on {RelativePath} @ {CommitSha}", RelativePath, model.Thread.CommitSha); + // Fall back to opening outdated file if we can't find a line number for the comment + file = await session.GetFile(RelativePath, model.Thread.OriginalCommitSha); + thread = file.InlineCommentThreads.FirstOrDefault(t => t.Comments.Any(c => c.Comment.Id == model.Id)); + } + } + } + + if (thread != null && thread.LineNumber != -1) + { + await editorService.OpenDiff(session, RelativePath, thread); + } + } + catch (Exception e) + { + log.Error(e, nameof(DoOpen)); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewSummaryViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewSummaryViewModel.cs new file mode 100644 index 0000000000..3ec2fb4e7d --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewSummaryViewModel.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GitHub.App; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays a short overview of a pull request review in the . + /// + public class PullRequestReviewSummaryViewModel : IPullRequestReviewSummaryViewModel + { + /// + public string Id { get; set; } + + /// + public IActorViewModel User { get; set; } + + /// + public PullRequestReviewState State { get; set; } + + /// + public string StateDisplay => ToString(State); + + /// + public int FileCommentCount { get; set; } + + /// + /// Builds a collection of s by user. + /// + /// The current user. + /// The pull request model. + /// + /// This method builds a list similar to that found in the "Reviewers" section at the top- + /// right of the Pull Request page on GitHub. + /// + public static IEnumerable BuildByUser( + ActorModel currentUser, + PullRequestDetailModel pullRequest) + { + var existing = new Dictionary(); + + foreach (var review in pullRequest.Reviews.OrderBy(x => x.SubmittedAt)) + { + if (review.State == PullRequestReviewState.Pending && review.Author.Login != currentUser.Login) + continue; + + PullRequestReviewSummaryViewModel previous; + existing.TryGetValue(review.Author.Login, out previous); + + var previousPriority = ToPriority(previous); + var reviewPriority = ToPriority(review.State); + + if (reviewPriority >= previousPriority) + { + existing[review.Author.Login] = new PullRequestReviewSummaryViewModel + { + Id = review.Id, + User = new ActorViewModel(review.Author), + State = review.State, + FileCommentCount = review.Comments.Count, + }; + } + } + + var result = existing.Values.OrderBy(x => x.User.Login).AsEnumerable(); + + if (!result.Any(x => x.State == PullRequestReviewState.Pending)) + { + var newReview = new PullRequestReviewSummaryViewModel + { + State = PullRequestReviewState.Pending, + User = new ActorViewModel(currentUser), + }; + result = result.Concat(new[] { newReview }); + } + + return result; + } + + static int ToPriority(PullRequestReviewSummaryViewModel review) + { + return review != null ? ToPriority(review.State) : 0; + } + + static int ToPriority(PullRequestReviewState state) + { + switch (state) + { + case PullRequestReviewState.Approved: + case PullRequestReviewState.ChangesRequested: + case PullRequestReviewState.Dismissed: + return 1; + case PullRequestReviewState.Pending: + return 2; + default: + return 0; + } + } + + static string ToString(PullRequestReviewState state) + { + switch (state) + { + case PullRequestReviewState.Approved: + return Resources.Approved; + case PullRequestReviewState.ChangesRequested: + return Resources.ChangesRequested; + case PullRequestReviewState.Commented: + case PullRequestReviewState.Dismissed: + return Resources.Commented; + case PullRequestReviewState.Pending: + return Resources.InProgress; + default: + throw new NotSupportedException(); + } + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModel.cs new file mode 100644 index 0000000000..8ff45e8442 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewViewModel.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// View model for displaying details of a pull request review. + /// + public class PullRequestReviewViewModel : ViewModelBase, IPullRequestReviewViewModel + { + bool isExpanded; + + /// + /// Initializes a new instance of the class. + /// + /// The pull request editor service. + /// The pull request session. + /// The pull request review model. + public PullRequestReviewViewModel( + IPullRequestEditorService editorService, + IPullRequestSession session, + PullRequestReviewModel model) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotNull(model, nameof(model)); + + Model = model; + Body = string.IsNullOrWhiteSpace(Model.Body) ? null : Model.Body; + StateDisplay = ToString(Model.State); + + var comments = new List(); + var outdated = new List(); + + foreach (var comment in model.Comments) + { + if (comment.Thread != null) + { + var vm = new PullRequestReviewCommentViewModel( + editorService, + session, + comment.Thread.Path, + comment); + + if (comment.Thread.Position != null) + comments.Add(vm); + else + outdated.Add(vm); + } + } + + FileComments = comments; + OutdatedFileComments = outdated; + + HasDetails = Body != null || + FileComments.Count > 0 || + OutdatedFileComments.Count > 0; + } + + /// + public PullRequestReviewModel Model { get; } + + /// + public string Body { get; } + + /// + public string StateDisplay { get; } + + /// + public bool IsExpanded + { + get { return isExpanded; } + set { this.RaiseAndSetIfChanged(ref isExpanded, value); } + } + + /// + public bool HasDetails { get; } + + /// + public IReadOnlyList FileComments { get; } + + /// + public IReadOnlyList OutdatedFileComments { get; } + + static string ToString(PullRequestReviewState state) + { + switch (state) + { + case PullRequestReviewState.Approved: + return "approved"; + case PullRequestReviewState.ChangesRequested: + return "requested changes"; + case PullRequestReviewState.Commented: + case PullRequestReviewState.Dismissed: + return "commented"; + default: + throw new NotSupportedException(); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs new file mode 100644 index 0000000000..2f1db0020f --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using Serilog; +using static System.FormattableString; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays all reviews made by a user on a pull request. + /// + [Export(typeof(IPullRequestUserReviewsViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestUserReviewsViewModel : PanePageViewModelBase, IPullRequestUserReviewsViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IPullRequestEditorService editorService; + readonly IPullRequestSessionManager sessionManager; + IPullRequestSession session; + string login; + IActorViewModel user; + string title; + IReadOnlyList reviews; + + [ImportingConstructor] + public PullRequestUserReviewsViewModel( + IPullRequestEditorService editorService, + IPullRequestSessionManager sessionManager) + { + Guard.ArgumentNotNull(editorService, nameof(editorService)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + + this.editorService = editorService; + this.sessionManager = sessionManager; + + NavigateToPullRequest = ReactiveCommand.Create(() => + NavigateTo(Invariant($"{LocalRepository.Owner}/{LocalRepository.Name}/pull/{PullRequestNumber}"))); + } + + /// + public LocalRepositoryModel LocalRepository { get; private set; } + + /// + public string RemoteRepositoryOwner { get; private set; } + + /// + public int PullRequestNumber { get; private set; } + + public IActorViewModel User + { + get { return user; } + private set { this.RaiseAndSetIfChanged(ref user, value); } + } + + /// + public IReadOnlyList Reviews + { + get { return reviews; } + private set { this.RaiseAndSetIfChanged(ref reviews, value); } + } + + /// + public string PullRequestTitle + { + get { return title; } + private set { this.RaiseAndSetIfChanged(ref title, value); } + } + + /// + public ReactiveCommand NavigateToPullRequest { get; } + + /// + [SuppressMessage("Microsoft.Maintainability", "CA1500:VariableNamesShouldNotMatchFieldNames", MessageId = "login")] + public async Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber, + string login) + { + if (repo != localRepository.Name) + { + throw new NotSupportedException(); + } + + IsLoading = true; + + try + { + LocalRepository = localRepository; + RemoteRepositoryOwner = owner; + PullRequestNumber = pullRequestNumber; + this.login = login; + session = await sessionManager.GetSession(owner, repo, pullRequestNumber); + await Load(session.PullRequest); + } + finally + { + IsLoading = false; + } + } + + /// + public override async Task Refresh() + { + try + { + Error = null; + IsBusy = true; + await session.Refresh(); + await Load(session.PullRequest); + } + catch (Exception ex) + { + log.Error( + ex, + "Error loading pull request reviews {Owner}/{Repo}/{Number} from {Address}", + RemoteRepositoryOwner, + LocalRepository.Name, + PullRequestNumber, + LocalRepository.CloneUrl.Host); + Error = ex; + IsBusy = false; + } + } + + /// + async Task Load(PullRequestDetailModel pullRequest) + { + IsBusy = true; + + try + { + PullRequestTitle = pullRequest.Title; + + var reviews = new List(); + var isFirst = true; + + foreach (var review in pullRequest.Reviews.OrderByDescending(x => x.SubmittedAt)) + { + if (review.Author.Login == login) + { + if (User == null) + { + User = new ActorViewModel(review.Author); + } + + if (review.State != PullRequestReviewState.Pending) + { + var vm = new PullRequestReviewViewModel(editorService, session, review); + vm.IsExpanded = isFirst; + reviews.Add(vm); + isFirst = false; + } + } + } + + Reviews = reviews; + + if (User == null) + { + User = new ActorViewModel(new ActorModel { Login = login }); + } + } + finally + { + IsBusy = false; + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/InlineAnnotationViewModel.cs b/src/GitHub.App/ViewModels/InlineAnnotationViewModel.cs new file mode 100644 index 0000000000..c395a9abef --- /dev/null +++ b/src/GitHub.App/ViewModels/InlineAnnotationViewModel.cs @@ -0,0 +1,21 @@ +using GitHub.Models; +using GitHub.ViewModels; + +namespace GitHub.ViewModels +{ + /// + public class InlineAnnotationViewModel: IInlineAnnotationViewModel + { + /// + public InlineAnnotationModel Model { get; } + + /// + /// Initializes a . + /// + /// The inline annotation model. + public InlineAnnotationViewModel(InlineAnnotationModel model) + { + Model = model; + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/IssueishViewModel.cs b/src/GitHub.App/ViewModels/IssueishViewModel.cs new file mode 100644 index 0000000000..ba79091eda --- /dev/null +++ b/src/GitHub.App/ViewModels/IssueishViewModel.cs @@ -0,0 +1,85 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Logging; +using GitHub.Models; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels +{ + /// + /// Base class for issue and pull request view models. + /// + public class IssueishViewModel : ViewModelBase, IIssueishViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + IActorViewModel author; + string body; + string title; + Uri webUrl; + + /// + /// Initializes a new instance of the class. + /// + [ImportingConstructor] + public IssueishViewModel() + { + } + + /// + public RemoteRepositoryModel Repository { get; private set; } + + /// + public string Id { get; private set; } + + /// + public int Number { get; private set; } + + /// + public IActorViewModel Author + { + get => author; + private set => this.RaiseAndSetIfChanged(ref author, value); + } + + /// + public string Body + { + get => body; + protected set => this.RaiseAndSetIfChanged(ref body, value); + } + + /// + public string Title + { + get => title; + protected set => this.RaiseAndSetIfChanged(ref title, value); + } + + /// + public Uri WebUrl + { + get { return webUrl; } + protected set { this.RaiseAndSetIfChanged(ref webUrl, value); } + } + + /// + public ReactiveCommand OpenOnGitHub { get; protected set; } + + protected Task InitializeAsync( + RemoteRepositoryModel repository, + IssueishDetailModel model) + { + Repository = repository; + Id = model.Id; + Author = new ActorViewModel(model.Author); + Body = model.Body; + Number = model.Number; + Title = model.Title; + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/ViewModels/LoggedOutViewModel.cs b/src/GitHub.App/ViewModels/LoggedOutViewModel.cs deleted file mode 100644 index 562184ed23..0000000000 --- a/src/GitHub.App/ViewModels/LoggedOutViewModel.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Reactive.Linq; -using GitHub.Exports; -using GitHub.Info; -using GitHub.Services; -using GitHub.UI; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// The view model for the "Sign in to GitHub" view in the GitHub pane. - /// - [ExportViewModel(ViewType = UIViewType.LoggedOut)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class LoggedOutViewModel : BaseViewModel, ILoggedOutViewModel - { - readonly IUIProvider uiProvider; - readonly IVisualStudioBrowser browser; - - /// - /// Initializes a new instance of the class. - /// - [ImportingConstructor] - public LoggedOutViewModel(IUIProvider uiProvider, IVisualStudioBrowser browser) - { - this.uiProvider = uiProvider; - this.browser = browser; - SignIn = ReactiveCommand.Create(); - SignIn.Subscribe(_ => OnSignIn()); - Register = ReactiveCommand.Create(); - Register.Subscribe(_ => OnRegister()); - } - - /// - public IReactiveCommand SignIn { get; } - - /// - public IReactiveCommand Register { get; } - - /// - /// Called when the command is executed. - /// - void OnSignIn() - { - // Show the Sign In dialog. We don't need to listen to the outcome of this: the parent - // GitHubPaneViewModel will listen to RepositoryHosts.IsLoggedInToAnyHost and close - // this view when the user logs in. - uiProvider.RunInDialog(UIControllerFlow.Authentication); - } - - /// - /// Called when the command is executed. - /// - void OnRegister() - { - browser.OpenUrl(GitHubUrls.Pricing); - } - } -} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/LoginControlViewModel.cs b/src/GitHub.App/ViewModels/LoginControlViewModel.cs deleted file mode 100644 index dc950fcb0d..0000000000 --- a/src/GitHub.App/ViewModels/LoginControlViewModel.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Reactive.Linq; -using GitHub.App; -using GitHub.Authentication; -using GitHub.Exports; -using GitHub.Models; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType = UIViewType.Login)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class LoginControlViewModel : BaseViewModel, ILoginControlViewModel - { - [ImportingConstructor] - public LoginControlViewModel( - IRepositoryHosts hosts, - ILoginToGitHubViewModel loginToGitHubViewModel, - ILoginToGitHubForEnterpriseViewModel loginToGitHubEnterpriseViewModel) - { - Title = Resources.LoginTitle; - RepositoryHosts = hosts; - GitHubLogin = loginToGitHubViewModel; - EnterpriseLogin = loginToGitHubEnterpriseViewModel; - - isLoginInProgress = this.WhenAny( - x => x.GitHubLogin.IsLoggingIn, - x => x.EnterpriseLogin.IsLoggingIn, - (x, y) => x.Value || y.Value - ).ToProperty(this, vm => vm.IsLoginInProgress); - - loginMode = this.WhenAny( - x => x.RepositoryHosts.GitHubHost.IsLoggedIn, - x => x.RepositoryHosts.EnterpriseHost.IsLoggedIn, - (x, y) => - { - var canLogInToGitHub = x.Value == false; - var canLogInToEnterprise = y.Value == false; - - return canLogInToGitHub && canLogInToEnterprise ? LoginMode.DotComOrEnterprise - : canLogInToGitHub ? LoginMode.DotComOnly - : canLogInToEnterprise ? LoginMode.EnterpriseOnly - : LoginMode.None; - - }).ToProperty(this, x => x.LoginMode); - - AuthenticationResults = Observable.Merge( - loginToGitHubViewModel.Login, - EnterpriseLogin.Login); - } - - ILoginToGitHubViewModel github; - public ILoginToGitHubViewModel GitHubLogin - { - get { return github; } - set { this.RaiseAndSetIfChanged(ref github, value); } - } - - ILoginToGitHubForEnterpriseViewModel githubEnterprise; - public ILoginToGitHubForEnterpriseViewModel EnterpriseLogin - { - get { return githubEnterprise; } - set { this.RaiseAndSetIfChanged(ref githubEnterprise, value); } - } - - IRepositoryHosts repositoryHosts; - public IRepositoryHosts RepositoryHosts - { - get { return repositoryHosts; } - set { this.RaiseAndSetIfChanged(ref repositoryHosts, value); } - } - - readonly ObservableAsPropertyHelper loginMode; - public LoginMode LoginMode { get { return loginMode.Value; } } - - readonly ObservableAsPropertyHelper isLoginInProgress; - public bool IsLoginInProgress { get { return isLoginInProgress.Value; } } - - public IObservable AuthenticationResults { get; private set; } - } - - public enum LoginTarget - { - None = 0, - DotCom = 1, - Enterprise = 2, - } - - public enum VisualState - { - None = 0, - DotCom = 1, - Enterprise = 2, - DotComOnly = 3, - EnterpriseOnly = 4 - } -} diff --git a/src/GitHub.App/ViewModels/LoginTabViewModel.cs b/src/GitHub.App/ViewModels/LoginTabViewModel.cs deleted file mode 100644 index e3dff08013..0000000000 --- a/src/GitHub.App/ViewModels/LoginTabViewModel.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading.Tasks; -using GitHub.App; -using GitHub.Authentication; -using GitHub.Extensions; -using GitHub.Extensions.Reactive; -using GitHub.Info; -using GitHub.Models; -using GitHub.Primitives; -using GitHub.Services; -using GitHub.Validation; -using NLog; -using NullGuard; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")] - public abstract class LoginTabViewModel : ReactiveObject - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - protected LoginTabViewModel(IRepositoryHosts repositoryHosts, IVisualStudioBrowser browser) - { - RepositoryHosts = repositoryHosts; - - UsernameOrEmailValidator = ReactivePropertyValidator.For(this, x => x.UsernameOrEmail) - .IfNullOrEmpty(Resources.UsernameOrEmailValidatorEmpty) - .IfMatch(@"\s", Resources.UsernameOrEmailValidatorSpaces); - - PasswordValidator = ReactivePropertyValidator.For(this, x => x.Password) - .IfNullOrEmpty(Resources.PasswordValidatorEmpty); - - canLogin = this.WhenAny( - x => x.UsernameOrEmailValidator.ValidationResult.IsValid, - x => x.PasswordValidator.ValidationResult.IsValid, - (x, y) => x.Value && y.Value).ToProperty(this, x => x.CanLogin); - - Login = ReactiveCommand.CreateAsyncObservable(this.WhenAny(x => x.CanLogin, x => x.Value), LogIn); - - Login.ThrownExceptions.Subscribe(ex => - { - if (ex.IsCriticalException()) return; - - log.Info(string.Format(CultureInfo.InvariantCulture, "Error logging into '{0}' as '{1}'", BaseUri, UsernameOrEmail), ex); - if (ex is Octokit.ForbiddenException) - { - UserError.Throw(new UserError(Resources.LoginFailedForbiddenMessage)); - } - else - { - UserError.Throw(new UserError(ex.Message)); - } - }); - - isLoggingIn = Login.IsExecuting.ToProperty(this, x => x.IsLoggingIn); - - Reset = ReactiveCommand.CreateAsyncTask(_ => Clear()); - - NavigateForgotPassword = new RecoveryCommand(Resources.ForgotPasswordLink, _ => - { - browser.OpenUrl(new Uri(BaseUri, GitHubUrls.ForgotPasswordPath)); - return RecoveryOptionResult.RetryOperation; - }); - - SignUp = ReactiveCommand.CreateAsyncObservable(_ => - { - browser.OpenUrl(GitHubUrls.Plans); - return Observable.Return(Unit.Default); - }); - } - protected IRepositoryHosts RepositoryHosts { get; } - protected abstract Uri BaseUri { get; } - public IReactiveCommand SignUp { get; } - - public IReactiveCommand Login { get; } - public IReactiveCommand Reset { get; } - public IRecoveryCommand NavigateForgotPassword { get; } - - string usernameOrEmail; - [AllowNull] - public string UsernameOrEmail - { - [return: AllowNull] - get - { return usernameOrEmail; } - set { this.RaiseAndSetIfChanged(ref usernameOrEmail, value); } - } - - ReactivePropertyValidator usernameOrEmailValidator; - public ReactivePropertyValidator UsernameOrEmailValidator - { - get { return usernameOrEmailValidator; } - private set { this.RaiseAndSetIfChanged(ref usernameOrEmailValidator, value); } - } - - string password; - [AllowNull] - public string Password - { - [return: AllowNull] - get - { return password; } - set { this.RaiseAndSetIfChanged(ref password, value); } - } - - ReactivePropertyValidator passwordValidator; - public ReactivePropertyValidator PasswordValidator - { - get { return passwordValidator; } - private set { this.RaiseAndSetIfChanged(ref passwordValidator, value); } - } - - readonly ObservableAsPropertyHelper isLoggingIn; - public bool IsLoggingIn - { - get { return isLoggingIn.Value; } - } - - protected ObservableAsPropertyHelper canLogin; - public bool CanLogin - { - get { return canLogin.Value; } - } - - protected abstract IObservable LogIn(object args); - - protected IObservable LogInToHost(HostAddress hostAddress) - { - return Observable.Defer(() => - { - return hostAddress != null ? - RepositoryHosts.LogIn(hostAddress, UsernameOrEmail, Password) - : Observable.Return(AuthenticationResult.CredentialFailure); - }) - .ObserveOn(RxApp.MainThreadScheduler) - .Do(authResult => { - switch (authResult) - { - case AuthenticationResult.CredentialFailure: - UserError.Throw(new UserError( - Resources.LoginFailedText, - Resources.LoginFailedMessage, - new[] { NavigateForgotPassword })); - break; - case AuthenticationResult.VerificationFailure: - break; - case AuthenticationResult.EnterpriseServerNotFound: - UserError.Throw(new UserError(Resources.CouldNotConnectToGitHub)); - break; - } - }) - .SelectMany(authResult => - { - switch (authResult) - { - case AuthenticationResult.CredentialFailure: - case AuthenticationResult.EnterpriseServerNotFound: - case AuthenticationResult.VerificationFailure: - Password = ""; - return Observable.FromAsync(PasswordValidator.ResetAsync) - .Select(_ => AuthenticationResult.CredentialFailure); - case AuthenticationResult.Success: - return Reset.ExecuteAsync() - .ContinueAfter(() => Observable.Return(AuthenticationResult.Success)); - default: - return Observable.Throw( - new InvalidOperationException("Unknown EnterpriseLoginResult: " + authResult)); - } - }); - } - - async Task Clear() - { - UsernameOrEmail = null; - Password = null; - await UsernameOrEmailValidator.ResetAsync(); - await PasswordValidator.ResetAsync(); - await ResetValidation(); - } - - protected virtual Task ResetValidation() - { - // noop - return Task.FromResult(0); - } - } -} diff --git a/src/GitHub.App/ViewModels/LoginToGitHubForEnterpriseViewModel.cs b/src/GitHub.App/ViewModels/LoginToGitHubForEnterpriseViewModel.cs deleted file mode 100644 index 2699f598f2..0000000000 --- a/src/GitHub.App/ViewModels/LoginToGitHubForEnterpriseViewModel.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Reactive; -using System.Reactive.Linq; -using System.Threading.Tasks; -using GitHub.App; -using GitHub.Authentication; -using GitHub.Info; -using GitHub.Models; -using GitHub.Primitives; -using GitHub.Services; -using GitHub.Validation; -using NullGuard; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [Export(typeof(ILoginToGitHubForEnterpriseViewModel))] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class LoginToGitHubForEnterpriseViewModel : LoginTabViewModel, ILoginToGitHubForEnterpriseViewModel - { - [ImportingConstructor] - public LoginToGitHubForEnterpriseViewModel(IRepositoryHosts hosts, IVisualStudioBrowser browser) : base(hosts, browser) - { - EnterpriseUrlValidator = ReactivePropertyValidator.For(this, x => x.EnterpriseUrl) - .IfNullOrEmpty(Resources.EnterpriseUrlValidatorEmpty) - .IfNotUri(Resources.EnterpriseUrlValidatorInvalid) - .IfGitHubDotComHost(Resources.EnterpriseUrlValidatorNotAGitHubHost); - - canLogin = this.WhenAny( - x => x.UsernameOrEmailValidator.ValidationResult.IsValid, - x => x.PasswordValidator.ValidationResult.IsValid, - x => x.EnterpriseUrlValidator.ValidationResult.IsValid, - (x, y, z) => x.Value && y.Value && z.Value) - .ToProperty(this, x => x.CanLogin); - - NavigateLearnMore = ReactiveCommand.CreateAsyncObservable(_ => - { - browser.OpenUrl(GitHubUrls.LearnMore); - return Observable.Return(Unit.Default); - - }); - } - - protected override IObservable LogIn(object args) - { - return LogInToHost(HostAddress.Create(EnterpriseUrl)); - } - - string enterpriseUrl; - [AllowNull] - public string EnterpriseUrl - { - [return: AllowNull] - get { return enterpriseUrl; } - set { this.RaiseAndSetIfChanged(ref enterpriseUrl, value); } - } - - public ReactivePropertyValidator EnterpriseUrlValidator { get; } - - protected override Uri BaseUri => new Uri(EnterpriseUrl); - - public IReactiveCommand NavigateLearnMore - { - get; - } - - protected override async Task ResetValidation() - { - EnterpriseUrl = null; - await EnterpriseUrlValidator.ResetAsync(); - } - } -} diff --git a/src/GitHub.App/ViewModels/LoginToGitHubViewModel.cs b/src/GitHub.App/ViewModels/LoginToGitHubViewModel.cs deleted file mode 100644 index b5557df5e2..0000000000 --- a/src/GitHub.App/ViewModels/LoginToGitHubViewModel.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Reactive; -using System.Reactive.Linq; -using GitHub.Authentication; -using GitHub.Info; -using GitHub.Models; -using GitHub.Primitives; -using GitHub.Services; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [Export(typeof(ILoginToGitHubViewModel))] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class LoginToGitHubViewModel : LoginTabViewModel, ILoginToGitHubViewModel - { - [ImportingConstructor] - public LoginToGitHubViewModel(IRepositoryHosts repositoryHosts, IVisualStudioBrowser browser) - : base(repositoryHosts, browser) - { - BaseUri = HostAddress.GitHubDotComHostAddress.WebUri; - - NavigatePricing = ReactiveCommand.CreateAsyncObservable(_ => - { - browser.OpenUrl(GitHubUrls.Pricing); - return Observable.Return(Unit.Default); - - }); - } - - public IReactiveCommand NavigatePricing { get; } - - protected override Uri BaseUri { get; } - - protected override IObservable LogIn(object args) - { - return LogInToHost(HostAddress.GitHubDotComHostAddress); - } - } -} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/LogoutRequiredViewModel.cs b/src/GitHub.App/ViewModels/LogoutRequiredViewModel.cs deleted file mode 100644 index 4c96ecc9fb..0000000000 --- a/src/GitHub.App/ViewModels/LogoutRequiredViewModel.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Threading.Tasks; -using GitHub.App; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using NLog; -using NullGuard; -using ReactiveUI; -using System.Diagnostics; -using System.Globalization; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType = UIViewType.LogoutRequired)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class LogoutRequiredViewModel : BaseViewModel, ILogoutRequiredViewModel - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - readonly IRepositoryHosts repositoryHosts; - readonly INotificationService notificationService; - - [ImportingConstructor] - public LogoutRequiredViewModel(IRepositoryHosts repositoryHosts, INotificationService notificationService) - { - this.repositoryHosts = repositoryHosts; - this.notificationService = notificationService; - - Title = Resources.LogoutRequiredTitle; - Logout = ReactiveCommand.CreateAsyncObservable(OnLogout); - Icon = Octicon.mark_github; - } - - public override void Initialize([AllowNull] ViewWithData data) - { - if (data.MainFlow == UIControllerFlow.Gist) - { - Icon = Octicon.logo_gist; - LogoutRequiredMessage = string.Format( - CultureInfo.CurrentCulture, - Resources.LogoutRequiredMessage, - Resources.LogoutRequiredFeatureGist); - } - else - Debug.Assert(false, "Add a resource string for feature " + data.MainFlow + "!"); - base.Initialize(data); - } - - public IReactiveCommand Logout { get; } - - IObservable OnLogout(object unused) - { - return DoLogout().ToObservable() - .Select(_ => ProgressState.Success) - .Catch(ex => - { - if (!ex.IsCriticalException()) - { - log.Error(ex); - var error = StandardUserErrors.GetUserFriendlyErrorMessage(ex, ErrorType.LogoutFailed); - notificationService.ShowError(error); - } - return Observable.Return(ProgressState.Fail); - }); - } - - async Task DoLogout() - { - if (repositoryHosts.GitHubHost?.SupportsGist == false) - { - await repositoryHosts.GitHubHost.LogOut(); - } - - if (repositoryHosts.EnterpriseHost?.SupportsGist == false) - { - await repositoryHosts.EnterpriseHost.LogOut(); - } - } - - string logoutRequiredMessage; - public string LogoutRequiredMessage - { - [return: AllowNull] - get { return logoutRequiredMessage; } - set { this.RaiseAndSetIfChanged(ref logoutRequiredMessage, value); } - } - - Octicon icon; - public Octicon Icon - { - get { return icon; } - set { this.RaiseAndSetIfChanged(ref icon, value); } - } - } -} diff --git a/src/GitHub.App/ViewModels/NotAGitRepositoryViewModel.cs b/src/GitHub.App/ViewModels/NotAGitRepositoryViewModel.cs deleted file mode 100644 index cc63ceb8ba..0000000000 --- a/src/GitHub.App/ViewModels/NotAGitRepositoryViewModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using GitHub.Exports; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// The view model for the "Not a Git repository" view in the GitHub pane. - /// - [ExportViewModel(ViewType = UIViewType.NotAGitRepository)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class NotAGitRepositoryViewModel : BaseViewModel, INotAGitRepositoryViewModel - { - } -} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/PullRequestCreationViewModel.cs deleted file mode 100644 index e3dfc417df..0000000000 --- a/src/GitHub.App/ViewModels/PullRequestCreationViewModel.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using GitHub.Exports; -using GitHub.Models; -using System.Collections.Generic; -using ReactiveUI; -using GitHub.Services; -using System.Reactive.Linq; -using GitHub.Extensions.Reactive; -using GitHub.UI; -using System.Linq; -using GitHub.Validation; -using GitHub.App; -using System.Diagnostics.CodeAnalysis; -using Octokit; -using NLog; -using LibGit2Sharp; -using System.Globalization; -using GitHub.Primitives; -using GitHub.Extensions; -using System.Reactive.Disposables; - -namespace GitHub.ViewModels -{ - [NullGuard.NullGuard(NullGuard.ValidationFlags.None)] - [ExportViewModel(ViewType = UIViewType.PRCreation)] - [PartCreationPolicy(CreationPolicy.NonShared)] - [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")] - public class PullRequestCreationViewModel : BaseViewModel, IPullRequestCreationViewModel, IDisposable - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly ObservableAsPropertyHelper githubRepository; - readonly ObservableAsPropertyHelper isExecuting; - readonly IRepositoryHost repositoryHost; - readonly IObservable githubObs; - readonly CompositeDisposable disposables = new CompositeDisposable(); - readonly ILocalRepositoryModel activeLocalRepo; - - [ImportingConstructor] - PullRequestCreationViewModel( - IConnectionRepositoryHostMap connectionRepositoryHostMap, ITeamExplorerServiceHolder teservice, - IPullRequestService service, INotificationService notifications) - : this(connectionRepositoryHostMap?.CurrentRepositoryHost, teservice?.ActiveRepo, service, - notifications) - {} - - public PullRequestCreationViewModel(IRepositoryHost repositoryHost, ILocalRepositoryModel activeRepo, - IPullRequestService service, INotificationService notifications) - { - Extensions.Guard.ArgumentNotNull(repositoryHost, nameof(repositoryHost)); - Extensions.Guard.ArgumentNotNull(activeRepo, nameof(activeRepo)); - Extensions.Guard.ArgumentNotNull(service, nameof(service)); - Extensions.Guard.ArgumentNotNull(notifications, nameof(notifications)); - - this.repositoryHost = repositoryHost; - activeLocalRepo = activeRepo; - - var obs = repositoryHost.ApiClient.GetRepository(activeRepo.Owner, activeRepo.Name) - .Select(r => new RemoteRepositoryModel(r)) - .PublishLast(); - disposables.Add(obs.Connect()); - githubObs = obs; - - githubRepository = githubObs.ToProperty(this, x => x.GitHubRepository); - - this.WhenAnyValue(x => x.GitHubRepository) - .WhereNotNull() - .Subscribe(r => - { - TargetBranch = r.IsFork ? r.Parent.DefaultBranch : r.DefaultBranch; - }); - - SourceBranch = activeRepo.CurrentBranch; - service.GetPullRequestTemplate(activeRepo) - .Subscribe(x => Description = x ?? String.Empty, () => Description = Description ?? String.Empty); - - this.WhenAnyValue(x => x.Branches) - .WhereNotNull() - .Where(_ => TargetBranch != null) - .Subscribe(x => - { - if (!x.Any(t => t.Equals(TargetBranch))) - TargetBranch = GitHubRepository.IsFork ? GitHubRepository.Parent.DefaultBranch : GitHubRepository.DefaultBranch; - }); - - SetupValidators(); - - var whenAnyValidationResultChanges = this.WhenAny( - x => x.TitleValidator.ValidationResult, - x => x.BranchValidator.ValidationResult, - x => x.IsBusy, - (x, y, z) => (x.Value?.IsValid ?? false) && (y.Value?.IsValid ?? false) && !z.Value); - - this.WhenAny(x => x.BranchValidator.ValidationResult, x => x.GetValue()) - .WhereNotNull() - .Where(x => !x.IsValid && x.DisplayValidationError) - .Subscribe(x => notifications.ShowError(BranchValidator.ValidationResult.Message)); - - createPullRequest = ReactiveCommand.CreateAsyncObservable(whenAnyValidationResultChanges, - _ => service - .CreatePullRequest(repositoryHost, activeRepo, TargetBranch.Repository, SourceBranch, TargetBranch, PRTitle, Description ?? String.Empty) - .Catch(ex => - { - log.Error(ex); - - //TODO:Will need a uniform solution to HTTP exception message handling - var apiException = ex as ApiValidationException; - var error = apiException?.ApiError?.Errors?.FirstOrDefault(); - notifications.ShowError(error?.Message ?? ex.Message); - return Observable.Empty(); - })) - .OnExecuteCompleted(pr => - { - notifications.ShowMessage(String.Format(CultureInfo.CurrentCulture, Resources.PRCreatedUpstream, SourceBranch.DisplayName, TargetBranch.Repository.Owner + "/" + TargetBranch.Repository.Name + "#" + pr.Number, - TargetBranch.Repository.CloneUrl.ToRepositoryUrl().Append("pull/" + pr.Number))); - }); - - isExecuting = CreatePullRequest.IsExecuting.ToProperty(this, x => x.IsExecuting); - - this.WhenAnyValue(x => x.Initialized, x => x.GitHubRepository, x => x.Description, x => x.IsExecuting) - .Select(x => !(x.Item1 && x.Item2 != null && x.Item3 != null && !x.Item4)) - .Subscribe(x => IsBusy = x); - } - - public override void Initialize(ViewWithData data = null) - { - base.Initialize(data); - - Initialized = false; - - githubObs.SelectMany(r => - { - var b = Observable.Empty(); - if (r.IsFork) - { - b = repositoryHost.ModelService.GetBranches(r.Parent).Select(x => - { - return x; - }); - } - return b.Concat(repositoryHost.ModelService.GetBranches(r)); - }) - .ToList() - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(x => - { - Branches = x.ToList(); - Initialized = true; - }); - - SourceBranch = activeLocalRepo.CurrentBranch; - } - - void SetupValidators() - { - var titleObs = this.WhenAnyValue(x => x.PRTitle); - TitleValidator = ReactivePropertyValidator.ForObservable(titleObs) - .IfNullOrEmpty(Resources.PullRequestCreationTitleValidatorEmpty); - - var branchObs = this.WhenAnyValue( - x => x.Initialized, - x => x.TargetBranch, - x => x.SourceBranch, - (init, target, source) => new { Initialized = init, Source = source, Target = target }) - .Where(x => x.Initialized); - - BranchValidator = ReactivePropertyValidator.ForObservable(branchObs) - .IfTrue(x => x.Source == null, Resources.PullRequestSourceBranchDoesNotExist) - .IfTrue(x => x.Source.Equals(x.Target), Resources.PullRequestSourceAndTargetBranchTheSame); - } - - bool disposed; // To detect redundant calls - void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - disposed = true; - - disposables.Dispose(); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public IRemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } - bool IsExecuting { get { return isExecuting.Value; } } - - bool initialized; - bool Initialized - { - get { return initialized; } - set { this.RaiseAndSetIfChanged(ref initialized, value); } - } - - IBranch sourceBranch; - public IBranch SourceBranch - { - get { return sourceBranch; } - set { this.RaiseAndSetIfChanged(ref sourceBranch, value); } - } - - IBranch targetBranch; - public IBranch TargetBranch - { - get { return targetBranch; } - set { this.RaiseAndSetIfChanged(ref targetBranch, value); } - } - - IReadOnlyList branches; - public IReadOnlyList Branches - { - get { return branches; } - set { this.RaiseAndSetIfChanged(ref branches, value); } - } - - IReactiveCommand createPullRequest; - public IReactiveCommand CreatePullRequest => createPullRequest; - - string title; - public string PRTitle - { - get { return title; } - set { this.RaiseAndSetIfChanged(ref title, value); } - } - - string description; - public string Description - { - get { return description; } - set { this.RaiseAndSetIfChanged(ref description, value); } - } - - ReactivePropertyValidator titleValidator; - public ReactivePropertyValidator TitleValidator - { - get { return titleValidator; } - set { this.RaiseAndSetIfChanged(ref titleValidator, value); } - } - - ReactivePropertyValidator branchValidator; - ReactivePropertyValidator BranchValidator - { - get { return branchValidator; } - set { this.RaiseAndSetIfChanged(ref branchValidator, value); } - } - } -} diff --git a/src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs b/src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs deleted file mode 100644 index e04cb3bafb..0000000000 --- a/src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs +++ /dev/null @@ -1,520 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.IO; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Threading.Tasks; -using GitHub.App; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using GitHub.Settings; -using GitHub.UI; -using LibGit2Sharp; -using NullGuard; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// A view model which displays the details of a pull request. - /// - [ExportViewModel(ViewType = UIViewType.PRDetail)] - [PartCreationPolicy(CreationPolicy.NonShared)] - [NullGuard(ValidationFlags.None)] - public class PullRequestDetailViewModel : BaseViewModel, IPullRequestDetailViewModel - { - readonly ILocalRepositoryModel repository; - readonly IModelService modelService; - readonly IPullRequestService pullRequestsService; - readonly IUsageTracker usageTracker; - IPullRequestModel model; - string sourceBranchDisplayName; - string targetBranchDisplayName; - string body; - IPullRequestCheckoutState checkoutState; - IPullRequestUpdateState updateState; - string operationError; - bool isFromFork; - bool isInCheckout; - - /// - /// Initializes a new instance of the class. - /// - /// The connection repository host map. - /// The team explorer service. - /// The pull requests service. - /// The avatar provider. - [ImportingConstructor] - PullRequestDetailViewModel( - IConnectionRepositoryHostMap connectionRepositoryHostMap, - ITeamExplorerServiceHolder teservice, - IPullRequestService pullRequestsService, - IUsageTracker usageTracker) - : this(teservice.ActiveRepo, - connectionRepositoryHostMap.CurrentRepositoryHost.ModelService, - pullRequestsService, - usageTracker) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The repository host. - /// The team explorer service. - /// The pull requests service. - /// The avatar provider. - public PullRequestDetailViewModel( - ILocalRepositoryModel repository, - IModelService modelService, - IPullRequestService pullRequestsService, - IUsageTracker usageTracker) - { - this.repository = repository; - this.modelService = modelService; - this.pullRequestsService = pullRequestsService; - this.usageTracker = usageTracker; - - Checkout = ReactiveCommand.CreateAsyncObservable( - this.WhenAnyValue(x => x.CheckoutState) - .Cast() - .Select(x => x != null && x.IsEnabled), - DoCheckout); - Checkout.IsExecuting.Subscribe(x => isInCheckout = x); - SubscribeOperationError(Checkout); - - Pull = ReactiveCommand.CreateAsyncObservable( - this.WhenAnyValue(x => x.UpdateState) - .Cast() - .Select(x => x != null && x.PullEnabled), - DoPull); - SubscribeOperationError(Pull); - - Push = ReactiveCommand.CreateAsyncObservable( - this.WhenAnyValue(x => x.UpdateState) - .Cast() - .Select(x => x != null && x.PushEnabled), - DoPush); - SubscribeOperationError(Push); - - OpenOnGitHub = ReactiveCommand.Create(); - OpenFile = ReactiveCommand.Create(); - DiffFile = ReactiveCommand.Create(); - } - - /// - /// Gets the underlying pull request model. - /// - public IPullRequestModel Model - { - get { return model; } - private set - { - // PullRequestModel overrides Equals such that two PRs with the same number are - // considered equal. This was causing the Model not to be updated on refresh: - // we need to use ReferenceEquals. - if (!ReferenceEquals(model, value)) - { - this.RaisePropertyChanging(nameof(Model)); - model = value; - this.RaisePropertyChanged(nameof(Model)); - } - } - } - - /// - /// Gets a string describing how to display the pull request's source branch. - /// - public string SourceBranchDisplayName - { - get { return sourceBranchDisplayName; } - private set { this.RaiseAndSetIfChanged(ref sourceBranchDisplayName, value); } - } - - /// - /// Gets a string describing how to display the pull request's target branch. - /// - public string TargetBranchDisplayName - { - get { return targetBranchDisplayName; } - private set { this.RaiseAndSetIfChanged(ref targetBranchDisplayName, value); } - } - - /// - /// Gets a value indicating whether the pull request comes from a fork. - /// - public bool IsFromFork - { - get { return isFromFork; } - private set { this.RaiseAndSetIfChanged(ref isFromFork, value); } - } - - /// - /// Gets the pull request body. - /// - public string Body - { - get { return body; } - private set { this.RaiseAndSetIfChanged(ref body, value); } - } - - /// - /// Gets the state associated with the command. - /// - public IPullRequestCheckoutState CheckoutState - { - get { return checkoutState; } - private set { this.RaiseAndSetIfChanged(ref checkoutState, value); } - } - - /// - /// Gets the state associated with the and commands. - /// - public IPullRequestUpdateState UpdateState - { - get { return updateState; } - private set { this.RaiseAndSetIfChanged(ref updateState, value); } - } - - /// - /// Gets the error message to be displayed in the action area as a result of an error in a - /// git operation. - /// - public string OperationError - { - get { return operationError; } - private set { this.RaiseAndSetIfChanged(ref operationError, value); } - } - - /// - /// Gets the changed files as a tree. - /// - public IReactiveList ChangedFilesTree { get; } = new ReactiveList(); - - /// - /// Gets a command that checks out the pull request locally. - /// - public ReactiveCommand Checkout { get; } - - /// - /// Gets a command that pulls changes to the current branch. - /// - public ReactiveCommand Pull { get; } - - /// - /// Gets a command that pushes changes from the current branch. - /// - public ReactiveCommand Push { get; } - - /// - /// Gets a command that opens the pull request on GitHub. - /// - public ReactiveCommand OpenOnGitHub { get; } - - /// - /// Gets a command that opens a . - /// - public ReactiveCommand OpenFile { get; } - - /// - /// Gets a command that diffs a . - /// - public ReactiveCommand DiffFile { get; } - - /// - /// Initializes the view model with new data. - /// - /// - public override void Initialize([AllowNull] ViewWithData data) - { - var prNumber = data?.Data != null ? (int)data.Data : Model.Number; - - IsBusy = true; - - OperationError = null; - modelService.GetPullRequest(repository, prNumber) - .TakeLast(1) - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(x => Load(x).Forget()); - } - - /// - /// Loads the view model from octokit models. - /// - /// The pull request model. - /// The pull request's changed files. - public async Task Load(IPullRequestModel pullRequest) - { - Model = pullRequest; - Title = Resources.PullRequestNavigationItemText + " #" + pullRequest.Number; - - IsFromFork = pullRequestsService.IsPullRequestFromFork(repository, Model); - SourceBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.Head?.Label); - TargetBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.Base.Label); - Body = !string.IsNullOrWhiteSpace(pullRequest.Body) ? pullRequest.Body : Resources.NoDescriptionProvidedMarkdown; - - ChangedFilesTree.Clear(); - - var treeChanges = await pullRequestsService.GetTreeChanges(repository, pullRequest); - - // WPF doesn't support AddRange here so iterate through the changes. - foreach (var change in CreateChangedFilesTree(pullRequest, treeChanges).Children) - { - ChangedFilesTree.Add(change); - } - - var localBranches = await pullRequestsService.GetLocalBranches(repository, pullRequest).ToList(); - var isCheckedOut = localBranches.Contains(repository.CurrentBranch); - - if (isCheckedOut) - { - var divergence = await pullRequestsService.CalculateHistoryDivergence(repository, Model.Number); - var pullEnabled = divergence.BehindBy > 0; - var pushEnabled = divergence.AheadBy > 0 && !pullEnabled; - string pullToolTip; - string pushToolTip; - - if (pullEnabled) - { - pullToolTip = string.Format( - Resources.PullRequestDetailsPullToolTip, - IsFromFork ? Resources.Fork : Resources.Remote, - SourceBranchDisplayName); - } - else - { - pullToolTip = Resources.NoCommitsToPull; - } - - if (pushEnabled) - { - pushToolTip = string.Format( - Resources.PullRequestDetailsPushToolTip, - IsFromFork ? Resources.Fork : Resources.Remote, - SourceBranchDisplayName); - } - else if (divergence.AheadBy == 0) - { - pushToolTip = Resources.NoCommitsToPush; - } - else - { - pushToolTip = Resources.MustPullBeforePush; - } - - UpdateState = new UpdateCommandState(divergence, pullEnabled, pushEnabled, pullToolTip, pushToolTip); - CheckoutState = null; - } - else - { - var caption = localBranches.Count > 0 ? - string.Format(Resources.PullRequestDetailsCheckout, localBranches.First().DisplayName) : - string.Format(Resources.PullRequestDetailsCheckoutTo, await pullRequestsService.GetDefaultLocalBranchName(repository, Model.Number, Model.Title)); - var clean = await pullRequestsService.IsWorkingDirectoryClean(repository); - string disabled = null; - - if (pullRequest.Head == null || !pullRequest.Head.RepositoryCloneUrl.IsValidUri) - { - disabled = Resources.SourceRepositoryNoLongerAvailable; - } - else if (!clean) - { - disabled = Resources.WorkingDirectoryHasUncommittedCHanges; - } - - CheckoutState = new CheckoutCommandState(caption, disabled); - UpdateState = null; - } - - IsBusy = false; - - if (!isInCheckout) - { - pullRequestsService.RemoveUnusedRemotes(repository).Subscribe(_ => { }); - } - } - - /// - /// Gets the specified file as it appears in the pull request. - /// - /// The file or directory node. - /// The path to the extracted file. - public Task ExtractFile(IPullRequestFileNode file) - { - var path = Path.Combine(file.DirectoryPath, file.FileName); - return pullRequestsService.ExtractFile(repository, modelService, model.Head.Sha, path, file.Sha).ToTask(); - } - - /// - /// Gets the before and after files needed for viewing a diff. - /// - /// The changed file. - /// A tuple containing the full path to the before and after files. - public Task> ExtractDiffFiles(IPullRequestFileNode file) - { - var path = Path.Combine(file.DirectoryPath, file.FileName); - return pullRequestsService.ExtractDiffFiles(repository, modelService, model, path, file.Sha).ToTask(); - } - - void SubscribeOperationError(ReactiveCommand command) - { - command.ThrownExceptions.Subscribe(x => OperationError = x.Message); - command.IsExecuting.Select(x => x).Subscribe(x => OperationError = null); - } - - IPullRequestDirectoryNode CreateChangedFilesTree(IPullRequestModel pullRequest, TreeChanges changes) - { - var dirs = new Dictionary - { - { string.Empty, new PullRequestDirectoryNode(string.Empty) } - }; - - foreach (var changedFile in pullRequest.ChangedFiles) - { - var node = new PullRequestFileNode( - repository.LocalPath, - changedFile.FileName, - changedFile.Sha, - changedFile.Status, - GetStatusDisplay(changedFile, changes)); - var dir = GetDirectory(node.DirectoryPath, dirs); - dir.Files.Add(node); - } - - return dirs[string.Empty]; - } - - static PullRequestDirectoryNode GetDirectory(string path, Dictionary dirs) - { - PullRequestDirectoryNode dir; - - if (!dirs.TryGetValue(path, out dir)) - { - var parentPath = Path.GetDirectoryName(path); - var parentDir = GetDirectory(parentPath, dirs); - - dir = new PullRequestDirectoryNode(path); - - if (!parentDir.Directories.Any(x => x.DirectoryName == dir.DirectoryName)) - { - parentDir.Directories.Add(dir); - dirs.Add(path, dir); - } - } - - return dir; - } - - static string GetBranchDisplayName(bool isFromFork, string targetBranchLabel) - { - if (targetBranchLabel != null) - { - return isFromFork ? targetBranchLabel : targetBranchLabel.Split(':')[1]; - } - else - { - return Resources.InvalidBranchName; - } - } - - string GetStatusDisplay(IPullRequestFileModel file, TreeChanges changes) - { - switch (file.Status) - { - case PullRequestFileStatus.Added: - return Resources.AddedFileStatus; - case PullRequestFileStatus.Renamed: - var fileName = file.FileName.Replace("/", "\\"); - var change = changes?.Renamed.FirstOrDefault(x => x.Path == fileName); - - if (change != null) - { - return Path.GetDirectoryName(change.OldPath) == Path.GetDirectoryName(change.Path) ? - Path.GetFileName(change.OldPath) : change.OldPath; - } - else - { - return Resources.RenamedFileStatus; - } - default: - return null; - } - } - - IObservable DoCheckout(object unused) - { - return Observable.Defer(async () => - { - var localBranches = await pullRequestsService.GetLocalBranches(repository, Model).ToList(); - - if (localBranches.Count > 0) - { - return pullRequestsService.SwitchToBranch(repository, Model); - } - else - { - return pullRequestsService - .GetDefaultLocalBranchName(repository, Model.Number, Model.Title) - .SelectMany(x => pullRequestsService.Checkout(repository, Model, x)); - } - }).Do(_ => usageTracker.IncrementPullRequestCheckOutCount(IsFromFork).Forget()); - } - - IObservable DoPull(object unused) - { - return pullRequestsService.Pull(repository) - .Do(_ => usageTracker.IncrementPullRequestPullCount(IsFromFork).Forget()); - } - - IObservable DoPush(object unused) - { - return pullRequestsService.Push(repository) - .Do(_ => usageTracker.IncrementPullRequestPushCount(IsFromFork).Forget()); - } - - class CheckoutCommandState : IPullRequestCheckoutState - { - public CheckoutCommandState(string caption, string disabledMessage) - { - Caption = caption; - IsEnabled = disabledMessage == null; - ToolTip = disabledMessage ?? caption; - } - - public string Caption { get; } - public bool IsEnabled { get; } - public string ToolTip { get; } - } - - class UpdateCommandState : IPullRequestUpdateState - { - public UpdateCommandState( - BranchTrackingDetails divergence, - bool pullEnabled, - bool pushEnabled, - string pullToolTip, - string pushToolTip) - { - CommitsAhead = divergence.AheadBy ?? 0; - CommitsBehind = divergence.BehindBy ?? 0; - PushEnabled = pushEnabled; - PullEnabled = pullEnabled; - PullToolTip = pullToolTip; - PushToolTip = pushToolTip; - } - - public int CommitsAhead { get; } - public int CommitsBehind { get; } - public bool UpToDate => CommitsAhead == 0 && CommitsBehind == 0; - public bool PullEnabled { get; } - public bool PushEnabled { get; } - public string PullToolTip { get; } - public string PushToolTip { get; } - } - } -} diff --git a/src/GitHub.App/ViewModels/PullRequestDirectoryNode.cs b/src/GitHub.App/ViewModels/PullRequestDirectoryNode.cs deleted file mode 100644 index c249569d2e..0000000000 --- a/src/GitHub.App/ViewModels/PullRequestDirectoryNode.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace GitHub.ViewModels -{ - /// - /// A directory node in a pull request changes tree. - /// - public class PullRequestDirectoryNode : IPullRequestDirectoryNode - { - /// - /// Initializes a new instance of the class. - /// - /// The path to the directory, relative to the repository. - public PullRequestDirectoryNode(string fullPath) - { - DirectoryName = System.IO.Path.GetFileName(fullPath); - DirectoryPath = fullPath; - Directories = new List(); - Files = new List(); - } - - /// - /// Gets the name of the directory without path information. - /// - public string DirectoryName { get; } - - /// - /// Gets the full directory path, relative to the root of the repository. - /// - public string DirectoryPath { get; } - - /// - /// Gets the directory children of the node. - /// - public IList Directories { get; } - - /// - /// Gets the file children of the node. - /// - public IList Files { get; } - - /// - /// Gets the children of the directory. - /// - public IEnumerable Children - { - get { return Directories.Cast().Concat(Files); } - } - } -} diff --git a/src/GitHub.App/ViewModels/PullRequestFileNode.cs b/src/GitHub.App/ViewModels/PullRequestFileNode.cs deleted file mode 100644 index 33c7d606c3..0000000000 --- a/src/GitHub.App/ViewModels/PullRequestFileNode.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.IO; -using GitHub.Models; -using NullGuard; - -namespace GitHub.ViewModels -{ - /// - /// A file node in a pull request changes tree. - /// - public class PullRequestFileNode : IPullRequestFileNode - { - /// - /// Initializes a new instance of the class. - /// - /// The absolute path to the repository. - /// The path to the file, relative to the repository. - /// The SHA of the file. - /// The way the file was changed. - /// The string to display in the [message] box next to the filename. - public PullRequestFileNode(string repositoryPath, string path, string sha, PullRequestFileStatus status, [AllowNull] string statusDisplay) - { - FileName = Path.GetFileName(path); - DirectoryPath = Path.GetDirectoryName(path); - DisplayPath = Path.Combine(Path.GetFileName(repositoryPath), DirectoryPath); - Sha = sha; - Status = status; - StatusDisplay = statusDisplay; - } - - /// - /// Gets the name of the file without path information. - /// - public string FileName { get; } - - /// - /// Gets the path to the file's directory, relative to the root of the repository. - /// - public string DirectoryPath { get; } - - /// - /// Gets the path to display in the "Path" column of the changed files list. - /// - public string DisplayPath { get; } - - /// - /// Gets the SHA of the file. - /// - public string Sha { get; } - - /// - /// Gets the type of change that was made to the file. - /// - public PullRequestFileStatus Status { get; } - - /// - /// Gets the string to display in the [message] box next to the filename. - /// - public string StatusDisplay { [return: AllowNull] get; } - } -} diff --git a/src/GitHub.App/ViewModels/PullRequestFileViewModel.cs b/src/GitHub.App/ViewModels/PullRequestFileViewModel.cs deleted file mode 100644 index 97e2f31582..0000000000 --- a/src/GitHub.App/ViewModels/PullRequestFileViewModel.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace GitHub.ViewModels -{ - /// - /// A file node in a pull request changes tree. - /// - public class PullRequestFileViewModel : IPullRequestFileViewModel - { - /// - /// Initializes a new instance of the class. - /// - /// The path to the file, relative to the repository. - /// The way the file was changed. - public PullRequestFileViewModel(string path, FileChangeType changeType) - { - ChangeType = changeType; - FileName = System.IO.Path.GetFileName(path); - Path = path; - } - - /// - /// Gets the type of change that was made to the file. - /// - public FileChangeType ChangeType { get; } - - /// - /// Gets the name of the file without path information. - /// - public string FileName { get; } - - /// - /// Gets the path to the file, relative to the root of the repository. - /// - public string Path { get; } - } -} diff --git a/src/GitHub.App/ViewModels/PullRequestListViewModel.cs b/src/GitHub.App/ViewModels/PullRequestListViewModel.cs deleted file mode 100644 index 07a720a86a..0000000000 --- a/src/GitHub.App/ViewModels/PullRequestListViewModel.cs +++ /dev/null @@ -1,260 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Linq; -using System.Windows.Input; -using System.Windows.Media.Imaging; -using GitHub.App; -using GitHub.Collections; -using GitHub.Exports; -using GitHub.Models; -using GitHub.Services; -using GitHub.Settings; -using GitHub.UI; -using NLog; -using NullGuard; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType = UIViewType.PRList)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class PullRequestListViewModel : BaseViewModel, IPullRequestListViewModel, IDisposable - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly ReactiveCommand openPullRequestCommand; - readonly IRepositoryHost repositoryHost; - readonly ILocalRepositoryModel repository; - readonly TrackingCollection trackingAuthors; - readonly TrackingCollection trackingAssignees; - readonly IPackageSettings settings; - readonly PullRequestListUIState listSettings; - - [ImportingConstructor] - PullRequestListViewModel( - IConnectionRepositoryHostMap connectionRepositoryHostMap, - ITeamExplorerServiceHolder teservice, - IPackageSettings settings) - : this(connectionRepositoryHostMap.CurrentRepositoryHost, teservice.ActiveRepo, settings) - { - } - - public PullRequestListViewModel( - IRepositoryHost repositoryHost, - ILocalRepositoryModel repository, - IPackageSettings settings) - { - this.repositoryHost = repositoryHost; - this.repository = repository; - this.settings = settings; - - Title = Resources.PullRequestsNavigationItemText; - - this.listSettings = settings.UIState - .GetOrCreateRepositoryState(repository.CloneUrl) - .PullRequests; - - openPullRequestCommand = ReactiveCommand.Create(); - - States = new List { - new PullRequestState { IsOpen = true, Name = "Open" }, - new PullRequestState { IsOpen = false, Name = "Closed" }, - new PullRequestState { Name = "All" } - }; - - trackingAuthors = new TrackingCollection(Observable.Empty(), - OrderedComparer.OrderByDescending(x => x.Login).Compare); - trackingAssignees = new TrackingCollection(Observable.Empty(), - OrderedComparer.OrderByDescending(x => x.Login).Compare); - trackingAuthors.Subscribe(); - trackingAssignees.Subscribe(); - - Authors = trackingAuthors.CreateListenerCollection(EmptyUser, this.WhenAnyValue(x => x.SelectedAuthor)); - Assignees = trackingAssignees.CreateListenerCollection(EmptyUser, this.WhenAnyValue(x => x.SelectedAssignee)); - - PullRequests = new TrackingCollection(); - pullRequests.Comparer = OrderedComparer.OrderByDescending(x => x.UpdatedAt).Compare; - pullRequests.NewerComparer = OrderedComparer.OrderByDescending(x => x.UpdatedAt).Compare; - - this.WhenAny(x => x.SelectedState, x => x.Value) - .Where(x => PullRequests != null) - .Subscribe(s => UpdateFilter(s, SelectedAssignee, SelectedAuthor)); - - this.WhenAny(x => x.SelectedAssignee, x => x.Value) - .Where(x => PullRequests != null && x != EmptyUser && IsLoaded) - .Subscribe(a => UpdateFilter(SelectedState, a, SelectedAuthor)); - - this.WhenAny(x => x.SelectedAuthor, x => x.Value) - .Where(x => PullRequests != null && x != EmptyUser && IsLoaded) - .Subscribe(a => UpdateFilter(SelectedState, SelectedAssignee, a)); - - SelectedState = States.FirstOrDefault(x => x.Name == listSettings.SelectedState) ?? States[0]; - } - - public override void Initialize([AllowNull] ViewWithData data) - { - base.Initialize(data); - - IsLoaded = false; - - PullRequests = repositoryHost.ModelService.GetPullRequests(repository, pullRequests); - pullRequests.Subscribe(pr => - { - trackingAssignees.AddItem(pr.Assignee); - trackingAuthors.AddItem(pr.Author); - }, () => { }); - - pullRequests.OriginalCompleted - .ObserveOn(RxApp.MainThreadScheduler) - .Catch(ex => - { - log.Info("Received AuthorizationException reading pull requests", ex); - return repositoryHost.LogOut(); - }) - .Catch(ex => - { - // Occurs on network error, when the repository was deleted on GitHub etc. - log.Info("Received Exception reading pull requests", ex); - return Observable.Empty(); - }) - .Subscribe(_ => - { - if (listSettings.SelectedAuthor != null) - { - SelectedAuthor = Authors.FirstOrDefault(x => x.Login == listSettings.SelectedAuthor); - } - - if (listSettings.SelectedAssignee != null) - { - SelectedAssignee = Assignees.FirstOrDefault(x => x.Login == listSettings.SelectedAssignee); - } - - IsLoaded = true; - UpdateFilter(SelectedState, SelectedAssignee, SelectedAuthor); - }); - } - - void UpdateFilter(PullRequestState state, [AllowNull]IAccount ass, [AllowNull]IAccount aut) - { - if (PullRequests == null) - return; - pullRequests.Filter = (pr, i, l) => - (!state.IsOpen.HasValue || state.IsOpen == pr.IsOpen) && - (ass == null || ass.Equals(pr.Assignee)) && - (aut == null || aut.Equals(pr.Author)); - } - - bool isLoaded; - public bool IsLoaded - { - get { return isLoaded; } - private set { this.RaiseAndSetIfChanged(ref isLoaded, value); } - } - - ITrackingCollection pullRequests; - public ITrackingCollection PullRequests - { - [return: AllowNull] - get { return pullRequests; } - private set { this.RaiseAndSetIfChanged(ref pullRequests, value); } - } - - IPullRequestModel selectedPullRequest; - [AllowNull] - public IPullRequestModel SelectedPullRequest - { - [return: AllowNull] - get { return selectedPullRequest; } - set { this.RaiseAndSetIfChanged(ref selectedPullRequest, value); } - } - - public ICommand OpenPullRequest - { - get { return openPullRequestCommand; } - } - - IReadOnlyList states; - public IReadOnlyList States - { - get { return states; } - set { this.RaiseAndSetIfChanged(ref states, value); } - } - - PullRequestState selectedState; - public PullRequestState SelectedState - { - [return: AllowNull] - get { return selectedState; } - set { this.RaiseAndSetIfChanged(ref selectedState, value); } - } - - ObservableCollection assignees; - public ObservableCollection Assignees - { - get { return assignees; } - set { this.RaiseAndSetIfChanged(ref assignees, value); } - } - - ObservableCollection authors; - public ObservableCollection Authors - { - get { return authors; } - set { this.RaiseAndSetIfChanged(ref authors, value); } - } - - IAccount selectedAuthor; - [AllowNull] - public IAccount SelectedAuthor - { - [return: AllowNull] - get { return selectedAuthor; } - set { this.RaiseAndSetIfChanged(ref selectedAuthor, value); } - } - - IAccount selectedAssignee; - [AllowNull] - public IAccount SelectedAssignee - { - [return: AllowNull] - get { return selectedAssignee; } - set { this.RaiseAndSetIfChanged(ref selectedAssignee, value); } - } - - IAccount emptyUser = new Account("[None]", false, false, 0, 0, Observable.Empty()); - public IAccount EmptyUser - { - get { return emptyUser; } - } - - bool disposed; - protected void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - pullRequests.Dispose(); - trackingAuthors.Dispose(); - trackingAssignees.Dispose(); - SaveSettings(); - disposed = true; - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - void SaveSettings() - { - listSettings.SelectedState = SelectedState.Name; - listSettings.SelectedAssignee = SelectedAssignee?.Login; - listSettings.SelectedAuthor = SelectedAuthor?.Login; - settings.Save(); - } - } -} diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs new file mode 100644 index 0000000000..5d6b1a12c6 --- /dev/null +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Models.Drafts; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; +using static System.FormattableString; + +namespace GitHub.ViewModels +{ + /// + /// A thread of pull request review comments. + /// + [Export(typeof(IPullRequestReviewCommentThreadViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestReviewCommentThreadViewModel : CommentThreadViewModel, IPullRequestReviewCommentThreadViewModel + { + readonly ReactiveList comments = new ReactiveList(); + readonly IViewViewModelFactory factory; + readonly ObservableAsPropertyHelper needsPush; + IPullRequestSessionFile file; + bool isNewThread; + + /// + /// Initializes a new instance of the class. + /// + /// The message draft store. + /// The view model factory. + [ImportingConstructor] + public PullRequestReviewCommentThreadViewModel( + IMessageDraftStore draftStore, + IViewViewModelFactory factory) + : base(draftStore) + { + Guard.ArgumentNotNull(factory, nameof(factory)); + + this.factory = factory; + + needsPush = this.WhenAnyValue( + x => x.File.CommitSha, + x => x.IsNewThread, + (sha, isNew) => isNew && sha == null) + .ToProperty(this, x => x.NeedsPush); + } + + /// + public IReactiveList Comments => comments; + + /// + public IPullRequestSession Session { get; private set; } + + /// + public IPullRequestSessionFile File + { + get => file; + private set => this.RaiseAndSetIfChanged(ref file, value); + } + + /// + public int LineNumber { get; private set; } + + /// + public DiffSide Side { get; private set; } + + /// + public bool IsResolved { get; private set; } + + public bool IsNewThread + { + get => isNewThread; + private set => this.RaiseAndSetIfChanged(ref isNewThread, value); + } + + /// + public bool NeedsPush => needsPush.Value; + + /// + IReadOnlyReactiveList IPullRequestReviewCommentThreadViewModel.Comments => comments; + + /// + public async Task InitializeAsync( + IPullRequestSession session, + IPullRequestSessionFile file, + IInlineCommentThreadModel thread, + bool addPlaceholder) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await base.InitializeAsync(session.User).ConfigureAwait(true); + + Session = session; + File = file; + LineNumber = thread.LineNumber; + Side = thread.DiffLineType == DiffChangeType.Delete ? DiffSide.Left : DiffSide.Right; + IsResolved = thread.IsResolved; + + foreach (var comment in thread.Comments) + { + var vm = factory.CreateViewModel(); + await vm.InitializeAsync( + session, + this, + comment.Review, + comment.Comment, + CommentEditState.None).ConfigureAwait(false); + Comments.Add(vm); + } + + if (addPlaceholder) + { + var vm = factory.CreateViewModel(); + + await vm.InitializeAsPlaceholderAsync( + session, + this, + session.HasPendingReview, + false).ConfigureAwait(true); + + var (key, secondaryKey) = GetDraftKeys(vm); + var draft = await DraftStore.GetDraft(key, secondaryKey).ConfigureAwait(true); + + if (draft?.Side == Side) + { + await vm.BeginEdit.Execute(); + vm.Body = draft.Body; + } + + InitializePlaceholder(vm); + comments.Add(vm); + } + } + + /// + public async Task InitializeNewAsync( + IPullRequestSession session, + IPullRequestSessionFile file, + int lineNumber, + DiffSide side, + bool isEditing) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await base.InitializeAsync(session.User).ConfigureAwait(false); + + Session = session; + File = file; + LineNumber = lineNumber; + Side = side; + IsNewThread = true; + + var vm = factory.CreateViewModel(); + await vm.InitializeAsPlaceholderAsync(session, this, session.HasPendingReview, isEditing).ConfigureAwait(false); + + var (key, secondaryKey) = GetDraftKeys(vm); + var draft = await DraftStore.GetDraft(key, secondaryKey).ConfigureAwait(true); + + if (draft?.Side == side) + { + vm.Body = draft.Body; + } + + InitializePlaceholder(vm); + comments.Add(vm); + } + + public override async Task PostComment(ICommentViewModel comment) + { + Guard.ArgumentNotNull(comment, nameof(comment)); + + await DeleteDraft(comment).ConfigureAwait(false); + + try + { + if (IsNewThread) + { + var diffPosition = File.Diff + .SelectMany(x => x.Lines) + .FirstOrDefault(x => + { + var line = Side == DiffSide.Left ? x.OldLineNumber : x.NewLineNumber; + return line == LineNumber + 1; + }); + + if (diffPosition == null) + { + throw new InvalidOperationException("Unable to locate line in diff."); + } + + await Session.PostReviewComment( + comment.Body, + File.CommitSha, + Paths.ToGitPath(File.RelativePath), + File.Diff, + diffPosition.DiffLineNumber).ConfigureAwait(false); + } + else + { + var replyId = Comments[0].Id; + await Session.PostReviewComment(comment.Body, replyId).ConfigureAwait(false); + } + } + catch + { + UpdateDraft(comment).Forget(); + throw; + } + } + + public override async Task EditComment(ICommentViewModel comment) + { + Guard.ArgumentNotNull(comment, nameof(comment)); + + await Session.EditComment(comment.Id, comment.Body).ConfigureAwait(false); + } + + public override async Task DeleteComment(ICommentViewModel comment) + { + Guard.ArgumentNotNull(comment, nameof(comment)); + + await Session.DeleteComment(comment.PullRequestId, comment.DatabaseId).ConfigureAwait(false); + } + + public static (string key, string secondaryKey) GetDraftKeys( + UriString cloneUri, + int pullRequestNumber, + string relativePath, + int lineNumber) + { + var gitPath = Paths.ToGitPath(relativePath); + var key = Invariant($"pr-review-comment|{cloneUri}|{pullRequestNumber}|{gitPath}"); + return (key, lineNumber.ToString(CultureInfo.InvariantCulture)); + } + + protected override CommentDraft BuildDraft(ICommentViewModel comment) + { + return !string.IsNullOrEmpty(comment.Body) ? + new PullRequestReviewCommentDraft + { + Body = comment.Body, + Side = Side, + UpdatedAt = DateTimeOffset.UtcNow, + } : null; + } + + protected override (string key, string secondaryKey) GetDraftKeys(ICommentViewModel comment) + { + return GetDraftKeys( + Session.LocalRepository.CloneUrl.WithOwner(Session.RepositoryOwner), + Session.PullRequest.Number, + File.RelativePath, + LineNumber); + } + + async Task UpdateDraft(ICommentViewModel comment) + { + var draft = BuildDraft(comment); + var (key, secondaryKey) = GetDraftKeys(comment); + await DraftStore.UpdateDraft(key, secondaryKey, draft).ConfigureAwait(true); + } + } +} diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs new file mode 100644 index 0000000000..4a2c1a8e97 --- /dev/null +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs @@ -0,0 +1,116 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// View model for a pull request review comment. + /// + [Export(typeof(IPullRequestReviewCommentViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestReviewCommentViewModel + { + readonly ObservableAsPropertyHelper canStartReview; + IPullRequestSession session; + bool isPending; + + /// + /// Initializes a new instance of the class. + /// + /// The comment service. + /// The auto complete advisor. + [ImportingConstructor] + public PullRequestReviewCommentViewModel(ICommentService commentService, + IAutoCompleteAdvisor autoCompleteAdvisor) + : base(commentService, autoCompleteAdvisor) + { + canStartReview = this.WhenAnyValue( + x => x.IsPending, + x => x.Id, + (isPending, id) => !isPending && id == null) + .ToProperty(this, x => x.CanStartReview); + + StartReview = ReactiveCommand.CreateFromTask(DoStartReview, CommitEdit.CanExecute); + AddErrorHandler(StartReview); + } + + /// + public async Task InitializeAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + PullRequestReviewModel review, + PullRequestReviewCommentModel comment, + CommentEditState state) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await InitializeAsync(thread, session.User, comment, state).ConfigureAwait(true); + this.session = session; + IsPending = review.State == PullRequestReviewState.Pending; + } + + /// + public async Task InitializeAsPlaceholderAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + bool isPending, + bool isEditing) + { + Guard.ArgumentNotNull(session, nameof(session)); + + await InitializeAsync( + thread, + session.User, + null, + isEditing ? CommentEditState.Editing : CommentEditState.Placeholder).ConfigureAwait(true); + this.session = session; + IsPending = isPending; + } + + /// + public bool CanStartReview => canStartReview.Value; + + /// + public bool IsPending + { + get => isPending; + private set => this.RaiseAndSetIfChanged(ref isPending, value); + } + + /// + public ReactiveCommand StartReview { get; } + + protected override IObservable GetCommitCaptionObservable() + { + return this.WhenAnyValue( + x => x.IsPending, + x => x.Id, + (pending, id) => id != null ? + Resources.UpdateComment : + pending ? Resources.AddReviewComment : Resources.AddSingleComment); + } + + async Task DoStartReview() + { + IsSubmitting = true; + + try + { + await session.StartReview().ConfigureAwait(true); + await CommitEdit.Execute(); + } + finally + { + IsSubmitting = false; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/PullRequestViewModelBase.cs b/src/GitHub.App/ViewModels/PullRequestViewModelBase.cs new file mode 100644 index 0000000000..d831effced --- /dev/null +++ b/src/GitHub.App/ViewModels/PullRequestViewModelBase.cs @@ -0,0 +1,78 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels +{ + /// + /// Base class for pull request view models. + /// + public class PullRequestViewModelBase : IssueishViewModel, IPullRequestViewModelBase + { + static readonly ILogger log = LogManager.ForContext(); + PullRequestState state; + string sourceBranchDisplayName; + string targetBranchDisplayName; + + /// + /// Initializes a new instance of the class. + /// + [ImportingConstructor] + public PullRequestViewModelBase() + { + } + + /// + public LocalRepositoryModel LocalRepository { get; private set; } + + public PullRequestState State + { + get => state; + protected set => this.RaiseAndSetIfChanged(ref state, value); + } + + public string SourceBranchDisplayName + { + get => sourceBranchDisplayName; + private set => this.RaiseAndSetIfChanged(ref sourceBranchDisplayName, value); + } + + public string TargetBranchDisplayName + { + get => targetBranchDisplayName; + private set => this.RaiseAndSetIfChanged(ref targetBranchDisplayName, value); + } + + protected virtual async Task InitializeAsync( + RemoteRepositoryModel repository, + LocalRepositoryModel localRepository, + PullRequestDetailModel model) + { + await base.InitializeAsync(repository, model).ConfigureAwait(true); + + var fork = model.BaseRepositoryOwner != model.HeadRepositoryOwner; + LocalRepository = localRepository; + State = model.State; + SourceBranchDisplayName = GetBranchDisplayName(fork, model.HeadRepositoryOwner, model.HeadRefName); + TargetBranchDisplayName = GetBranchDisplayName(fork, model.BaseRepositoryOwner, model.BaseRefName); + WebUrl = localRepository.CloneUrl.ToRepositoryUrl().Append("pull/" + Number); + } + + static string GetBranchDisplayName(bool isFromFork, string owner, string label) + { + if (owner != null) + { + return isFromFork ? owner + ':' + label : label; + } + else + { + return Resources.InvalidBranchName; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/RepositoryCloneViewModel.cs b/src/GitHub.App/ViewModels/RepositoryCloneViewModel.cs deleted file mode 100644 index 0e609f356c..0000000000 --- a/src/GitHub.App/ViewModels/RepositoryCloneViewModel.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Text.RegularExpressions; -using System.Windows.Input; -using GitHub.App; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using GitHub.Validation; -using NLog; -using NullGuard; -using ReactiveUI; -using Rothko; -using System.Collections.ObjectModel; -using GitHub.Collections; -using GitHub.UI; -using GitHub.Extensions.Reactive; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType=UIViewType.Clone)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class RepositoryCloneViewModel : BaseViewModel, IRepositoryCloneViewModel - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly IRepositoryHost repositoryHost; - readonly IOperatingSystem operatingSystem; - readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(); - bool isLoading; - bool noRepositoriesFound; - readonly ObservableAsPropertyHelper canClone; - string baseRepositoryPath; - bool loadingFailed; - - [ImportingConstructor] - RepositoryCloneViewModel( - IConnectionRepositoryHostMap connectionRepositoryHostMap, - IRepositoryCloneService repositoryCloneService, - IOperatingSystem operatingSystem) - : this(connectionRepositoryHostMap.CurrentRepositoryHost, repositoryCloneService, operatingSystem) - { } - - - public RepositoryCloneViewModel( - IRepositoryHost repositoryHost, - IRepositoryCloneService cloneService, - IOperatingSystem operatingSystem) - { - this.repositoryHost = repositoryHost; - this.operatingSystem = operatingSystem; - - Title = string.Format(CultureInfo.CurrentCulture, Resources.CloneTitle, repositoryHost.Title); - - Repositories = new TrackingCollection(); - repositories.ProcessingDelay = TimeSpan.Zero; - repositories.Comparer = OrderedComparer.OrderBy(x => x.Owner).ThenBy(x => x.Name).Compare; - repositories.Filter = FilterRepository; - repositories.NewerComparer = OrderedComparer.OrderByDescending(x => x.UpdatedAt).Compare; - - filterTextIsEnabled = this.WhenAny(x => x.IsLoading, - loading => loading.Value || repositories.UnfilteredCount > 0 && !LoadingFailed) - .ToProperty(this, x => x.FilterTextIsEnabled); - - this.WhenAny( - x => x.repositories.UnfilteredCount, - x => x.IsLoading, - x => x.LoadingFailed, - (unfilteredCount, loading, failed) => - { - if (loading.Value) - return false; - - if (failed.Value) - return false; - - return unfilteredCount.Value == 0; - }) - .Subscribe(x => - { - NoRepositoriesFound = x; - }); - - this.WhenAny(x => x.FilterText, x => x.Value) - .DistinctUntilChanged(StringComparer.OrdinalIgnoreCase) - .Throttle(TimeSpan.FromMilliseconds(100), RxApp.MainThreadScheduler) - .Subscribe(_ => repositories.Filter = FilterRepository); - - var baseRepositoryPath = this.WhenAny( - x => x.BaseRepositoryPath, - x => x.SelectedRepository, - (x, y) => x.Value); - - BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(baseRepositoryPath) - .IfNullOrEmpty(Resources.RepositoryCreationClonePathEmpty) - .IfTrue(x => x.Length > 200, Resources.RepositoryCreationClonePathTooLong) - .IfContainsInvalidPathChars(Resources.RepositoryCreationClonePathInvalidCharacters) - .IfPathNotRooted(Resources.RepositoryCreationClonePathInvalid) - .IfTrue(IsAlreadyRepoAtPath, Resources.RepositoryNameValidatorAlreadyExists); - - var canCloneObservable = this.WhenAny( - x => x.SelectedRepository, - x => x.BaseRepositoryPathValidator.ValidationResult.IsValid, - (x, y) => x.Value != null && y.Value); - canClone = canCloneObservable.ToProperty(this, x => x.CanClone); - CloneCommand = ReactiveCommand.Create(canCloneObservable); - - browseForDirectoryCommand.Subscribe(_ => ShowBrowseForDirectoryDialog()); - this.WhenAny(x => x.BaseRepositoryPathValidator.ValidationResult, x => x.Value) - .Subscribe(); - BaseRepositoryPath = cloneService.DefaultClonePath; - NoRepositoriesFound = true; - } - - public override void Initialize([AllowNull] ViewWithData data) - { - base.Initialize(data); - - IsLoading = true; - repositoryHost.ModelService.GetRepositories(repositories); - repositories.OriginalCompleted.Subscribe( - _ => { } - , ex => - { - LoadingFailed = true; - IsLoading = false; - log.Error("Error while loading repositories", ex); - }, - () => IsLoading = false - ); - repositories.Subscribe(); - } - - bool FilterRepository(IRemoteRepositoryModel repo, int position, IList list) - { - if (string.IsNullOrWhiteSpace(FilterText)) - return true; - - // Not matching on NameWithOwner here since that's already been filtered on by the selected account - return repo.Name.IndexOf(FilterText ?? "", StringComparison.OrdinalIgnoreCase) != -1; - } - - bool IsAlreadyRepoAtPath(string path) - { - Debug.Assert(path != null, "RepositoryCloneViewModel.IsAlreadyRepoAtPath cannot be passed null as a path parameter."); - - bool isAlreadyRepoAtPath = false; - - if (SelectedRepository != null) - { - string potentialPath = Path.Combine(path, SelectedRepository.Name); - isAlreadyRepoAtPath = operatingSystem.Directory.Exists(potentialPath); - } - - return isAlreadyRepoAtPath; - } - - IObservable ShowBrowseForDirectoryDialog() - { - return Observable.Start(() => - { - // We store this in a local variable to prevent it changing underneath us while the - // folder dialog is open. - var localBaseRepositoryPath = BaseRepositoryPath; - var browseResult = operatingSystem.Dialog.BrowseForDirectory(localBaseRepositoryPath, Resources.BrowseForDirectory); - - if (!browseResult.Success) - return; - - var directory = browseResult.DirectoryPath ?? localBaseRepositoryPath; - - try - { - BaseRepositoryPath = directory; - } - catch (Exception e) - { - // TODO: We really should limit this to exceptions we know how to handle. - log.Error(string.Format(CultureInfo.InvariantCulture, - "Failed to set base repository path.{0}localBaseRepositoryPath = \"{1}\"{0}BaseRepositoryPath = \"{2}\"{0}Chosen directory = \"{3}\"", - System.Environment.NewLine, localBaseRepositoryPath ?? "(null)", BaseRepositoryPath ?? "(null)", directory ?? "(null)"), e); - } - }, RxApp.MainThreadScheduler); - } - - /// - /// Path to clone repositories into - /// - public string BaseRepositoryPath - { - [return: AllowNull] - get { return baseRepositoryPath; } - set { this.RaiseAndSetIfChanged(ref baseRepositoryPath, value); } - } - - /// - /// Signals that the user clicked the clone button. - /// - public IReactiveCommand CloneCommand { get; private set; } - - TrackingCollection repositories; - public ObservableCollection Repositories - { - [return: AllowNull] - get { return repositories; } - private set { this.RaiseAndSetIfChanged(ref repositories, (TrackingCollection)value); } - } - - IRepositoryModel selectedRepository; - /// - /// Selected repository to clone - /// - [AllowNull] - public IRepositoryModel SelectedRepository - { - [return: AllowNull] - get { return selectedRepository; } - set { this.RaiseAndSetIfChanged(ref selectedRepository, value); } - } - - readonly ObservableAsPropertyHelper filterTextIsEnabled; - /// - /// True if there are repositories (otherwise no point in filtering) - /// - public bool FilterTextIsEnabled { get { return filterTextIsEnabled.Value; } } - - string filterText; - /// - /// User text to filter the repositories list - /// - [AllowNull] - public string FilterText - { - [return: AllowNull] - get { return filterText; } - set { this.RaiseAndSetIfChanged(ref filterText, value); } - } - - public bool IsLoading - { - get { return isLoading; } - private set { this.RaiseAndSetIfChanged(ref isLoading, value); } - } - - public bool LoadingFailed - { - get { return loadingFailed; } - private set { this.RaiseAndSetIfChanged(ref loadingFailed, value); } - } - - public bool NoRepositoriesFound - { - get { return noRepositoriesFound; } - private set { this.RaiseAndSetIfChanged(ref noRepositoriesFound, value); } - } - - public ICommand BrowseForDirectory - { - get { return browseForDirectoryCommand; } - } - - public bool CanClone - { - get { return canClone.Value; } - } - - public ReactivePropertyValidator BaseRepositoryPathValidator - { - get; - private set; - } - } -} diff --git a/src/GitHub.App/ViewModels/RepositoryCreationViewModel.cs b/src/GitHub.App/ViewModels/RepositoryCreationViewModel.cs deleted file mode 100644 index 2691490a29..0000000000 --- a/src/GitHub.App/ViewModels/RepositoryCreationViewModel.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel.Composition; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; -using System.Windows.Input; -using GitHub.App; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Extensions.Reactive; -using GitHub.Models; -using GitHub.Services; -using GitHub.UserErrors; -using GitHub.Validation; -using NLog; -using NullGuard; -using Octokit; -using ReactiveUI; -using Rothko; -using GitHub.Collections; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType=UIViewType.Create)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class RepositoryCreationViewModel : RepositoryFormViewModel, IRepositoryCreationViewModel - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(); - readonly ObservableAsPropertyHelper> accounts; - readonly IRepositoryHost repositoryHost; - readonly IRepositoryCreationService repositoryCreationService; - readonly ObservableAsPropertyHelper isCreating; - readonly ObservableAsPropertyHelper canKeepPrivate; - readonly IOperatingSystem operatingSystem; - readonly IUsageTracker usageTracker; - - [ImportingConstructor] - RepositoryCreationViewModel( - IConnectionRepositoryHostMap connectionRepositoryHostMap, - IOperatingSystem operatingSystem, - IRepositoryCreationService repositoryCreationService, - IUsageTracker usageTracker) - : this(connectionRepositoryHostMap.CurrentRepositoryHost, operatingSystem, repositoryCreationService, usageTracker) - {} - - public RepositoryCreationViewModel( - IRepositoryHost repositoryHost, - IOperatingSystem operatingSystem, - IRepositoryCreationService repositoryCreationService, - IUsageTracker usageTracker) - { - this.repositoryHost = repositoryHost; - this.operatingSystem = operatingSystem; - this.repositoryCreationService = repositoryCreationService; - this.usageTracker = usageTracker; - - Title = string.Format(CultureInfo.CurrentCulture, Resources.CreateTitle, repositoryHost.Title); - SelectedGitIgnoreTemplate = GitIgnoreItem.None; - SelectedLicense = LicenseItem.None; - - accounts = repositoryHost.ModelService.GetAccounts() - .ObserveOn(RxApp.MainThreadScheduler) - .ToProperty(this, vm => vm.Accounts, initialValue: new ReadOnlyCollection(new IAccount[] {})); - - this.WhenAny(x => x.Accounts, x => x.Value) - .Select(accts => accts?.FirstOrDefault()) - .WhereNotNull() - .Subscribe(a => SelectedAccount = a); - - browseForDirectoryCommand.Subscribe(_ => ShowBrowseForDirectoryDialog()); - - BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(this.WhenAny(x => x.BaseRepositoryPath, x => x.Value)) - .IfNullOrEmpty(Resources.RepositoryCreationClonePathEmpty) - .IfTrue(x => x.Length > 200, Resources.RepositoryCreationClonePathTooLong) - .IfContainsInvalidPathChars(Resources.RepositoryCreationClonePathInvalidCharacters) - .IfPathNotRooted(Resources.RepositoryCreationClonePathInvalid); - - var nonNullRepositoryName = this.WhenAny( - x => x.RepositoryName, - x => x.BaseRepositoryPath, - (x, y) => x.Value) - .WhereNotNull(); - - RepositoryNameValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) - .IfNullOrEmpty(Resources.RepositoryNameValidatorEmpty) - .IfTrue(x => x.Length > 100, Resources.RepositoryNameValidatorTooLong) - .IfTrue(IsAlreadyRepoAtPath, Resources.RepositoryNameValidatorAlreadyExists); - - SafeRepositoryNameWarningValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) - .Add(repoName => - { - var parsedReference = GetSafeRepositoryName(repoName); - return parsedReference != repoName ? String.Format(CultureInfo.CurrentCulture, Resources.SafeRepositoryNameWarning, parsedReference) : null; - }); - - this.WhenAny(x => x.BaseRepositoryPathValidator.ValidationResult, x => x.Value) - .Subscribe(); - - CreateRepository = InitializeCreateRepositoryCommand(); - - canKeepPrivate = CanKeepPrivateObservable.CombineLatest(CreateRepository.IsExecuting, - (canKeep, publishing) => canKeep && !publishing) - .ToProperty(this, x => x.CanKeepPrivate); - - isCreating = CreateRepository.IsExecuting - .ToProperty(this, x => x.IsCreating); - - GitIgnoreTemplates = TrackingCollection.CreateListenerCollectionAndRun( - repositoryHost.ModelService.GetGitIgnoreTemplates(), - new[] { GitIgnoreItem.None }, - OrderedComparer.OrderByDescending(item => GitIgnoreItem.IsRecommended(item.Name)).Compare, - x => - { - if (x.Name.Equals("VisualStudio", StringComparison.OrdinalIgnoreCase)) - SelectedGitIgnoreTemplate = x; - }); - - Licenses = TrackingCollection.CreateListenerCollectionAndRun( - repositoryHost.ModelService.GetLicenses(), - new[] { LicenseItem.None }, - OrderedComparer.OrderByDescending(item => LicenseItem.IsRecommended(item.Name)).Compare); - - BaseRepositoryPath = repositoryCreationService.DefaultClonePath; - } - - string baseRepositoryPath; - /// - /// Path to clone repositories into - /// - public string BaseRepositoryPath - { - [return: AllowNull] - get { return baseRepositoryPath; } - set { this.RaiseAndSetIfChanged(ref baseRepositoryPath, StripSurroundingQuotes(value)); } - } - - /// - /// Fires up a file dialog to select the directory to clone into - /// - public ICommand BrowseForDirectory { get { return browseForDirectoryCommand; } } - - /// - /// Is running the creation process - /// - public bool IsCreating { get { return isCreating.Value; } } - - /// - /// If the repo can be made private (depends on the user plan) - /// - public bool CanKeepPrivate { get { return canKeepPrivate.Value; } } - - IReadOnlyList gitIgnoreTemplates; - public IReadOnlyList GitIgnoreTemplates - { - get { return gitIgnoreTemplates; } - set { this.RaiseAndSetIfChanged(ref gitIgnoreTemplates, value); } - } - - IReadOnlyList licenses; - public IReadOnlyList Licenses - { - get { return licenses; } - set { this.RaiseAndSetIfChanged(ref licenses, value); } - } - - GitIgnoreItem selectedGitIgnoreTemplate; - [AllowNull] - public GitIgnoreItem SelectedGitIgnoreTemplate - { - get { return selectedGitIgnoreTemplate; } - set { this.RaiseAndSetIfChanged(ref selectedGitIgnoreTemplate, value ?? GitIgnoreItem.None); } - } - - LicenseItem selectedLicense; - [AllowNull] - public LicenseItem SelectedLicense - { - get { return selectedLicense; } - set { this.RaiseAndSetIfChanged(ref selectedLicense, value ?? LicenseItem.None); } - } - - /// - /// List of accounts (at least one) - /// - public IReadOnlyList Accounts { get { return accounts.Value; } } - - public ReactivePropertyValidator BaseRepositoryPathValidator { get; private set; } - - /// - /// Fires off the process of creating the repository remotely and then cloning it locally - /// - public IReactiveCommand CreateRepository { get; private set; } - - protected override NewRepository GatherRepositoryInfo() - { - var gitHubRepository = base.GatherRepositoryInfo(); - - if (SelectedLicense != LicenseItem.None) - { - gitHubRepository.LicenseTemplate = SelectedLicense.Key; - gitHubRepository.AutoInit = true; - } - - if (SelectedGitIgnoreTemplate != GitIgnoreItem.None) - { - gitHubRepository.GitignoreTemplate = SelectedGitIgnoreTemplate.Name; - gitHubRepository.AutoInit = true; - } - return gitHubRepository; - } - - IObservable ShowBrowseForDirectoryDialog() - { - return Observable.Start(() => - { - // We store this in a local variable to prevent it changing underneath us while the - // folder dialog is open. - var localBaseRepositoryPath = BaseRepositoryPath; - var browseResult = operatingSystem.Dialog.BrowseForDirectory(localBaseRepositoryPath, - Resources.BrowseForDirectory); - - if (!browseResult.Success) - return; - - var directory = browseResult.DirectoryPath ?? localBaseRepositoryPath; - - try - { - BaseRepositoryPath = directory; - } - catch (Exception e) - { - // TODO: We really should limit this to exceptions we know how to handle. - log.Error(string.Format(CultureInfo.InvariantCulture, - "Failed to set base repository path.{0}localBaseRepositoryPath = \"{1}\"{0}BaseRepositoryPath = \"{2}\"{0}Chosen directory = \"{3}\"", - System.Environment.NewLine, localBaseRepositoryPath ?? "(null)", BaseRepositoryPath ?? "(null)", directory ?? "(null)"), e); - } - }, RxApp.MainThreadScheduler); - } - - bool IsAlreadyRepoAtPath(string potentialRepositoryName) - { - bool isAlreadyRepoAtPath = false; - var validationResult = BaseRepositoryPathValidator.ValidationResult; - string safeRepositoryName = GetSafeRepositoryName(potentialRepositoryName); - if (validationResult != null && validationResult.IsValid) - { - if (Path.GetInvalidPathChars().Any(chr => BaseRepositoryPath.Contains(chr))) - return false; - string potentialPath = Path.Combine(BaseRepositoryPath, safeRepositoryName); - isAlreadyRepoAtPath = operatingSystem.Directory.Exists(potentialPath); - } - return isAlreadyRepoAtPath; - } - - IObservable OnCreateRepository(object state) - { - var newRepository = GatherRepositoryInfo(); - - return repositoryCreationService.CreateRepository( - newRepository, - SelectedAccount, - BaseRepositoryPath, - repositoryHost.ApiClient) - .Do(_ => usageTracker.IncrementCreateCount().Forget()); - } - - ReactiveCommand InitializeCreateRepositoryCommand() - { - var canCreate = this.WhenAny( - x => x.RepositoryNameValidator.ValidationResult.IsValid, - x => x.BaseRepositoryPathValidator.ValidationResult.IsValid, - (x, y) => x.Value && y.Value); - var createCommand = ReactiveCommand.CreateAsyncObservable(canCreate, OnCreateRepository); - createCommand.ThrownExceptions.Subscribe(ex => - { - if (!Extensions.ExceptionExtensions.IsCriticalException(ex)) - { - log.Error("Error creating repository.", ex); - UserError.Throw(TranslateRepositoryCreateException(ex)); - } - }); - - return createCommand; - } - - static string StripSurroundingQuotes(string path) - { - if (string.IsNullOrEmpty(path) - || path.Length < 2 - || !path.StartsWith("\"", StringComparison.Ordinal) - || !path.EndsWith("\"", StringComparison.Ordinal)) - { - return path; - } - - return path.Substring(1, path.Length - 2); - } - - PublishRepositoryUserError TranslateRepositoryCreateException(Exception ex) - { - var existsException = ex as RepositoryExistsException; - if (existsException != null && SelectedAccount != null) - { - string message = string.Format( - CultureInfo.InvariantCulture, - Resources.RepositoryCreationFailedAlreadyExists, - SelectedAccount.Login, RepositoryName); - return new PublishRepositoryUserError(message, Resources.RepositoryCreationFailedAlreadyExistsMessage); - } - var quotaExceededException = ex as PrivateRepositoryQuotaExceededException; - if (quotaExceededException != null && SelectedAccount != null) - { - return new PublishRepositoryUserError(Resources.RepositoryCreationFailedQuota, quotaExceededException.Message); - } - return new PublishRepositoryUserError(ex.Message); - } - } -} diff --git a/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs b/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs index 6d5313c2c8..bd69c9b141 100644 --- a/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs +++ b/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs @@ -4,7 +4,6 @@ using System.Windows.Input; using GitHub.Models; using GitHub.Validation; -using NullGuard; using ReactiveUI; namespace GitHub.ViewModels @@ -12,23 +11,12 @@ namespace GitHub.ViewModels /// /// Base class for the Repository publish/create dialogs. It represents the details about the repository itself. /// - public abstract class RepositoryFormViewModel : BaseViewModel + public abstract class RepositoryFormViewModel : ViewModelBase { readonly ObservableAsPropertyHelper safeRepositoryName; protected RepositoryFormViewModel() { - CanKeepPrivateObservable = this.WhenAny( - x => x.SelectedAccount.IsEnterprise, - x => x.SelectedAccount.IsOnFreePlan, - x => x.SelectedAccount.HasMaximumPrivateRepositories, - (isEnterprise, isOnFreePlan, hasMaxPrivateRepos) => - isEnterprise.Value || (!isOnFreePlan.Value && !hasMaxPrivateRepos.Value)); - - CanKeepPrivateObservable - .Where(x => !x) - .Subscribe(x => KeepPrivate = false); - safeRepositoryName = this.WhenAny(x => x.RepositoryName, x => x.Value) .Select(x => x != null ? GetSafeRepositoryName(x) : null) .ToProperty(this, x => x.SafeRepositoryName); @@ -38,10 +26,8 @@ protected RepositoryFormViewModel() /// /// Description to set on the repo (optional) /// - [AllowNull] public string Description { - [return: AllowNull] get { return description; } set { this.RaiseAndSetIfChanged(ref description, value); } } @@ -60,10 +46,8 @@ public bool KeepPrivate /// /// Name of the repository as typed by user /// - [AllowNull] public string RepositoryName { - [return: AllowNull] get { return repositoryName; } set { this.RaiseAndSetIfChanged(ref repositoryName, value); } } @@ -75,7 +59,6 @@ public string RepositoryName /// public string SafeRepositoryName { - [return: AllowNull] get { return safeRepositoryName.Value; } } @@ -85,23 +68,12 @@ public string SafeRepositoryName /// /// Account where the repository is going to be created on /// - [AllowNull] public IAccount SelectedAccount { - [return: AllowNull] get { return selectedAccount; } set { this.RaiseAndSetIfChanged(ref selectedAccount, value); } } - public bool ShowUpgradePlanWarning { get; private set; } - - public bool ShowUpgradeToMicroPlanWarning { get; private set; } - - [AllowNull] - public ICommand UpgradeAccountPlan { get; private set; } - - protected IObservable CanKeepPrivateObservable { get; private set; } - // These are the characters which are permitted when creating a repository name on GitHub The Website static readonly Regex invalidRepositoryCharsRegex = new Regex(@"[^0-9A-Za-z_\.\-]", RegexOptions.ECMAScript); diff --git a/src/GitHub.App/ViewModels/RepositoryPublishViewModel.cs b/src/GitHub.App/ViewModels/RepositoryPublishViewModel.cs deleted file mode 100644 index 16b00d4c07..0000000000 --- a/src/GitHub.App/ViewModels/RepositoryPublishViewModel.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel.Composition; -using System.Globalization; -using System.Linq; -using System.Reactive.Linq; -using GitHub.App; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Extensions.Reactive; -using GitHub.Models; -using GitHub.Services; -using GitHub.UserErrors; -using GitHub.Validation; -using NLog; -using NullGuard; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType = UIViewType.Publish)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class RepositoryPublishViewModel : RepositoryFormViewModel, IRepositoryPublishViewModel - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly IRepositoryHosts hosts; - readonly IRepositoryPublishService repositoryPublishService; - readonly INotificationService notificationService; - readonly ObservableAsPropertyHelper> accounts; - readonly ObservableAsPropertyHelper isHostComboBoxVisible; - readonly ObservableAsPropertyHelper canKeepPrivate; - readonly ObservableAsPropertyHelper isPublishing; - readonly ObservableAsPropertyHelper title; - readonly IUsageTracker usageTracker; - - [ImportingConstructor] - public RepositoryPublishViewModel( - IRepositoryHosts hosts, - IRepositoryPublishService repositoryPublishService, - INotificationService notificationService, - IConnectionManager connectionManager, - IUsageTracker usageTracker) - { - this.notificationService = notificationService; - this.hosts = hosts; - this.usageTracker = usageTracker; - - title = this.WhenAny( - x => x.SelectedHost, - x => x.Value != null ? - string.Format(CultureInfo.CurrentCulture, Resources.PublishToTitle, x.Value.Title) : - Resources.PublishTitle - ) - .ToProperty(this, x => x.Title); - - Connections = connectionManager.Connections; - this.repositoryPublishService = repositoryPublishService; - - if (Connections.Any()) - SelectedConnection = Connections.FirstOrDefault(x => x.HostAddress.IsGitHubDotCom()) ?? Connections[0]; - - accounts = this.WhenAny(x => x.SelectedConnection, x => x.Value != null ? hosts.LookupHost(x.Value.HostAddress) : RepositoryHosts.DisconnectedRepositoryHost) - .Where(x => !(x is DisconnectedRepositoryHost)) - .SelectMany(host => host.ModelService.GetAccounts()) - .ObserveOn(RxApp.MainThreadScheduler) - .ToProperty(this, x => x.Accounts, initialValue: new ReadOnlyCollection(new IAccount[] {})); - - this.WhenAny(x => x.Accounts, x => x.Value) - .WhereNotNull() - .Where(accts => accts.Any()) - .Subscribe(accts => { - var selectedAccount = accts.FirstOrDefault(); - if (selectedAccount != null) - SelectedAccount = accts.FirstOrDefault(); - }); - - isHostComboBoxVisible = this.WhenAny(x => x.Connections, x => x.Value) - .WhereNotNull() - .Select(h => h.Count > 1) - .ToProperty(this, x => x.IsHostComboBoxVisible); - - InitializeValidation(); - - PublishRepository = InitializePublishRepositoryCommand(); - - canKeepPrivate = CanKeepPrivateObservable.CombineLatest(PublishRepository.IsExecuting, - (canKeep, publishing) => canKeep && !publishing) - .ToProperty(this, x => x.CanKeepPrivate); - - isPublishing = PublishRepository.IsExecuting - .ToProperty(this, x => x.IsPublishing); - - var defaultRepositoryName = repositoryPublishService.LocalRepositoryName; - if (!string.IsNullOrEmpty(defaultRepositoryName)) - RepositoryName = defaultRepositoryName; - - this.WhenAny(x => x.SelectedConnection, x => x.SelectedAccount, - (a,b) => true) - .Where(x => RepositoryNameValidator.ValidationResult != null && SafeRepositoryNameWarningValidator.ValidationResult != null) - .Subscribe(async _ => - { - var name = RepositoryName; - RepositoryName = null; - await RepositoryNameValidator.ResetAsync(); - await SafeRepositoryNameWarningValidator.ResetAsync(); - RepositoryName = name; - }); - } - - public new string Title { get { return title.Value; } } - public bool CanKeepPrivate { get { return canKeepPrivate.Value; } } - public bool IsPublishing { get { return isPublishing.Value; } } - - public IReactiveCommand PublishRepository { get; private set; } - public ObservableCollection Connections { get; private set; } - - IConnection selectedConnection; - [AllowNull] - public IConnection SelectedConnection - { - [return: AllowNull] - get { return selectedConnection; } - set { this.RaiseAndSetIfChanged(ref selectedConnection, value); } - } - - IRepositoryHost SelectedHost - { - [return:AllowNull] - get { return selectedConnection != null ? hosts.LookupHost(selectedConnection.HostAddress) : null; } - } - - public IReadOnlyList Accounts - { - get { return accounts.Value; } - } - - public bool IsHostComboBoxVisible - { - get { return isHostComboBoxVisible.Value; } - } - - ReactiveCommand InitializePublishRepositoryCommand() - { - var canCreate = this.WhenAny(x => x.RepositoryNameValidator.ValidationResult.IsValid, x => x.Value); - return ReactiveCommand.CreateAsyncObservable(canCreate, OnPublishRepository); - } - - IObservable OnPublishRepository(object arg) - { - var newRepository = GatherRepositoryInfo(); - var account = SelectedAccount; - - return repositoryPublishService.PublishRepository(newRepository, account, SelectedHost.ApiClient) - .Do(_ => usageTracker.IncrementPublishCount().Forget()) - .Select(_ => ProgressState.Success) - .Catch(ex => - { - if (!ex.IsCriticalException()) - { - log.Error(ex); - var error = new PublishRepositoryUserError(ex.Message); - notificationService.ShowError((error.ErrorMessage + Environment.NewLine + error.ErrorCauseOrResolution).TrimEnd()); - } - return Observable.Return(ProgressState.Fail); - }); - } - - void InitializeValidation() - { - var nonNullRepositoryName = this.WhenAny( - x => x.RepositoryName, - x => x.Value) - .WhereNotNull(); - - RepositoryNameValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) - .IfNullOrEmpty(Resources.RepositoryNameValidatorEmpty) - .IfTrue(x => x.Length > 100, Resources.RepositoryNameValidatorTooLong); - - SafeRepositoryNameWarningValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) - .Add(repoName => - { - var parsedReference = GetSafeRepositoryName(repoName); - return parsedReference != repoName ? String.Format(CultureInfo.CurrentCulture, Resources.SafeRepositoryNameWarning, parsedReference) : null; - }); - } - } -} diff --git a/src/GitHub.App/ViewModels/SpinnerViewModel.cs b/src/GitHub.App/ViewModels/SpinnerViewModel.cs new file mode 100644 index 0000000000..3395ca8789 --- /dev/null +++ b/src/GitHub.App/ViewModels/SpinnerViewModel.cs @@ -0,0 +1,14 @@ +using System; +using System.ComponentModel.Composition; + +namespace GitHub.ViewModels +{ + /// + /// View model which displays a spinner. + /// + [Export(typeof(ISpinnerViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class SpinnerViewModel : ViewModelBase, ISpinnerViewModel + { + } +} diff --git a/src/GitHub.App/ViewModels/StartPageCloneViewModel.cs b/src/GitHub.App/ViewModels/StartPageCloneViewModel.cs deleted file mode 100644 index c7d49a9e65..0000000000 --- a/src/GitHub.App/ViewModels/StartPageCloneViewModel.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Reactive; -using System.Reactive.Linq; -using System.Windows.Input; -using GitHub.App; -using GitHub.Exports; -using GitHub.Models; -using GitHub.Services; -using GitHub.Validation; -using NLog; -using NullGuard; -using ReactiveUI; -using Rothko; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType=UIViewType.StartPageClone)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class StartPageCloneViewModel : BaseViewModel, IBaseCloneViewModel - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - readonly IOperatingSystem operatingSystem; - readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(); - readonly ObservableAsPropertyHelper canClone; - string baseRepositoryPath; - - [ImportingConstructor] - StartPageCloneViewModel( - IConnectionRepositoryHostMap connectionRepositoryHostMap, - IRepositoryCloneService repositoryCloneService, - IOperatingSystem operatingSystem) - : this(connectionRepositoryHostMap.CurrentRepositoryHost, repositoryCloneService, operatingSystem) - { } - - public StartPageCloneViewModel( - IRepositoryHost repositoryHost, - IRepositoryCloneService cloneService, - IOperatingSystem operatingSystem) - { - this.operatingSystem = operatingSystem; - - Title = string.Format(CultureInfo.CurrentCulture, Resources.CloneTitle, repositoryHost.Title); - - var baseRepositoryPath = this.WhenAny( - x => x.BaseRepositoryPath, - x => x.SelectedRepository, - (x, y) => x.Value); - - BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(baseRepositoryPath) - .IfNullOrEmpty(Resources.RepositoryCreationClonePathEmpty) - .IfTrue(x => x.Length > 200, Resources.RepositoryCreationClonePathTooLong) - .IfContainsInvalidPathChars(Resources.RepositoryCreationClonePathInvalidCharacters) - .IfPathNotRooted(Resources.RepositoryCreationClonePathInvalid) - .IfTrue(IsAlreadyRepoAtPath, Resources.RepositoryNameValidatorAlreadyExists); - - var canCloneObservable = this.WhenAny( - x => x.SelectedRepository, - x => x.BaseRepositoryPathValidator.ValidationResult.IsValid, - (x, y) => x.Value != null && y.Value); - canClone = canCloneObservable.ToProperty(this, x => x.CanClone); - CloneCommand = ReactiveCommand.Create(canCloneObservable); - - browseForDirectoryCommand.Subscribe(_ => ShowBrowseForDirectoryDialog()); - this.WhenAny(x => x.BaseRepositoryPathValidator.ValidationResult, x => x.Value) - .Subscribe(); - BaseRepositoryPath = cloneService.DefaultClonePath; - } - - bool IsAlreadyRepoAtPath(string path) - { - Debug.Assert(path != null, "RepositoryCloneViewModel.IsAlreadyRepoAtPath cannot be passed null as a path parameter."); - - bool isAlreadyRepoAtPath = false; - - if (SelectedRepository != null) - { - string potentialPath = Path.Combine(path, SelectedRepository.Name); - isAlreadyRepoAtPath = operatingSystem.Directory.Exists(potentialPath); - } - - return isAlreadyRepoAtPath; - } - - IObservable ShowBrowseForDirectoryDialog() - { - return Observable.Start(() => - { - // We store this in a local variable to prevent it changing underneath us while the - // folder dialog is open. - var localBaseRepositoryPath = BaseRepositoryPath; - var browseResult = operatingSystem.Dialog.BrowseForDirectory(localBaseRepositoryPath, Resources.BrowseForDirectory); - - if (!browseResult.Success) - return; - - var directory = browseResult.DirectoryPath ?? localBaseRepositoryPath; - - try - { - BaseRepositoryPath = directory; - } - catch (Exception e) - { - // TODO: We really should limit this to exceptions we know how to handle. - log.Error(string.Format(CultureInfo.InvariantCulture, - "Failed to set base repository path.{0}localBaseRepositoryPath = \"{1}\"{0}BaseRepositoryPath = \"{2}\"{0}Chosen directory = \"{3}\"", - System.Environment.NewLine, localBaseRepositoryPath ?? "(null)", BaseRepositoryPath ?? "(null)", directory ?? "(null)"), e); - } - }, RxApp.MainThreadScheduler); - } - - /// - /// Path to clone repositories into - /// - public string BaseRepositoryPath - { - [return: AllowNull] - get { return baseRepositoryPath; } - set { this.RaiseAndSetIfChanged(ref baseRepositoryPath, value); } - } - - /// - /// Signals that the user clicked the clone button. - /// - public IReactiveCommand CloneCommand { get; private set; } - - IRepositoryModel selectedRepository; - /// - /// Selected repository to clone - /// - [AllowNull] - public IRepositoryModel SelectedRepository - { - [return: AllowNull] - get { return selectedRepository; } - set { this.RaiseAndSetIfChanged(ref selectedRepository, value); } - } - - public ICommand BrowseForDirectory - { - get { return browseForDirectoryCommand; } - } - - public bool CanClone - { - get { return canClone.Value; } - } - - public ReactivePropertyValidator BaseRepositoryPathValidator - { - get; - private set; - } - } -} diff --git a/src/GitHub.App/ViewModels/TeamExplorer/RepositoryPublishViewModel.cs b/src/GitHub.App/ViewModels/TeamExplorer/RepositoryPublishViewModel.cs new file mode 100644 index 0000000000..3ad6d48e30 --- /dev/null +++ b/src/GitHub.App/ViewModels/TeamExplorer/RepositoryPublishViewModel.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel.Composition; +using System.Globalization; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Factories; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using GitHub.UserErrors; +using GitHub.Validation; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels.TeamExplorer +{ + [Export(typeof(IRepositoryPublishViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class RepositoryPublishViewModel : RepositoryFormViewModel, IRepositoryPublishViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + readonly IRepositoryPublishService repositoryPublishService; + readonly INotificationService notificationService; + readonly IModelServiceFactory modelServiceFactory; + readonly IDialogService dialogService; + readonly ObservableAsPropertyHelper> accounts; + readonly ObservableAsPropertyHelper isHostComboBoxVisible; + readonly IUsageTracker usageTracker; + + [ImportingConstructor] + public RepositoryPublishViewModel( + IRepositoryPublishService repositoryPublishService, + INotificationService notificationService, + IConnectionManager connectionManager, + IModelServiceFactory modelServiceFactory, + IDialogService dialogService, + IUsageTracker usageTracker) + { + Guard.ArgumentNotNull(repositoryPublishService, nameof(repositoryPublishService)); + Guard.ArgumentNotNull(notificationService, nameof(notificationService)); + Guard.ArgumentNotNull(connectionManager, nameof(connectionManager)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); + Guard.ArgumentNotNull(dialogService, nameof(dialogService)); + + this.notificationService = notificationService; + this.usageTracker = usageTracker; + this.modelServiceFactory = modelServiceFactory; + this.dialogService = dialogService; + + Connections = connectionManager.Connections; + this.repositoryPublishService = repositoryPublishService; + + if (Connections.Any()) + SelectedConnection = Connections.FirstOrDefault(x => x.HostAddress.IsGitHubDotCom()) ?? Connections[0]; + + accounts = this.WhenAnyValue(x => x.SelectedConnection) + .Where(x => x != null) + .SelectMany(async c => (await modelServiceFactory.CreateAsync(c)).GetAccounts()) + .Switch() + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.Accounts, initialValue: new ReadOnlyCollection(Array.Empty())); + + this.WhenAny(x => x.Accounts, x => x.Value) + .WhereNotNull() + .Where(accts => accts.Any()) + .Subscribe(accts => { + var selectedAccount = accts.FirstOrDefault(); + if (selectedAccount != null) + SelectedAccount = accts.FirstOrDefault(); + }); + + isHostComboBoxVisible = this.WhenAny(x => x.Connections, x => x.Value) + .WhereNotNull() + .Select(h => h.Count > 1) + .ToProperty(this, x => x.IsHostComboBoxVisible); + + InitializeValidation(); + + PublishRepository = InitializePublishRepositoryCommand(); + PublishRepository.IsExecuting.Subscribe(x => IsBusy = x); + + LoginAsDifferentUser = ReactiveCommand.CreateFromTask(LoginAsDifferentUserAsync); + + var defaultRepositoryName = repositoryPublishService.LocalRepositoryName; + if (!string.IsNullOrEmpty(defaultRepositoryName)) + RepositoryName = defaultRepositoryName; + + this.WhenAny(x => x.SelectedConnection, x => x.SelectedAccount, + (a,b) => true) + .Where(x => RepositoryNameValidator.ValidationResult != null && SafeRepositoryNameWarningValidator.ValidationResult != null) + .Subscribe(async _ => + { + var name = RepositoryName; + RepositoryName = null; + await RepositoryNameValidator.ResetAsync(); + await SafeRepositoryNameWarningValidator.ResetAsync(); + RepositoryName = name; + }); + } + + public ReactiveCommand PublishRepository { get; private set; } + + public ReactiveCommand LoginAsDifferentUser { get; private set; } + + public IReadOnlyObservableCollection Connections { get; private set; } + + bool isBusy; + public bool IsBusy + { + get { return isBusy; } + private set { this.RaiseAndSetIfChanged(ref isBusy, value); } + } + + IConnection selectedConnection; + public IConnection SelectedConnection + { + get { return selectedConnection; } + set { this.RaiseAndSetIfChanged(ref selectedConnection, value); } + } + + public IReadOnlyList Accounts + { + get { return accounts.Value; } + } + + public bool IsHostComboBoxVisible + { + get { return isHostComboBoxVisible.Value; } + } + + async Task LoginAsDifferentUserAsync() + { + if (await dialogService.ShowLoginDialog() is IConnection connection) + { + SelectedConnection = connection; + } + } + + ReactiveCommand InitializePublishRepositoryCommand() + { + var canCreate = this.WhenAny(x => x.RepositoryNameValidator.ValidationResult.IsValid, x => x.Value); + return ReactiveCommand.CreateFromObservable(OnPublishRepository, canCreate); + } + + IObservable OnPublishRepository() + { + var newRepository = GatherRepositoryInfo(); + var account = SelectedAccount; + var modelService = modelServiceFactory.CreateBlocking(SelectedConnection); + + return repositoryPublishService.PublishRepository(newRepository, account, modelService.ApiClient) + .Do(_ => usageTracker.IncrementCounter(x => x.NumberOfReposPublished).Forget()) + .Select(_ => ProgressState.Success) + .Catch(ex => + { + if (!ex.IsCriticalException()) + { + log.Error(ex, "Error Publishing Repository"); + var error = new PublishRepositoryUserError(ex.Message); + notificationService.ShowError((error.ErrorMessage + Environment.NewLine + error.ErrorCauseOrResolution).TrimEnd()); + } + return Observable.Return(ProgressState.Fail); + }); + } + + void InitializeValidation() + { + var nonNullRepositoryName = this.WhenAny( + x => x.RepositoryName, + x => x.Value) + .WhereNotNull(); + + RepositoryNameValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) + .IfNullOrEmpty(Resources.RepositoryNameValidatorEmpty) + .IfTrue(x => x.Length > 100, Resources.RepositoryNameValidatorTooLong); + + SafeRepositoryNameWarningValidator = ReactivePropertyValidator.ForObservable(nonNullRepositoryName) + .Add(repoName => + { + var parsedReference = GetSafeRepositoryName(repoName); + return parsedReference != repoName ? String.Format(CultureInfo.CurrentCulture, Resources.SafeRepositoryNameWarning, parsedReference) : null; + }); + } + } +} diff --git a/src/GitHub.App/ViewModels/TwoFactorDialogViewModel.cs b/src/GitHub.App/ViewModels/TwoFactorDialogViewModel.cs deleted file mode 100644 index 2d691bf9f8..0000000000 --- a/src/GitHub.App/ViewModels/TwoFactorDialogViewModel.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.Globalization; -using System.Reactive.Linq; -using GitHub.App; -using GitHub.Authentication; -using GitHub.Exports; -using GitHub.Info; -using GitHub.Services; -using GitHub.Validation; -using NullGuard; -using Octokit; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [ExportViewModel(ViewType = UIViewType.TwoFactor)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class TwoFactorDialogViewModel : BaseViewModel, ITwoFactorDialogViewModel - { - bool isAuthenticationCodeSent; - bool invalidAuthenticationCode; - string authenticationCode; - TwoFactorType twoFactorType; - readonly ObservableAsPropertyHelper description; - readonly ObservableAsPropertyHelper isSms; - readonly ObservableAsPropertyHelper showErrorMessage; - - [ImportingConstructor] - public TwoFactorDialogViewModel( - IVisualStudioBrowser browser, - ITwoFactorChallengeHandler twoFactorChallengeHandler) - { - Title = Resources.TwoFactorTitle; - twoFactorChallengeHandler.SetViewModel(this); - - var canVerify = this.WhenAny( - x => x.AuthenticationCode, - x => x.IsBusy, - (code, busy) => !string.IsNullOrEmpty(code.Value) && code.Value.Length == 6 && !busy.Value); - - OkCommand = ReactiveCommand.Create(canVerify); - NavigateLearnMore = ReactiveCommand.Create(); - NavigateLearnMore.Subscribe(x => browser.OpenUrl(GitHubUrls.TwoFactorLearnMore)); - //TODO: ShowHelpCommand.Subscribe(x => browser.OpenUrl(twoFactorHelpUri)); - ResendCodeCommand = ReactiveCommand.Create(); - - showErrorMessage = this.WhenAny( - x => x.IsAuthenticationCodeSent, - x => x.InvalidAuthenticationCode, - (authSent, invalid) => invalid.Value && !authSent.Value) - .ToProperty(this, x => x.ShowErrorMessage); - - description = this.WhenAny(x => x.TwoFactorType, x => x.Value) - .Select(type => - { - switch (type) - { - case TwoFactorType.Sms: - return Resources.TwoFactorSms; - case TwoFactorType.AuthenticatorApp: - return Resources.TwoFactorApp; - case TwoFactorType.Unknown: - return Resources.TwoFactorUnknown; - - default: - return null; - } - }) - .ToProperty(this, x => x.Description); - - isShowing = this.WhenAny(x => x.TwoFactorType, x => x.Value) - .Select(factorType => factorType != TwoFactorType.None) - .ToProperty(this, x => x.IsShowing); - - isSms = this.WhenAny(x => x.TwoFactorType, x => x.Value) - .Select(factorType => factorType == TwoFactorType.Sms) - .ToProperty(this, x => x.IsSms); - } - - public IObservable Show(UserError userError) - { - IsBusy = false; - var error = userError as TwoFactorRequiredUserError; - Debug.Assert(error != null, - String.Format(CultureInfo.InvariantCulture, "The user error is '{0}' not a TwoFactorRequiredUserError", userError)); - InvalidAuthenticationCode = error.RetryFailed; - TwoFactorType = error.TwoFactorType; - var ok = OkCommand - .Do(_ => IsBusy = true) - .Select(_ => AuthenticationCode == null - ? RecoveryOptionResult.CancelOperation - : RecoveryOptionResult.RetryOperation) - .Do(_ => error.ChallengeResult = AuthenticationCode != null - ? new TwoFactorChallengeResult(AuthenticationCode) - : null); - var resend = ResendCodeCommand.Select(_ => RecoveryOptionResult.RetryOperation) - .Do(_ => error.ChallengeResult = TwoFactorChallengeResult.RequestResendCode); - var cancel = CancelCommand.Select(_ => RecoveryOptionResult.CancelOperation); - return Observable.Merge(ok, cancel, resend) - .Take(1) - .Do(_ => IsAuthenticationCodeSent = error.ChallengeResult == TwoFactorChallengeResult.RequestResendCode); - } - - public TwoFactorType TwoFactorType - { - get { return twoFactorType; } - private set { this.RaiseAndSetIfChanged(ref twoFactorType, value); } - } - - public bool IsSms { get { return isSms.Value; } } - - public bool IsAuthenticationCodeSent - { - get { return isAuthenticationCodeSent; } - private set { this.RaiseAndSetIfChanged(ref isAuthenticationCodeSent, value); } - } - - public string Description - { - [return: AllowNull] - get { return description.Value; } - } - - [AllowNull] - public string AuthenticationCode - { - [return: AllowNull] - get { return authenticationCode; } - set { this.RaiseAndSetIfChanged(ref authenticationCode, value); } - } - - public ReactiveCommand OkCommand { get; private set; } - public ReactiveCommand NavigateLearnMore { get; private set; } - public ReactiveCommand ResendCodeCommand { get; private set; } - public ReactivePropertyValidator AuthenticationCodeValidator { get; private set; } - - public bool InvalidAuthenticationCode - { - get { return invalidAuthenticationCode; } - private set { this.RaiseAndSetIfChanged(ref invalidAuthenticationCode, value); } - } - - public bool ShowErrorMessage - { - get { return showErrorMessage.Value; } - } - } -} diff --git a/src/GitHub.App/ViewModels/UserFilterViewModel.cs b/src/GitHub.App/ViewModels/UserFilterViewModel.cs new file mode 100644 index 0000000000..3cf2020f89 --- /dev/null +++ b/src/GitHub.App/ViewModels/UserFilterViewModel.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using GitHub.Extensions; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + public class UserFilterViewModel : ViewModelBase, IUserFilterViewModel + { + readonly LoadPageDelegate load; + ReactiveList users; + ListCollectionView usersView; + string filter; + IActorViewModel selected; + IActorViewModel ersatzUser; + + public delegate Task> LoadPageDelegate(string after); + + public UserFilterViewModel(LoadPageDelegate load) + { + Guard.ArgumentNotNull(load, nameof(load)); + + this.load = load; + this.WhenAnyValue(x => x.Filter).Subscribe(FilterChanged); + this.WhenAnyValue(x => x.Selected).Subscribe(_ => Filter = null); + ClearSelection = ReactiveCommand.Create( + () => { Selected = null; }, + this.WhenAnyValue(x => x.Selected).Select(x => x != null)); + } + + public IReadOnlyList Users + { + get + { + if (users == null) + { + users = new ReactiveList(); + Load().Forget(); + } + + return users; + } + } + + public ICollectionView UsersView + { + get + { + if (usersView == null) + { + usersView = new ListCollectionView((IList)Users); + usersView.CustomSort = new UserComparer(this); + usersView.Filter = FilterUsers; + } + + return usersView; + } + } + + public string Filter + { + get { return filter; } + set { this.RaiseAndSetIfChanged(ref filter, value); } + } + + public IActorViewModel Selected + { + get { return selected; } + set { this.RaiseAndSetIfChanged(ref selected, value); } + } + + public ReactiveCommand ClearSelection { get; } + + void FilterChanged(string filter) + { + if (users == null) return; + + if (ersatzUser != null) + { + users.Remove(ersatzUser); + ersatzUser = null; + } + + if (!string.IsNullOrWhiteSpace(filter)) + { + var existing = users.FirstOrDefault(x => x.Login.Equals(filter, StringComparison.CurrentCultureIgnoreCase)); + + if (existing == null) + { + ersatzUser = new ActorViewModel(new ActorModel { Login = filter }); + users.Add(ersatzUser); + } + } + + UsersView.Refresh(); + } + + bool FilterUsers(object obj) + { + if (Filter != null) + { + var user = obj as IActorViewModel; + return user?.Login.IndexOf(Filter, StringComparison.CurrentCultureIgnoreCase) != -1; + } + + return true; + } + + async Task Load() + { + string after = null; + + while (true) + { + var page = await load(after); + + foreach (var item in page.Items) + { + var vm = new ActorViewModel(item); + users.Add(vm); + } + + after = page.EndCursor; + if (!page.HasNextPage) break; + } + } + + class UserComparer : IComparer + { + readonly UserFilterViewModel owner; + + public UserComparer(UserFilterViewModel owner) + { + this.owner = owner; + } + + public int Compare(object x, object y) + { + if (x == owner.ersatzUser) return -1; + if (y == owner.ersatzUser) return 1; + return string.Compare( + ((IActorViewModel)x).Login, + ((IActorViewModel)y).Login, + StringComparison.Ordinal); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/app.config b/src/GitHub.App/app.config deleted file mode 100644 index 074e2f8475..0000000000 --- a/src/GitHub.App/app.config +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.App/packages.config b/src/GitHub.App/packages.config deleted file mode 100644 index 0b2b1ffdcc..0000000000 --- a/src/GitHub.App/packages.config +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.App/sqlite-net/SQLite.cs b/src/GitHub.App/sqlite-net/SQLite.cs new file mode 100644 index 0000000000..96a3b36728 --- /dev/null +++ b/src/GitHub.App/sqlite-net/SQLite.cs @@ -0,0 +1,4540 @@ +// +// Copyright (c) 2009-2018 Krueger Systems, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// +#if WINDOWS_PHONE && !USE_WP8_NATIVE_SQLITE +#define USE_CSHARP_SQLITE +#endif + +using System; +using System.Collections; +using System.Diagnostics; +#if !USE_SQLITEPCL_RAW +using System.Runtime.InteropServices; +#endif +using System.Collections.Generic; +using System.Reflection; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Threading; + +#if USE_CSHARP_SQLITE +using Sqlite3 = Community.CsharpSqlite.Sqlite3; +using Sqlite3DatabaseHandle = Community.CsharpSqlite.Sqlite3.sqlite3; +using Sqlite3Statement = Community.CsharpSqlite.Sqlite3.Vdbe; +#elif USE_WP8_NATIVE_SQLITE +using Sqlite3 = Sqlite.Sqlite3; +using Sqlite3DatabaseHandle = Sqlite.Database; +using Sqlite3Statement = Sqlite.Statement; +#elif USE_SQLITEPCL_RAW +using Sqlite3DatabaseHandle = SQLitePCL.sqlite3; +using Sqlite3Statement = SQLitePCL.sqlite3_stmt; +using Sqlite3 = SQLitePCL.raw; +#else +using Sqlite3DatabaseHandle = System.IntPtr; +using Sqlite3Statement = System.IntPtr; +#endif + +#pragma warning disable 1591 // XML Doc Comments +#pragma warning disable CA1018 // Mark attributes with AttributeUsageAttribute +#pragma warning disable CA1032 // Implement standard exception constructors +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1051 // Do not declare visible instance fields +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +#pragma warning disable CA1060 // Move pinvokes to native methods class +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1305 // Specify IFormatProvider +#pragma warning disable CA1401 // P/Invokes should not be visible +#pragma warning disable CA1710 // Identifiers should have correct suffix +#pragma warning disable CA1720 // Identifier contains type name +#pragma warning disable CA1806 // Do not ignore method results +#pragma warning disable CA1819 // Properties should not return arrays +#pragma warning disable CA2101 // Specify marshaling for P/Invoke string arguments +#pragma warning disable CA2200 // Rethrow to preserve stack details. +#pragma warning disable CA2208 // Instantiate argument exceptions correctly +#pragma warning disable CA2237 // Mark ISerializable types with serializable + +namespace SQLite +{ + public class SQLiteException : Exception + { + public SQLite3.Result Result { get; private set; } + + protected SQLiteException(SQLite3.Result r, string message) : base(message) + { + Result = r; + } + + public static SQLiteException New(SQLite3.Result r, string message) + { + return new SQLiteException(r, message); + } + } + + public class NotNullConstraintViolationException : SQLiteException + { + public IEnumerable Columns { get; protected set; } + + protected NotNullConstraintViolationException(SQLite3.Result r, string message) + : this(r, message, null, null) + { + + } + + protected NotNullConstraintViolationException(SQLite3.Result r, string message, TableMapping mapping, object obj) + : base(r, message) + { + if (mapping != null && obj != null) + { + this.Columns = from c in mapping.Columns + where c.IsNullable == false && c.GetValue(obj) == null + select c; + } + } + + public static new NotNullConstraintViolationException New(SQLite3.Result r, string message) + { + return new NotNullConstraintViolationException(r, message); + } + + public static NotNullConstraintViolationException New(SQLite3.Result r, string message, TableMapping mapping, object obj) + { + return new NotNullConstraintViolationException(r, message, mapping, obj); + } + + public static NotNullConstraintViolationException New(SQLiteException exception, TableMapping mapping, object obj) + { + return new NotNullConstraintViolationException(exception.Result, exception.Message, mapping, obj); + } + } + + [Flags] + public enum SQLiteOpenFlags + { + ReadOnly = 1, ReadWrite = 2, Create = 4, + NoMutex = 0x8000, FullMutex = 0x10000, + SharedCache = 0x20000, PrivateCache = 0x40000, + ProtectionComplete = 0x00100000, + ProtectionCompleteUnlessOpen = 0x00200000, + ProtectionCompleteUntilFirstUserAuthentication = 0x00300000, + ProtectionNone = 0x00400000 + } + + [Flags] + public enum CreateFlags + { + /// + /// Use the default creation options + /// + None = 0x000, + /// + /// Create a primary key index for a property called 'Id' (case-insensitive). + /// This avoids the need for the [PrimaryKey] attribute. + /// + ImplicitPK = 0x001, + /// + /// Create indices for properties ending in 'Id' (case-insensitive). + /// + ImplicitIndex = 0x002, + /// + /// Create a primary key for a property called 'Id' and + /// create an indices for properties ending in 'Id' (case-insensitive). + /// + AllImplicit = 0x003, + /// + /// Force the primary key property to be auto incrementing. + /// This avoids the need for the [AutoIncrement] attribute. + /// The primary key property on the class should have type int or long. + /// + AutoIncPK = 0x004, + /// + /// Create virtual table using FTS3 + /// + FullTextSearch3 = 0x100, + /// + /// Create virtual table using FTS4 + /// + FullTextSearch4 = 0x200 + } + + /// + /// An open connection to a SQLite database. + /// + [Preserve(AllMembers = true)] + public partial class SQLiteConnection : IDisposable + { + private bool _open; + private TimeSpan _busyTimeout; + readonly static Dictionary _mappings = new Dictionary(); + private System.Diagnostics.Stopwatch _sw; + private long _elapsedMilliseconds = 0; + + private int _transactionDepth = 0; + private Random _rand = new Random(); + + public Sqlite3DatabaseHandle Handle { get; private set; } + static readonly Sqlite3DatabaseHandle NullHandle = default(Sqlite3DatabaseHandle); + + /// + /// Gets the database path used by this connection. + /// + public string DatabasePath { get; private set; } + + /// + /// Gets the SQLite library version number. 3007014 would be v3.7.14 + /// + public int LibVersionNumber { get; private set; } + + /// + /// Whether Trace lines should be written that show the execution time of queries. + /// + public bool TimeExecution { get; set; } + + /// + /// Whether to writer queries to during execution. + /// + /// The tracer. + public bool Trace { get; set; } + + /// + /// The delegate responsible for writing trace lines. + /// + /// The tracer. + public Action Tracer { get; set; } + + /// + /// Whether to store DateTime properties as ticks (true) or strings (false). + /// + public bool StoreDateTimeAsTicks { get; private set; } + +#if USE_SQLITEPCL_RAW && !NO_SQLITEPCL_RAW_BATTERIES + static SQLiteConnection () + { + SQLitePCL.Batteries_V2.Init (); + } +#endif + + /// + /// Constructs a new SQLiteConnection and opens a SQLite database specified by databasePath. + /// + /// + /// Specifies the path to the database file. + /// + /// + /// Specifies whether to store DateTime properties as ticks (true) or strings (false). You + /// absolutely do want to store them as Ticks in all new projects. The value of false is + /// only here for backwards compatibility. There is a *significant* speed advantage, with no + /// down sides, when setting storeDateTimeAsTicks = true. + /// If you use DateTimeOffset properties, it will be always stored as ticks regardingless + /// the storeDateTimeAsTicks parameter. + /// + /// + /// Specifies the encryption key to use on the database. Should be a string or a byte[]. + /// + public SQLiteConnection(string databasePath, bool storeDateTimeAsTicks = true, object key = null) + : this(databasePath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create, storeDateTimeAsTicks, key: key) + { + } + + /// + /// Constructs a new SQLiteConnection and opens a SQLite database specified by databasePath. + /// + /// + /// Specifies the path to the database file. + /// + /// + /// Flags controlling how the connection should be opened. + /// + /// + /// Specifies whether to store DateTime properties as ticks (true) or strings (false). You + /// absolutely do want to store them as Ticks in all new projects. The value of false is + /// only here for backwards compatibility. There is a *significant* speed advantage, with no + /// down sides, when setting storeDateTimeAsTicks = true. + /// If you use DateTimeOffset properties, it will be always stored as ticks regardingless + /// the storeDateTimeAsTicks parameter. + /// + /// + /// Specifies the encryption key to use on the database. Should be a string or a byte[]. + /// + public SQLiteConnection(string databasePath, SQLiteOpenFlags openFlags, bool storeDateTimeAsTicks = true, object key = null) + { + if (databasePath == null) + throw new ArgumentException("Must be specified", nameof(databasePath)); + + DatabasePath = databasePath; + + LibVersionNumber = SQLite3.LibVersionNumber(); + +#if NETFX_CORE + SQLite3.SetDirectory(/*temp directory type*/2, Windows.Storage.ApplicationData.Current.TemporaryFolder.Path); +#endif + + Sqlite3DatabaseHandle handle; + +#if SILVERLIGHT || USE_CSHARP_SQLITE || USE_SQLITEPCL_RAW + var r = SQLite3.Open (databasePath, out handle, (int)openFlags, IntPtr.Zero); +#else + // open using the byte[] + // in the case where the path may include Unicode + // force open to using UTF-8 using sqlite3_open_v2 + var databasePathAsBytes = GetNullTerminatedUtf8(DatabasePath); + var r = SQLite3.Open(databasePathAsBytes, out handle, (int)openFlags, IntPtr.Zero); +#endif + + Handle = handle; + if (r != SQLite3.Result.OK) + { + throw SQLiteException.New(r, String.Format("Could not open database file: {0} ({1})", DatabasePath, r)); + } + _open = true; + + StoreDateTimeAsTicks = storeDateTimeAsTicks; + + BusyTimeout = TimeSpan.FromSeconds(0.1); + Tracer = line => Debug.WriteLine(line); + + if (key is string stringKey) + { + SetKey(stringKey); + } + else if (key is byte[] bytesKey) + { + SetKey(bytesKey); + } + else if (key != null) + { + throw new ArgumentException("Encryption keys must be strings or byte arrays", nameof(key)); + } + if (openFlags.HasFlag(SQLiteOpenFlags.ReadWrite)) + { + ExecuteScalar("PRAGMA journal_mode=WAL"); + } + } + + /// + /// Convert an input string to a quoted SQL string that can be safely used in queries. + /// + /// The quoted string. + /// The unsafe string to quote. + static string Quote(string unsafeString) + { + // TODO: Doesn't call sqlite3_mprintf("%Q", u) because we're waiting on https://github.com/ericsink/SQLitePCL.raw/issues/153 + if (unsafeString == null) return "NULL"; + var safe = unsafeString.Replace("'", "''"); + return "'" + safe + "'"; + } + + /// + /// Sets the key used to encrypt/decrypt the database with "pragma key = ...". + /// This must be the first thing you call before doing anything else with this connection + /// if your database is encrypted. + /// This only has an effect if you are using the SQLCipher nuget package. + /// + /// Ecryption key plain text that is converted to the real encryption key using PBKDF2 key derivation + void SetKey(string key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + var q = Quote(key); + Execute("pragma key = " + q); + } + + /// + /// Sets the key used to encrypt/decrypt the database. + /// This must be the first thing you call before doing anything else with this connection + /// if your database is encrypted. + /// This only has an effect if you are using the SQLCipher nuget package. + /// + /// 256-bit (32 byte) ecryption key data + void SetKey(byte[] key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (key.Length != 32) throw new ArgumentException("Key must be 32 bytes (256-bit)", nameof(key)); + var s = String.Join("", key.Select(x => x.ToString("X2"))); + Execute("pragma key = \"x'" + s + "'\""); + } + + /// + /// Enable or disable extension loading. + /// + public void EnableLoadExtension(bool enabled) + { + SQLite3.Result r = SQLite3.EnableLoadExtension(Handle, enabled ? 1 : 0); + if (r != SQLite3.Result.OK) + { + string msg = SQLite3.GetErrmsg(Handle); + throw SQLiteException.New(r, msg); + } + } + +#if !USE_SQLITEPCL_RAW + static byte[] GetNullTerminatedUtf8(string s) + { + var utf8Length = System.Text.Encoding.UTF8.GetByteCount(s); + var bytes = new byte[utf8Length + 1]; + utf8Length = System.Text.Encoding.UTF8.GetBytes(s, 0, s.Length, bytes, 0); + return bytes; + } +#endif + + /// + /// Sets a busy handler to sleep the specified amount of time when a table is locked. + /// The handler will sleep multiple times until a total time of has accumulated. + /// + public TimeSpan BusyTimeout + { + get { return _busyTimeout; } + set + { + _busyTimeout = value; + if (Handle != NullHandle) + { + SQLite3.BusyTimeout(Handle, (int)_busyTimeout.TotalMilliseconds); + } + } + } + + /// + /// Returns the mappings from types to tables that the connection + /// currently understands. + /// + public IEnumerable TableMappings + { + get + { + lock (_mappings) + { + return new List(_mappings.Values); + } + } + } + + /// + /// Retrieves the mapping that is automatically generated for the given type. + /// + /// + /// The type whose mapping to the database is returned. + /// + /// + /// Optional flags allowing implicit PK and indexes based on naming conventions + /// + /// + /// The mapping represents the schema of the columns of the database and contains + /// methods to set and get properties of objects. + /// + public TableMapping GetMapping(Type type, CreateFlags createFlags = CreateFlags.None) + { + TableMapping map; + var key = type.FullName; + lock (_mappings) + { + if (_mappings.TryGetValue(key, out map)) + { + if (createFlags != CreateFlags.None && createFlags != map.CreateFlags) + { + map = new TableMapping(type, createFlags); + _mappings[key] = map; + } + } + else + { + map = new TableMapping(type, createFlags); + _mappings.Add(key, map); + } + } + return map; + } + + /// + /// Retrieves the mapping that is automatically generated for the given type. + /// + /// + /// Optional flags allowing implicit PK and indexes based on naming conventions + /// + /// + /// The mapping represents the schema of the columns of the database and contains + /// methods to set and get properties of objects. + /// + public TableMapping GetMapping(CreateFlags createFlags = CreateFlags.None) + { + return GetMapping(typeof(T), createFlags); + } + + private struct IndexedColumn + { + public int Order; + public string ColumnName; + } + + private struct IndexInfo + { + public string IndexName; + public string TableName; + public bool Unique; + public List Columns; + } + + /// + /// Executes a "drop table" on the database. This is non-recoverable. + /// + public int DropTable() + { + return DropTable(GetMapping(typeof(T))); + } + + /// + /// Executes a "drop table" on the database. This is non-recoverable. + /// + /// + /// The TableMapping used to identify the table. + /// + public int DropTable(TableMapping map) + { + var query = string.Format("drop table if exists \"{0}\"", map.TableName); + return Execute(query); + } + + /// + /// Executes a "create table if not exists" on the database. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated. + /// + public CreateTableResult CreateTable(CreateFlags createFlags = CreateFlags.None) + { + return CreateTable(typeof(T), createFlags); + } + + /// + /// Executes a "create table if not exists" on the database. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// Type to reflect to a database table. + /// Optional flags allowing implicit PK and indexes based on naming conventions. + /// + /// Whether the table was created or migrated. + /// + public CreateTableResult CreateTable(Type ty, CreateFlags createFlags = CreateFlags.None) + { + var map = GetMapping(ty, createFlags); + + // Present a nice error if no columns specified + if (map.Columns.Length == 0) + { + throw new Exception(string.Format("Cannot create a table without columns (does '{0}' have public properties?)", ty.FullName)); + } + + // Check if the table exists + var result = CreateTableResult.Created; + var existingCols = GetTableInfo(map.TableName); + + // Create or migrate it + if (existingCols.Count == 0) + { + + // Facilitate virtual tables a.k.a. full-text search. + bool fts3 = (createFlags & CreateFlags.FullTextSearch3) != 0; + bool fts4 = (createFlags & CreateFlags.FullTextSearch4) != 0; + bool fts = fts3 || fts4; + var @virtual = fts ? "virtual " : string.Empty; + var @using = fts3 ? "using fts3 " : fts4 ? "using fts4 " : string.Empty; + + // Build query. + var query = "create " + @virtual + "table if not exists \"" + map.TableName + "\" " + @using + "(\n"; + var decls = map.Columns.Select(p => Orm.SqlDecl(p, StoreDateTimeAsTicks)); + var decl = string.Join(",\n", decls.ToArray()); + query += decl; + query += ")"; + if (map.WithoutRowId) + { + query += " without rowid"; + } + + Execute(query); + } + else + { + result = CreateTableResult.Migrated; + MigrateTable(map, existingCols); + } + + var indexes = new Dictionary(); + foreach (var c in map.Columns) + { + foreach (var i in c.Indices) + { + var iname = i.Name ?? map.TableName + "_" + c.Name; + IndexInfo iinfo; + if (!indexes.TryGetValue(iname, out iinfo)) + { + iinfo = new IndexInfo + { + IndexName = iname, + TableName = map.TableName, + Unique = i.Unique, + Columns = new List() + }; + indexes.Add(iname, iinfo); + } + + if (i.Unique != iinfo.Unique) + throw new Exception("All the columns in an index must have the same value for their Unique property"); + + iinfo.Columns.Add(new IndexedColumn + { + Order = i.Order, + ColumnName = c.Name + }); + } + } + + foreach (var indexName in indexes.Keys) + { + var index = indexes[indexName]; + var columns = index.Columns.OrderBy(i => i.Order).Select(i => i.ColumnName).ToArray(); + CreateIndex(indexName, index.TableName, columns, index.Unique); + } + + return result; + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + { + return CreateTables(createFlags, typeof(T), typeof(T2)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + where T3 : new() + { + return CreateTables(createFlags, typeof(T), typeof(T2), typeof(T3)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + where T3 : new() + where T4 : new() + { + return CreateTables(createFlags, typeof(T), typeof(T2), typeof(T3), typeof(T4)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + where T3 : new() + where T4 : new() + where T5 : new() + { + return CreateTables(createFlags, typeof(T), typeof(T2), typeof(T3), typeof(T4), typeof(T5)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None, params Type[] types) + { + var result = new CreateTablesResult(); + foreach (Type type in types) + { + var aResult = CreateTable(type, createFlags); + result.Results[type] = aResult; + } + return result; + } + + /// + /// Creates an index for the specified table and columns. + /// + /// Name of the index to create + /// Name of the database table + /// An array of column names to index + /// Whether the index should be unique + public int CreateIndex(string indexName, string tableName, string[] columnNames, bool unique = false) + { + const string sqlFormat = "create {2} index if not exists \"{3}\" on \"{0}\"(\"{1}\")"; + var sql = String.Format(sqlFormat, tableName, string.Join("\", \"", columnNames), unique ? "unique" : "", indexName); + return Execute(sql); + } + + /// + /// Creates an index for the specified table and column. + /// + /// Name of the index to create + /// Name of the database table + /// Name of the column to index + /// Whether the index should be unique + public int CreateIndex(string indexName, string tableName, string columnName, bool unique = false) + { + return CreateIndex(indexName, tableName, new string[] { columnName }, unique); + } + + /// + /// Creates an index for the specified table and column. + /// + /// Name of the database table + /// Name of the column to index + /// Whether the index should be unique + public int CreateIndex(string tableName, string columnName, bool unique = false) + { + return CreateIndex(tableName + "_" + columnName, tableName, columnName, unique); + } + + /// + /// Creates an index for the specified table and columns. + /// + /// Name of the database table + /// An array of column names to index + /// Whether the index should be unique + public int CreateIndex(string tableName, string[] columnNames, bool unique = false) + { + return CreateIndex(tableName + "_" + string.Join("_", columnNames), tableName, columnNames, unique); + } + + /// + /// Creates an index for the specified object property. + /// e.g. CreateIndex<Client>(c => c.Name); + /// + /// Type to reflect to a database table. + /// Property to index + /// Whether the index should be unique + public int CreateIndex(Expression> property, bool unique = false) + { + MemberExpression mx; + if (property.Body.NodeType == ExpressionType.Convert) + { + mx = ((UnaryExpression)property.Body).Operand as MemberExpression; + } + else + { + mx = (property.Body as MemberExpression); + } + var propertyInfo = mx.Member as PropertyInfo; + if (propertyInfo == null) + { + throw new ArgumentException("The lambda expression 'property' should point to a valid Property"); + } + + var propName = propertyInfo.Name; + + var map = GetMapping(); + var colName = map.FindColumnWithPropertyName(propName).Name; + + return CreateIndex(map.TableName, colName, unique); + } + + [Preserve(AllMembers = true)] + public class ColumnInfo + { + // public int cid { get; set; } + + [Column("name")] + public string Name { get; set; } + + // [Column ("type")] + // public string ColumnType { get; set; } + + public int notnull { get; set; } + + // public string dflt_value { get; set; } + + // public int pk { get; set; } + + public override string ToString() + { + return Name; + } + } + + /// + /// Query the built-in sqlite table_info table for a specific tables columns. + /// + /// The columns contains in the table. + /// Table name. + public List GetTableInfo(string tableName) + { + var query = "pragma table_info(\"" + tableName + "\")"; + return Query(query); + } + + void MigrateTable(TableMapping map, List existingCols) + { + var toBeAdded = new List(); + + foreach (var p in map.Columns) + { + var found = false; + foreach (var c in existingCols) + { + found = (string.Compare(p.Name, c.Name, StringComparison.OrdinalIgnoreCase) == 0); + if (found) + break; + } + if (!found) + { + toBeAdded.Add(p); + } + } + + foreach (var p in toBeAdded) + { + var addCol = "alter table \"" + map.TableName + "\" add column " + Orm.SqlDecl(p, StoreDateTimeAsTicks); + Execute(addCol); + } + } + + /// + /// Creates a new SQLiteCommand. Can be overridden to provide a sub-class. + /// + /// + protected virtual SQLiteCommand NewCommand() + { + return new SQLiteCommand(this); + } + + /// + /// Creates a new SQLiteCommand given the command text with arguments. Place a '?' + /// in the command text for each of the arguments. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the command text. + /// + /// + /// A + /// + public SQLiteCommand CreateCommand(string cmdText, params object[] ps) + { + if (!_open) + throw SQLiteException.New(SQLite3.Result.Error, "Cannot create commands from unopened database"); + + var cmd = NewCommand(); + cmd.CommandText = cmdText; + foreach (var o in ps) + { + cmd.Bind(o); + } + return cmd; + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// Use this method instead of Query when you don't expect rows back. Such cases include + /// INSERTs, UPDATEs, and DELETEs. + /// You can set the Trace or TimeExecution properties of the connection + /// to profile execution. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The number of rows modified in the database as a result of this execution. + /// + public int Execute(string query, params object[] args) + { + var cmd = CreateCommand(query, args); + + if (TimeExecution) + { + if (_sw == null) + { + _sw = new Stopwatch(); + } + _sw.Reset(); + _sw.Start(); + } + + var r = cmd.ExecuteNonQuery(); + + if (TimeExecution) + { + _sw.Stop(); + _elapsedMilliseconds += _sw.ElapsedMilliseconds; + Tracer?.Invoke(string.Format("Finished in {0} ms ({1:0.0} s total)", _sw.ElapsedMilliseconds, _elapsedMilliseconds / 1000.0)); + } + + return r; + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// Use this method when return primitive values. + /// You can set the Trace or TimeExecution properties of the connection + /// to profile execution. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The number of rows modified in the database as a result of this execution. + /// + public T ExecuteScalar(string query, params object[] args) + { + var cmd = CreateCommand(query, args); + + if (TimeExecution) + { + if (_sw == null) + { + _sw = new Stopwatch(); + } + _sw.Reset(); + _sw.Start(); + } + + var r = cmd.ExecuteScalar(); + + if (TimeExecution) + { + _sw.Stop(); + _elapsedMilliseconds += _sw.ElapsedMilliseconds; + Tracer?.Invoke(string.Format("Finished in {0} ms ({1:0.0} s total)", _sw.ElapsedMilliseconds, _elapsedMilliseconds / 1000.0)); + } + + return r; + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the mapping automatically generated for + /// the given type. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// + public List Query(string query, params object[] args) where T : new() + { + var cmd = CreateCommand(query, args); + return cmd.ExecuteQuery(); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the mapping automatically generated for + /// the given type. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// The enumerator (retrieved by calling GetEnumerator() on the result of this method) + /// will call sqlite3_step on each call to MoveNext, so the database + /// connection must remain open for the lifetime of the enumerator. + /// + public IEnumerable DeferredQuery(string query, params object[] args) where T : new() + { + var cmd = CreateCommand(query, args); + return cmd.ExecuteDeferredQuery(); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the specified mapping. This function is + /// only used by libraries in order to query the database via introspection. It is + /// normally not used. + /// + /// + /// A to use to convert the resulting rows + /// into objects. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// + public List Query(TableMapping map, string query, params object[] args) + { + var cmd = CreateCommand(query, args); + return cmd.ExecuteQuery(map); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the specified mapping. This function is + /// only used by libraries in order to query the database via introspection. It is + /// normally not used. + /// + /// + /// A to use to convert the resulting rows + /// into objects. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// The enumerator (retrieved by calling GetEnumerator() on the result of this method) + /// will call sqlite3_step on each call to MoveNext, so the database + /// connection must remain open for the lifetime of the enumerator. + /// + public IEnumerable DeferredQuery(TableMapping map, string query, params object[] args) + { + var cmd = CreateCommand(query, args); + return cmd.ExecuteDeferredQuery(map); + } + + /// + /// Returns a queryable interface to the table represented by the given type. + /// + /// + /// A queryable object that is able to translate Where, OrderBy, and Take + /// queries into native SQL. + /// + public TableQuery Table() where T : new() + { + return new TableQuery(this); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The object with the given primary key. Throws a not found exception + /// if the object is not found. + /// + public T Get(object pk) where T : new() + { + var map = GetMapping(typeof(T)); + return Query(map.GetByPrimaryKeySql, pk).First(); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The object with the given primary key. Throws a not found exception + /// if the object is not found. + /// + public object Get(object pk, TableMapping map) + { + return Query(map, map.GetByPrimaryKeySql, pk).First(); + } + + /// + /// Attempts to retrieve the first object that matches the predicate from the table + /// associated with the specified type. + /// + /// + /// A predicate for which object to find. + /// + /// + /// The object that matches the given predicate. Throws a not found exception + /// if the object is not found. + /// + public T Get(Expression> predicate) where T : new() + { + return Table().Where(predicate).First(); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The object with the given primary key or null + /// if the object is not found. + /// + public T Find(object pk) where T : new() + { + var map = GetMapping(typeof(T)); + return Query(map.GetByPrimaryKeySql, pk).FirstOrDefault(); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The object with the given primary key or null + /// if the object is not found. + /// + public object Find(object pk, TableMapping map) + { + return Query(map, map.GetByPrimaryKeySql, pk).FirstOrDefault(); + } + + /// + /// Attempts to retrieve the first object that matches the predicate from the table + /// associated with the specified type. + /// + /// + /// A predicate for which object to find. + /// + /// + /// The object that matches the given predicate or null + /// if the object is not found. + /// + public T Find(Expression> predicate) where T : new() + { + return Table().Where(predicate).FirstOrDefault(); + } + + /// + /// Attempts to retrieve the first object that matches the query from the table + /// associated with the specified type. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The object that matches the given predicate or null + /// if the object is not found. + /// + public T FindWithQuery(string query, params object[] args) where T : new() + { + return Query(query, args).FirstOrDefault(); + } + + /// + /// Attempts to retrieve the first object that matches the query from the table + /// associated with the specified type. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The object that matches the given predicate or null + /// if the object is not found. + /// + public object FindWithQuery(TableMapping map, string query, params object[] args) + { + return Query(map, query, args).FirstOrDefault(); + } + + /// + /// Whether has been called and the database is waiting for a . + /// + public bool IsInTransaction + { + get { return _transactionDepth > 0; } + } + + /// + /// Begins a new transaction. Call to end the transaction. + /// + /// Throws if a transaction has already begun. + public void BeginTransaction() + { + // The BEGIN command only works if the transaction stack is empty, + // or in other words if there are no pending transactions. + // If the transaction stack is not empty when the BEGIN command is invoked, + // then the command fails with an error. + // Rather than crash with an error, we will just ignore calls to BeginTransaction + // that would result in an error. + if (Interlocked.CompareExchange(ref _transactionDepth, 1, 0) == 0) + { + try + { + Execute("begin transaction"); + } + catch (Exception ex) + { + var sqlExp = ex as SQLiteException; + if (sqlExp != null) + { + // It is recommended that applications respond to the errors listed below + // by explicitly issuing a ROLLBACK command. + // TODO: This rollback failsafe should be localized to all throw sites. + switch (sqlExp.Result) + { + case SQLite3.Result.IOError: + case SQLite3.Result.Full: + case SQLite3.Result.Busy: + case SQLite3.Result.NoMem: + case SQLite3.Result.Interrupt: + RollbackTo(null, true); + break; + } + } + else + { + // Call decrement and not VolatileWrite in case we've already + // created a transaction point in SaveTransactionPoint since the catch. + Interlocked.Decrement(ref _transactionDepth); + } + + throw; + } + } + else + { + // Calling BeginTransaction on an already open transaction is invalid + throw new InvalidOperationException("Cannot begin a transaction while already in a transaction."); + } + } + + /// + /// Creates a savepoint in the database at the current point in the transaction timeline. + /// Begins a new transaction if one is not in progress. + /// + /// Call to undo transactions since the returned savepoint. + /// Call to commit transactions after the savepoint returned here. + /// Call to end the transaction, committing all changes. + /// + /// A string naming the savepoint. + public string SaveTransactionPoint() + { + int depth = Interlocked.Increment(ref _transactionDepth) - 1; + string retVal = "S" + _rand.Next(short.MaxValue) + "D" + depth; + + try + { + Execute("savepoint " + retVal); + } + catch (Exception ex) + { + var sqlExp = ex as SQLiteException; + if (sqlExp != null) + { + // It is recommended that applications respond to the errors listed below + // by explicitly issuing a ROLLBACK command. + // TODO: This rollback failsafe should be localized to all throw sites. + switch (sqlExp.Result) + { + case SQLite3.Result.IOError: + case SQLite3.Result.Full: + case SQLite3.Result.Busy: + case SQLite3.Result.NoMem: + case SQLite3.Result.Interrupt: + RollbackTo(null, true); + break; + } + } + else + { + Interlocked.Decrement(ref _transactionDepth); + } + + throw; + } + + return retVal; + } + + /// + /// Rolls back the transaction that was begun by or . + /// + public void Rollback() + { + RollbackTo(null, false); + } + + /// + /// Rolls back the savepoint created by or SaveTransactionPoint. + /// + /// The name of the savepoint to roll back to, as returned by . If savepoint is null or empty, this method is equivalent to a call to + public void RollbackTo(string savepoint) + { + RollbackTo(savepoint, false); + } + + /// + /// Rolls back the transaction that was begun by . + /// + /// The name of the savepoint to roll back to, as returned by . If savepoint is null or empty, this method is equivalent to a call to + /// true to avoid throwing exceptions, false otherwise + void RollbackTo(string savepoint, bool noThrow) + { + // Rolling back without a TO clause rolls backs all transactions + // and leaves the transaction stack empty. + try + { + if (String.IsNullOrEmpty(savepoint)) + { + if (Interlocked.Exchange(ref _transactionDepth, 0) > 0) + { + Execute("rollback"); + } + } + else + { + DoSavePointExecute(savepoint, "rollback to "); + } + } + catch (SQLiteException) + { + if (!noThrow) + throw; + + } + // No need to rollback if there are no transactions open. + } + + /// + /// Releases a savepoint returned from . Releasing a savepoint + /// makes changes since that savepoint permanent if the savepoint began the transaction, + /// or otherwise the changes are permanent pending a call to . + /// + /// The RELEASE command is like a COMMIT for a SAVEPOINT. + /// + /// The name of the savepoint to release. The string should be the result of a call to + public void Release(string savepoint) + { + try + { + DoSavePointExecute(savepoint, "release "); + } + catch (SQLiteException ex) + { + if (ex.Result == SQLite3.Result.Busy) + { + // Force a rollback since most people don't know this function can fail + // Don't call Rollback() since the _transactionDepth is 0 and it won't try + // Calling rollback makes our _transactionDepth variable correct. + // Writes to the database only happen at depth=0, so this failure will only happen then. + try + { + Execute("rollback"); + } + catch + { + // rollback can fail in all sorts of wonderful version-dependent ways. Let's just hope for the best + } + } + throw; + } + } + + void DoSavePointExecute(string savepoint, string cmd) + { + // Validate the savepoint + int firstLen = savepoint.IndexOf('D'); + if (firstLen >= 2 && savepoint.Length > firstLen + 1) + { + int depth; + if (Int32.TryParse(savepoint.Substring(firstLen + 1), out depth)) + { + // TODO: Mild race here, but inescapable without locking almost everywhere. + if (0 <= depth && depth < _transactionDepth) + { +#if NETFX_CORE || USE_SQLITEPCL_RAW || NETCORE + Volatile.Write (ref _transactionDepth, depth); +#elif SILVERLIGHT + _transactionDepth = depth; +#else + Thread.VolatileWrite(ref _transactionDepth, depth); +#endif + Execute(cmd + savepoint); + return; + } + } + } + + throw new ArgumentException("savePoint is not valid, and should be the result of a call to SaveTransactionPoint.", "savePoint"); + } + + /// + /// Commits the transaction that was begun by . + /// + public void Commit() + { + if (Interlocked.Exchange(ref _transactionDepth, 0) != 0) + { + try + { + Execute("commit"); + } + catch + { + // Force a rollback since most people don't know this function can fail + // Don't call Rollback() since the _transactionDepth is 0 and it won't try + // Calling rollback makes our _transactionDepth variable correct. + try + { + Execute("rollback"); + } + catch + { + // rollback can fail in all sorts of wonderful version-dependent ways. Let's just hope for the best + } + throw; + } + } + // Do nothing on a commit with no open transaction + } + + /// + /// Executes within a (possibly nested) transaction by wrapping it in a SAVEPOINT. If an + /// exception occurs the whole transaction is rolled back, not just the current savepoint. The exception + /// is rethrown. + /// + /// + /// The to perform within a transaction. can contain any number + /// of operations on the connection but should never call or + /// . + /// + public void RunInTransaction(Action action) + { + try + { + var savePoint = SaveTransactionPoint(); + action(); + Release(savePoint); + } + catch (Exception) + { + Rollback(); + throw; + } + } + + /// + /// Inserts all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// A boolean indicating if the inserts should be wrapped in a transaction. + /// + /// + /// The number of rows added to the table. + /// + public int InsertAll(System.Collections.IEnumerable objects, bool runInTransaction = true) + { + var c = 0; + if (runInTransaction) + { + RunInTransaction(() => { + foreach (var r in objects) + { + c += Insert(r); + } + }); + } + else + { + foreach (var r in objects) + { + c += Insert(r); + } + } + return c; + } + + /// + /// Inserts all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// + /// Literal SQL code that gets placed into the command. INSERT {extra} INTO ... + /// + /// + /// A boolean indicating if the inserts should be wrapped in a transaction. + /// + /// + /// The number of rows added to the table. + /// + public int InsertAll(System.Collections.IEnumerable objects, string extra, bool runInTransaction = true) + { + var c = 0; + if (runInTransaction) + { + RunInTransaction(() => { + foreach (var r in objects) + { + c += Insert(r, extra); + } + }); + } + else + { + foreach (var r in objects) + { + c += Insert(r); + } + } + return c; + } + + /// + /// Inserts all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// + /// The type of object to insert. + /// + /// + /// A boolean indicating if the inserts should be wrapped in a transaction. + /// + /// + /// The number of rows added to the table. + /// + public int InsertAll(System.Collections.IEnumerable objects, Type objType, bool runInTransaction = true) + { + var c = 0; + if (runInTransaction) + { + RunInTransaction(() => { + foreach (var r in objects) + { + c += Insert(r, objType); + } + }); + } + else + { + foreach (var r in objects) + { + c += Insert(r, objType); + } + } + return c; + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// + /// + /// The object to insert. + /// + /// + /// The number of rows added to the table. + /// + public int Insert(object obj) + { + if (obj == null) + { + return 0; + } + return Insert(obj, "", Orm.GetType(obj)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// If a UNIQUE constraint violation occurs with + /// some pre-existing object, this function deletes + /// the old object. + /// + /// + /// The object to insert. + /// + /// + /// The number of rows modified. + /// + public int InsertOrReplace(object obj) + { + if (obj == null) + { + return 0; + } + return Insert(obj, "OR REPLACE", Orm.GetType(obj)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// + /// + /// The object to insert. + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows added to the table. + /// + public int Insert(object obj, Type objType) + { + return Insert(obj, "", objType); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// If a UNIQUE constraint violation occurs with + /// some pre-existing object, this function deletes + /// the old object. + /// + /// + /// The object to insert. + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows modified. + /// + public int InsertOrReplace(object obj, Type objType) + { + return Insert(obj, "OR REPLACE", objType); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// + /// + /// The object to insert. + /// + /// + /// Literal SQL code that gets placed into the command. INSERT {extra} INTO ... + /// + /// + /// The number of rows added to the table. + /// + public int Insert(object obj, string extra) + { + if (obj == null) + { + return 0; + } + return Insert(obj, extra, Orm.GetType(obj)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// + /// + /// The object to insert. + /// + /// + /// Literal SQL code that gets placed into the command. INSERT {extra} INTO ... + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows added to the table. + /// + public int Insert(object obj, string extra, Type objType) + { + if (obj == null || objType == null) + { + return 0; + } + + var map = GetMapping(objType); + + if (map.PK != null && map.PK.IsAutoGuid) + { + if (map.PK.GetValue(obj).Equals(Guid.Empty)) + { + map.PK.SetValue(obj, Guid.NewGuid()); + } + } + + var replacing = string.Compare(extra, "OR REPLACE", StringComparison.OrdinalIgnoreCase) == 0; + + var cols = replacing ? map.InsertOrReplaceColumns : map.InsertColumns; + var vals = new object[cols.Length]; + for (var i = 0; i < vals.Length; i++) + { + vals[i] = cols[i].GetValue(obj); + } + + var insertCmd = GetInsertCommand(map, extra); + int count; + + lock (insertCmd) + { + // We lock here to protect the prepared statement returned via GetInsertCommand. + // A SQLite prepared statement can be bound for only one operation at a time. + try + { + count = insertCmd.ExecuteNonQuery(vals); + } + catch (SQLiteException ex) + { + if (SQLite3.ExtendedErrCode(this.Handle) == SQLite3.ExtendedResult.ConstraintNotNull) + { + throw NotNullConstraintViolationException.New(ex.Result, ex.Message, map, obj); + } + throw; + } + + if (map.HasAutoIncPK) + { + var id = SQLite3.LastInsertRowid(Handle); + map.SetAutoIncPK(obj, id); + } + } + if (count > 0) + OnTableChanged(map, NotifyTableChangedAction.Insert); + + return count; + } + + readonly Dictionary, PreparedSqlLiteInsertCommand> _insertCommandMap = new Dictionary, PreparedSqlLiteInsertCommand>(); + + PreparedSqlLiteInsertCommand GetInsertCommand(TableMapping map, string extra) + { + PreparedSqlLiteInsertCommand prepCmd; + + var key = Tuple.Create(map.MappedType.FullName, extra); + + lock (_insertCommandMap) + { + _insertCommandMap.TryGetValue(key, out prepCmd); + } + + if (prepCmd == null) + { + prepCmd = CreateInsertCommand(map, extra); + var added = false; + lock (_insertCommandMap) + { + if (!_insertCommandMap.ContainsKey(key)) + { + _insertCommandMap.Add(key, prepCmd); + added = true; + } + } + if (!added) + { + prepCmd.Dispose(); + } + } + + return prepCmd; + } + + PreparedSqlLiteInsertCommand CreateInsertCommand(TableMapping map, string extra) + { + var cols = map.InsertColumns; + string insertSql; + if (cols.Length == 0 && map.Columns.Length == 1 && map.Columns[0].IsAutoInc) + { + insertSql = string.Format("insert {1} into \"{0}\" default values", map.TableName, extra); + } + else + { + var replacing = string.Compare(extra, "OR REPLACE", StringComparison.OrdinalIgnoreCase) == 0; + + if (replacing) + { + cols = map.InsertOrReplaceColumns; + } + + insertSql = string.Format("insert {3} into \"{0}\"({1}) values ({2})", map.TableName, + string.Join(",", (from c in cols + select "\"" + c.Name + "\"").ToArray()), + string.Join(",", (from c in cols + select "?").ToArray()), extra); + + } + + var insertCommand = new PreparedSqlLiteInsertCommand(this, insertSql); + return insertCommand; + } + + /// + /// Updates all of the columns of a table using the specified object + /// except for its primary key. + /// The object is required to have a primary key. + /// + /// + /// The object to update. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The number of rows updated. + /// + public int Update(object obj) + { + if (obj == null) + { + return 0; + } + return Update(obj, Orm.GetType(obj)); + } + + /// + /// Updates all of the columns of a table using the specified object + /// except for its primary key. + /// The object is required to have a primary key. + /// + /// + /// The object to update. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows updated. + /// + public int Update(object obj, Type objType) + { + int rowsAffected = 0; + if (obj == null || objType == null) + { + return 0; + } + + var map = GetMapping(objType); + + var pk = map.PK; + + if (pk == null) + { + throw new NotSupportedException("Cannot update " + map.TableName + ": it has no PK"); + } + + var cols = from p in map.Columns + where p != pk + select p; + var vals = from c in cols + select c.GetValue(obj); + var ps = new List(vals); + if (ps.Count == 0) + { + // There is a PK but no accompanying data, + // so reset the PK to make the UPDATE work. + cols = map.Columns; + vals = from c in cols + select c.GetValue(obj); + ps = new List(vals); + } + ps.Add(pk.GetValue(obj)); + var q = string.Format("update \"{0}\" set {1} where {2} = ? ", map.TableName, string.Join(",", (from c in cols + select "\"" + c.Name + "\" = ? ").ToArray()), pk.Name); + + try + { + rowsAffected = Execute(q, ps.ToArray()); + } + catch (SQLiteException ex) + { + + if (ex.Result == SQLite3.Result.Constraint && SQLite3.ExtendedErrCode(this.Handle) == SQLite3.ExtendedResult.ConstraintNotNull) + { + throw NotNullConstraintViolationException.New(ex, map, obj); + } + + throw ex; + } + + if (rowsAffected > 0) + OnTableChanged(map, NotifyTableChangedAction.Update); + + return rowsAffected; + } + + /// + /// Updates all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// + /// A boolean indicating if the inserts should be wrapped in a transaction + /// + /// + /// The number of rows modified. + /// + public int UpdateAll(System.Collections.IEnumerable objects, bool runInTransaction = true) + { + var c = 0; + if (runInTransaction) + { + RunInTransaction(() => { + foreach (var r in objects) + { + c += Update(r); + } + }); + } + else + { + foreach (var r in objects) + { + c += Update(r); + } + } + return c; + } + + /// + /// Deletes the given object from the database using its primary key. + /// + /// + /// The object to delete. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The number of rows deleted. + /// + public int Delete(object objectToDelete) + { + var map = GetMapping(Orm.GetType(objectToDelete)); + var pk = map.PK; + if (pk == null) + { + throw new NotSupportedException("Cannot delete " + map.TableName + ": it has no PK"); + } + var q = string.Format("delete from \"{0}\" where \"{1}\" = ?", map.TableName, pk.Name); + var count = Execute(q, pk.GetValue(objectToDelete)); + if (count > 0) + OnTableChanged(map, NotifyTableChangedAction.Delete); + return count; + } + + /// + /// Deletes the object with the specified primary key. + /// + /// + /// The primary key of the object to delete. + /// + /// + /// The number of objects deleted. + /// + /// + /// The type of object. + /// + public int Delete(object primaryKey) + { + return Delete(primaryKey, GetMapping(typeof(T))); + } + + /// + /// Deletes the object with the specified primary key. + /// + /// + /// The primary key of the object to delete. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The number of objects deleted. + /// + public int Delete(object primaryKey, TableMapping map) + { + var pk = map.PK; + if (pk == null) + { + throw new NotSupportedException("Cannot delete " + map.TableName + ": it has no PK"); + } + var q = string.Format("delete from \"{0}\" where \"{1}\" = ?", map.TableName, pk.Name); + var count = Execute(q, primaryKey); + if (count > 0) + OnTableChanged(map, NotifyTableChangedAction.Delete); + return count; + } + + /// + /// Deletes all the objects from the specified table. + /// WARNING WARNING: Let me repeat. It deletes ALL the objects from the + /// specified table. Do you really want to do that? + /// + /// + /// The number of objects deleted. + /// + /// + /// The type of objects to delete. + /// + public int DeleteAll() + { + var map = GetMapping(typeof(T)); + return DeleteAll(map); + } + + /// + /// Deletes all the objects from the specified table. + /// WARNING WARNING: Let me repeat. It deletes ALL the objects from the + /// specified table. Do you really want to do that? + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The number of objects deleted. + /// + public int DeleteAll(TableMapping map) + { + var query = string.Format("delete from \"{0}\"", map.TableName); + var count = Execute(query); + if (count > 0) + OnTableChanged(map, NotifyTableChangedAction.Delete); + return count; + } + + ~SQLiteConnection() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public void Close() + { + Dispose(true); + } + + protected virtual void Dispose(bool disposing) + { + var useClose2 = LibVersionNumber >= 3007014; + + if (_open && Handle != NullHandle) + { + try + { + if (disposing) + { + lock (_insertCommandMap) + { + foreach (var sqlInsertCommand in _insertCommandMap.Values) + { + sqlInsertCommand.Dispose(); + } + _insertCommandMap.Clear(); + } + + var r = useClose2 ? SQLite3.Close2(Handle) : SQLite3.Close(Handle); + if (r != SQLite3.Result.OK) + { + string msg = SQLite3.GetErrmsg(Handle); + throw SQLiteException.New(r, msg); + } + } + else + { + var r = useClose2 ? SQLite3.Close2(Handle) : SQLite3.Close(Handle); + } + } + finally + { + Handle = NullHandle; + _open = false; + } + } + } + + void OnTableChanged(TableMapping table, NotifyTableChangedAction action) + { + var ev = TableChanged; + if (ev != null) + ev(this, new NotifyTableChangedEventArgs(table, action)); + } + + public event EventHandler TableChanged; + } + + public class NotifyTableChangedEventArgs : EventArgs + { + public TableMapping Table { get; private set; } + public NotifyTableChangedAction Action { get; private set; } + + public NotifyTableChangedEventArgs(TableMapping table, NotifyTableChangedAction action) + { + Table = table; + Action = action; + } + } + + public enum NotifyTableChangedAction + { + Insert, + Update, + Delete, + } + + /// + /// Represents a parsed connection string. + /// + public class SQLiteConnectionString + { + public string ConnectionString { get; private set; } + public string DatabasePath { get; private set; } + public bool StoreDateTimeAsTicks { get; private set; } + public object Key { get; private set; } + +#if NETFX_CORE + static readonly string MetroStyleDataPath = Windows.Storage.ApplicationData.Current.LocalFolder.Path; + + public static readonly string[] InMemoryDbPaths = new[] + { + ":memory:", + "file::memory:" + }; + + public static bool IsInMemoryPath(string databasePath) + { + return InMemoryDbPaths.Any(i => i.Equals(databasePath, StringComparison.OrdinalIgnoreCase)); + } + +#endif + + public SQLiteConnectionString(string databasePath, bool storeDateTimeAsTicks, object key) + { + ConnectionString = databasePath; + StoreDateTimeAsTicks = storeDateTimeAsTicks; + Key = key; + +#if NETFX_CORE + DatabasePath = IsInMemoryPath(databasePath) + ? databasePath + : System.IO.Path.Combine(MetroStyleDataPath, databasePath); + +#else + DatabasePath = databasePath; +#endif + } + } + + [AttributeUsage(AttributeTargets.Class)] + public class TableAttribute : Attribute + { + public string Name { get; set; } + + /// + /// Flag whether to create the table without rowid (see https://sqlite.org/withoutrowid.html) + /// + /// The default is false so that sqlite adds an implicit rowid to every table created. + /// + public bool WithoutRowId { get; set; } + + public TableAttribute(string name) + { + Name = name; + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class ColumnAttribute : Attribute + { + public string Name { get; set; } + + public ColumnAttribute(string name) + { + Name = name; + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class PrimaryKeyAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property)] + public class AutoIncrementAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property)] + public class IndexedAttribute : Attribute + { + public string Name { get; set; } + public int Order { get; set; } + public virtual bool Unique { get; set; } + + public IndexedAttribute() + { + } + + public IndexedAttribute(string name, int order) + { + Name = name; + Order = order; + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class IgnoreAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property)] + public class UniqueAttribute : IndexedAttribute + { + public override bool Unique + { + get { return true; } + set { /* throw? */ } + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class MaxLengthAttribute : Attribute + { + public int Value { get; private set; } + + public MaxLengthAttribute(int length) + { + Value = length; + } + } + + public sealed class PreserveAttribute : System.Attribute + { + public bool AllMembers; + public bool Conditional; + } + + /// + /// Select the collating sequence to use on a column. + /// "BINARY", "NOCASE", and "RTRIM" are supported. + /// "BINARY" is the default. + /// + [AttributeUsage(AttributeTargets.Property)] + public class CollationAttribute : Attribute + { + public string Value { get; private set; } + + public CollationAttribute(string collation) + { + Value = collation; + } + } + + [AttributeUsage(AttributeTargets.Property)] + public class NotNullAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Enum)] + public class StoreAsTextAttribute : Attribute + { + } + + public class TableMapping + { + public Type MappedType { get; private set; } + + public string TableName { get; private set; } + + public bool WithoutRowId { get; private set; } + + public Column[] Columns { get; private set; } + + public Column PK { get; private set; } + + public string GetByPrimaryKeySql { get; private set; } + + public CreateFlags CreateFlags { get; private set; } + + readonly Column _autoPk; + readonly Column[] _insertColumns; + readonly Column[] _insertOrReplaceColumns; + + public TableMapping(Type type, CreateFlags createFlags = CreateFlags.None) + { + MappedType = type; + CreateFlags = createFlags; + + var typeInfo = type.GetTypeInfo(); + var tableAttr = + typeInfo.CustomAttributes + .Where(x => x.AttributeType == typeof(TableAttribute)) + .Select(x => (TableAttribute)Orm.InflateAttribute(x)) + .FirstOrDefault(); + + TableName = (tableAttr != null && !string.IsNullOrEmpty(tableAttr.Name)) ? tableAttr.Name : MappedType.Name; + WithoutRowId = tableAttr != null ? tableAttr.WithoutRowId : false; + + var props = new List(); + var baseType = type; + var propNames = new HashSet(); + while (baseType != typeof(object)) + { + var ti = baseType.GetTypeInfo(); + var newProps = ( + from p in ti.DeclaredProperties + where + !propNames.Contains(p.Name) && + p.CanRead && p.CanWrite && + (p.GetMethod != null) && (p.SetMethod != null) && + (p.GetMethod.IsPublic && p.SetMethod.IsPublic) && + (!p.GetMethod.IsStatic) && (!p.SetMethod.IsStatic) + select p).ToList(); + foreach (var p in newProps) + { + propNames.Add(p.Name); + } + props.AddRange(newProps); + baseType = ti.BaseType; + } + + var cols = new List(); + foreach (var p in props) + { + var ignore = p.IsDefined(typeof(IgnoreAttribute), true); + if (!ignore) + { + cols.Add(new Column(p, createFlags)); + } + } + Columns = cols.ToArray(); + foreach (var c in Columns) + { + if (c.IsAutoInc && c.IsPK) + { + _autoPk = c; + } + if (c.IsPK) + { + PK = c; + } + } + + HasAutoIncPK = _autoPk != null; + + if (PK != null) + { + GetByPrimaryKeySql = string.Format("select * from \"{0}\" where \"{1}\" = ?", TableName, PK.Name); + } + else + { + // People should not be calling Get/Find without a PK + GetByPrimaryKeySql = string.Format("select * from \"{0}\" limit 1", TableName); + } + + _insertColumns = Columns.Where(c => !c.IsAutoInc).ToArray(); + _insertOrReplaceColumns = Columns.ToArray(); + } + + public bool HasAutoIncPK { get; private set; } + + public void SetAutoIncPK(object obj, long id) + { + if (_autoPk != null) + { + _autoPk.SetValue(obj, Convert.ChangeType(id, _autoPk.ColumnType, null)); + } + } + + public Column[] InsertColumns + { + get + { + return _insertColumns; + } + } + + public Column[] InsertOrReplaceColumns + { + get + { + return _insertOrReplaceColumns; + } + } + + public Column FindColumnWithPropertyName(string propertyName) + { + var exact = Columns.FirstOrDefault(c => c.PropertyName == propertyName); + return exact; + } + + public Column FindColumn(string columnName) + { + var exact = Columns.FirstOrDefault(c => c.Name.ToLower() == columnName.ToLower()); + return exact; + } + + public class Column + { + PropertyInfo _prop; + + public string Name { get; private set; } + + public PropertyInfo PropertyInfo => _prop; + + public string PropertyName { get { return _prop.Name; } } + + public Type ColumnType { get; private set; } + + public string Collation { get; private set; } + + public bool IsAutoInc { get; private set; } + public bool IsAutoGuid { get; private set; } + + public bool IsPK { get; private set; } + + public IEnumerable Indices { get; set; } + + public bool IsNullable { get; private set; } + + public int? MaxStringLength { get; private set; } + + public bool StoreAsText { get; private set; } + + public Column(PropertyInfo prop, CreateFlags createFlags = CreateFlags.None) + { + var colAttr = prop.CustomAttributes.FirstOrDefault(x => x.AttributeType == typeof(ColumnAttribute)); + + _prop = prop; + Name = (colAttr != null && colAttr.ConstructorArguments.Count > 0) ? + colAttr.ConstructorArguments[0].Value?.ToString() : + prop.Name; + //If this type is Nullable then Nullable.GetUnderlyingType returns the T, otherwise it returns null, so get the actual type instead + ColumnType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; + Collation = Orm.Collation(prop); + + IsPK = Orm.IsPK(prop) || + (((createFlags & CreateFlags.ImplicitPK) == CreateFlags.ImplicitPK) && + string.Compare(prop.Name, Orm.ImplicitPkName, StringComparison.OrdinalIgnoreCase) == 0); + + var isAuto = Orm.IsAutoInc(prop) || (IsPK && ((createFlags & CreateFlags.AutoIncPK) == CreateFlags.AutoIncPK)); + IsAutoGuid = isAuto && ColumnType == typeof(Guid); + IsAutoInc = isAuto && !IsAutoGuid; + + Indices = Orm.GetIndices(prop); + if (!Indices.Any() + && !IsPK + && ((createFlags & CreateFlags.ImplicitIndex) == CreateFlags.ImplicitIndex) + && Name.EndsWith(Orm.ImplicitIndexSuffix, StringComparison.OrdinalIgnoreCase) + ) + { + Indices = new IndexedAttribute[] { new IndexedAttribute() }; + } + IsNullable = !(IsPK || Orm.IsMarkedNotNull(prop)); + MaxStringLength = Orm.MaxStringLength(prop); + + StoreAsText = prop.PropertyType.GetTypeInfo().CustomAttributes.Any(x => x.AttributeType == typeof(StoreAsTextAttribute)); + } + + public void SetValue(object obj, object val) + { + if (val != null && ColumnType.GetTypeInfo().IsEnum) + { + _prop.SetValue(obj, Enum.ToObject(ColumnType, val)); + } + else + { + _prop.SetValue(obj, val, null); + } + } + + public object GetValue(object obj) + { + return _prop.GetValue(obj, null); + } + } + } + + class EnumCacheInfo + { + public EnumCacheInfo(Type type) + { + var typeInfo = type.GetTypeInfo(); + + IsEnum = typeInfo.IsEnum; + + if (IsEnum) + { + StoreAsText = typeInfo.CustomAttributes.Any(x => x.AttributeType == typeof(StoreAsTextAttribute)); + + if (StoreAsText) + { + EnumValues = new Dictionary(); + foreach (object e in Enum.GetValues(type)) + { + EnumValues[Convert.ToInt32(e)] = e.ToString(); + } + } + } + } + + public bool IsEnum { get; private set; } + + public bool StoreAsText { get; private set; } + + public Dictionary EnumValues { get; private set; } + } + + static class EnumCache + { + static readonly Dictionary Cache = new Dictionary(); + + public static EnumCacheInfo GetInfo() + { + return GetInfo(typeof(T)); + } + + public static EnumCacheInfo GetInfo(Type type) + { + lock (Cache) + { + EnumCacheInfo info = null; + if (!Cache.TryGetValue(type, out info)) + { + info = new EnumCacheInfo(type); + Cache[type] = info; + } + + return info; + } + } + } + + public static class Orm + { + public const int DefaultMaxStringLength = 140; + public const string ImplicitPkName = "Id"; + public const string ImplicitIndexSuffix = "Id"; + + public static Type GetType(object obj) + { + if (obj == null) + return typeof(object); + var rt = obj as IReflectableType; + if (rt != null) + return rt.GetTypeInfo().AsType(); + return obj.GetType(); + } + + public static string SqlDecl(TableMapping.Column p, bool storeDateTimeAsTicks) + { + string decl = "\"" + p.Name + "\" " + SqlType(p, storeDateTimeAsTicks) + " "; + + if (p.IsPK) + { + decl += "primary key "; + } + if (p.IsAutoInc) + { + decl += "autoincrement "; + } + if (!p.IsNullable) + { + decl += "not null "; + } + if (!string.IsNullOrEmpty(p.Collation)) + { + decl += "collate " + p.Collation + " "; + } + + return decl; + } + + public static string SqlType(TableMapping.Column p, bool storeDateTimeAsTicks) + { + var clrType = p.ColumnType; + if (clrType == typeof(Boolean) || clrType == typeof(Byte) || clrType == typeof(UInt16) || clrType == typeof(SByte) || clrType == typeof(Int16) || clrType == typeof(Int32) || clrType == typeof(UInt32) || clrType == typeof(Int64)) + { + return "integer"; + } + else if (clrType == typeof(Single) || clrType == typeof(Double) || clrType == typeof(Decimal)) + { + return "float"; + } + else if (clrType == typeof(String) || clrType == typeof(StringBuilder) || clrType == typeof(Uri) || clrType == typeof(UriBuilder)) + { + int? len = p.MaxStringLength; + + if (len.HasValue) + return "varchar(" + len.Value + ")"; + + return "varchar"; + } + else if (clrType == typeof(TimeSpan)) + { + return "bigint"; + } + else if (clrType == typeof(DateTime)) + { + return storeDateTimeAsTicks ? "bigint" : "datetime"; + } + else if (clrType == typeof(DateTimeOffset)) + { + return "bigint"; + } + else if (clrType.GetTypeInfo().IsEnum) + { + if (p.StoreAsText) + return "varchar"; + else + return "integer"; + } + else if (clrType == typeof(byte[])) + { + return "blob"; + } + else if (clrType == typeof(Guid)) + { + return "varchar(36)"; + } + else + { + throw new NotSupportedException("Don't know about " + clrType); + } + } + + public static bool IsPK(MemberInfo p) + { + return p.CustomAttributes.Any(x => x.AttributeType == typeof(PrimaryKeyAttribute)); + } + + public static string Collation(MemberInfo p) + { + return + (p.CustomAttributes + .Where(x => typeof(CollationAttribute) == x.AttributeType) + .Select(x => { + var args = x.ConstructorArguments; + return args.Count > 0 ? ((args[0].Value as string) ?? "") : ""; + }) + .FirstOrDefault()) ?? ""; + } + + public static bool IsAutoInc(MemberInfo p) + { + return p.CustomAttributes.Any(x => x.AttributeType == typeof(AutoIncrementAttribute)); + } + + public static FieldInfo GetField(TypeInfo t, string name) + { + var f = t.GetDeclaredField(name); + if (f != null) + return f; + return GetField(t.BaseType.GetTypeInfo(), name); + } + + public static PropertyInfo GetProperty(TypeInfo t, string name) + { + var f = t.GetDeclaredProperty(name); + if (f != null) + return f; + return GetProperty(t.BaseType.GetTypeInfo(), name); + } + + public static object InflateAttribute(CustomAttributeData x) + { + var atype = x.AttributeType; + var typeInfo = atype.GetTypeInfo(); + var args = x.ConstructorArguments.Select(a => a.Value).ToArray(); + var r = Activator.CreateInstance(x.AttributeType, args); + foreach (var arg in x.NamedArguments) + { + if (arg.IsField) + { + GetField(typeInfo, arg.MemberName).SetValue(r, arg.TypedValue.Value); + } + else + { + GetProperty(typeInfo, arg.MemberName).SetValue(r, arg.TypedValue.Value); + } + } + return r; + } + + public static IEnumerable GetIndices(MemberInfo p) + { + var indexedInfo = typeof(IndexedAttribute).GetTypeInfo(); + return + p.CustomAttributes + .Where(x => indexedInfo.IsAssignableFrom(x.AttributeType.GetTypeInfo())) + .Select(x => (IndexedAttribute)InflateAttribute(x)); + } + + public static int? MaxStringLength(PropertyInfo p) + { + var attr = p.CustomAttributes.FirstOrDefault(x => x.AttributeType == typeof(MaxLengthAttribute)); + if (attr != null) + { + var attrv = (MaxLengthAttribute)InflateAttribute(attr); + return attrv.Value; + } + return null; + } + + public static bool IsMarkedNotNull(MemberInfo p) + { + return p.CustomAttributes.Any(x => x.AttributeType == typeof(NotNullAttribute)); + } + } + + public partial class SQLiteCommand + { + SQLiteConnection _conn; + private List _bindings; + + public string CommandText { get; set; } + + public SQLiteCommand(SQLiteConnection conn) + { + _conn = conn; + _bindings = new List(); + CommandText = ""; + } + + public int ExecuteNonQuery() + { + if (_conn.Trace) + { + _conn.Tracer?.Invoke("Executing: " + this); + } + + var r = SQLite3.Result.OK; + var stmt = Prepare(); + r = SQLite3.Step(stmt); + Finalize(stmt); + if (r == SQLite3.Result.Done) + { + int rowsAffected = SQLite3.Changes(_conn.Handle); + return rowsAffected; + } + else if (r == SQLite3.Result.Error) + { + string msg = SQLite3.GetErrmsg(_conn.Handle); + throw SQLiteException.New(r, msg); + } + else if (r == SQLite3.Result.Constraint) + { + if (SQLite3.ExtendedErrCode(_conn.Handle) == SQLite3.ExtendedResult.ConstraintNotNull) + { + throw NotNullConstraintViolationException.New(r, SQLite3.GetErrmsg(_conn.Handle)); + } + } + + throw SQLiteException.New(r, r.ToString()); + } + + public IEnumerable ExecuteDeferredQuery() + { + return ExecuteDeferredQuery(_conn.GetMapping(typeof(T))); + } + + public List ExecuteQuery() + { + return ExecuteDeferredQuery(_conn.GetMapping(typeof(T))).ToList(); + } + + public List ExecuteQuery(TableMapping map) + { + return ExecuteDeferredQuery(map).ToList(); + } + + /// + /// Invoked every time an instance is loaded from the database. + /// + /// + /// The newly created object. + /// + /// + /// This can be overridden in combination with the + /// method to hook into the life-cycle of objects. + /// + protected virtual void OnInstanceCreated(object obj) + { + // Can be overridden. + } + + public IEnumerable ExecuteDeferredQuery(TableMapping map) + { + if (_conn.Trace) + { + _conn.Tracer?.Invoke("Executing Query: " + this); + } + + var stmt = Prepare(); + try + { + var cols = new TableMapping.Column[SQLite3.ColumnCount(stmt)]; + + for (int i = 0; i < cols.Length; i++) + { + var name = SQLite3.ColumnName16(stmt, i); + cols[i] = map.FindColumn(name); + } + + while (SQLite3.Step(stmt) == SQLite3.Result.Row) + { + var obj = Activator.CreateInstance(map.MappedType); + for (int i = 0; i < cols.Length; i++) + { + if (cols[i] == null) + continue; + var colType = SQLite3.ColumnType(stmt, i); + var val = ReadCol(stmt, i, colType, cols[i].ColumnType); + cols[i].SetValue(obj, val); + } + OnInstanceCreated(obj); + yield return (T)obj; + } + } + finally + { + SQLite3.Finalize(stmt); + } + } + + public T ExecuteScalar() + { + if (_conn.Trace) + { + _conn.Tracer?.Invoke("Executing Query: " + this); + } + + T val = default(T); + + var stmt = Prepare(); + + try + { + var r = SQLite3.Step(stmt); + if (r == SQLite3.Result.Row) + { + var colType = SQLite3.ColumnType(stmt, 0); + val = (T)ReadCol(stmt, 0, colType, typeof(T)); + } + else if (r == SQLite3.Result.Done) + { + } + else + { + throw SQLiteException.New(r, SQLite3.GetErrmsg(_conn.Handle)); + } + } + finally + { + Finalize(stmt); + } + + return val; + } + + public void Bind(string name, object val) + { + _bindings.Add(new Binding + { + Name = name, + Value = val + }); + } + + public void Bind(object val) + { + Bind(null, val); + } + + public override string ToString() + { + var parts = new string[1 + _bindings.Count]; + parts[0] = CommandText; + var i = 1; + foreach (var b in _bindings) + { + parts[i] = string.Format(" {0}: {1}", i - 1, b.Value); + i++; + } + return string.Join(Environment.NewLine, parts); + } + + Sqlite3Statement Prepare() + { + var stmt = SQLite3.Prepare2(_conn.Handle, CommandText); + BindAll(stmt); + return stmt; + } + + void Finalize(Sqlite3Statement stmt) + { + SQLite3.Finalize(stmt); + } + + void BindAll(Sqlite3Statement stmt) + { + int nextIdx = 1; + foreach (var b in _bindings) + { + if (b.Name != null) + { + b.Index = SQLite3.BindParameterIndex(stmt, b.Name); + } + else + { + b.Index = nextIdx++; + } + + BindParameter(stmt, b.Index, b.Value, _conn.StoreDateTimeAsTicks); + } + } + + static IntPtr NegativePointer = new IntPtr(-1); + + const string DateTimeExactStoreFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff"; + + internal static void BindParameter(Sqlite3Statement stmt, int index, object value, bool storeDateTimeAsTicks) + { + if (value == null) + { + SQLite3.BindNull(stmt, index); + } + else + { + if (value is Int32) + { + SQLite3.BindInt(stmt, index, (int)value); + } + else if (value is String) + { + SQLite3.BindText(stmt, index, (string)value, -1, NegativePointer); + } + else if (value is Byte || value is UInt16 || value is SByte || value is Int16) + { + SQLite3.BindInt(stmt, index, Convert.ToInt32(value)); + } + else if (value is Boolean) + { + SQLite3.BindInt(stmt, index, (bool)value ? 1 : 0); + } + else if (value is UInt32 || value is Int64) + { + SQLite3.BindInt64(stmt, index, Convert.ToInt64(value)); + } + else if (value is Single || value is Double || value is Decimal) + { + SQLite3.BindDouble(stmt, index, Convert.ToDouble(value)); + } + else if (value is TimeSpan) + { + SQLite3.BindInt64(stmt, index, ((TimeSpan)value).Ticks); + } + else if (value is DateTime) + { + if (storeDateTimeAsTicks) + { + SQLite3.BindInt64(stmt, index, ((DateTime)value).Ticks); + } + else + { + SQLite3.BindText(stmt, index, ((DateTime)value).ToString(DateTimeExactStoreFormat, System.Globalization.CultureInfo.InvariantCulture), -1, NegativePointer); + } + } + else if (value is DateTimeOffset) + { + SQLite3.BindInt64(stmt, index, ((DateTimeOffset)value).UtcTicks); + } + else if (value is byte[]) + { + SQLite3.BindBlob(stmt, index, (byte[])value, ((byte[])value).Length, NegativePointer); + } + else if (value is Guid) + { + SQLite3.BindText(stmt, index, ((Guid)value).ToString(), 72, NegativePointer); + } + else if (value is Uri) + { + SQLite3.BindText(stmt, index, ((Uri)value).ToString(), -1, NegativePointer); + } + else if (value is StringBuilder) + { + SQLite3.BindText(stmt, index, ((StringBuilder)value).ToString(), -1, NegativePointer); + } + else if (value is UriBuilder) + { + SQLite3.BindText(stmt, index, ((UriBuilder)value).ToString(), -1, NegativePointer); + } + else + { + // Now we could possibly get an enum, retrieve cached info + var valueType = value.GetType(); + var enumInfo = EnumCache.GetInfo(valueType); + if (enumInfo.IsEnum) + { + var enumIntValue = Convert.ToInt32(value); + if (enumInfo.StoreAsText) + SQLite3.BindText(stmt, index, enumInfo.EnumValues[enumIntValue], -1, NegativePointer); + else + SQLite3.BindInt(stmt, index, enumIntValue); + } + else + { + throw new NotSupportedException("Cannot store type: " + Orm.GetType(value)); + } + } + } + } + + class Binding + { + public string Name { get; set; } + + public object Value { get; set; } + + public int Index { get; set; } + } + + object ReadCol(Sqlite3Statement stmt, int index, SQLite3.ColType type, Type clrType) + { + if (type == SQLite3.ColType.Null) + { + return null; + } + else + { + var clrTypeInfo = clrType.GetTypeInfo(); + if (clrType == typeof(String)) + { + return SQLite3.ColumnString(stmt, index); + } + else if (clrType == typeof(Int32)) + { + return (int)SQLite3.ColumnInt(stmt, index); + } + else if (clrType == typeof(Boolean)) + { + return SQLite3.ColumnInt(stmt, index) == 1; + } + else if (clrType == typeof(double)) + { + return SQLite3.ColumnDouble(stmt, index); + } + else if (clrType == typeof(float)) + { + return (float)SQLite3.ColumnDouble(stmt, index); + } + else if (clrType == typeof(TimeSpan)) + { + return new TimeSpan(SQLite3.ColumnInt64(stmt, index)); + } + else if (clrType == typeof(DateTime)) + { + if (_conn.StoreDateTimeAsTicks) + { + return new DateTime(SQLite3.ColumnInt64(stmt, index)); + } + else + { + var text = SQLite3.ColumnString(stmt, index); + DateTime resultDate; + if (!DateTime.TryParseExact(text, DateTimeExactStoreFormat, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out resultDate)) + { + resultDate = DateTime.Parse(text); + } + return resultDate; + } + } + else if (clrType == typeof(DateTimeOffset)) + { + return new DateTimeOffset(SQLite3.ColumnInt64(stmt, index), TimeSpan.Zero); + } + else if (clrTypeInfo.IsEnum) + { + if (type == SQLite3.ColType.Text) + { + var value = SQLite3.ColumnString(stmt, index); + return Enum.Parse(clrType, value.ToString(), true); + } + else + return SQLite3.ColumnInt(stmt, index); + } + else if (clrType == typeof(Int64)) + { + return SQLite3.ColumnInt64(stmt, index); + } + else if (clrType == typeof(UInt32)) + { + return (uint)SQLite3.ColumnInt64(stmt, index); + } + else if (clrType == typeof(decimal)) + { + return (decimal)SQLite3.ColumnDouble(stmt, index); + } + else if (clrType == typeof(Byte)) + { + return (byte)SQLite3.ColumnInt(stmt, index); + } + else if (clrType == typeof(UInt16)) + { + return (ushort)SQLite3.ColumnInt(stmt, index); + } + else if (clrType == typeof(Int16)) + { + return (short)SQLite3.ColumnInt(stmt, index); + } + else if (clrType == typeof(sbyte)) + { + return (sbyte)SQLite3.ColumnInt(stmt, index); + } + else if (clrType == typeof(byte[])) + { + return SQLite3.ColumnByteArray(stmt, index); + } + else if (clrType == typeof(Guid)) + { + var text = SQLite3.ColumnString(stmt, index); + return new Guid(text); + } + else if (clrType == typeof(Uri)) + { + var text = SQLite3.ColumnString(stmt, index); + return new Uri(text); + } + else if (clrType == typeof(StringBuilder)) + { + var text = SQLite3.ColumnString(stmt, index); + return new StringBuilder(text); + } + else if (clrType == typeof(UriBuilder)) + { + var text = SQLite3.ColumnString(stmt, index); + return new UriBuilder(text); + } + else + { + throw new NotSupportedException("Don't know how to read " + clrType); + } + } + } + } + + /// + /// Since the insert never changed, we only need to prepare once. + /// + class PreparedSqlLiteInsertCommand : IDisposable + { + bool Initialized; + + SQLiteConnection Connection; + + string CommandText; + + Sqlite3Statement Statement; + static readonly Sqlite3Statement NullStatement = default(Sqlite3Statement); + + public PreparedSqlLiteInsertCommand(SQLiteConnection conn, string commandText) + { + Connection = conn; + CommandText = commandText; + } + + public int ExecuteNonQuery(object[] source) + { + if (Initialized && Statement == NullStatement) + { + throw new ObjectDisposedException(nameof(PreparedSqlLiteInsertCommand)); + } + + if (Connection.Trace) + { + Connection.Tracer?.Invoke("Executing: " + CommandText); + } + + var r = SQLite3.Result.OK; + + if (!Initialized) + { + Statement = SQLite3.Prepare2(Connection.Handle, CommandText); + Initialized = true; + } + + //bind the values. + if (source != null) + { + for (int i = 0; i < source.Length; i++) + { + SQLiteCommand.BindParameter(Statement, i + 1, source[i], Connection.StoreDateTimeAsTicks); + } + } + r = SQLite3.Step(Statement); + + if (r == SQLite3.Result.Done) + { + int rowsAffected = SQLite3.Changes(Connection.Handle); + SQLite3.Reset(Statement); + return rowsAffected; + } + else if (r == SQLite3.Result.Error) + { + string msg = SQLite3.GetErrmsg(Connection.Handle); + SQLite3.Reset(Statement); + throw SQLiteException.New(r, msg); + } + else if (r == SQLite3.Result.Constraint && SQLite3.ExtendedErrCode(Connection.Handle) == SQLite3.ExtendedResult.ConstraintNotNull) + { + SQLite3.Reset(Statement); + throw NotNullConstraintViolationException.New(r, SQLite3.GetErrmsg(Connection.Handle)); + } + else + { + SQLite3.Reset(Statement); + throw SQLiteException.New(r, r.ToString()); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + void Dispose(bool disposing) + { + var s = Statement; + Statement = NullStatement; + Connection = null; + if (s != NullStatement) + { + SQLite3.Finalize(s); + } + } + + ~PreparedSqlLiteInsertCommand() + { + Dispose(false); + } + } + + public enum CreateTableResult + { + Created, + Migrated, + } + + public class CreateTablesResult + { + public Dictionary Results { get; private set; } + + public CreateTablesResult() + { + Results = new Dictionary(); + } + } + + public abstract class BaseTableQuery + { + protected class Ordering + { + public string ColumnName { get; set; } + public bool Ascending { get; set; } + } + } + + public class TableQuery : BaseTableQuery, IEnumerable + { + public SQLiteConnection Connection { get; private set; } + + public TableMapping Table { get; private set; } + + Expression _where; + List _orderBys; + int? _limit; + int? _offset; + + BaseTableQuery _joinInner; + Expression _joinInnerKeySelector; + BaseTableQuery _joinOuter; + Expression _joinOuterKeySelector; + Expression _joinSelector; + + Expression _selector; + + TableQuery(SQLiteConnection conn, TableMapping table) + { + Connection = conn; + Table = table; + } + + public TableQuery(SQLiteConnection conn) + { + Connection = conn; + Table = Connection.GetMapping(typeof(T)); + } + + public TableQuery Clone() + { + var q = new TableQuery(Connection, Table); + q._where = _where; + q._deferred = _deferred; + if (_orderBys != null) + { + q._orderBys = new List(_orderBys); + } + q._limit = _limit; + q._offset = _offset; + q._joinInner = _joinInner; + q._joinInnerKeySelector = _joinInnerKeySelector; + q._joinOuter = _joinOuter; + q._joinOuterKeySelector = _joinOuterKeySelector; + q._joinSelector = _joinSelector; + q._selector = _selector; + return q; + } + + /// + /// Filters the query based on a predicate. + /// + public TableQuery Where(Expression> predExpr) + { + if (predExpr.NodeType == ExpressionType.Lambda) + { + var lambda = (LambdaExpression)predExpr; + var pred = lambda.Body; + var q = Clone(); + q.AddWhere(pred); + return q; + } + else + { + throw new NotSupportedException("Must be a predicate"); + } + } + + /// + /// Delete all the rows that match this query. + /// + public int Delete() + { + return Delete(null); + } + + /// + /// Delete all the rows that match this query and the given predicate. + /// + public int Delete(Expression> predExpr) + { + if (_limit.HasValue || _offset.HasValue) + throw new InvalidOperationException("Cannot delete with limits or offsets"); + + if (_where == null && predExpr == null) + throw new InvalidOperationException("No condition specified"); + + var pred = _where; + + if (predExpr != null && predExpr.NodeType == ExpressionType.Lambda) + { + var lambda = (LambdaExpression)predExpr; + pred = pred != null ? Expression.AndAlso(pred, lambda.Body) : lambda.Body; + } + + var args = new List(); + var cmdText = "delete from \"" + Table.TableName + "\""; + var w = CompileExpr(pred, args); + cmdText += " where " + w.CommandText; + + var command = Connection.CreateCommand(cmdText, args.ToArray()); + + int result = command.ExecuteNonQuery(); + return result; + } + + /// + /// Yields a given number of elements from the query and then skips the remainder. + /// + public TableQuery Take(int n) + { + var q = Clone(); + q._limit = n; + return q; + } + + /// + /// Skips a given number of elements from the query and then yields the remainder. + /// + public TableQuery Skip(int n) + { + var q = Clone(); + q._offset = n; + return q; + } + + /// + /// Returns the element at a given index + /// + public T ElementAt(int index) + { + return Skip(index).Take(1).First(); + } + + bool _deferred; + public TableQuery Deferred() + { + var q = Clone(); + q._deferred = true; + return q; + } + + /// + /// Order the query results according to a key. + /// + public TableQuery OrderBy(Expression> orderExpr) + { + return AddOrderBy(orderExpr, true); + } + + /// + /// Order the query results according to a key. + /// + public TableQuery OrderByDescending(Expression> orderExpr) + { + return AddOrderBy(orderExpr, false); + } + + /// + /// Order the query results according to a key. + /// + public TableQuery ThenBy(Expression> orderExpr) + { + return AddOrderBy(orderExpr, true); + } + + /// + /// Order the query results according to a key. + /// + public TableQuery ThenByDescending(Expression> orderExpr) + { + return AddOrderBy(orderExpr, false); + } + + TableQuery AddOrderBy(Expression> orderExpr, bool asc) + { + if (orderExpr.NodeType == ExpressionType.Lambda) + { + var lambda = (LambdaExpression)orderExpr; + + MemberExpression mem = null; + + var unary = lambda.Body as UnaryExpression; + if (unary != null && unary.NodeType == ExpressionType.Convert) + { + mem = unary.Operand as MemberExpression; + } + else + { + mem = lambda.Body as MemberExpression; + } + + if (mem != null && (mem.Expression.NodeType == ExpressionType.Parameter)) + { + var q = Clone(); + if (q._orderBys == null) + { + q._orderBys = new List(); + } + q._orderBys.Add(new Ordering + { + ColumnName = Table.FindColumnWithPropertyName(mem.Member.Name).Name, + Ascending = asc + }); + return q; + } + else + { + throw new NotSupportedException("Order By does not support: " + orderExpr); + } + } + else + { + throw new NotSupportedException("Must be a predicate"); + } + } + + private void AddWhere(Expression pred) + { + if (_where == null) + { + _where = pred; + } + else + { + _where = Expression.AndAlso(_where, pred); + } + } + + ///// + ///// Performs an inner join of two queries based on matching keys extracted from the elements. + ///// + //public TableQuery Join ( + // TableQuery inner, + // Expression> outerKeySelector, + // Expression> innerKeySelector, + // Expression> resultSelector) + //{ + // var q = new TableQuery (Connection, Connection.GetMapping (typeof (TResult))) { + // _joinOuter = this, + // _joinOuterKeySelector = outerKeySelector, + // _joinInner = inner, + // _joinInnerKeySelector = innerKeySelector, + // _joinSelector = resultSelector, + // }; + // return q; + //} + + // Not needed until Joins are supported + // Keeping this commented out forces the default Linq to objects processor to run + //public TableQuery Select (Expression> selector) + //{ + // var q = Clone (); + // q._selector = selector; + // return q; + //} + + private SQLiteCommand GenerateCommand(string selectionList) + { + if (_joinInner != null && _joinOuter != null) + { + throw new NotSupportedException("Joins are not supported."); + } + else + { + var cmdText = "select " + selectionList + " from \"" + Table.TableName + "\""; + var args = new List(); + if (_where != null) + { + var w = CompileExpr(_where, args); + cmdText += " where " + w.CommandText; + } + if ((_orderBys != null) && (_orderBys.Count > 0)) + { + var t = string.Join(", ", _orderBys.Select(o => "\"" + o.ColumnName + "\"" + (o.Ascending ? "" : " desc")).ToArray()); + cmdText += " order by " + t; + } + if (_limit.HasValue) + { + cmdText += " limit " + _limit.Value; + } + if (_offset.HasValue) + { + if (!_limit.HasValue) + { + cmdText += " limit -1 "; + } + cmdText += " offset " + _offset.Value; + } + return Connection.CreateCommand(cmdText, args.ToArray()); + } + } + + class CompileResult + { + public string CommandText { get; set; } + + public object Value { get; set; } + } + + private CompileResult CompileExpr(Expression expr, List queryArgs) + { + if (expr == null) + { + throw new NotSupportedException("Expression is NULL"); + } + else if (expr is BinaryExpression) + { + var bin = (BinaryExpression)expr; + + // VB turns 'x=="foo"' into 'CompareString(x,"foo",true/false)==0', so we need to unwrap it + // http://blogs.msdn.com/b/vbteam/archive/2007/09/18/vb-expression-trees-string-comparisons.aspx + if (bin.Left.NodeType == ExpressionType.Call) + { + var call = (MethodCallExpression)bin.Left; + if (call.Method.DeclaringType.FullName == "Microsoft.VisualBasic.CompilerServices.Operators" + && call.Method.Name == "CompareString") + bin = Expression.MakeBinary(bin.NodeType, call.Arguments[0], call.Arguments[1]); + } + + + var leftr = CompileExpr(bin.Left, queryArgs); + var rightr = CompileExpr(bin.Right, queryArgs); + + //If either side is a parameter and is null, then handle the other side specially (for "is null"/"is not null") + string text; + if (leftr.CommandText == "?" && leftr.Value == null) + text = CompileNullBinaryExpression(bin, rightr); + else if (rightr.CommandText == "?" && rightr.Value == null) + text = CompileNullBinaryExpression(bin, leftr); + else + text = "(" + leftr.CommandText + " " + GetSqlName(bin) + " " + rightr.CommandText + ")"; + return new CompileResult { CommandText = text }; + } + else if (expr.NodeType == ExpressionType.Not) + { + var operandExpr = ((UnaryExpression)expr).Operand; + var opr = CompileExpr(operandExpr, queryArgs); + object val = opr.Value; + if (val is bool) + val = !((bool)val); + return new CompileResult + { + CommandText = "NOT(" + opr.CommandText + ")", + Value = val + }; + } + else if (expr.NodeType == ExpressionType.Call) + { + + var call = (MethodCallExpression)expr; + var args = new CompileResult[call.Arguments.Count]; + var obj = call.Object != null ? CompileExpr(call.Object, queryArgs) : null; + + for (var i = 0; i < args.Length; i++) + { + args[i] = CompileExpr(call.Arguments[i], queryArgs); + } + + var sqlCall = ""; + + if (call.Method.Name == "Like" && args.Length == 2) + { + sqlCall = "(" + args[0].CommandText + " like " + args[1].CommandText + ")"; + } + else if (call.Method.Name == "Contains" && args.Length == 2) + { + sqlCall = "(" + args[1].CommandText + " in " + args[0].CommandText + ")"; + } + else if (call.Method.Name == "Contains" && args.Length == 1) + { + if (call.Object != null && call.Object.Type == typeof(string)) + { + sqlCall = "( instr(" + obj.CommandText + "," + args[0].CommandText + ") >0 )"; + } + else + { + sqlCall = "(" + args[0].CommandText + " in " + obj.CommandText + ")"; + } + } + else if (call.Method.Name == "StartsWith" && args.Length >= 1) + { + var startsWithCmpOp = StringComparison.CurrentCulture; + if (args.Length == 2) + { + startsWithCmpOp = (StringComparison)args[1].Value; + } + switch (startsWithCmpOp) + { + case StringComparison.Ordinal: + case StringComparison.CurrentCulture: + sqlCall = "( substr(" + obj.CommandText + ", 1, " + args[0].Value.ToString().Length + ") = " + args[0].CommandText + ")"; + break; + case StringComparison.OrdinalIgnoreCase: + case StringComparison.CurrentCultureIgnoreCase: + sqlCall = "(" + obj.CommandText + " like (" + args[0].CommandText + " || '%'))"; + break; + } + + } + else if (call.Method.Name == "EndsWith" && args.Length >= 1) + { + var endsWithCmpOp = StringComparison.CurrentCulture; + if (args.Length == 2) + { + endsWithCmpOp = (StringComparison)args[1].Value; + } + switch (endsWithCmpOp) + { + case StringComparison.Ordinal: + case StringComparison.CurrentCulture: + sqlCall = "( substr(" + obj.CommandText + ", length(" + obj.CommandText + ") - " + args[0].Value.ToString().Length + "+1, " + args[0].Value.ToString().Length + ") = " + args[0].CommandText + ")"; + break; + case StringComparison.OrdinalIgnoreCase: + case StringComparison.CurrentCultureIgnoreCase: + sqlCall = "(" + obj.CommandText + " like ('%' || " + args[0].CommandText + "))"; + break; + } + } + else if (call.Method.Name == "Equals" && args.Length == 1) + { + sqlCall = "(" + obj.CommandText + " = (" + args[0].CommandText + "))"; + } + else if (call.Method.Name == "ToLower") + { + sqlCall = "(lower(" + obj.CommandText + "))"; + } + else if (call.Method.Name == "ToUpper") + { + sqlCall = "(upper(" + obj.CommandText + "))"; + } + else if (call.Method.Name == "Replace" && args.Length == 2) + { + sqlCall = "(replace(" + obj.CommandText + "," + args[0].CommandText + "," + args[1].CommandText + "))"; + } + else + { + sqlCall = call.Method.Name.ToLower() + "(" + string.Join(",", args.Select(a => a.CommandText).ToArray()) + ")"; + } + return new CompileResult { CommandText = sqlCall }; + + } + else if (expr.NodeType == ExpressionType.Constant) + { + var c = (ConstantExpression)expr; + queryArgs.Add(c.Value); + return new CompileResult + { + CommandText = "?", + Value = c.Value + }; + } + else if (expr.NodeType == ExpressionType.Convert) + { + var u = (UnaryExpression)expr; + var ty = u.Type; + var valr = CompileExpr(u.Operand, queryArgs); + return new CompileResult + { + CommandText = valr.CommandText, + Value = valr.Value != null ? ConvertTo(valr.Value, ty) : null + }; + } + else if (expr.NodeType == ExpressionType.MemberAccess) + { + var mem = (MemberExpression)expr; + + var paramExpr = mem.Expression as ParameterExpression; + if (paramExpr == null) + { + var convert = mem.Expression as UnaryExpression; + if (convert != null && convert.NodeType == ExpressionType.Convert) + { + paramExpr = convert.Operand as ParameterExpression; + } + } + + if (paramExpr != null) + { + // + // This is a column of our table, output just the column name + // Need to translate it if that column name is mapped + // + var columnName = Table.FindColumnWithPropertyName(mem.Member.Name).Name; + return new CompileResult { CommandText = "\"" + columnName + "\"" }; + } + else + { + object obj = null; + if (mem.Expression != null) + { + var r = CompileExpr(mem.Expression, queryArgs); + if (r.Value == null) + { + throw new NotSupportedException("Member access failed to compile expression"); + } + if (r.CommandText == "?") + { + queryArgs.RemoveAt(queryArgs.Count - 1); + } + obj = r.Value; + } + + // + // Get the member value + // + object val = null; + + if (mem.Member is PropertyInfo) + { + var m = (PropertyInfo)mem.Member; + val = m.GetValue(obj, null); + } + else if (mem.Member is FieldInfo) + { + var m = (FieldInfo)mem.Member; + val = m.GetValue(obj); + } + else + { + throw new NotSupportedException("MemberExpr: " + mem.Member.GetType()); + } + + // + // Work special magic for enumerables + // + if (val != null && val is System.Collections.IEnumerable && !(val is string) && !(val is System.Collections.Generic.IEnumerable)) + { + var sb = new System.Text.StringBuilder(); + sb.Append("("); + var head = ""; + foreach (var a in (System.Collections.IEnumerable)val) + { + queryArgs.Add(a); + sb.Append(head); + sb.Append("?"); + head = ","; + } + sb.Append(")"); + return new CompileResult + { + CommandText = sb.ToString(), + Value = val + }; + } + else + { + queryArgs.Add(val); + return new CompileResult + { + CommandText = "?", + Value = val + }; + } + } + } + throw new NotSupportedException("Cannot compile: " + expr.NodeType.ToString()); + } + + static object ConvertTo(object obj, Type t) + { + Type nut = Nullable.GetUnderlyingType(t); + + if (nut != null) + { + if (obj == null) return null; + return Convert.ChangeType(obj, nut); + } + else + { + return Convert.ChangeType(obj, t); + } + } + + /// + /// Compiles a BinaryExpression where one of the parameters is null. + /// + /// The expression to compile + /// The non-null parameter + private string CompileNullBinaryExpression(BinaryExpression expression, CompileResult parameter) + { + if (expression.NodeType == ExpressionType.Equal) + return "(" + parameter.CommandText + " is ?)"; + else if (expression.NodeType == ExpressionType.NotEqual) + return "(" + parameter.CommandText + " is not ?)"; + else if (expression.NodeType == ExpressionType.GreaterThan + || expression.NodeType == ExpressionType.GreaterThanOrEqual + || expression.NodeType == ExpressionType.LessThan + || expression.NodeType == ExpressionType.LessThanOrEqual) + return "(" + parameter.CommandText + " < ?)"; // always false + else + throw new NotSupportedException("Cannot compile Null-BinaryExpression with type " + expression.NodeType.ToString()); + } + + string GetSqlName(Expression expr) + { + var n = expr.NodeType; + if (n == ExpressionType.GreaterThan) + return ">"; + else if (n == ExpressionType.GreaterThanOrEqual) + { + return ">="; + } + else if (n == ExpressionType.LessThan) + { + return "<"; + } + else if (n == ExpressionType.LessThanOrEqual) + { + return "<="; + } + else if (n == ExpressionType.And) + { + return "&"; + } + else if (n == ExpressionType.AndAlso) + { + return "and"; + } + else if (n == ExpressionType.Or) + { + return "|"; + } + else if (n == ExpressionType.OrElse) + { + return "or"; + } + else if (n == ExpressionType.Equal) + { + return "="; + } + else if (n == ExpressionType.NotEqual) + { + return "!="; + } + else + { + throw new NotSupportedException("Cannot get SQL for: " + n); + } + } + + /// + /// Execute SELECT COUNT(*) on the query + /// + public int Count() + { + return GenerateCommand("count(*)").ExecuteScalar(); + } + + /// + /// Execute SELECT COUNT(*) on the query with an additional WHERE clause. + /// + public int Count(Expression> predExpr) + { + return Where(predExpr).Count(); + } + + public IEnumerator GetEnumerator() + { + if (!_deferred) + return GenerateCommand("*").ExecuteQuery().GetEnumerator(); + + return GenerateCommand("*").ExecuteDeferredQuery().GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Queries the database and returns the results as a List. + /// + public List ToList() + { + return GenerateCommand("*").ExecuteQuery(); + } + + /// + /// Queries the database and returns the results as an array. + /// + public T[] ToArray() + { + return GenerateCommand("*").ExecuteQuery().ToArray(); + } + + /// + /// Returns the first element of this query. + /// + public T First() + { + var query = Take(1); + return query.ToList().First(); + } + + /// + /// Returns the first element of this query, or null if no element is found. + /// + public T FirstOrDefault() + { + var query = Take(1); + return query.ToList().FirstOrDefault(); + } + + /// + /// Returns the first element of this query that matches the predicate. + /// + public T First(Expression> predExpr) + { + return Where(predExpr).First(); + } + + /// + /// Returns the first element of this query that matches the predicate, or null + /// if no element is found. + /// + public T FirstOrDefault(Expression> predExpr) + { + return Where(predExpr).FirstOrDefault(); + } + } + + public static class SQLite3 + { + public enum Result : int + { + OK = 0, + Error = 1, + Internal = 2, + Perm = 3, + Abort = 4, + Busy = 5, + Locked = 6, + NoMem = 7, + ReadOnly = 8, + Interrupt = 9, + IOError = 10, + Corrupt = 11, + NotFound = 12, + Full = 13, + CannotOpen = 14, + LockErr = 15, + Empty = 16, + SchemaChngd = 17, + TooBig = 18, + Constraint = 19, + Mismatch = 20, + Misuse = 21, + NotImplementedLFS = 22, + AccessDenied = 23, + Format = 24, + Range = 25, + NonDBFile = 26, + Notice = 27, + Warning = 28, + Row = 100, + Done = 101 + } + + public enum ExtendedResult : int + { + IOErrorRead = (Result.IOError | (1 << 8)), + IOErrorShortRead = (Result.IOError | (2 << 8)), + IOErrorWrite = (Result.IOError | (3 << 8)), + IOErrorFsync = (Result.IOError | (4 << 8)), + IOErrorDirFSync = (Result.IOError | (5 << 8)), + IOErrorTruncate = (Result.IOError | (6 << 8)), + IOErrorFStat = (Result.IOError | (7 << 8)), + IOErrorUnlock = (Result.IOError | (8 << 8)), + IOErrorRdlock = (Result.IOError | (9 << 8)), + IOErrorDelete = (Result.IOError | (10 << 8)), + IOErrorBlocked = (Result.IOError | (11 << 8)), + IOErrorNoMem = (Result.IOError | (12 << 8)), + IOErrorAccess = (Result.IOError | (13 << 8)), + IOErrorCheckReservedLock = (Result.IOError | (14 << 8)), + IOErrorLock = (Result.IOError | (15 << 8)), + IOErrorClose = (Result.IOError | (16 << 8)), + IOErrorDirClose = (Result.IOError | (17 << 8)), + IOErrorSHMOpen = (Result.IOError | (18 << 8)), + IOErrorSHMSize = (Result.IOError | (19 << 8)), + IOErrorSHMLock = (Result.IOError | (20 << 8)), + IOErrorSHMMap = (Result.IOError | (21 << 8)), + IOErrorSeek = (Result.IOError | (22 << 8)), + IOErrorDeleteNoEnt = (Result.IOError | (23 << 8)), + IOErrorMMap = (Result.IOError | (24 << 8)), + LockedSharedcache = (Result.Locked | (1 << 8)), + BusyRecovery = (Result.Busy | (1 << 8)), + CannottOpenNoTempDir = (Result.CannotOpen | (1 << 8)), + CannotOpenIsDir = (Result.CannotOpen | (2 << 8)), + CannotOpenFullPath = (Result.CannotOpen | (3 << 8)), + CorruptVTab = (Result.Corrupt | (1 << 8)), + ReadonlyRecovery = (Result.ReadOnly | (1 << 8)), + ReadonlyCannotLock = (Result.ReadOnly | (2 << 8)), + ReadonlyRollback = (Result.ReadOnly | (3 << 8)), + AbortRollback = (Result.Abort | (2 << 8)), + ConstraintCheck = (Result.Constraint | (1 << 8)), + ConstraintCommitHook = (Result.Constraint | (2 << 8)), + ConstraintForeignKey = (Result.Constraint | (3 << 8)), + ConstraintFunction = (Result.Constraint | (4 << 8)), + ConstraintNotNull = (Result.Constraint | (5 << 8)), + ConstraintPrimaryKey = (Result.Constraint | (6 << 8)), + ConstraintTrigger = (Result.Constraint | (7 << 8)), + ConstraintUnique = (Result.Constraint | (8 << 8)), + ConstraintVTab = (Result.Constraint | (9 << 8)), + NoticeRecoverWAL = (Result.Notice | (1 << 8)), + NoticeRecoverRollback = (Result.Notice | (2 << 8)) + } + + + public enum ConfigOption : int + { + SingleThread = 1, + MultiThread = 2, + Serialized = 3 + } + + const string LibraryPath = "e_sqlite3"; + +#if !USE_CSHARP_SQLITE && !USE_WP8_NATIVE_SQLITE && !USE_SQLITEPCL_RAW + [DllImport(LibraryPath, EntryPoint = "sqlite3_threadsafe", CallingConvention = CallingConvention.Cdecl)] + public static extern int Threadsafe(); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_open", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Open([MarshalAs(UnmanagedType.LPStr)] string filename, out IntPtr db); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_open_v2", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Open([MarshalAs(UnmanagedType.LPStr)] string filename, out IntPtr db, int flags, IntPtr zvfs); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_open_v2", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Open(byte[] filename, out IntPtr db, int flags, IntPtr zvfs); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_open16", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Open16([MarshalAs(UnmanagedType.LPWStr)] string filename, out IntPtr db); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_enable_load_extension", CallingConvention = CallingConvention.Cdecl)] + public static extern Result EnableLoadExtension(IntPtr db, int onoff); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_close", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Close(IntPtr db); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_close_v2", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Close2(IntPtr db); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_initialize", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Initialize(); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_shutdown", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Shutdown(); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_config", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Config(ConfigOption option); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_win32_set_directory", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public static extern int SetDirectory(uint directoryType, string directoryPath); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_busy_timeout", CallingConvention = CallingConvention.Cdecl)] + public static extern Result BusyTimeout(IntPtr db, int milliseconds); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_changes", CallingConvention = CallingConvention.Cdecl)] + public static extern int Changes(IntPtr db); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_prepare_v2", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Prepare2(IntPtr db, [MarshalAs(UnmanagedType.LPStr)] string sql, int numBytes, out IntPtr stmt, IntPtr pzTail); + +#if NETFX_CORE + [DllImport (LibraryPath, EntryPoint = "sqlite3_prepare_v2", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Prepare2 (IntPtr db, byte[] queryBytes, int numBytes, out IntPtr stmt, IntPtr pzTail); +#endif + + public static IntPtr Prepare2(IntPtr db, string query) + { + IntPtr stmt; +#if NETFX_CORE + byte[] queryBytes = System.Text.UTF8Encoding.UTF8.GetBytes (query); + var r = Prepare2 (db, queryBytes, queryBytes.Length, out stmt, IntPtr.Zero); +#else + var r = Prepare2(db, query, System.Text.UTF8Encoding.UTF8.GetByteCount(query), out stmt, IntPtr.Zero); +#endif + if (r != Result.OK) + { + throw SQLiteException.New(r, GetErrmsg(db)); + } + return stmt; + } + + [DllImport(LibraryPath, EntryPoint = "sqlite3_step", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Step(IntPtr stmt); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_reset", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Reset(IntPtr stmt); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_finalize", CallingConvention = CallingConvention.Cdecl)] + public static extern Result Finalize(IntPtr stmt); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_last_insert_rowid", CallingConvention = CallingConvention.Cdecl)] + public static extern long LastInsertRowid(IntPtr db); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_errmsg16", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr Errmsg(IntPtr db); + + public static string GetErrmsg(IntPtr db) + { + return Marshal.PtrToStringUni(Errmsg(db)); + } + + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_parameter_index", CallingConvention = CallingConvention.Cdecl)] + public static extern int BindParameterIndex(IntPtr stmt, [MarshalAs(UnmanagedType.LPStr)] string name); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_null", CallingConvention = CallingConvention.Cdecl)] + public static extern int BindNull(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_int", CallingConvention = CallingConvention.Cdecl)] + public static extern int BindInt(IntPtr stmt, int index, int val); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_int64", CallingConvention = CallingConvention.Cdecl)] + public static extern int BindInt64(IntPtr stmt, int index, long val); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_double", CallingConvention = CallingConvention.Cdecl)] + public static extern int BindDouble(IntPtr stmt, int index, double val); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_text16", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + public static extern int BindText(IntPtr stmt, int index, [MarshalAs(UnmanagedType.LPWStr)] string val, int n, IntPtr free); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_bind_blob", CallingConvention = CallingConvention.Cdecl)] + public static extern int BindBlob(IntPtr stmt, int index, byte[] val, int n, IntPtr free); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_count", CallingConvention = CallingConvention.Cdecl)] + public static extern int ColumnCount(IntPtr stmt); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_name", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ColumnName(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_name16", CallingConvention = CallingConvention.Cdecl)] + static extern IntPtr ColumnName16Internal(IntPtr stmt, int index); + public static string ColumnName16(IntPtr stmt, int index) + { + return Marshal.PtrToStringUni(ColumnName16Internal(stmt, index)); + } + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_type", CallingConvention = CallingConvention.Cdecl)] + public static extern ColType ColumnType(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_int", CallingConvention = CallingConvention.Cdecl)] + public static extern int ColumnInt(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_int64", CallingConvention = CallingConvention.Cdecl)] + public static extern long ColumnInt64(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_double", CallingConvention = CallingConvention.Cdecl)] + public static extern double ColumnDouble(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_text", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ColumnText(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_text16", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ColumnText16(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_blob", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ColumnBlob(IntPtr stmt, int index); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_column_bytes", CallingConvention = CallingConvention.Cdecl)] + public static extern int ColumnBytes(IntPtr stmt, int index); + + public static string ColumnString(IntPtr stmt, int index) + { + return Marshal.PtrToStringUni(SQLite3.ColumnText16(stmt, index)); + } + + public static byte[] ColumnByteArray(IntPtr stmt, int index) + { + int length = ColumnBytes(stmt, index); + var result = new byte[length]; + if (length > 0) + Marshal.Copy(ColumnBlob(stmt, index), result, 0, length); + return result; + } + + [DllImport(LibraryPath, EntryPoint = "sqlite3_extended_errcode", CallingConvention = CallingConvention.Cdecl)] + public static extern ExtendedResult ExtendedErrCode(IntPtr db); + + [DllImport(LibraryPath, EntryPoint = "sqlite3_libversion_number", CallingConvention = CallingConvention.Cdecl)] + public static extern int LibVersionNumber(); +#else + public static Result Open (string filename, out Sqlite3DatabaseHandle db) + { + return (Result)Sqlite3.sqlite3_open (filename, out db); + } + + public static Result Open (string filename, out Sqlite3DatabaseHandle db, int flags, IntPtr zVfs) + { +#if USE_WP8_NATIVE_SQLITE + return (Result)Sqlite3.sqlite3_open_v2(filename, out db, flags, ""); +#else + return (Result)Sqlite3.sqlite3_open_v2 (filename, out db, flags, null); +#endif + } + + public static Result Close (Sqlite3DatabaseHandle db) + { + return (Result)Sqlite3.sqlite3_close (db); + } + + public static Result Close2 (Sqlite3DatabaseHandle db) + { + return (Result)Sqlite3.sqlite3_close_v2 (db); + } + + public static Result BusyTimeout (Sqlite3DatabaseHandle db, int milliseconds) + { + return (Result)Sqlite3.sqlite3_busy_timeout (db, milliseconds); + } + + public static int Changes (Sqlite3DatabaseHandle db) + { + return Sqlite3.sqlite3_changes (db); + } + + public static Sqlite3Statement Prepare2 (Sqlite3DatabaseHandle db, string query) + { + Sqlite3Statement stmt = default (Sqlite3Statement); +#if USE_WP8_NATIVE_SQLITE || USE_SQLITEPCL_RAW + var r = Sqlite3.sqlite3_prepare_v2 (db, query, out stmt); +#else + stmt = new Sqlite3Statement(); + var r = Sqlite3.sqlite3_prepare_v2(db, query, -1, ref stmt, 0); +#endif + if (r != 0) { + throw SQLiteException.New ((Result)r, GetErrmsg (db)); + } + return stmt; + } + + public static Result Step (Sqlite3Statement stmt) + { + return (Result)Sqlite3.sqlite3_step (stmt); + } + + public static Result Reset (Sqlite3Statement stmt) + { + return (Result)Sqlite3.sqlite3_reset (stmt); + } + + public static Result Finalize (Sqlite3Statement stmt) + { + return (Result)Sqlite3.sqlite3_finalize (stmt); + } + + public static long LastInsertRowid (Sqlite3DatabaseHandle db) + { + return Sqlite3.sqlite3_last_insert_rowid (db); + } + + public static string GetErrmsg (Sqlite3DatabaseHandle db) + { + return Sqlite3.sqlite3_errmsg (db); + } + + public static int BindParameterIndex (Sqlite3Statement stmt, string name) + { + return Sqlite3.sqlite3_bind_parameter_index (stmt, name); + } + + public static int BindNull (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_bind_null (stmt, index); + } + + public static int BindInt (Sqlite3Statement stmt, int index, int val) + { + return Sqlite3.sqlite3_bind_int (stmt, index, val); + } + + public static int BindInt64 (Sqlite3Statement stmt, int index, long val) + { + return Sqlite3.sqlite3_bind_int64 (stmt, index, val); + } + + public static int BindDouble (Sqlite3Statement stmt, int index, double val) + { + return Sqlite3.sqlite3_bind_double (stmt, index, val); + } + + public static int BindText (Sqlite3Statement stmt, int index, string val, int n, IntPtr free) + { +#if USE_WP8_NATIVE_SQLITE + return Sqlite3.sqlite3_bind_text(stmt, index, val, n); +#elif USE_SQLITEPCL_RAW + return Sqlite3.sqlite3_bind_text (stmt, index, val); +#else + return Sqlite3.sqlite3_bind_text(stmt, index, val, n, null); +#endif + } + + public static int BindBlob (Sqlite3Statement stmt, int index, byte[] val, int n, IntPtr free) + { +#if USE_WP8_NATIVE_SQLITE + return Sqlite3.sqlite3_bind_blob(stmt, index, val, n); +#elif USE_SQLITEPCL_RAW + return Sqlite3.sqlite3_bind_blob (stmt, index, val); +#else + return Sqlite3.sqlite3_bind_blob(stmt, index, val, n, null); +#endif + } + + public static int ColumnCount (Sqlite3Statement stmt) + { + return Sqlite3.sqlite3_column_count (stmt); + } + + public static string ColumnName (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_name (stmt, index); + } + + public static string ColumnName16 (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_name (stmt, index); + } + + public static ColType ColumnType (Sqlite3Statement stmt, int index) + { + return (ColType)Sqlite3.sqlite3_column_type (stmt, index); + } + + public static int ColumnInt (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_int (stmt, index); + } + + public static long ColumnInt64 (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_int64 (stmt, index); + } + + public static double ColumnDouble (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_double (stmt, index); + } + + public static string ColumnText (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_text (stmt, index); + } + + public static string ColumnText16 (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_text (stmt, index); + } + + public static byte[] ColumnBlob (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_blob (stmt, index); + } + + public static int ColumnBytes (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_bytes (stmt, index); + } + + public static string ColumnString (Sqlite3Statement stmt, int index) + { + return Sqlite3.sqlite3_column_text (stmt, index); + } + + public static byte[] ColumnByteArray (Sqlite3Statement stmt, int index) + { + int length = ColumnBytes (stmt, index); + if (length > 0) { + return ColumnBlob (stmt, index); + } + return new byte[0]; + } + + public static Result EnableLoadExtension (Sqlite3DatabaseHandle db, int onoff) + { + return (Result)Sqlite3.sqlite3_enable_load_extension (db, onoff); + } + + public static int LibVersionNumber () + { + return Sqlite3.sqlite3_libversion_number (); + } + + public static ExtendedResult ExtendedErrCode (Sqlite3DatabaseHandle db) + { + return (ExtendedResult)Sqlite3.sqlite3_extended_errcode (db); + } +#endif + + public enum ColType : int + { + Integer = 1, + Float = 2, + Text = 3, + Blob = 4, + Null = 5 + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/sqlite-net/SQLiteAsync.cs b/src/GitHub.App/sqlite-net/SQLiteAsync.cs new file mode 100644 index 0000000000..e41fa43e53 --- /dev/null +++ b/src/GitHub.App/sqlite-net/SQLiteAsync.cs @@ -0,0 +1,1451 @@ +// +// Copyright (c) 2012-2017 Krueger Systems, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable CA1001 // Types that own disposable fields should be disposable +#pragma warning disable CA1200 // Avoid using cref tags with a prefix +#pragma warning disable CA1720 // Identifier contains type name + +namespace SQLite +{ + /// + /// A pooled asynchronous connection to a SQLite database. + /// + public partial class SQLiteAsyncConnection + { + SQLiteConnectionString _connectionString; + SQLiteConnectionWithLock _fullMutexReadConnection; + readonly bool isFullMutex; + SQLiteOpenFlags _openFlags; + + /// + /// Constructs a new SQLiteAsyncConnection and opens a pooled SQLite database specified by databasePath. + /// + /// + /// Specifies the path to the database file. + /// + /// + /// Specifies whether to store DateTime properties as ticks (true) or strings (false). You + /// absolutely do want to store them as Ticks in all new projects. The value of false is + /// only here for backwards compatibility. There is a *significant* speed advantage, with no + /// down sides, when setting storeDateTimeAsTicks = true. + /// If you use DateTimeOffset properties, it will be always stored as ticks regardingless + /// the storeDateTimeAsTicks parameter. + /// + /// + /// Specifies the encryption key to use on the database. Should be a string or a byte[]. + /// + public SQLiteAsyncConnection(string databasePath, bool storeDateTimeAsTicks = true, object key = null) + : this(databasePath, SQLiteOpenFlags.FullMutex | SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create, storeDateTimeAsTicks, key: key) + { + } + + /// + /// Constructs a new SQLiteAsyncConnection and opens a pooled SQLite database specified by databasePath. + /// + /// + /// Specifies the path to the database file. + /// + /// + /// Flags controlling how the connection should be opened. + /// + /// + /// Specifies whether to store DateTime properties as ticks (true) or strings (false). You + /// absolutely do want to store them as Ticks in all new projects. The value of false is + /// only here for backwards compatibility. There is a *significant* speed advantage, with no + /// down sides, when setting storeDateTimeAsTicks = true. + /// If you use DateTimeOffset properties, it will be always stored as ticks regardingless + /// the storeDateTimeAsTicks parameter. + /// + /// + /// Specifies the encryption key to use on the database. Should be a string or a byte[]. + /// + public SQLiteAsyncConnection(string databasePath, SQLiteOpenFlags openFlags, bool storeDateTimeAsTicks = true, object key = null) + { + _openFlags = openFlags; + isFullMutex = _openFlags.HasFlag(SQLiteOpenFlags.FullMutex); + _connectionString = new SQLiteConnectionString(databasePath, storeDateTimeAsTicks, key); + if (isFullMutex) + _fullMutexReadConnection = new SQLiteConnectionWithLock(_connectionString, openFlags) { SkipLock = true }; + } + + /// + /// Gets the database path used by this connection. + /// + public string DatabasePath => GetConnection().DatabasePath; + + /// + /// Gets the SQLite library version number. 3007014 would be v3.7.14 + /// + public int LibVersionNumber => GetConnection().LibVersionNumber; + + /// + /// The amount of time to wait for a table to become unlocked. + /// + public TimeSpan GetBusyTimeout() + { + return GetConnection().BusyTimeout; + } + + /// + /// Sets the amount of time to wait for a table to become unlocked. + /// + public Task SetBusyTimeoutAsync(TimeSpan value) + { + return ReadAsync(conn => { + conn.BusyTimeout = value; + return null; + }); + } + + /// + /// Whether to store DateTime properties as ticks (true) or strings (false). + /// + public bool StoreDateTimeAsTicks => GetConnection().StoreDateTimeAsTicks; + + /// + /// Whether to writer queries to during execution. + /// + /// The tracer. + public bool Trace + { + get { return GetConnection().Trace; } + set { GetConnection().Trace = value; } + } + + /// + /// The delegate responsible for writing trace lines. + /// + /// The tracer. + public Action Tracer + { + get { return GetConnection().Tracer; } + set { GetConnection().Tracer = value; } + } + + /// + /// Whether Trace lines should be written that show the execution time of queries. + /// + public bool TimeExecution + { + get { return GetConnection().TimeExecution; } + set { GetConnection().TimeExecution = value; } + } + + /// + /// Returns the mappings from types to tables that the connection + /// currently understands. + /// + public IEnumerable TableMappings => GetConnection().TableMappings; + + /// + /// Closes all connections to all async databases. + /// You should *never* need to do this. + /// This is a blocking operation that will return when all connections + /// have been closed. + /// + public static void ResetPool() + { + SQLiteConnectionPool.Shared.Reset(); + } + + /// + /// Gets the pooled lockable connection used by this async connection. + /// You should never need to use this. This is provided only to add additional + /// functionality to SQLite-net. If you use this connection, you must use + /// the Lock method on it while using it. + /// + public SQLiteConnectionWithLock GetConnection() + { + return SQLiteConnectionPool.Shared.GetConnection(_connectionString, _openFlags); + } + + /// + /// Closes any pooled connections used by the database. + /// + public Task CloseAsync() + { + return Task.Factory.StartNew(() => { + SQLiteConnectionPool.Shared.CloseConnection(_connectionString, _openFlags); + }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + Task ReadAsync(Func read) + { + return Task.Factory.StartNew(() => { + var conn = isFullMutex ? _fullMutexReadConnection : GetConnection(); + using (conn.Lock()) + { + return read(conn); + } + }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + Task WriteAsync(Func write) + { + return Task.Factory.StartNew(() => { + var conn = GetConnection(); + using (conn.Lock()) + { + return write(conn); + } + }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + /// + /// Enable or disable extension loading. + /// + public Task EnableLoadExtensionAsync(bool enabled) + { + return WriteAsync(conn => { + conn.EnableLoadExtension(enabled); + return null; + }); + } + + /// + /// Executes a "create table if not exists" on the database. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated. + /// + public Task CreateTableAsync(CreateFlags createFlags = CreateFlags.None) + where T : new() + { + return WriteAsync(conn => conn.CreateTable(createFlags)); + } + + /// + /// Executes a "create table if not exists" on the database. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// Type to reflect to a database table. + /// Optional flags allowing implicit PK and indexes based on naming conventions. + /// + /// Whether the table was created or migrated. + /// + public Task CreateTableAsync(Type ty, CreateFlags createFlags = CreateFlags.None) + { + return WriteAsync(conn => conn.CreateTable(ty, createFlags)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public Task CreateTablesAsync(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + { + return CreateTablesAsync(createFlags, typeof(T), typeof(T2)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public Task CreateTablesAsync(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + where T3 : new() + { + return CreateTablesAsync(createFlags, typeof(T), typeof(T2), typeof(T3)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public Task CreateTablesAsync(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + where T3 : new() + where T4 : new() + { + return CreateTablesAsync(createFlags, typeof(T), typeof(T2), typeof(T3), typeof(T4)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public Task CreateTablesAsync(CreateFlags createFlags = CreateFlags.None) + where T : new() + where T2 : new() + where T3 : new() + where T4 : new() + where T5 : new() + { + return CreateTablesAsync(createFlags, typeof(T), typeof(T2), typeof(T3), typeof(T4), typeof(T5)); + } + + /// + /// Executes a "create table if not exists" on the database for each type. It also + /// creates any specified indexes on the columns of the table. It uses + /// a schema automatically generated from the specified type. You can + /// later access this schema by calling GetMapping. + /// + /// + /// Whether the table was created or migrated for each type. + /// + public Task CreateTablesAsync(CreateFlags createFlags = CreateFlags.None, params Type[] types) + { + return WriteAsync(conn => conn.CreateTables(createFlags, types)); + } + + /// + /// Executes a "drop table" on the database. This is non-recoverable. + /// + public Task DropTableAsync() + where T : new() + { + return WriteAsync(conn => conn.DropTable()); + } + + /// + /// Executes a "drop table" on the database. This is non-recoverable. + /// + /// + /// The TableMapping used to identify the table. + /// + public Task DropTableAsync(TableMapping map) + { + return WriteAsync(conn => conn.DropTable(map)); + } + + /// + /// Creates an index for the specified table and column. + /// + /// Name of the database table + /// Name of the column to index + /// Whether the index should be unique + public Task CreateIndexAsync(string tableName, string columnName, bool unique = false) + { + return WriteAsync(conn => conn.CreateIndex(tableName, columnName, unique)); + } + + /// + /// Creates an index for the specified table and column. + /// + /// Name of the index to create + /// Name of the database table + /// Name of the column to index + /// Whether the index should be unique + public Task CreateIndexAsync(string indexName, string tableName, string columnName, bool unique = false) + { + return WriteAsync(conn => conn.CreateIndex(indexName, tableName, columnName, unique)); + } + + /// + /// Creates an index for the specified table and columns. + /// + /// Name of the database table + /// An array of column names to index + /// Whether the index should be unique + public Task CreateIndexAsync(string tableName, string[] columnNames, bool unique = false) + { + return WriteAsync(conn => conn.CreateIndex(tableName, columnNames, unique)); + } + + /// + /// Creates an index for the specified table and columns. + /// + /// Name of the index to create + /// Name of the database table + /// An array of column names to index + /// Whether the index should be unique + public Task CreateIndexAsync(string indexName, string tableName, string[] columnNames, bool unique = false) + { + return WriteAsync(conn => conn.CreateIndex(indexName, tableName, columnNames, unique)); + } + + /// + /// Creates an index for the specified object property. + /// e.g. CreateIndex<Client>(c => c.Name); + /// + /// Type to reflect to a database table. + /// Property to index + /// Whether the index should be unique + public Task CreateIndexAsync(Expression> property, bool unique = false) + { + return WriteAsync(conn => conn.CreateIndex(property, unique)); + } + + /// + /// Inserts the given object and retrieves its + /// auto incremented primary key if it has one. + /// + /// + /// The object to insert. + /// + /// + /// The number of rows added to the table. + /// + public Task InsertAsync(object obj) + { + return WriteAsync(conn => conn.Insert(obj)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// + /// + /// The object to insert. + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows added to the table. + /// + public Task InsertAsync(object obj, Type objType) + { + return WriteAsync(conn => conn.Insert(obj, objType)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// + /// + /// The object to insert. + /// + /// + /// Literal SQL code that gets placed into the command. INSERT {extra} INTO ... + /// + /// + /// The number of rows added to the table. + /// + public Task InsertAsync(object obj, string extra) + { + return WriteAsync(conn => conn.Insert(obj, extra)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// + /// + /// The object to insert. + /// + /// + /// Literal SQL code that gets placed into the command. INSERT {extra} INTO ... + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows added to the table. + /// + public Task InsertAsync(object obj, string extra, Type objType) + { + return WriteAsync(conn => conn.Insert(obj, extra, objType)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// If a UNIQUE constraint violation occurs with + /// some pre-existing object, this function deletes + /// the old object. + /// + /// + /// The object to insert. + /// + /// + /// The number of rows modified. + /// + public Task InsertOrReplaceAsync(object obj) + { + return WriteAsync(conn => conn.InsertOrReplace(obj)); + } + + /// + /// Inserts the given object (and updates its + /// auto incremented primary key if it has one). + /// The return value is the number of rows added to the table. + /// If a UNIQUE constraint violation occurs with + /// some pre-existing object, this function deletes + /// the old object. + /// + /// + /// The object to insert. + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows modified. + /// + public Task InsertOrReplaceAsync(object obj, Type objType) + { + return WriteAsync(conn => conn.InsertOrReplace(obj, objType)); + } + + /// + /// Updates all of the columns of a table using the specified object + /// except for its primary key. + /// The object is required to have a primary key. + /// + /// + /// The object to update. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The number of rows updated. + /// + public Task UpdateAsync(object obj) + { + return WriteAsync(conn => conn.Update(obj)); + } + + /// + /// Updates all of the columns of a table using the specified object + /// except for its primary key. + /// The object is required to have a primary key. + /// + /// + /// The object to update. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The type of object to insert. + /// + /// + /// The number of rows updated. + /// + public Task UpdateAsync(object obj, Type objType) + { + return WriteAsync(conn => conn.Update(obj, objType)); + } + + /// + /// Updates all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// + /// A boolean indicating if the inserts should be wrapped in a transaction + /// + /// + /// The number of rows modified. + /// + public Task UpdateAllAsync(IEnumerable objects, bool runInTransaction = true) + { + return WriteAsync(conn => conn.UpdateAll(objects, runInTransaction)); + } + + /// + /// Deletes the given object from the database using its primary key. + /// + /// + /// The object to delete. It must have a primary key designated using the PrimaryKeyAttribute. + /// + /// + /// The number of rows deleted. + /// + public Task DeleteAsync(object objectToDelete) + { + return WriteAsync(conn => conn.Delete(objectToDelete)); + } + + /// + /// Deletes the object with the specified primary key. + /// + /// + /// The primary key of the object to delete. + /// + /// + /// The number of objects deleted. + /// + /// + /// The type of object. + /// + public Task DeleteAsync(object primaryKey) + { + return WriteAsync(conn => conn.Delete(primaryKey)); + } + + /// + /// Deletes the object with the specified primary key. + /// + /// + /// The primary key of the object to delete. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The number of objects deleted. + /// + public Task DeleteAsync(object primaryKey, TableMapping map) + { + return WriteAsync(conn => conn.Delete(primaryKey, map)); + } + + /// + /// Deletes all the objects from the specified table. + /// WARNING WARNING: Let me repeat. It deletes ALL the objects from the + /// specified table. Do you really want to do that? + /// + /// + /// The number of objects deleted. + /// + /// + /// The type of objects to delete. + /// + public Task DeleteAllAsync() + { + return WriteAsync(conn => conn.DeleteAll()); + } + + /// + /// Deletes all the objects from the specified table. + /// WARNING WARNING: Let me repeat. It deletes ALL the objects from the + /// specified table. Do you really want to do that? + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The number of objects deleted. + /// + public Task DeleteAllAsync(TableMapping map) + { + return WriteAsync(conn => conn.DeleteAll(map)); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The object with the given primary key. Throws a not found exception + /// if the object is not found. + /// + public Task GetAsync(object pk) + where T : new() + { + return ReadAsync(conn => conn.Get(pk)); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The object with the given primary key. Throws a not found exception + /// if the object is not found. + /// + public Task GetAsync(object pk, TableMapping map) + { + return ReadAsync(conn => conn.Get(pk, map)); + } + + /// + /// Attempts to retrieve the first object that matches the predicate from the table + /// associated with the specified type. + /// + /// + /// A predicate for which object to find. + /// + /// + /// The object that matches the given predicate. Throws a not found exception + /// if the object is not found. + /// + public Task GetAsync(Expression> predicate) + where T : new() + { + return ReadAsync(conn => conn.Get(predicate)); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The object with the given primary key or null + /// if the object is not found. + /// + public Task FindAsync(object pk) + where T : new() + { + return ReadAsync(conn => conn.Find(pk)); + } + + /// + /// Attempts to retrieve an object with the given primary key from the table + /// associated with the specified type. Use of this method requires that + /// the given type have a designated PrimaryKey (using the PrimaryKeyAttribute). + /// + /// + /// The primary key. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The object with the given primary key or null + /// if the object is not found. + /// + public Task FindAsync(object pk, TableMapping map) + { + return ReadAsync(conn => conn.Find(pk, map)); + } + + /// + /// Attempts to retrieve the first object that matches the predicate from the table + /// associated with the specified type. + /// + /// + /// A predicate for which object to find. + /// + /// + /// The object that matches the given predicate or null + /// if the object is not found. + /// + public Task FindAsync(Expression> predicate) + where T : new() + { + return ReadAsync(conn => conn.Find(predicate)); + } + + /// + /// Attempts to retrieve the first object that matches the query from the table + /// associated with the specified type. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The object that matches the given predicate or null + /// if the object is not found. + /// + public Task FindWithQueryAsync(string query, params object[] args) + where T : new() + { + return ReadAsync(conn => conn.FindWithQuery(query, args)); + } + + /// + /// Attempts to retrieve the first object that matches the query from the table + /// associated with the specified type. + /// + /// + /// The TableMapping used to identify the table. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The object that matches the given predicate or null + /// if the object is not found. + /// + public Task FindWithQueryAsync(TableMapping map, string query, params object[] args) + { + return ReadAsync(conn => conn.FindWithQuery(map, query, args)); + } + + /// + /// Retrieves the mapping that is automatically generated for the given type. + /// + /// + /// The type whose mapping to the database is returned. + /// + /// + /// Optional flags allowing implicit PK and indexes based on naming conventions + /// + /// + /// The mapping represents the schema of the columns of the database and contains + /// methods to set and get properties of objects. + /// + public Task GetMappingAsync(Type type, CreateFlags createFlags = CreateFlags.None) + { + return ReadAsync(conn => conn.GetMapping(type, createFlags)); + } + + /// + /// Retrieves the mapping that is automatically generated for the given type. + /// + /// + /// Optional flags allowing implicit PK and indexes based on naming conventions + /// + /// + /// The mapping represents the schema of the columns of the database and contains + /// methods to set and get properties of objects. + /// + public Task GetMappingAsync(CreateFlags createFlags = CreateFlags.None) + where T : new() + { + return ReadAsync(conn => conn.GetMapping(createFlags)); + } + + /// + /// Query the built-in sqlite table_info table for a specific tables columns. + /// + /// The columns contains in the table. + /// Table name. + public Task> GetTableInfoAsync(string tableName) + { + return ReadAsync(conn => conn.GetTableInfo(tableName)); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// Use this method instead of Query when you don't expect rows back. Such cases include + /// INSERTs, UPDATEs, and DELETEs. + /// You can set the Trace or TimeExecution properties of the connection + /// to profile execution. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The number of rows modified in the database as a result of this execution. + /// + public Task ExecuteAsync(string query, params object[] args) + { + return WriteAsync(conn => conn.Execute(query, args)); + } + + /// + /// Inserts all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// A boolean indicating if the inserts should be wrapped in a transaction. + /// + /// + /// The number of rows added to the table. + /// + public Task InsertAllAsync(IEnumerable objects, bool runInTransaction = true) + { + return WriteAsync(conn => conn.InsertAll(objects, runInTransaction)); + } + + /// + /// Inserts all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// + /// Literal SQL code that gets placed into the command. INSERT {extra} INTO ... + /// + /// + /// A boolean indicating if the inserts should be wrapped in a transaction. + /// + /// + /// The number of rows added to the table. + /// + public Task InsertAllAsync(IEnumerable objects, string extra, bool runInTransaction = true) + { + return WriteAsync(conn => conn.InsertAll(objects, extra, runInTransaction)); + } + + /// + /// Inserts all specified objects. + /// + /// + /// An of the objects to insert. + /// + /// + /// The type of object to insert. + /// + /// + /// A boolean indicating if the inserts should be wrapped in a transaction. + /// + /// + /// The number of rows added to the table. + /// + public Task InsertAllAsync(IEnumerable objects, Type objType, bool runInTransaction = true) + { + return WriteAsync(conn => conn.InsertAll(objects, objType, runInTransaction)); + } + + /// + /// Executes within a (possibly nested) transaction by wrapping it in a SAVEPOINT. If an + /// exception occurs the whole transaction is rolled back, not just the current savepoint. The exception + /// is rethrown. + /// + /// + /// The to perform within a transaction. can contain any number + /// of operations on the connection but should never call or + /// . + /// + public Task RunInTransactionAsync(Action action) + { + return WriteAsync(conn => { + conn.BeginTransaction(); + try + { + action(conn); + conn.Commit(); + return null; + } + catch (Exception) + { + conn.Rollback(); + throw; + } + }); + } + + /// + /// Returns a queryable interface to the table represented by the given type. + /// + /// + /// A queryable object that is able to translate Where, OrderBy, and Take + /// queries into native SQL. + /// + public AsyncTableQuery Table() + where T : new() + { + // + // This isn't async as the underlying connection doesn't go out to the database + // until the query is performed. The Async methods are on the query iteself. + // + var conn = GetConnection(); + return new AsyncTableQuery(conn.Table()); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// Use this method when return primitive values. + /// You can set the Trace or TimeExecution properties of the connection + /// to profile execution. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// The number of rows modified in the database as a result of this execution. + /// + public Task ExecuteScalarAsync(string query, params object[] args) + { + return WriteAsync(conn => { + var command = conn.CreateCommand(query, args); + return command.ExecuteScalar(); + }); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the mapping automatically generated for + /// the given type. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// + public Task> QueryAsync(string query, params object[] args) + where T : new() + { + return ReadAsync(conn => conn.Query(query, args)); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the specified mapping. This function is + /// only used by libraries in order to query the database via introspection. It is + /// normally not used. + /// + /// + /// A to use to convert the resulting rows + /// into objects. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// + public Task> QueryAsync(TableMapping map, string query, params object[] args) + { + return ReadAsync(conn => conn.Query(map, query, args)); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the mapping automatically generated for + /// the given type. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// The enumerator will call sqlite3_step on each call to MoveNext, so the database + /// connection must remain open for the lifetime of the enumerator. + /// + public Task> DeferredQueryAsync(string query, params object[] args) + where T : new() + { + return ReadAsync(conn => (IEnumerable)conn.DeferredQuery(query, args).ToList()); + } + + /// + /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?' + /// in the command text for each of the arguments and then executes that command. + /// It returns each row of the result using the specified mapping. This function is + /// only used by libraries in order to query the database via introspection. It is + /// normally not used. + /// + /// + /// A to use to convert the resulting rows + /// into objects. + /// + /// + /// The fully escaped SQL. + /// + /// + /// Arguments to substitute for the occurences of '?' in the query. + /// + /// + /// An enumerable with one result for each row returned by the query. + /// The enumerator will call sqlite3_step on each call to MoveNext, so the database + /// connection must remain open for the lifetime of the enumerator. + /// + public Task> DeferredQueryAsync(TableMapping map, string query, params object[] args) + { + return ReadAsync(conn => (IEnumerable)conn.DeferredQuery(map, query, args).ToList()); + } + } + + // + // TODO: Bind to AsyncConnection.GetConnection instead so that delayed + // execution can still work after a Pool.Reset. + // + + /// + /// Query to an asynchronous database connection. + /// + public class AsyncTableQuery + where T : new() + { + TableQuery _innerQuery; + + /// + /// Creates a new async query that uses given the synchronous query. + /// + public AsyncTableQuery(TableQuery innerQuery) + { + _innerQuery = innerQuery; + } + + Task ReadAsync(Func read) + { + return Task.Factory.StartNew(() => { + var conn = (SQLiteConnectionWithLock)_innerQuery.Connection; + using (conn.Lock()) + { + return read(conn); + } + }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + Task WriteAsync(Func write) + { + return Task.Factory.StartNew(() => { + var conn = (SQLiteConnectionWithLock)_innerQuery.Connection; + using (conn.Lock()) + { + return write(conn); + } + }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + /// + /// Filters the query based on a predicate. + /// + public AsyncTableQuery Where(Expression> predExpr) + { + return new AsyncTableQuery(_innerQuery.Where(predExpr)); + } + + /// + /// Skips a given number of elements from the query and then yields the remainder. + /// + public AsyncTableQuery Skip(int n) + { + return new AsyncTableQuery(_innerQuery.Skip(n)); + } + + /// + /// Yields a given number of elements from the query and then skips the remainder. + /// + public AsyncTableQuery Take(int n) + { + return new AsyncTableQuery(_innerQuery.Take(n)); + } + + /// + /// Order the query results according to a key. + /// + public AsyncTableQuery OrderBy(Expression> orderExpr) + { + return new AsyncTableQuery(_innerQuery.OrderBy(orderExpr)); + } + + /// + /// Order the query results according to a key. + /// + public AsyncTableQuery OrderByDescending(Expression> orderExpr) + { + return new AsyncTableQuery(_innerQuery.OrderByDescending(orderExpr)); + } + + /// + /// Order the query results according to a key. + /// + public AsyncTableQuery ThenBy(Expression> orderExpr) + { + return new AsyncTableQuery(_innerQuery.ThenBy(orderExpr)); + } + + /// + /// Order the query results according to a key. + /// + public AsyncTableQuery ThenByDescending(Expression> orderExpr) + { + return new AsyncTableQuery(_innerQuery.ThenByDescending(orderExpr)); + } + + /// + /// Queries the database and returns the results as a List. + /// + public Task> ToListAsync() + { + return ReadAsync(conn => _innerQuery.ToList()); + } + + /// + /// Queries the database and returns the results as an array. + /// + public Task ToArrayAsync() + { + return ReadAsync(conn => _innerQuery.ToArray()); + } + + /// + /// Execute SELECT COUNT(*) on the query + /// + public Task CountAsync() + { + return ReadAsync(conn => _innerQuery.Count()); + } + + /// + /// Execute SELECT COUNT(*) on the query with an additional WHERE clause. + /// + public Task CountAsync(Expression> predExpr) + { + return ReadAsync(conn => _innerQuery.Count(predExpr)); + } + + /// + /// Returns the element at a given index + /// + public Task ElementAtAsync(int index) + { + return ReadAsync(conn => _innerQuery.ElementAt(index)); + } + + /// + /// Returns the first element of this query. + /// + public Task FirstAsync() + { + return ReadAsync(conn => _innerQuery.First()); + } + + /// + /// Returns the first element of this query, or null if no element is found. + /// + public Task FirstOrDefaultAsync() + { + return ReadAsync(conn => _innerQuery.FirstOrDefault()); + } + + /// + /// Returns the first element of this query that matches the predicate. + /// + public Task FirstAsync(Expression> predExpr) + { + return ReadAsync(conn => _innerQuery.First(predExpr)); + } + + /// + /// Returns the first element of this query that matches the predicate. + /// + public Task FirstOrDefaultAsync(Expression> predExpr) + { + return ReadAsync(conn => _innerQuery.FirstOrDefault(predExpr)); + } + + /// + /// Delete all the rows that match this query and the given predicate. + /// + public Task DeleteAsync(Expression> predExpr) + { + return WriteAsync(conn => _innerQuery.Delete(predExpr)); + } + + /// + /// Delete all the rows that match this query. + /// + public Task DeleteAsync() + { + return WriteAsync(conn => _innerQuery.Delete()); + } + } + + class SQLiteConnectionPool + { + class Entry + { + public SQLiteConnectionString ConnectionString { get; private set; } + public SQLiteConnectionWithLock Connection { get; private set; } + + public Entry(SQLiteConnectionString connectionString, SQLiteOpenFlags openFlags) + { + ConnectionString = connectionString; + Connection = new SQLiteConnectionWithLock(connectionString, openFlags); + } + + public void Close() + { + if (Connection == null) + return; + using (var l = Connection.Lock()) + { + Connection.Dispose(); + } + Connection = null; + } + } + + readonly Dictionary _entries = new Dictionary(); + readonly object _entriesLock = new object(); + + static readonly SQLiteConnectionPool _shared = new SQLiteConnectionPool(); + + /// + /// Gets the singleton instance of the connection tool. + /// + public static SQLiteConnectionPool Shared + { + get + { + return _shared; + } + } + + public SQLiteConnectionWithLock GetConnection(SQLiteConnectionString connectionString, SQLiteOpenFlags openFlags) + { + lock (_entriesLock) + { + Entry entry; + string key = connectionString.ConnectionString; + + if (!_entries.TryGetValue(key, out entry)) + { + entry = new Entry(connectionString, openFlags); + _entries[key] = entry; + } + + return entry.Connection; + } + } + + public void CloseConnection(SQLiteConnectionString connectionString, SQLiteOpenFlags openFlags) + { + var key = connectionString.ConnectionString; + + Entry entry; + lock (_entriesLock) + { + if (_entries.TryGetValue(key, out entry)) + { + _entries.Remove(key); + } + } + + entry.Close(); + } + + /// + /// Closes all connections managed by this pool. + /// + public void Reset() + { + List entries; + lock (_entriesLock) + { + entries = new List(_entries.Values); + _entries.Clear(); + } + + foreach (var e in entries) + { + e.Close(); + } + } + } + + /// + /// This is a normal connection except it contains a Lock method that + /// can be used to serialize access to the database. + /// + public class SQLiteConnectionWithLock : SQLiteConnection + { + readonly object _lockPoint = new object(); + + /// + /// Initializes a new instance of the class. + /// + /// Connection string containing the DatabasePath. + /// Open flags. + public SQLiteConnectionWithLock(SQLiteConnectionString connectionString, SQLiteOpenFlags openFlags) + : base(connectionString.DatabasePath, openFlags, connectionString.StoreDateTimeAsTicks, key: connectionString.Key) + { + } + + /// + /// Gets or sets a value indicating whether this skip lock. + /// + /// true if skip lock; otherwise, false. + public bool SkipLock { get; set; } + + /// + /// Lock the database to serialize access to it. To unlock it, call Dispose + /// on the returned object. + /// + /// The lock. + public IDisposable Lock() + { + return SkipLock ? (IDisposable)new FakeLockWrapper() : new LockWrapper(_lockPoint); + } + + class LockWrapper : IDisposable + { + object _lockPoint; + + public LockWrapper(object lockPoint) + { + _lockPoint = lockPoint; + Monitor.Enter(_lockPoint); + } + + public void Dispose() + { + Monitor.Exit(_lockPoint); + } + } + class FakeLockWrapper : IDisposable + { + public void Dispose() + { + } + } + } +} diff --git a/src/GitHub.Exports.Reactive/Api/IApiClient.cs b/src/GitHub.Exports.Reactive/Api/IApiClient.cs index a901c125c3..28bb4044ce 100644 --- a/src/GitHub.Exports.Reactive/Api/IApiClient.cs +++ b/src/GitHub.Exports.Reactive/Api/IApiClient.cs @@ -9,9 +9,15 @@ namespace GitHub.Api public interface IApiClient { HostAddress HostAddress { get; } + + // HACK: This is temporary. Should be removed in login refactor timeframe. + IGitHubClient GitHubClient { get; } + IObservable CreateRepository(NewRepository repository, string login, bool isUser); + IObservable ForkRepository(string owner, string name, NewRepositoryFork repository); IObservable CreateGist(NewGist newGist); - IObservable GetUser(); + IObservable GetUser(); + IObservable GetUser(string login); IObservable GetOrganizations(); /// /// Retrieves all repositories that belong to this user. @@ -23,19 +29,91 @@ public interface IApiClient /// /// IObservable GetRepositoriesForOrganization(string organization); - IObservable GetOrCreateApplicationAuthenticationCode( - Func> twoFactorChallengeHander, - string authenticationCode = null, - bool useOldScopes = false, - bool useFingerprint = true); + IObservable GetForks(string owner, string name); IObservable GetGitIgnoreTemplates(); IObservable GetLicenses(); IObservable DeleteApplicationAuthorization(int id, string twoFactorAuthorizationCode); + IObservable GetIssueComments(string owner, string name, int number); IObservable GetPullRequest(string owner, string name, int number); IObservable GetPullRequestFiles(string owner, string name, int number); + IObservable GetPullRequestReviewComments(string owner, string name, int number); IObservable GetPullRequestsForRepository(string owner, string name); IObservable CreatePullRequest(NewPullRequest pullRequest, string owner, string repo); + + /// + /// Posts a new PR review. + /// + /// The repository owner. + /// The repository name. + /// The pull request number. + /// The SHA of the commit being reviewed. + /// The review body. + /// The review event. + /// + IObservable PostPullRequestReview( + string owner, + string name, + int number, + string commitId, + string body, + PullRequestReviewEvent e); + + /// + /// Creates a new PR review comment. + /// + /// The repository owner. + /// The repository name. + /// The pull request number. + /// The comment body. + /// THe SHA of the commit to comment on. + /// The relative path of the file to comment on. + /// The line index in the diff to comment on. + /// + IObservable CreatePullRequestReviewComment( + string owner, + string name, + int number, + string body, + string commitId, + string path, + int position); + + /// + /// Creates a new PR review comment reply. + /// + /// The repository owner. + /// The repository name. + /// The pull request number. + /// The comment body. + /// The comment ID to reply to. + /// + IObservable CreatePullRequestReviewComment(string owner, string name, int number, string body, int inReplyTo); + + /// + /// Delete a PR review comment. + /// + /// The repository owner. + /// The repository name. + /// The pull request comment number. + IObservable DeletePullRequestReviewComment( + string owner, + string name, + int number); + + /// + /// Edits a PR review comment. + /// + /// The repository owner. + /// The repository name. + /// The pull request comment number. + /// The replacement comment body. + IObservable EditPullRequestReviewComment( + string owner, + string name, + int number, + string body); + IObservable GetBranches(string owner, string repo); IObservable GetRepositories(); IObservable GetRepository(string owner, string repo); diff --git a/src/GitHub.Exports.Reactive/Authentication/IDelegatingTwoFactorChallengeHandler.cs b/src/GitHub.Exports.Reactive/Authentication/IDelegatingTwoFactorChallengeHandler.cs new file mode 100644 index 0000000000..2590479433 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Authentication/IDelegatingTwoFactorChallengeHandler.cs @@ -0,0 +1,13 @@ +using System; +using Octokit; +using GitHub.ViewModels; +using GitHub.Api; + +namespace GitHub.Authentication +{ + public interface IDelegatingTwoFactorChallengeHandler : ITwoFactorChallengeHandler + { + void SetViewModel(IViewModel vm); + IViewModel CurrentViewModel { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/Authentication/ITwoFactorChallengeHandler.cs b/src/GitHub.Exports.Reactive/Authentication/ITwoFactorChallengeHandler.cs deleted file mode 100644 index cbc5e37ac4..0000000000 --- a/src/GitHub.Exports.Reactive/Authentication/ITwoFactorChallengeHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using Octokit; -using GitHub.ViewModels; - -namespace GitHub.Authentication -{ - public interface ITwoFactorChallengeHandler - { - void SetViewModel(IViewModel vm); - IViewModel CurrentViewModel { get; } - IObservable HandleTwoFactorException(TwoFactorAuthorizationException exception); - } -} diff --git a/src/GitHub.Exports.Reactive/Caches/AccountCacheItem.cs b/src/GitHub.Exports.Reactive/Caches/AccountCacheItem.cs index c74b329602..6d3a0eec10 100644 --- a/src/GitHub.Exports.Reactive/Caches/AccountCacheItem.cs +++ b/src/GitHub.Exports.Reactive/Caches/AccountCacheItem.cs @@ -2,6 +2,7 @@ using GitHub.Models; using GitHub.Primitives; using Octokit; +using Account = Octokit.Account; namespace GitHub.Caches { @@ -12,11 +13,6 @@ public static AccountCacheItem Create(Account apiAccount) return new AccountCacheItem(apiAccount); } - public static AccountCacheItem Create(UserAndScopes userAndScopes) - { - return new AccountCacheItem(userAndScopes.User); - } - public AccountCacheItem() { } diff --git a/src/GitHub.Exports.Reactive/Caches/ILoginCache.cs b/src/GitHub.Exports.Reactive/Caches/ILoginCache.cs deleted file mode 100644 index aecaa03a3b..0000000000 --- a/src/GitHub.Exports.Reactive/Caches/ILoginCache.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Reactive; -using Akavache; -using GitHub.Primitives; - -namespace GitHub.Caches -{ - public interface ILoginCache : IDisposable - { - IObservable GetLoginAsync(HostAddress hostAddress); - IObservable SaveLogin(string user, string password, HostAddress hostAddress); - IObservable EraseLogin(HostAddress hostAddress); - IObservable Flush(); - } -} diff --git a/src/GitHub.Exports.Reactive/Caches/ISharedCache.cs b/src/GitHub.Exports.Reactive/Caches/ISharedCache.cs index 06ed9b386c..1df074ca27 100644 --- a/src/GitHub.Exports.Reactive/Caches/ISharedCache.cs +++ b/src/GitHub.Exports.Reactive/Caches/ISharedCache.cs @@ -12,7 +12,6 @@ public interface ISharedCache : IDisposable { IBlobCache UserAccount { get; } IBlobCache LocalMachine { get; } - ISecureBlobCache Secure { get; } /// /// Retrieves the Enterpise Host Uri from cache if present. diff --git a/src/GitHub.Exports.Reactive/Collections/ISelectable.cs b/src/GitHub.Exports.Reactive/Collections/ISelectable.cs deleted file mode 100644 index f26de2b41a..0000000000 --- a/src/GitHub.Exports.Reactive/Collections/ISelectable.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ReactiveUI; - -namespace GitHub.Exports -{ - public interface ISelectable : IReactiveNotifyPropertyChanged - { - bool IsSelected { get; set; } - } -} diff --git a/src/GitHub.Exports.Reactive/Collections/ITrackingCollection.cs b/src/GitHub.Exports.Reactive/Collections/ITrackingCollection.cs deleted file mode 100644 index 23892fdaa2..0000000000 --- a/src/GitHub.Exports.Reactive/Collections/ITrackingCollection.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Reactive; - -namespace GitHub.Collections -{ - /// - /// TrackingCollection is a specialization of ObservableCollection that gets items from - /// an observable sequence and updates its contents in such a way that two updates to - /// the same object (as defined by an Equals call) will result in one object on - /// the list being updated (as opposed to having two different instances of the object - /// added to the list). - /// It is always sorted, either via the supplied comparer or using the default comparer - /// for T - /// - /// - public interface ITrackingCollection : IDisposable, - INotifyCollectionChanged, INotifyPropertyChanged, - IList, ICollection, IEnumerable - where T : class, ICopyable - { - /// - /// Sets up an observable as source for the collection. - /// - /// - /// An observable that will return all the items that are - /// fed via the original observer, for further processing by user code - /// if desired - IObservable Listen(IObservable obs); - IDisposable Subscribe(); - IDisposable Subscribe(Action onNext, Action onCompleted); - /// - /// Set a new comparer for the existing data. This will cause the - /// collection to be resorted and refiltered. - /// - /// The comparer method for sorting, or null if not sorting - Func Comparer { get; set; } - - /// - /// Set a new filter. This will cause the collection to be filtered - /// - /// The new filter, or null to not have any filtering - Func, bool> Filter { get; set; } - - /// - /// Set a comparer that determines whether the item being processed is newer than the same - /// item seen before. This is to prevent stale items from overriding newer items when data - /// is coming simultaneously from cache and from live data. Use a timestamp-like comparison - /// for best results - /// - /// The comparer method for sorting, or null if not sorting - Func NewerComparer { get; set; } - - void AddItem(T item); - void RemoveItem(T item); - /// - /// How long to delay between processing incoming items - /// - TimeSpan ProcessingDelay { get; set; } - IObservable OriginalCompleted { get; } - - /// - /// Returns the number of elements that the collection contains - /// regardless of filtering - /// - int UnfilteredCount { get; } - - bool Disposed { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Collections/TrackingCollection.cs b/src/GitHub.Exports.Reactive/Collections/TrackingCollection.cs deleted file mode 100644 index 18912e2e82..0000000000 --- a/src/GitHub.Exports.Reactive/Collections/TrackingCollection.cs +++ /dev/null @@ -1,1288 +0,0 @@ -#if !DISABLE_REACTIVEUI -using GitHub.VisualStudio.Helpers; -using ReactiveUI; -#else -using System.Windows.Threading; -#endif -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Linq; -using System.Collections.Specialized; -using System.ComponentModel; - -namespace GitHub.Collections -{ - public static class TrackingCollection - { - public static TrackingCollection Create(IObservable source, - Func comparer = null, - Func, bool> filter = null, - Func newer = null, - IScheduler scheduler = null) - where T : class, ICopyable - { - return new TrackingCollection(source, comparer, filter, newer, scheduler); - } - - public static ObservableCollection CreateListenerCollectionAndRun(IObservable source, - IList stickieItemsOnTop = null, - Func comparer = null, - Action onNext = null) - where T : class, ICopyable - { - var col = Create(source, comparer); - var ret = col.CreateListenerCollection(stickieItemsOnTop); - col.Subscribe(onNext ?? (_ => {}), () => {}); - return ret; - } - - public static ObservableCollection CreateListenerCollection(this ITrackingCollection tcol, - IList stickieItemsOnTop = null) - where T : class, ICopyable - { - if (stickieItemsOnTop == null) - { - stickieItemsOnTop = new T[0]; - } - - var col = new ObservableCollection(stickieItemsOnTop.Concat(tcol)); - tcol.CollectionChanged += (_, e) => UpdateStickieItems(col, e, stickieItemsOnTop); - return col; - } - - /// - /// Creates an observable collection that tracks an - /// and adds a sticky item to the top of the collection when a related selection is null. - /// - /// The type of items in the collection. - /// The source tracking collection - /// The sticky item to add to the top of the collection. - /// - /// The current selection. If null or equal to the sticky item then the sticky item will be - /// added to the collection. - /// - /// An . - public static ObservableCollection CreateListenerCollection(this ITrackingCollection tcol, - T stickieItemOnTop, - IObservable selection) - where T : class, ICopyable - { - Debug.Assert(stickieItemOnTop != null, "stickieItemOnTop may not be null in CreateListenerCollection"); - Debug.Assert(selection != null, "selection may not be null in CreateListenerCollection"); - - var stickieItems = new[] { stickieItemOnTop }; - var result = new ObservableCollection(tcol); - var hasSelection = false; - - tcol.CollectionChanged += (_, e) => - { - UpdateStickieItems(result, e, hasSelection ? stickieItems : null); - }; - - selection.Subscribe(x => - { - hasSelection = x != null && !object.Equals(x, stickieItemOnTop); - var hasStickie = result.FirstOrDefault() == stickieItemOnTop; - - if (hasSelection && !hasStickie) - { - result.Insert(0, stickieItemOnTop); - } - else if (!hasSelection && hasStickie) - { - result.Remove(stickieItemOnTop); - } - }); - - return result; - } - - static void UpdateStickieItems( - ObservableCollection col, - NotifyCollectionChangedEventArgs e, - IList stickieItemsOnTop) - { - var offset = 0; - if (stickieItemsOnTop != null) - { - if (object.Equals(col.FirstOrDefault(), stickieItemsOnTop.FirstOrDefault())) - offset = stickieItemsOnTop.Count; - } - - if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Move) - { - for (int i = 0, oldIdx = e.OldStartingIndex, newIdx = e.NewStartingIndex; - i < e.OldItems.Count; i++, oldIdx++, newIdx++) - { - col.Move(oldIdx + offset, newIdx + offset); - } - } - else if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add) - { - foreach (T item in e.NewItems) - col.Add(item); - } - else if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Remove) - { - foreach (T item in e.OldItems) - col.Remove(item); - } - else if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Replace) - { - for (int i = 0, idx = e.OldStartingIndex; i < e.OldItems.Count; i++, idx++) - col[idx + offset] = (T)e.NewItems[i]; - } - else if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset) - { - col.Clear(); - if (stickieItemsOnTop != null) - { - foreach (var item in stickieItemsOnTop) - col.Add(item); - } - } - } - } - - /// - /// TrackingCollection is a specialization of ObservableCollection that gets items from - /// an observable sequence and updates its contents in such a way that two updates to - /// the same object (as defined by an Equals call) will result in one object on - /// the list being updated (as opposed to having two different instances of the object - /// added to the list). - /// It is always sorted, either via the supplied comparer or using the default comparer - /// for T - /// - /// - public class TrackingCollection : ObservableCollection, ITrackingCollection, IDisposable - where T : class, ICopyable - { - enum TheAction - { - None, - Move, - Add, - Insert, - Remove, - Ignore - } - - bool isChanging; - - Func comparer; - Func, bool> filter; - Func newer; // comparer to check whether the item being processed is newer than the existing one - - IObservable source; - IConnectableObservable dataPump; - IConnectableObservable cachePump; - ConcurrentQueue cache; - - ReplaySubject signalHaveData; - ReplaySubject signalNeedData; - ReplaySubject dataListener; - - bool resetting = false; - - readonly CompositeDisposable disposables = new CompositeDisposable(); - readonly CompositeDisposable pumpDisposables = new CompositeDisposable(); - - readonly IScheduler scheduler; - readonly List original = new List(); -#if DEBUG - public IList DebugInternalList => original; -#endif - - // lookup optimizations - // for speeding up IndexOf in the unfiltered list - readonly Dictionary sortedIndexCache = new Dictionary(); - - // for speeding up IndexOf in the filtered list - readonly Dictionary filteredIndexCache = new Dictionary(); - - bool originalSourceIsCompleted; - bool signalOriginalSourceCompletion; - ReplaySubject originalSourceCompleted; - public IObservable OriginalCompleted => originalSourceCompleted; - - TimeSpan requestedDelay; - readonly TimeSpan fuzziness; - - public TimeSpan ProcessingDelay - { - get { return requestedDelay; } - set { requestedDelay = value; } - } - - /// - /// Returns the number of elements that the collection contains - /// regardless of filtering - /// - public int UnfilteredCount => original.Count; - - bool ManualProcessing => cache.IsEmpty && originalSourceIsCompleted; - - public TrackingCollection(Func comparer = null, Func, bool> filter = null, - Func newer = null, IScheduler scheduler = null) - { - cache = new ConcurrentQueue(); - ProcessingDelay = TimeSpan.FromMilliseconds(10); - fuzziness = TimeSpan.FromMilliseconds(1); - -#if DISABLE_REACTIVEUI - this.scheduler = GetScheduler(scheduler); -#else - this.scheduler = scheduler ?? RxApp.MainThreadScheduler; -#endif - this.comparer = comparer ?? Comparer.Default.Compare; - this.filter = filter; - this.newer = newer; - } - - public TrackingCollection(IObservable source, - Func comparer = null, - Func, bool> filter = null, - Func newer = null, - IScheduler scheduler = null) - : this(comparer, filter, newer, scheduler) - { - Listen(source); - } - - /// - /// Sets up an observable as source for the collection. - /// - /// - /// An observable that will return all the items that are - /// fed via the original observer, for further processing by user code - /// if desired - public IObservable Listen(IObservable obs) - { - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - - Reset(); - - // ManualResetEvent uses the realtime clock for accurate <50ms delays - var waitHandle = new ManualResetEventSlim(); - - // empty the source observable as fast as possible - // to the cache queue, and signal that data is available - // for processing - dataPump = obs - .Catch(ex => - { - originalSourceCompleted.OnError(ex); - return Observable.Throw(ex); - }) - .Do(data => - { - cache.Enqueue(new ActionData(data)); - signalHaveData.OnNext(Unit.Default); - }) - .Finally(() => - { - if (disposed) - return; - - originalSourceIsCompleted = true; - if (!cache.IsEmpty) - { - signalOriginalSourceCompletion = true; - } - else - { - originalSourceCompleted.OnNext(Unit.Default); - originalSourceCompleted.OnCompleted(); - signalNeedData.OnCompleted(); - signalHaveData.OnCompleted(); - } - }) - .Publish(); - - // when both signalHaveData and signalNeedData produce a value, dataListener gets a value - // this will empty the queue of items that have been cached in regular intervals according - // to the requested delay - cachePump = signalHaveData - .Zip(signalNeedData, (a, b) => Unit.Default) - .ObserveOn(TaskPoolScheduler.Default) - .TimeInterval() - .Select(interval => - { - var delay = CalculateProcessingDelay(interval); - waitHandle.Wait(delay); - dataListener.OnNext(GetFromQueue()); - return Unit.Default; - }) - .Publish(); - - source = dataListener - .Where(data => data.Item != null) - .ObserveOn(scheduler) - .Select(data => - { - data = ProcessItem(data, original); - - // if we're removing an item that doesn't exist, ignore it - if (data.TheAction == TheAction.Remove && data.OldPosition < 0) - return ActionData.Default; - - data = SortedNone(data); - data = SortedAdd(data); - data = SortedInsert(data); - data = SortedMove(data); - data = SortedRemove(data); - data = CheckFilter(data); - data = FilteredAdd(data); - data = CalculateIndexes(data); - data = FilteredNone(data); - data = FilteredInsert(data); - data = FilteredMove(data); - data = FilteredRemove(data); - return data; - }) - .Do(_ => - { - if (ManualProcessing) - { - if (signalOriginalSourceCompletion) - { - signalOriginalSourceCompletion = false; - originalSourceCompleted.OnNext(Unit.Default); - originalSourceCompleted.OnCompleted(); - } - } - else - signalNeedData.OnNext(Unit.Default); - }) - .Where(data => data.Item != null) - .Select(data => data.Item) - .Publish() - .RefCount(); - - return source; - } - - /// - /// Set a new comparer for the existing data. This will cause the - /// collection to be resorted and refiltered. - /// - /// The comparer method for sorting, or null if not sorting - public Func Comparer - { - get - { - return comparer; - } - set - { - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - SetAndRecalculateSort(value); - Filter = filter; - } - } - - /// - /// Set a new filter. This will cause the collection to be filtered - /// - /// The new filter, or null to not have any filtering - public Func, bool> Filter - { - get - { - return filter; - } - set - { - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - SetAndRecalculateFilter(value); - } - } - - /// - /// Set a comparer that determines whether the item being processed is newer than the same - /// item seen before. This is to prevent stale items from overriding newer items when data - /// is coming simultaneously from cache and from live data. Use a timestamp-like comparison - /// for best results - /// - /// The comparer method for sorting, or null if not sorting - public Func NewerComparer - { - get - { - return newer; - } - set - { - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - newer = value; - } - } - - public IDisposable Subscribe() - { - if (source == null) - throw new InvalidOperationException("No source observable has been set. Call Listen or pass an observable to the constructor"); - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - pumpDisposables.Add(source.Subscribe()); - StartQueue(); - return this; - } - - public IDisposable Subscribe(Action onNext, Action onCompleted) - { - if (source == null) - throw new InvalidOperationException("No source observable has been set. Call Listen or pass an observable to the constructor"); - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - pumpDisposables.Add(source.Subscribe(onNext, onCompleted)); - StartQueue(); - return this; - } - - public void AddItem(T item) - { - if (source == null) - throw new InvalidOperationException("No source observable has been set. Call Listen or pass an observable to the constructor"); - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - - if (ManualProcessing) - dataListener.OnNext(new ActionData(item)); - else - { - cache.Enqueue(new ActionData(item)); - signalHaveData.OnNext(Unit.Default); - } - } - - public void RemoveItem(T item) - { - if (source == null) - throw new InvalidOperationException("No source observable has been set. Call Listen or pass an observable to the constructor"); - if (disposed) - throw new ObjectDisposedException("TrackingCollection"); - - if (ManualProcessing) - dataListener.OnNext(new ActionData(TheAction.Remove, item)); - else - { - cache.Enqueue(new ActionData(TheAction.Remove, item)); - signalHaveData.OnNext(Unit.Default); - } - } - - void SetAndRecalculateSort(Func theComparer) - { - comparer = theComparer ?? Comparer.Default.Compare; - RecalculateSort(original, 0, original.Count); - } - - void RecalculateSort(List list, int start, int end) - { - sortedIndexCache.Clear(); - list.Sort(start, end, new LambdaComparer(comparer)); - } - - void SetAndRecalculateFilter(Func, bool> newFilter) - { - ClearItems(); - filter = newFilter; - RecalculateFilter(original, 0, 0, original.Count, true); - } - - #region Source pipeline processing - - int StartQueue() - { - pumpDisposables.Add(cachePump.Connect()); - pumpDisposables.Add(dataPump.Connect()); - signalNeedData.OnNext(Unit.Default); - return 0; - } - - ActionData GetFromQueue() - { - try - { - ActionData d = ActionData.Default; - if (cache?.TryDequeue(out d) ?? false) - return d; - } - catch { } - return ActionData.Default; - } - - ActionData ProcessItem(ActionData data, List list) - { - ActionData ret; - T item = data.Item; - - var idx = GetIndexUnfiltered(item); - - if (data.TheAction == TheAction.Remove) - return new ActionData(TheAction.Remove, original, item, null, idx, idx); - - if (idx >= 0) - { - var old = list[idx]; - if (newer != null) - { - // the object is not "newer" than the one we have, ignore it - if (newer(item, old) >= 0) - return new ActionData(TheAction.Ignore, list, item, null, idx, idx); - } - - var comparison = comparer(item, old); - - // no sorting to be done, just replacing the element in-place - if (comparison == 0) - ret = new ActionData(TheAction.None, list, item, null, idx, idx); - else - // element has moved, save the original object, because we want to update its contents and move it - // but not overwrite the instance. - ret = new ActionData(TheAction.Move, list, item, old, comparison, idx); - } - // the element doesn't exist yet - // figure out whether we're larger than the last element or smaller than the first or - // if we have to place the new item somewhere in the middle - else if (list.Count > 0) - { - if (comparer(list[0], item) >= 0) - ret = new ActionData(TheAction.Insert, list, item, null, 0, -1); - - else if (comparer(list[list.Count - 1], item) <= 0) - ret = new ActionData(TheAction.Add, list, item, null, list.Count, -1); - - // this happens if the original observable is not sorted, or it's sorting order doesn't - // match the comparer that has been set - else - { - idx = BinarySearch(list, item, comparer); - if (idx < 0) - idx = ~idx; - ret = new ActionData(TheAction.Insert, list, item, null, idx, -1); - } - } - else - ret = new ActionData(TheAction.Add, list, item, null, list.Count, -1); - return ret; - } - - ActionData SortedNone(ActionData data) - { - if (data.TheAction != TheAction.None) - return data; - data.List[data.OldPosition].CopyFrom(data.Item); - return data; - } - - ActionData SortedAdd(ActionData data) - { - if (data.TheAction != TheAction.Add) - return data; - data.List.Add(data.Item); - RaiseUnfilteredCountPropertyChange(); - return data; - } - - ActionData SortedInsert(ActionData data) - { - if (data.TheAction != TheAction.Insert) - return data; - data.List.Insert(data.Position, data.Item); - UpdateIndexCache(data.Position, data.List.Count, data.List, sortedIndexCache); - RaiseUnfilteredCountPropertyChange(); - return data; - } - ActionData SortedMove(ActionData data) - { - if (data.TheAction != TheAction.Move) - return data; - data.OldItem.CopyFrom(data.Item); - var pos = FindNewPositionForItem(data.OldPosition, data.Position < 0, data.List, comparer, sortedIndexCache); - // the old item is the one moving around - return new ActionData(data, pos); - } - - ActionData SortedRemove(ActionData data) - { - if (data.TheAction != TheAction.Remove) - return data; - - // unfiltered list update - sortedIndexCache.Remove(data.Item); - UpdateIndexCache(data.List.Count - 1, data.OldPosition, data.List, sortedIndexCache); - data.List.Remove(data.Item); - RaiseUnfilteredCountPropertyChange(); - return data; - } - - ActionData CheckFilter(ActionData data) - { - var isIncluded = true; - if (data.TheAction == TheAction.Remove) - isIncluded = false; - else if (filter != null) - isIncluded = filter(data.Item, data.Position, this); - return new ActionData(data, isIncluded); - } - - ActionData FilteredAdd(ActionData data) - { - if (data.TheAction != TheAction.Add) - return data; - - if (data.IsIncluded) - InternalAddItem(data.Item); - return data; - } - - ActionData CalculateIndexes(ActionData data) - { - var index = GetIndexFiltered(data.Item); - var indexPivot = GetLiveListPivot(data.Position, data.List); - return new ActionData(data, index, indexPivot); - } - - ActionData FilteredNone(ActionData data) - { - if (data.TheAction != TheAction.None) - return data; - - // nothing has changed as far as the live list is concerned - if ((data.IsIncluded && data.Index >= 0) || !data.IsIncluded && data.Index < 0) - return data; - - // wasn't on the live list, but it is now - if (data.IsIncluded && data.Index < 0) - InsertAndRecalculate(data.List, data.Item, data.IndexPivot, data.Position, false); - - // was on the live list, it's not anymore - else - RemoveAndRecalculate(data.List, data.Item, data.IndexPivot, data.Position); - - return data; - } - - ActionData FilteredInsert(ActionData data) - { - if (data.TheAction != TheAction.Insert) - return data; - - if (data.IsIncluded) - InsertAndRecalculate(data.List, data.Item, data.IndexPivot, data.Position, false); - - // need to recalculate the filter because inserting an object (even if it's not itself visible) - // can change visibility of other items after it - else - RecalculateFilter(data.List, data.IndexPivot, data.Position, data.List.Count); - return data; - } - - /// - /// Checks if the object being moved affects the filtered list in any way and update - /// the list accordingly - /// - /// - /// - ActionData FilteredMove(ActionData data) - { - if (data.TheAction != TheAction.Move) - return data; - - var start = data.OldPosition < data.Position ? data.OldPosition : data.Position; - var end = data.Position > data.OldPosition ? data.Position : data.OldPosition; - - // if there's no filter, the filtered list is equal to the unfiltered list, just move - if (filter == null) - { - MoveAndRecalculate(data.List, data.Index, data.IndexPivot, start, end); - return data; - } - - var filteredListChanged = false; - var startPosition = Int32.MaxValue; - // check if the filtered list is affected indirectly by the move (eg., if the filter involves position of items, - // moving an item outside the bounds of the filter can affect the items being currently shown/hidden) - if (Count > 0) - { - startPosition = GetIndexUnfiltered(this[0]); - var endPosition = GetIndexUnfiltered(this[Count - 1]); - // true if the filtered list has been indirectly affected by this objects' move - filteredListChanged = (!filter(this[0], startPosition, this) || !filter(this[Count - 1], endPosition, this)); - } - - // the move caused the object to not be visible in the live list anymore, so remove - if (!data.IsIncluded && data.Index >= 0) - RemoveAndRecalculate(data.List, data.Item, filteredListChanged ? 0 : (data.Position < data.OldPosition ? data.IndexPivot : data.Index), filteredListChanged ? startPosition : start); - - // the move caused the object to become visible in the live list, insert it - // and recalculate all the other things on the live list from the start position - else if (data.IsIncluded && data.Index < 0) - { - start = startPosition < start ? startPosition : start; - InsertAndRecalculate(data.List, data.Item, data.IndexPivot, start, filteredListChanged); - } - - // move the object and recalculate the filter between the bounds of the move - else if (data.IsIncluded) - MoveAndRecalculate(data.List, data.Index, data.IndexPivot, start, end); - - // recalculate the filter for every item, there's no way of telling what changed - else if (filteredListChanged) - RecalculateFilter(data.List, 0, 0, data.List.Count); - - return data; - } - - /// - /// Checks if the object being moved affects the filtered list in any way and update - /// the list accordingly - /// - /// - /// - ActionData FilteredRemove(ActionData data) - { - if (data.TheAction != TheAction.Remove) - return data; - - var filteredListChanged = false; - var startPosition = Int32.MaxValue; - // check if the filtered list is affected indirectly by the move (eg., if the filter involves position of items, - // removing an item outside the bounds of the filter can affect the items being currently shown/hidden) - if (filter != null && Count > 0) - { - startPosition = GetIndexUnfiltered(this[0]); - var endPosition = GetIndexUnfiltered(this[Count - 1]); - // true if the filtered list has been indirectly affected by this objects' removal - filteredListChanged = (!filter(this[0], startPosition, this) || !filter(this[Count - 1], endPosition, this)); - } - - // remove the object if it was visible in the first place - if (data.Index >= 0) - RemoveAndRecalculate(data.List, data.Item, filteredListChanged ? 0 : data.IndexPivot, filteredListChanged ? startPosition : data.Position); - - // recalculate the filter for every item, there's no way of telling what changed - else if (filteredListChanged) - RecalculateFilter(data.List, 0, 0, data.List.Count); - - return data; - } - - /// - /// Compensate time between items by time taken in processing them - /// so that the average time between an item being processed - /// is +- the requested processing delay. - /// - TimeSpan CalculateProcessingDelay(TimeInterval interval) - { - var delay = TimeSpan.Zero; - if (requestedDelay > TimeSpan.Zero) - { - var time = interval.Interval; - if (time > requestedDelay + fuzziness) - delay -= time - requestedDelay; - else if (time < requestedDelay + fuzziness) - delay += requestedDelay - time; - delay = delay < TimeSpan.Zero ? TimeSpan.Zero : delay; - } - - return delay; - } - - #endregion - - /// - /// Insert an object into the live list at liveListCurrentIndex and recalculate - /// positions for all objects from the position - /// - /// The unfiltered, sorted list of items - /// - /// - /// Index of the unfiltered, sorted list to start reevaluating the filtered list - /// Whether the whole filtered list needs to be reevaluated - void InsertAndRecalculate(IList list, T item, int index, int position, bool rescanAll) - { - InternalInsertItem(item, index); - if (rescanAll) - index = 0; // reevaluate filter from the start of the filtered list - else - { - // if the item in position is different from the item we're inserting, - // that means that this insertion might require some filter reevaluation of items - // before the one we're inserting. We need to figure out if the item in position - // is in the filtered list, and if it is, then that's where we need to start - // reevaluating the filter. If it isn't, then there's no need to reevaluate from - // there - var needToBacktrack = false; - if (!Equals(item, list[position])) - { - var idx = GetIndexFiltered(list[position]); - if (idx >= 0) - { - needToBacktrack = true; - index = idx; - } - } - - if (!needToBacktrack) - { - index++; - position++; - } - } - RecalculateFilter(list, index, position, list.Count); - } - - /// - /// Remove an object from the live list at index and recalculate positions - /// for all objects after that - /// - /// The unfiltered, sorted list of items - /// - /// The index in the live list - /// The position in the sorted, unfiltered list - void RemoveAndRecalculate(IList list, T item, int index, int position) - { - InternalRemoveItem(item); - RecalculateFilter(list, index, position, list.Count); - } - - /// - /// Move an object in the live list and recalculate positions - /// for all objects between the bounds of the affected indexes - /// - /// The unfiltered, sorted list of items - /// Index in the live list where the object is - /// Index in the live list where the object is going to be - /// Index in the unfiltered, sorted list to start reevaluating the filter - /// Index in the unfiltered, sorted list to end reevaluating the filter - void MoveAndRecalculate(IList list, int from, int to, int start, int end) - { - if (start > end) - throw new ArgumentOutOfRangeException(nameof(start), "Start cannot be bigger than end, evaluation of the filter goes forward."); - - InternalMoveItem(from, to); - RecalculateFilter(list, (from < to ? from : to), start, end); - } - - /// - /// Go through the list of objects and adjust their "visibility" in the live list - /// (by removing/inserting as needed). - /// - /// The unfiltered, sorted list of items - /// Index in the live list corresponding to the start index of the object list - /// Start index of the object list - /// End index of the object list - /// If there's no filter set, this method does nothing. Pass true to force a reevaluation - /// of the whole list regardless of filter. - void RecalculateFilter(IList list, int index, int start, int end, bool force = false) - { - if (filter == null && !force) - return; - - for (int i = start; i < end; i++) - { - var item = list[i]; - var idx = GetIndexFiltered(item); - var isIncluded = filter != null ? filter(item, i, this) : true; - - // element is included - if (isIncluded) - { - // element wasn't included before - if (idx < 0) - { - if (index == Count) - InternalAddItem(item); - else - InternalInsertItem(item, index); - } - index++; - } - // element is not included and was before - else if (idx >= 0) - InternalRemoveItem(item); - } - } - - /// - /// Get the index in the live list of an object at position. - /// This will scan back to the beginning of the live list looking for - /// the closest left neighbour and return the position after that. - /// - /// The index of an object in the unfiltered, sorted list that we want to map to the filtered live list - /// The unfiltered, sorted list of items - /// - int GetLiveListPivot(int position, IList list) - { - var index = -1; - if (position > 0) - { - for (int i = position - 1; i >= 0; i--) - { - index = GetIndexFiltered(list[i]); - if (index >= 0) - { - // found an element to the left of what we want, so now we know the index where to start - // manipulating the list - index++; - break; - } - } - } - - // there was no element to the left of the one we want, start at the beginning of the live list - if (index < 0) - index = 0; - return index; - } - - /// - /// Adds an item to the filtered list - /// - void InternalAddItem(T item) - { - isChanging = true; - Add(item); - } - - /// - /// Inserts an item into the filtered list - /// - void InternalInsertItem(T item, int position) - { - isChanging = true; - Insert(position, item); - } - - protected override void InsertItem(int index, T item) - { -#if DEBUG && !DISABLE_REACTIVEUI - if (Splat.ModeDetector.InDesignMode() && !isChanging) - { - base.InsertItem(index, item); - return; - } -#endif - - if (!isChanging) - throw new InvalidOperationException("Items cannot be manually inserted into the collection."); - isChanging = false; - - filteredIndexCache.Add(item, index); - UpdateIndexCache(index, Count, Items, filteredIndexCache); - base.InsertItem(index, item); - } - - /// - /// Removes an item from the filtered list - /// - void InternalRemoveItem(T item) - { - int idx; - // this only happens if the cache is lazy, which is not the case at this time - if (!filteredIndexCache.TryGetValue(item, out idx)) - { - Debug.Assert(false); - return; - } - - isChanging = true; - RemoveItem(idx); - } - - protected override void RemoveItem(int index) - { -#if DEBUG && !DISABLE_REACTIVEUI - if (Splat.ModeDetector.InDesignMode() && !isChanging) - { - base.RemoveItem(index); - return; - } -#endif - - if (!isChanging) - throw new InvalidOperationException("Items cannot be removed from the collection except via RemoveItem(T)."); - isChanging = false; - filteredIndexCache.Remove(this[index]); - UpdateIndexCache(Count - 1, index, Items, filteredIndexCache); - base.RemoveItem(index); - } - - /// - /// Moves an item in the filtered list - /// - void InternalMoveItem(int positionFrom, int positionTo) - { - isChanging = true; - positionTo = positionFrom < positionTo ? positionTo - 1 : positionTo; - Move(positionFrom, positionTo); - } - - protected override void MoveItem(int oldIndex, int newIndex) - { -#if DEBUG && !DISABLE_REACTIVEUI - if (Splat.ModeDetector.InDesignMode() && !isChanging) - { - base.MoveItem(oldIndex, newIndex); - return; - } -#endif - - if (!isChanging) - throw new InvalidOperationException("Items cannot be manually moved in the collection."); - isChanging = false; - - if (oldIndex != newIndex) - { - UpdateIndexCache(newIndex, oldIndex, Items, filteredIndexCache); - filteredIndexCache[this[oldIndex]] = newIndex; - } - base.MoveItem(oldIndex, newIndex); - } - - protected override void ClearItems() - { - filteredIndexCache.Clear(); - base.ClearItems(); - } - - /// - /// The filtered list always has a cache filled up with - /// all the items that are visible. - /// - int GetIndexFiltered(T item) - { - int idx; - if (filteredIndexCache.TryGetValue(item, out idx)) - return idx; - return -1; - } - - /// - /// The unfiltered has a lazy cache that gets filled - /// up when something is looked up. - /// - /// - /// - int GetIndexUnfiltered(T item) - { - int ret; - if (!sortedIndexCache.TryGetValue(item, out ret)) - { - ret = original.IndexOf(item); - if (ret >= 0) - sortedIndexCache.Add(original[ret], ret); - - } - return ret; - } - - /// - /// When items get moved/inserted/deleted, update the indexes in the cache. - /// If start < end, we're inserting an item and want to shift all the indexes - /// between start and end to the right (+1) - /// If start > end, we're removing an item and want to shift all - /// indexes to the left (-1). - /// - static void UpdateIndexCache(int start, int end, IList list, Dictionary indexCache) - { - var change = end < start ? -1 : 1; - for (int i = start; i != end; i += change) - if (indexCache.ContainsKey(list[i])) - indexCache[list[i]] += change; - } - - static int FindNewPositionForItem(int idx, bool lower, IList list, Func comparer, Dictionary indexCache) - { - var i = idx; - if (lower) // replacing element has lower sorting order, find the correct spot towards the beginning - for (var pos = i - 1; i > 0 && comparer(list[i], list[pos]) < 0; i--, pos--) - { - Swap(list, i, pos); - SwapIndex(list, i, 1, indexCache); - } - - else // replacing element has higher sorting order, find the correct spot towards the end - for (var pos = i + 1; i < list.Count - 1 && comparer(list[i], list[pos]) > 0; i++, pos++) - { - Swap(list, i, pos); - SwapIndex(list, i, -1, indexCache); - } - indexCache[list[i]] = i; - return i; - } - - /// - /// Swap two elements - /// - static void Swap(IList list, int left, int right) - { - var l = list[left]; - list[left] = list[right]; - list[right] = l; - } - - static void SwapIndex(IList list, int pos, int change, Dictionary cache) - { - if (cache.ContainsKey(list[pos])) - cache[list[pos]] += change; - } - - static int BinarySearch(List list, T item, Func comparer) - { - return list.BinarySearch(item, new LambdaComparer(comparer)); - } - -#if DISABLE_REACTIVEUI - static IScheduler GetScheduler(IScheduler scheduler) - { - Dispatcher d = null; - if (scheduler == null) - d = Dispatcher.FromThread(Thread.CurrentThread); - return scheduler ?? (d != null ? new DispatcherScheduler(d) : null as IScheduler) ?? CurrentThreadScheduler.Instance; - } -#endif - void RaiseUnfilteredCountPropertyChange() - { - OnPropertyChanged(new PropertyChangedEventArgs(nameof(UnfilteredCount))); - } - - void Reset() - { - if (resetting) - return; - - resetting = true; - - pumpDisposables.Clear(); - disposables.Clear(); - originalSourceIsCompleted = false; - signalOriginalSourceCompletion = false; - cache = new ConcurrentQueue(); - dataListener = new ReplaySubject(); - disposables.Add(dataListener); - signalHaveData = new ReplaySubject(); - disposables.Add(signalHaveData); - signalNeedData = new ReplaySubject(); - disposables.Add(signalNeedData); - originalSourceCompleted = new ReplaySubject(); - - resetting = false; - } - - bool disposed = false; - public bool Disposed => disposed; - - void Dispose(bool disposing) - { - if (disposing) - { - if (!disposed) - { - disposed = true; - pumpDisposables.Dispose(); - disposables.Dispose(); - cache = null; - } - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - struct ActionData - { - public static readonly ActionData Default = new ActionData(null); - - readonly public TheAction TheAction; - readonly public int Position; - readonly public int OldPosition; - readonly public int Index; - readonly public int IndexPivot; - readonly public bool IsIncluded; - readonly public T Item; - readonly public T OldItem; - readonly public List List; - - public ActionData(ActionData other, int index, int indexPivot) - : this(other.TheAction, other.List, - other.Item, other.OldItem, - other.Position, other.OldPosition, - index, indexPivot, - other.IsIncluded) - { - } - - public ActionData(ActionData other, int position) - : this(other.TheAction, other.List, - other.Item, other.OldItem, - position, other.OldPosition, - other.Index, other.IndexPivot, - other.IsIncluded) - { - } - - public ActionData(ActionData other, bool isIncluded) - : this(other.TheAction, other.List, - other.Item, other.OldItem, - other.Position, other.OldPosition, - other.Index, other.IndexPivot, - isIncluded) - { - } - - public ActionData(TheAction action, List list, T item, T oldItem, int position, int oldPosition) - : this(action, list, - item, oldItem, - position, oldPosition, - -1, -1, false) - { - } - - public ActionData(T item) - : this(TheAction.None, item) - { - } - - public ActionData(TheAction action, T item) - : this(action, null, - item, null, - -1, -1, - -1, -1, false) - { - } - - public ActionData(TheAction action, List list, T item, T oldItem, int position, int oldPosition, int index, int indexPivot, bool isIncluded) - { - TheAction = action; - Item = item; - OldItem = oldItem; - Position = position; - OldPosition = oldPosition; - List = list; - Index = index; - IndexPivot = indexPivot; - IsIncluded = isIncluded; - } - } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs b/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs index 33d2d29714..36737b61aa 100644 --- a/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs +++ b/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs @@ -1,51 +1,20 @@ -using System.Reactive.Linq; +using System; +using System.Threading.Tasks; +using GitHub.Factories; using GitHub.Models; -using System; -using ReactiveUI; -using GitHub.Primitives; -using System.Linq; +using GitHub.Services; namespace GitHub.Extensions { public static class ConnectionManagerExtensions { - public static IObservable IsLoggedIn(this IConnectionManager cm, IRepositoryHosts hosts) + public static async Task GetModelService( + this IConnectionManager cm, + LocalRepositoryModel repository, + IModelServiceFactory factory) { - Guard.ArgumentNotNull(hosts, nameof(hosts)); - return cm.Connections.ToObservable() - .SelectMany(c => c.Login()) - .Any(c => hosts.LookupHost(c.HostAddress).IsLoggedIn); - } - - public static IObservable IsLoggedIn(this IConnectionManager cm, IRepositoryHosts hosts, HostAddress address) - { - Guard.ArgumentNotNull(hosts, nameof(hosts)); - Guard.ArgumentNotNull(address, nameof(address)); - return cm.Connections.ToObservable() - .Where(c => c.HostAddress.Equals(address)) - .SelectMany(c => c.Login()) - .Any(c => hosts.LookupHost(c.HostAddress).IsLoggedIn); - } - - public static IObservable IsLoggedIn(this IConnection connection, IRepositoryHosts hosts) - { - Guard.ArgumentNotNull(hosts, nameof(hosts)); - return connection?.Login().Any(c => hosts.LookupHost(c.HostAddress).IsLoggedIn) ?? Observable.Return(false); - } - - public static IObservable GetLoggedInConnections(this IConnectionManager cm, IRepositoryHosts hosts) - { - Guard.ArgumentNotNull(hosts, nameof(hosts)); - return cm.Connections.ToObservable() - .SelectMany(c => c.Login()) - .Where(c => hosts.LookupHost(c.HostAddress).IsLoggedIn); - } - - public static IObservable LookupConnection(this IConnectionManager cm, ILocalRepositoryModel repository) - { - return Observable.Return(repository?.CloneUrl != null - ? cm.Connections.FirstOrDefault(c => c.HostAddress.Equals(HostAddress.Create(repository.CloneUrl))) - : null); + var connection = await cm.GetConnection(repository); + return connection?.IsLoggedIn == true ? await factory.CreateAsync(connection) : null; } } } diff --git a/src/GitHub.Exports.Reactive/Factories/IApiClientFactory.cs b/src/GitHub.Exports.Reactive/Factories/IApiClientFactory.cs index 014de51281..5253d3f470 100644 --- a/src/GitHub.Exports.Reactive/Factories/IApiClientFactory.cs +++ b/src/GitHub.Exports.Reactive/Factories/IApiClientFactory.cs @@ -1,11 +1,16 @@ using GitHub.Api; using GitHub.Primitives; using System; +using System.Threading.Tasks; +using Octokit; +using GitHub.Services; namespace GitHub.Factories { public interface IApiClientFactory { - IApiClient Create(HostAddress hostAddress); + Task CreateGitHubClient(HostAddress hostAddress); + + Task Create(HostAddress hostAddress); } } diff --git a/src/GitHub.Exports.Reactive/Factories/IModelServiceFactory.cs b/src/GitHub.Exports.Reactive/Factories/IModelServiceFactory.cs new file mode 100644 index 0000000000..8e476cd607 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Factories/IModelServiceFactory.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; + +namespace GitHub.Factories +{ + public interface IModelServiceFactory + { + Task CreateAsync(IConnection connection); + IModelService CreateBlocking(IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/Factories/IRepositoryHostFactory.cs b/src/GitHub.Exports.Reactive/Factories/IRepositoryHostFactory.cs deleted file mode 100644 index e4b303aa78..0000000000 --- a/src/GitHub.Exports.Reactive/Factories/IRepositoryHostFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using GitHub.Models; -using GitHub.Primitives; - -namespace GitHub.Factories -{ - public interface IRepositoryHostFactory : IDisposable - { - IRepositoryHost Create(HostAddress hostAddress); - void Remove(IRepositoryHost host); - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj index 7f11f6a610..5a1579f915 100644 --- a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj +++ b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj @@ -1,198 +1,31 @@ - - - - + - Debug - AnyCPU - {E4ED0537-D1D9-44B6-9212-3096D7C3F7A1} - Library - Properties + net46 GitHub - GitHub.Exports.Reactive - v4.6.1 - 512 - Internal - - - bin\$(Configuration)\ - - - - true full - false - DEBUG;TRACE - prompt - 4 - true - ..\common\GitHubVS.ruleset - true - true - - - pdbonly - true - TRACE - prompt - 4 - true - true - true - ..\common\GitHubVS.ruleset - - - ..\..\script\Key.snk - true - false + true + + + - - ..\..\packages\LibGit2Sharp.0.22.0\lib\net40\LibGit2Sharp.dll - True - - - - - - - ..\..\packages\Rx-Core.2.2.5-custom\lib\net45\System.Reactive.Core.dll - True - - - ..\..\packages\Rx-Interfaces.2.2.5-custom\lib\net45\System.Reactive.Interfaces.dll - True - - - ..\..\packages\Rx-Linq.2.2.5-custom\lib\net45\System.Reactive.Linq.dll - True - - - ..\..\packages\Rx-PlatformServices.2.2.5-custom\lib\net45\System.Reactive.PlatformServices.dll - True - - - ..\..\packages\Rx-XAML.2.2.5-custom\lib\net45\System.Reactive.Windows.Threading.dll - True - - - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Key.snk - - - - - - - Properties\SolutionInfo.cs - - - - - - - - - - - - - - - - - - + + + + + + + - - {b4e665e5-6caf-4414-a6e2-8de1c3bcf203} - Akavache_Net45 - False - - - {08dd4305-7787-4823-a53f-4d0f725a07f3} - Octokit - - - {1ce2d235-8072-4649-ba5a-cfb1af8776e0} - ReactiveUI_Net45 - - - {252ce1c2-027a-4445-a3c2-e4d6c80a935a} - Splat-Net45 - - - {9aea02db-02b5-409c-b0ca-115d05331a6b} - GitHub.Exports - - - {6afe2e2d-6db0-4430-a2ea-f5f5388d2f78} - GitHub.Extensions - + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - \ No newline at end of file + diff --git a/src/GitHub.Exports.Reactive/GlobalSuppressions.cs b/src/GitHub.Exports.Reactive/GlobalSuppressions.cs deleted file mode 100644 index 2538500fd8..0000000000 Binary files a/src/GitHub.Exports.Reactive/GlobalSuppressions.cs and /dev/null differ diff --git a/src/GitHub.Exports.Reactive/Helpers/ExceptionHelper.cs b/src/GitHub.Exports.Reactive/Helpers/ExceptionHelper.cs index 753471d89e..deb1bcefa4 100644 --- a/src/GitHub.Exports.Reactive/Helpers/ExceptionHelper.cs +++ b/src/GitHub.Exports.Reactive/Helpers/ExceptionHelper.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.Reactive.Linq; +#pragma warning disable CA1720 // Identifier contains type name + namespace GitHub.Helpers { public static class ExceptionHelper diff --git a/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs new file mode 100644 index 0000000000..c9461025f9 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace GitHub.Models +{ + public class AutoCompleteResult + { + public static AutoCompleteResult Empty = new AutoCompleteResult(0, new AutoCompleteSuggestion[] {}); + + public AutoCompleteResult(int offset, IReadOnlyList suggestions) + { + Offset = offset; + Suggestions = suggestions; + } + + public int Offset { get; private set; } + public IReadOnlyList Suggestions { get; private set; } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs new file mode 100644 index 0000000000..e00e67274e --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/AutoCompleteSuggestion.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Windows.Media.Imaging; +using GitHub.Extensions; +using GitHub.Helpers; +using ReactiveUI; + +namespace GitHub.Models +{ + public class AutoCompleteSuggestion + { + readonly string prefix; + readonly string suffix; + readonly string[] descriptionWords; + + public AutoCompleteSuggestion(string name, string description, string prefix) + : this(name, description, Observable.Return(null), prefix) + { + } + + public AutoCompleteSuggestion(string name, string description, IObservable image, string prefix) + : this(name, description, image, prefix, null) + { + } + + public AutoCompleteSuggestion(string name, IObservable image, string prefix, string suffix) + : this(name, null, image, prefix, suffix) + { + } + + public AutoCompleteSuggestion(string name, string description, IObservable image, string prefix, string suffix) + { + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotEmptyString(prefix, "prefix"); // Suggestions have to have a triggering prefix. + Guard.ArgumentNotNull(image, "image"); + + Name = name; + Description = description; + if (image != null) + { + image = image.ObserveOn(RxApp.MainThreadScheduler); + } + Image = image; + + this.prefix = prefix; + this.suffix = suffix; + + // This is pretty naive, but since the Description is currently limited to a user's FullName, + // This is fine. When we add #issue completion, we may need to fancy this up a bit. + descriptionWords = (description ?? String.Empty) + .Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries); + } + + /// + /// The name to display in the autocomplete list box. This should not have the "@" or ":" characters around it. + /// + public string Name { get; private set; } + + public string Description { get; private set; } + + public IObservable Image { get; private set; } + + protected IReadOnlyCollection DescriptionWords { get { return descriptionWords; } } + + // What gets autocompleted. + public override string ToString() + { + return prefix + Name + suffix; + } + + /// + /// Used to determine if the suggestion matches the text and if so, how it should be sorted. The larger the + /// rank, the higher it sorts. + /// + /// + /// For mentions we sort suggestions in the following order: + /// + /// 1. Login starts with text + /// 2. Component of Name starts with text (split name by spaces, then match each word) + /// + /// Non matches return -1. The secondary sort is by Login ascending. + /// + /// The suggestion text to match + /// -1 for non-match and the sort order described in the remarks for matches + public virtual int GetSortRank(string text) + { + Guard.ArgumentNotNull(text, "text"); + + return Name.StartsWith(text, StringComparison.OrdinalIgnoreCase) + ? 1 + : descriptionWords.Any(word => word.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + ? 0 + : -1; + } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/GitIgnoreItem.cs b/src/GitHub.Exports.Reactive/Models/GitIgnoreItem.cs index 7d4312f55c..ed41f3e18d 100644 --- a/src/GitHub.Exports.Reactive/Models/GitIgnoreItem.cs +++ b/src/GitHub.Exports.Reactive/Models/GitIgnoreItem.cs @@ -1,5 +1,6 @@ using GitHub.Collections; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -7,7 +8,7 @@ namespace GitHub.Models { [DebuggerDisplay("{DebuggerDisplay,nq}")] - public sealed class GitIgnoreItem : ICopyable + public sealed class GitIgnoreItem : ICopyable, IEquatable { static readonly string[] recommendedIgnoreFiles = { "None", "VisualStudio", "Node", "Eclipse", "C++", "Windows" }; @@ -40,6 +41,16 @@ public static bool IsRecommended(string name) return recommendedIgnoreFiles.Any(item => item.Equals(name, StringComparison.OrdinalIgnoreCase)); } + bool IEquatable.Equals(GitIgnoreItem other) => Name == other.Name; + + public override bool Equals(object obj) + { + var item = obj as GitIgnoreItem; + return item != null && Name == item.Name; + } + + public override int GetHashCode() => 539060726 + EqualityComparer.Default.GetHashCode(Name); + internal string DebuggerDisplay { get diff --git a/src/GitHub.Exports.Reactive/Models/IConnectionRepositoryHostMap.cs b/src/GitHub.Exports.Reactive/Models/IConnectionRepositoryHostMap.cs deleted file mode 100644 index e1a7050355..0000000000 --- a/src/GitHub.Exports.Reactive/Models/IConnectionRepositoryHostMap.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace GitHub.Models -{ - /// - /// Used to associate the current connection to its related repository host. - /// - public interface IConnectionRepositoryHostMap - { - /// - /// The current repository host associated with the current action. - /// - IRepositoryHost CurrentRepositoryHost { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/Models/IInlineCommentThreadModel.cs b/src/GitHub.Exports.Reactive/Models/IInlineCommentThreadModel.cs new file mode 100644 index 0000000000..802971c167 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/IInlineCommentThreadModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Represents a thread of inline comments on an . + /// + public interface IInlineCommentThreadModel + { + /// + /// Gets the comments in the thread. + /// + IReadOnlyList Comments { get; } + + /// + /// Gets the last five lines of the thread's diff hunk, in reverse order. + /// + IList DiffMatch { get; } + + /// + /// Gets the type of diff line that the thread was left on + /// + DiffChangeType DiffLineType { get; } + + /// + /// Gets or sets a value indicating that the is approximate and + /// needs to be updated. + /// + /// + /// As edits are made, the for a thread can be shifted up or down, + /// but until is called we can't tell + /// whether the comment is still valid at the new position. This property indicates such a + /// state. + /// + bool IsStale { get; set; } + + /// + /// Gets or sets the 0-based line number of the comment, or -1 of the thread is outdated. + /// + int LineNumber { get; set; } + + /// + /// Gets the SHA of the commit that the thread appears on. + /// + string CommitSha { get; } + + /// + /// Gets the relative path to the file that the thread is on. + /// + string RelativePath { get; } + + /// + /// Gets a value indicating whether comment thread has been marked as resolved by a user. + /// + bool IsResolved { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/IPullRequestSessionFile.cs b/src/GitHub.Exports.Reactive/Models/IPullRequestSessionFile.cs new file mode 100644 index 0000000000..0cd2a87cf0 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/IPullRequestSessionFile.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; + +namespace GitHub.Models +{ + public enum DiffSide + { + Left, + Right, + } + + /// + /// Represents a file in a pull request. + /// + /// + /// A holds the review comments for a file in a pull + /// request together with associated information such as the commit SHA of the file and the + /// diff with the file's merge base. + /// + /// + /// + public interface IPullRequestSessionFile : INotifyPropertyChanged + { + /// + /// Gets the SHA of the base commit of the file in the pull request. + /// + string BaseSha { get; } + + /// + /// Gets the SHA of the current commit of the file, or null if the file has uncommitted + /// changes. + /// + string CommitSha { get; } + + /// + /// Gets a value indicating whether is tracking the related pull + /// request HEAD or whether it is pinned at a particular commit. + /// + bool IsTrackingHead { get; } + + /// + /// Gets the path to the file relative to the repository. + /// + string RelativePath { get; } + + /// + /// Gets the diff between the PR merge base and the current state of the file. + /// + IReadOnlyList Diff { get; } + + /// + /// Gets the inline comments threads for the file. + /// + IReadOnlyList InlineCommentThreads { get; } + + /// + /// Gets the inline annotations for the file. + /// + IReadOnlyList InlineAnnotations { get; } + + /// + /// Gets an observable that is raised with a collection of 0-based line numbers when the + /// review comments on the file are changed. + /// + IObservable>> LinesChanged { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/IRepositoryHost.cs b/src/GitHub.Exports.Reactive/Models/IRepositoryHost.cs deleted file mode 100644 index 7f8f78e383..0000000000 --- a/src/GitHub.Exports.Reactive/Models/IRepositoryHost.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Reactive; -using GitHub.Api; -using GitHub.Authentication; -using GitHub.Primitives; -using GitHub.Services; - -namespace GitHub.Models -{ - public interface IRepositoryHost : IDisposable - { - HostAddress Address { get; } - IApiClient ApiClient { get; } - IModelService ModelService { get; } - bool IsLoggedIn { get; } - bool SupportsGist { get; } - string Title { get; } - - IObservable LogIn(string usernameOrEmail, string password); - IObservable LogInFromCache(); - IObservable LogOut(); - } -} diff --git a/src/GitHub.Exports.Reactive/Models/IRepositoryHosts.cs b/src/GitHub.Exports.Reactive/Models/IRepositoryHosts.cs deleted file mode 100644 index e9b9b45096..0000000000 --- a/src/GitHub.Exports.Reactive/Models/IRepositoryHosts.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using GitHub.Authentication; -using GitHub.Factories; -using GitHub.Primitives; -using ReactiveUI; - -namespace GitHub.Models -{ - public interface IRepositoryHosts : IReactiveObject, IDisposable - { - IRepositoryHost EnterpriseHost { get; set; } - IRepositoryHost GitHubHost { get; } - IObservable LogIn( - HostAddress hostAddress, - string usernameOrEmail, - string password); - IObservable LogInFromCache(HostAddress hostAddress); - IRepositoryHostFactory RepositoryHostFactory { get; } - bool IsLoggedInToAnyHost { get; } - IRepositoryHost LookupHost(HostAddress address); - } -} diff --git a/src/GitHub.Exports.Reactive/Models/InlineAnnotationModel.cs b/src/GitHub.Exports.Reactive/Models/InlineAnnotationModel.cs new file mode 100644 index 0000000000..cee1391194 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/InlineAnnotationModel.cs @@ -0,0 +1,82 @@ +using GitHub.Extensions; + +namespace GitHub.Models +{ + /// + /// Represents an inline annotation on an . + /// + public class InlineAnnotationModel + { + readonly CheckSuiteModel checkSuite; + readonly CheckRunModel checkRun; + readonly CheckRunAnnotationModel annotation; + + /// + /// Initializes the . + /// + /// The check suite model. + /// The check run model. + /// The annotation model. + public InlineAnnotationModel(CheckSuiteModel checkSuite, CheckRunModel checkRun, + CheckRunAnnotationModel annotation) + { + Guard.ArgumentNotNull(checkRun, nameof(checkRun)); + Guard.ArgumentNotNull(annotation, nameof(annotation)); + Guard.ArgumentNotNull(annotation.AnnotationLevel, nameof(annotation.AnnotationLevel)); + + this.checkSuite = checkSuite; + this.checkRun = checkRun; + this.annotation = annotation; + } + + /// + /// Gets the annotation path. + /// + public string Path => annotation.Path; + + /// + /// Gets the 1-based start line of the annotation. + /// + public int StartLine => annotation.StartLine; + + /// + /// Gets the 1-based end line of the annotation. + /// + public int EndLine => annotation.EndLine; + + /// + /// Gets the annotation level. + /// + public CheckAnnotationLevel AnnotationLevel => annotation.AnnotationLevel; + + /// + /// Gets the name of the check suite. + /// + public string CheckSuiteName => checkSuite.ApplicationName; + + /// + /// Gets the name of the check run. + /// + public string CheckRunName => checkRun.Name; + + /// + /// Gets the annotation title. + /// + public string Title => annotation.Title; + + /// + /// Gets the annotation message. + /// + public string Message => annotation.Message; + + /// + /// Gets the sha the check run was created on. + /// + public string HeadSha => checkSuite.HeadSha; + + /// + /// Gets the a descriptor for the line(s) reported. + /// + public string LineDescription => $"{StartLine}:{EndLine}"; + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Models/InlineCommentModel.cs b/src/GitHub.Exports.Reactive/Models/InlineCommentModel.cs new file mode 100644 index 0000000000..8593b45169 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/InlineCommentModel.cs @@ -0,0 +1,26 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Relates a to an + /// . + /// + public class InlineCommentModel + { + /// + /// Gets or sets the thread that the comment appears in. + /// + public IInlineCommentThreadModel Thread { get; set; } + + /// + /// Gets or sets the review that the comment appears in. + /// + public PullRequestReviewModel Review { get; set; } + + /// + /// Gets or sets the comment. + /// + public PullRequestReviewCommentModel Comment { get; set; } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/LicenseItem.cs b/src/GitHub.Exports.Reactive/Models/LicenseItem.cs index fdffaf0eaa..6fee70dbb4 100644 --- a/src/GitHub.Exports.Reactive/Models/LicenseItem.cs +++ b/src/GitHub.Exports.Reactive/Models/LicenseItem.cs @@ -2,10 +2,11 @@ using Octokit; using GitHub.Collections; using System; +using System.Collections.Generic; namespace GitHub.Models { - public class LicenseItem : ICopyable + public class LicenseItem : ICopyable, IEquatable { static readonly LicenseItem none = new LicenseItem(); public static LicenseItem None { get { return none; } } @@ -36,6 +37,19 @@ public void CopyFrom(LicenseItem other) Recommended = other.Recommended; } + bool IEquatable.Equals(LicenseItem other) => Key == other.Key; + + public override bool Equals(object obj) + { + var item = obj as LicenseItem; + return item != null && Key == item.Key; + } + + public override int GetHashCode() + { + return 539060726 + EqualityComparer.Default.GetHashCode(Key); + } + public string Key { get; private set; } public string Name { get; private set; } diff --git a/src/GitHub.Exports.Reactive/Models/PullRequestTextBufferInfo.cs b/src/GitHub.Exports.Reactive/Models/PullRequestTextBufferInfo.cs new file mode 100644 index 0000000000..704002064c --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/PullRequestTextBufferInfo.cs @@ -0,0 +1,57 @@ +using System; +using GitHub.Extensions; +using GitHub.Services; + +namespace GitHub.Models +{ + /// + /// When attached as a property to a Visual Studio ITextBuffer, informs the inline comment + /// tagger that the buffer represents a buffer opened from a pull request at the specified + /// commit of a pull request. + /// + public class PullRequestTextBufferInfo + { + /// + /// Initializes a new instance of the class. + /// + /// The pull request session. + /// The relative path to the file in the repository. + /// The SHA of the commit. + /// Which side of a diff comparision the buffer represents. + public PullRequestTextBufferInfo( + IPullRequestSession session, + string relativePath, + string commitSha, + DiffSide? side) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + Guard.ArgumentNotEmptyString(commitSha, nameof(commitSha)); + + Session = session; + RelativePath = relativePath; + CommitSha = commitSha; + Side = side; + } + + /// + /// Gets the pull request session. + /// + public IPullRequestSession Session { get; } + + /// + /// Gets the relative path to the file in the repository. + /// + public string RelativePath { get; } + + /// + /// Gets the SHA of the commit. + /// + public string CommitSha { get; } + + /// + /// Gets a value indicating which side of a diff comparision the buffer represents. + /// + public DiffSide? Side { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/RemoteRepositoryModel.cs b/src/GitHub.Exports.Reactive/Models/RemoteRepositoryModel.cs new file mode 100644 index 0000000000..dd33e6f8a4 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Models/RemoteRepositoryModel.cs @@ -0,0 +1,149 @@ +using System; +using System.Globalization; +using GitHub.Primitives; +using GitHub.Extensions; +using GitHub.Collections; + +namespace GitHub.Models +{ + /// + /// A repository read from the GitHub API. + /// + public class RemoteRepositoryModel : RepositoryModel, ICopyable, + IEquatable, IComparable + { + /// + /// Initializes a new instance of the class. + /// + /// The API ID of the repository. + /// The repository name. + /// The repository's clone URL. + /// Whether the repository is private. + /// Whether the repository is a fork. + /// The repository owner account. + /// The parent repository if this repository is a fork. + /// The default branch name (or "master" if undefined). + public RemoteRepositoryModel(long id, string name, UriString cloneUrl, bool isPrivate, bool isFork, IAccount ownerAccount, + RemoteRepositoryModel parent, string defaultBranchName = "master") + : base(name, cloneUrl) + { + Guard.ArgumentNotEmptyString(name, nameof(name)); + + Id = id; + OwnerAccount = ownerAccount; + IsFork = isFork; + SetIcon(isPrivate, isFork); + DefaultBranch = new BranchModel(defaultBranchName, this); + Parent = parent; + } + + /// + /// This is used by . + /// + protected RemoteRepositoryModel() + { + } + + #region Equality Things + public void CopyFrom(RemoteRepositoryModel other) + { + if (!Equals(other)) + throw new ArgumentException("Instance to copy from doesn't match this instance. this:(" + this + ") other:(" + other + ")", nameof(other)); + Icon = other.Icon; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(this, obj)) + return true; + var other = obj as RemoteRepositoryModel; + return Equals(other); + } + + public bool Equals(RemoteRepositoryModel other) + { + if (ReferenceEquals(this, other)) + return true; + return other != null && Id == other.Id; + } + + public int CompareTo(RemoteRepositoryModel other) + { + return other != null ? UpdatedAt.CompareTo(other.UpdatedAt) : 1; + } + + public static bool operator >(RemoteRepositoryModel lhs, RemoteRepositoryModel rhs) + { + if (ReferenceEquals(lhs, rhs)) + return false; + return lhs?.CompareTo(rhs) > 0; + } + + public static bool operator <(RemoteRepositoryModel lhs, RemoteRepositoryModel rhs) + { + if (ReferenceEquals(lhs, rhs)) + return false; + return (object)lhs == null || lhs.CompareTo(rhs) < 0; + } + + public static bool operator ==(RemoteRepositoryModel lhs, RemoteRepositoryModel rhs) + { + return ReferenceEquals(lhs, rhs); + } + + public static bool operator !=(RemoteRepositoryModel lhs, RemoteRepositoryModel rhs) + { + return !(lhs == rhs); + } + #endregion + + /// + /// Gets the account that is the ower of the repository. + /// + public IAccount OwnerAccount { get; } + + /// + /// Gets the repository's API ID. + /// + public long Id { get; } + + /// + /// Gets the date and time at which the repository was created. + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets the repository's last update date and time. + /// + public DateTimeOffset UpdatedAt { get; set; } + + /// + /// Gets a value indicating whether the repository is a fork. + /// + public bool IsFork { get; } + + /// + /// Gets the repository from which this repository was forked, if any. + /// + public RemoteRepositoryModel Parent { get; } + + /// + /// Gets the default branch for the repository. + /// + public BranchModel DefaultBranch { get; } + + internal string DebuggerDisplay + { + get + { + return String.Format(CultureInfo.InvariantCulture, + "{4}\tId: {0} Name: {1} CloneUrl: {2} Account: {3}", Id, Name, CloneUrl, Owner, GetHashCode()); + } + } + } +} diff --git a/src/GitHub.Exports.Reactive/Models/UserAndScopes.cs b/src/GitHub.Exports.Reactive/Models/UserAndScopes.cs deleted file mode 100644 index c03964bfc0..0000000000 --- a/src/GitHub.Exports.Reactive/Models/UserAndScopes.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using Octokit; - -namespace GitHub.Models -{ - /// - /// Holds an model together with the OAuth scopes that were - /// received when the user was read. - /// - public class UserAndScopes - { - /// - /// Initializes a new instance of the class. - /// - /// The user information. - /// The scopes. May be null. - public UserAndScopes(User user, IReadOnlyList scopes) - { - User = user; - Scopes = scopes; - } - - /// - /// Gets the user information. - /// - public User User { get; } - - /// - /// Gets the OAuth scopes read when the user was read. - /// - /// - /// A value of null means that no X-OAuth-Scopes header was received. - /// - public IReadOnlyList Scopes { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/Properties/AssemblyInfo.cs b/src/GitHub.Exports.Reactive/Properties/AssemblyInfo.cs index a969fcf414..c79269fb29 100644 --- a/src/GitHub.Exports.Reactive/Properties/AssemblyInfo.cs +++ b/src/GitHub.Exports.Reactive/Properties/AssemblyInfo.cs @@ -1,6 +1,5 @@ -using System.Reflection; -using System.Runtime.InteropServices; +using System.Windows.Markup; -[assembly: AssemblyTitle("GitHub.Exports.Reactive")] -[assembly: AssemblyDescription("GitHub interfaces for mef exports with reactive dependencies")] -[assembly: Guid("e4ed0537-d1d9-44b6-9212-3096d7c3f7a1")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Dialog")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.GitHubPane")] \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs new file mode 100644 index 0000000000..b071be1bff --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IAutoCompleteAdvisor.cs @@ -0,0 +1,10 @@ +using System; +using GitHub.Models; + +namespace GitHub.Services +{ + public interface IAutoCompleteAdvisor + { + IObservable GetAutoCompletionSuggestions(string text, int caretPosition); + } +} diff --git a/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs b/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs index ccf656280a..1006256d96 100644 --- a/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs +++ b/src/GitHub.Exports.Reactive/Services/IAvatarProvider.cs @@ -10,6 +10,7 @@ public interface IAvatarProvider : IDisposable BitmapImage DefaultUserBitmapImage { get; } BitmapImage DefaultOrgBitmapImage { get; } IObservable GetAvatar(IAvatarContainer account); + IObservable GetAvatar(string avatarUri); IObservable InvalidateAvatar(IAvatarContainer account); } } diff --git a/src/GitHub.Exports.Reactive/Services/ICommentService.cs b/src/GitHub.Exports.Reactive/Services/ICommentService.cs new file mode 100644 index 0000000000..7c7330163e --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/ICommentService.cs @@ -0,0 +1,14 @@ +namespace GitHub.Services +{ + /// + /// This service allows for functionality to be injected into the chain of different peek Comment ViewModel types. + /// + public interface ICommentService + { + /// + /// This function uses MessageBox.Show to display a confirmation if a comment should be deleted. + /// + /// + bool ConfirmCommentDelete(); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Services/IGitClient.cs b/src/GitHub.Exports.Reactive/Services/IGitClient.cs index 76bae5abf0..192612f9fa 100644 --- a/src/GitHub.Exports.Reactive/Services/IGitClient.cs +++ b/src/GitHub.Exports.Reactive/Services/IGitClient.cs @@ -1,6 +1,9 @@ using System; using System.Threading.Tasks; using LibGit2Sharp; +using GitHub.Primitives; +using System.Collections.Generic; +using GitHub.Models; namespace GitHub.Services { @@ -23,21 +26,27 @@ public interface IGitClient Task Push(IRepository repository, string branchName, string remoteName); /// - /// Fetches the remote. + /// Fetches from the remote, using custom refspecs. /// /// The repository to pull /// The name of the remote + /// The custom refspecs or none to use the default /// - Task Fetch(IRepository repository, string remoteName); + Task Fetch(IRepository repository, string remoteName, params string[] refspecs); /// - /// Fetches from the remote, using custom refspecs. + /// Fetches from a remote URI, using custom refspecs. /// + /// + /// If the URI is the same as origin then origin will be used, otherwise a + /// temporary remote will be created for the fetch. The fetch will always be made + /// using HTTPS. + /// /// The repository to pull - /// The name of the remote + /// The remote URI to fetch from /// The custom refspecs /// - Task Fetch(IRepository repository, string remoteName, params string[] refspecs); + Task Fetch(IRepository repository, UriString remoteUri, params string[] refspecs); /// /// Checks out a branch. @@ -47,6 +56,14 @@ public interface IGitClient /// Task Checkout(IRepository repository, string branchName); + /// + /// Checks if a commit exists a the repository. + /// + /// The repository. + /// The SHA of the commit. + /// + Task CommitExists(IRepository repository, string sha); + /// /// Creates a new branch. /// @@ -56,18 +73,6 @@ public interface IGitClient Task CreateBranch(IRepository repository, string branchName); /// - /// Compares two commits. - /// - /// The repository - /// The SHA of the first commit. - /// The SHA of the second commit. - /// Whether to detect renames - /// - /// A object or null if one of the commits could not be found in the repository, - /// (e.g. it is from a fork). - /// - Task Compare(IRepository repository, string sha1, string sha2, bool detectRenames = false); - /// Gets the value of a configuration key. /// /// The repository. @@ -97,8 +102,8 @@ public interface IGitClient /// Sets the remote branch that the local branch tracks /// /// The repository to set - /// The name of the local remote - /// The name of the remote branch + /// The name of the local branch + /// The name of the remote /// Task SetTrackingBranch(IRepository repository, string branchName, string remoteName); @@ -119,8 +124,67 @@ public interface IGitClient /// The SHA of the commit. /// The path to the file, relative to the repository. /// - /// The filename of a temporary file containing the file contents. + /// The contents of the file, or null if the file was not found at the specified commit. /// Task ExtractFile(IRepository repository, string commitSha, string fileName); + + /// + /// Extracts a file at a specified commit from the repository as binary data. + /// + /// The repository. + /// The SHA of the commit. + /// The path to the file, relative to the repository. + /// + /// The contents of the file, or null if the file was not found at the specified commit. + /// + Task ExtractFileBinary(IRepository repository, string commitSha, string fileName); + + /// + /// Checks whether the latest commit of a file in the repository has the specified file + /// contents. + /// + /// The repository. + /// The relative path to the file. + /// The file contents to test. + /// + Task IsModified(IRepository repository, string path, byte[] contents); + + /// + /// Find the merge base SHA between two commits. + /// + /// The repository. + /// The clone url of the PR target repo. + /// The PR base SHA. + /// The PR head SHA. + /// The PR base ref (e.g. 'master'). + /// The PR number. + /// + /// The merge base SHA or null. + /// + /// Thrown when the merge base can't be found. + Task GetPullRequestMergeBase(IRepository repo, UriString targetCloneUrl, string baseSha, string headSha, string baseRef, int pullNumber); + + /// + /// Checks whether the current head is pushed to its remote tracking branch. + /// + /// The repository. + /// + Task IsHeadPushed(IRepository repo); + + /// + /// Gets the unique commits from to the merge base of + /// and and returns their + /// commit messages. + /// + /// The repository. + /// The base branch to find a merge base with. + /// The compare branch to find a merge base with. + /// The maximum number of commits to return. + /// An enumerable of unique commits from the merge base to the compareBranch. + Task> GetMessagesForUniqueCommits( + IRepository repo, + string baseBranch, + string compareBranch, + int maxCommits); } } diff --git a/src/GitHub.Exports.Reactive/Services/IInlineCommentPeekService.cs b/src/GitHub.Exports.Reactive/Services/IInlineCommentPeekService.cs new file mode 100644 index 0000000000..357ba17b84 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IInlineCommentPeekService.cs @@ -0,0 +1,38 @@ +using System; +using GitHub.Models; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; + +namespace GitHub.Services +{ + /// + /// Shows inline comments in a peek view. + /// + public interface IInlineCommentPeekService + { + /// + /// Gets the 0-based line number for a peek session tracking point. + /// + /// The peek session. + /// The peek session tracking point + /// + /// A tuple containing the 0-based line number and whether the line number represents a line in the + /// left hand side of a diff view. + /// + Tuple GetLineNumber(IPeekSession session, ITrackingPoint point); + + /// + /// Hides the inline comment peek view for a text view. + /// + /// The text view. + void Hide(ITextView textView); + + /// + /// Shows the peek view for on an . + /// + /// The text view. + /// The tag. + ITrackingPoint Show(ITextView textView, DiffSide side, int lineNumber); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Services/IIssueishService.cs b/src/GitHub.Exports.Reactive/Services/IIssueishService.cs new file mode 100644 index 0000000000..e4a3ba55e6 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IIssueishService.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; + +namespace GitHub.Services +{ + /// + /// Services for issues and pull requests. + /// + public interface IIssueishService + { + /// + /// Closes an issue or pull request. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + Task CloseIssueish(HostAddress address, string owner, string repository, int number); + + /// + /// Reopens an issue or pull request. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + Task ReopenIssueish(HostAddress address, string owner, string repository, int number); + + /// + /// Posts an issue or pull request comment. + /// + /// The address of the server. + /// The GraphQL ID of the issue or pull request. + /// The comment body. + /// The model for the comment that was added. + Task PostComment( + HostAddress address, + string issueishId, + string body); + + /// + /// Deletes an issue or pull request comment. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The database ID of the comment. + Task DeleteComment( + HostAddress address, + string owner, + string repository, + int commentId); + + /// + /// Edits an issue or pull request comment. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The database ID of the comment. + /// The new comment body. + Task EditComment( + HostAddress address, + string owner, + string repository, + int commentId, + string body); + } +} diff --git a/src/GitHub.Exports.Reactive/Services/IModelService.cs b/src/GitHub.Exports.Reactive/Services/IModelService.cs index 1ad2b880eb..4a680f933e 100644 --- a/src/GitHub.Exports.Reactive/Services/IModelService.cs +++ b/src/GitHub.Exports.Reactive/Services/IModelService.cs @@ -4,7 +4,7 @@ using GitHub.Models; using GitHub.Caches; using GitHub.Collections; -using System.Threading.Tasks; +using GitHub.Api; namespace GitHub.Services { @@ -14,19 +14,22 @@ namespace GitHub.Services /// public interface IModelService : IDisposable { - IObservable GetUserFromCache(); + IApiClient ApiClient { get; } + + IObservable GetCurrentUser(); + IObservable GetUser(string login); IObservable InsertUser(AccountCacheItem user); IObservable> GetAccounts(); - ITrackingCollection GetRepositories(ITrackingCollection collection); + IObservable GetRepository(string owner, string repo); + IObservable GetForks(RepositoryModel repository); IObservable GetLicenses(); IObservable GetGitIgnoreTemplates(); - IObservable GetPullRequest(ILocalRepositoryModel repo, int number); - ITrackingCollection GetPullRequests(ILocalRepositoryModel repo, ITrackingCollection collection); - IObservable CreatePullRequest(ILocalRepositoryModel sourceRepository, IRepositoryModel targetRepository, - IBranch sourceBranch, IBranch targetBranch, + IObservable GetPullRequest(string owner, string name, int number); + IObservable CreatePullRequest(LocalRepositoryModel sourceRepository, RepositoryModel targetRepository, + BranchModel sourceBranch, BranchModel targetBranch, string title, string body); - IObservable GetBranches(IRepositoryModel repo); + IObservable GetBranches(RepositoryModel repo); IObservable InvalidateAll(); - IObservable GetFileContents(IRepositoryModel repo, string commitSha, string path, string fileSha); + IObservable GetFileContents(RepositoryModel repo, string commitSha, string path, string fileSha); } } diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs new file mode 100644 index 0000000000..53855dc416 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs @@ -0,0 +1,91 @@ +using System.Threading.Tasks; +using GitHub.Models; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Differencing; +using Microsoft.VisualStudio.TextManager.Interop; + +namespace GitHub.Services +{ + /// + /// Services for opening views of pull request files in Visual Studio. + /// + public interface IPullRequestEditorService + { + /// + /// Opens an editor for a file in a pull request. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// + /// If true opens the file in the working directory, if false opens the file in the HEAD + /// commit of the pull request. + /// + /// The opened file. + Task OpenFile(IPullRequestSession session, string relativePath, bool workingDirectory); + + /// + /// Opens an diff viewer for a file in a pull request. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// + /// The commit SHA of the right hand side of the diff. Pass null to compare with the + /// working directory, or "HEAD" to compare with the HEAD commit of the pull request. + /// + /// True to scroll to first difference in file. Set to false if caret is being moved after opening. + /// The opened diff viewer. + Task OpenDiff(IPullRequestSession session, string relativePath, string headSha = null, bool scrollToFirstDiff = true); + + /// + /// Opens an diff viewer for a file in a pull request with the specified inline review + /// comment thread open. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// The thread to open + /// The opened diff viewer. + Task OpenDiff(IPullRequestSession session, string relativePath, IInlineCommentThreadModel thread); + + /// + /// Opens an diff viewer for a file in a pull request with the specified inline review line open. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// + /// The commit SHA of the right hand side of the diff. Pass null to compare with the + /// working directory, or "HEAD" to compare with the HEAD commit of the pull request. + /// + /// The 0-based line number to execute NextInlineCommentCommand from + /// The opened diff viewer. + Task OpenDiff(IPullRequestSession session, string relativePath, string headSha, int nextInlineTagFromLine); + + /// + /// Find the active text view. + /// + /// The active view or null if view can't be found. + IVsTextView FindActiveView(); + + /// + /// Place the caret at the best guess equivalent position in . + /// + /// The text view to navigate from. + /// The text view to navigate to. + void NavigateToEquivalentPosition(IVsTextView sourceView, IVsTextView targetView); + + /// + /// Check to see if text view is an ediatable document. + /// + /// The text view to check. + /// True if text view is editable and part of a diff view. + bool IsEditableDiff(ITextView textView); + + /// + /// Open the active document in a new code view. + /// + /// + /// If the active document is part of a diff view, open in a new code view. + /// + /// The source view to use the line and position from. + void OpenActiveDocumentInCodeView(IVsTextView sourceView); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs index 0c7a8bd91e..e472ffd47a 100644 --- a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs @@ -1,16 +1,58 @@ using System; +using System.Collections.Generic; using System.Reactive; +using System.Text; +using System.Threading.Tasks; using GitHub.Models; +using GitHub.Primitives; using LibGit2Sharp; -using Octokit; namespace GitHub.Services { - public interface IPullRequestService + public interface IPullRequestService : IIssueishService { - IObservable CreatePullRequest(IRepositoryHost host, - ILocalRepositoryModel sourceRepository, IRepositoryModel targetRepository, - IBranch sourceBranch, IBranch targetBranch, + /// + /// Reads a page of pull request items. + /// + /// The host address. + /// The repository owner. + /// The repository name. + /// The end cursor of the previous page, or null for the first page. + /// The pull request states to filter by + /// Whether the data should be refreshed instead of read from the cache. + /// A page of pull request item models. + Task> ReadPullRequests( + HostAddress address, + string owner, + string name, + string after, + PullRequestState[] states); + + /// + /// Clears the cache for . + /// + /// The host address. + /// The repository owner. + /// The repository name. + Task ClearPullRequestsCache(HostAddress address, string owner, string name); + + /// + /// Reads a page of users that can be assigned to pull requests. + /// + /// The host address. + /// The repository owner. + /// The repository name. + /// The end cursor of the previous page, or null for the first page. + /// A page of author models. + Task> ReadAssignableUsers( + HostAddress address, + string owner, + string name, + string after); + + IObservable CreatePullRequest(IModelService modelService, + LocalRepositoryModel sourceRepository, RepositoryModel targetRepository, + BranchModel sourceBranch, BranchModel targetBranch, string title, string body); /// @@ -18,7 +60,14 @@ IObservable CreatePullRequest(IRepositoryHost host, /// /// The repository. /// - IObservable IsWorkingDirectoryClean(ILocalRepositoryModel repository); + IObservable IsWorkingDirectoryClean(LocalRepositoryModel repository); + + /// + /// Count the number of submodules that require syncing. + /// + /// The repository. + /// The number of submodules that need to be synced. + IObservable CountSubmodulesToSync(LocalRepositoryModel repository); /// /// Checks out a pull request to a local branch. @@ -27,19 +76,35 @@ IObservable CreatePullRequest(IRepositoryHost host, /// The pull request details. /// The name of the local branch. /// - IObservable Checkout(ILocalRepositoryModel repository, IPullRequestModel pullRequest, string localBranchName); + IObservable Checkout(LocalRepositoryModel repository, PullRequestDetailModel pullRequest, string localBranchName); + + /// + /// Checks if a commit is available and if not tries to fetch the commit. + /// + /// The local repository. + /// The remote repository. + /// The SHA of the commit. + /// True if the commit was found, otherwise false. + Task FetchCommit(LocalRepositoryModel localRepository, RepositoryModel remoteRepository, string sha); /// /// Carries out a pull on the current branch. /// /// The repository. - IObservable Pull(ILocalRepositoryModel repository); + IObservable Pull(LocalRepositoryModel repository); /// /// Carries out a push of the current branch. /// /// The repository. - IObservable Push(ILocalRepositoryModel repository); + IObservable Push(LocalRepositoryModel repository); + + /// + /// Sync submodules on the current branch. + /// + /// The repository. + /// A method that will be called with progress messages + Task SyncSubmodules(LocalRepositoryModel repository, Action progress); /// /// Calculates the name of a local branch for a pull request avoiding clashes with existing branches. @@ -48,22 +113,39 @@ IObservable CreatePullRequest(IRepositoryHost host, /// The pull request number. /// The pull request title. /// - IObservable GetDefaultLocalBranchName(ILocalRepositoryModel repository, int pullRequestNumber, string pullRequestTitle); + IObservable GetDefaultLocalBranchName(LocalRepositoryModel repository, int pullRequestNumber, string pullRequestTitle); /// /// Gets the local branches that exist for the specified pull request. /// + /// The repository. /// The pull request details. /// - IObservable GetLocalBranches(ILocalRepositoryModel repository, IPullRequestModel pullRequest); + IObservable GetLocalBranches(LocalRepositoryModel repository, PullRequestDetailModel pullRequest); /// - /// Determines whether the specified pull request is from a fork. + /// Ensures that all local branches for the specified pull request are marked as PR branches. + /// + /// The repository. + /// The pull request details. + /// + /// An observable that produces a single value indicating whether a change to the repository was made. + /// + /// + /// Pull request branches are marked in the local repository with a config value so that they can + /// be easily identified without a roundtrip to the server. This method ensures that the local branches + /// for the specified pull request are indeed marked and returns a value indicating whether any branches + /// have had the mark added. + /// + IObservable EnsureLocalBranchesAreMarkedAsPullRequests(LocalRepositoryModel repository, PullRequestDetailModel pullRequest); + + /// + /// Determines whether the specified pull request is from the specified repository. /// /// The repository. /// The pull request details. /// - bool IsPullRequestFromFork(ILocalRepositoryModel repository, IPullRequestModel pullRequest); + bool IsPullRequestFromRepository(LocalRepositoryModel repository, PullRequestDetailModel pullRequest); /// /// Switches to an existing branch for the specified pull request. @@ -71,7 +153,7 @@ IObservable CreatePullRequest(IRepositoryHost host, /// The repository. /// The pull request details. /// - IObservable SwitchToBranch(ILocalRepositoryModel repository, IPullRequestModel pullRequest); + IObservable SwitchToBranch(LocalRepositoryModel repository, PullRequestDetailModel pullRequest); /// /// Gets the history divergence between the current HEAD and the specified pull request. @@ -79,54 +161,65 @@ IObservable CreatePullRequest(IRepositoryHost host, /// The repository. /// The pull request number. /// - IObservable CalculateHistoryDivergence(ILocalRepositoryModel repository, int pullRequestNumber); + IObservable CalculateHistoryDivergence(LocalRepositoryModel repository, int pullRequestNumber); /// - /// Gets the changes between the pull request base and head. + /// Gets the SHA of the merge base for a pull request. /// /// The repository. /// The pull request details. /// - IObservable GetTreeChanges(ILocalRepositoryModel repository, IPullRequestModel pullRequest); + Task GetMergeBase(LocalRepositoryModel repository, PullRequestDetailModel pullRequest); /// - /// Removes any association between the current branch and a pull request. + /// Gets the changes between the pull request base and head. /// /// The repository. + /// The pull request details. /// - IObservable UnmarkLocalBranch(ILocalRepositoryModel repository); + IObservable GetTreeChanges(LocalRepositoryModel repository, PullRequestDetailModel pullRequest); /// - /// Extracts a file at a specified commit from the repository. + /// Gets the pull request associated with the current branch. /// /// The repository. - /// A model service to use as a cache if the file is not fetched. - /// The SHA of the commit. - /// The path to the file, relative to the repository. - /// The SHA of the file in the pull request. - /// The filename of the extracted file. - IObservable ExtractFile( - ILocalRepositoryModel repository, - IModelService modelService, - string commitSha, - string fileName, - string fileSha); + /// + /// An observable that produces a single tuple which contains the owner of the fork and the + /// pull request number. Returns null if the current branch is not a PR branch. + /// + /// + /// This method does not do an API request - it simply checks the mark left in the git + /// config by . + /// + IObservable<(string owner, int number)> GetPullRequestForCurrentBranch(LocalRepositoryModel repository); + + /// + /// Gets the encoding for the specified local file. + /// + /// The repository. + /// The relative path to the file in the repository. + /// + /// The file's encoding or null if the file doesn't exist locally. + /// + Encoding GetEncoding(LocalRepositoryModel repository, string relativePath); /// - /// Gets the left and right files for a diff. + /// Extracts a file at the specified commit to a temporary file. /// /// The repository. - /// A model service to use as a cache if the file is not fetched. /// The pull request details. - /// The filename relative to the repository root. - /// The SHA of the file in the pull request. - /// The filenames of the left and right files for the diff. - IObservable> ExtractDiffFiles( - ILocalRepositoryModel repository, - IModelService modelService, - IPullRequestModel pullRequest, - string fileName, - string fileSha); + /// The path to the file, relative to the repository root. + /// The SHA of the commit. + /// + /// The encoding to save the file with. If null, will use the file's original encoding. + /// + /// The path to the temporary file. + Task ExtractToTempFile( + LocalRepositoryModel repository, + PullRequestDetailModel pullRequest, + string relativePath, + string commitSha, + Encoding encoding); /// /// Remotes all unused remotes that were created by GitHub for Visual Studio to track PRs @@ -134,8 +227,30 @@ IObservable> ExtractDiffFiles( /// /// The repository. /// - IObservable RemoveUnusedRemotes(ILocalRepositoryModel repository); + IObservable RemoveUnusedRemotes(LocalRepositoryModel repository); - IObservable GetPullRequestTemplate(ILocalRepositoryModel repository); + IObservable GetPullRequestTemplate(LocalRepositoryModel repository); + + /// + /// Gets the unique commits from to the merge base of + /// and and returns their + /// commit messages. + /// + /// The repository. + /// The base branch to find a merge base with. + /// The compare branch to find a merge base with. + /// The maximum number of commits to return. + /// An enumerable of unique commits from the merge base to the compareBranch. + IObservable> GetMessagesForUniqueCommits( + LocalRepositoryModel repository, + string baseBranch, + string compareBranch, + int maxCommits); + + /// + /// Displays a confirmation diaglog to ask if the user wants to cancel a pending review. + /// + /// + bool ConfirmCancelPendingReview(); } } diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestSession.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestSession.cs new file mode 100644 index 0000000000..05a497afdc --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestSession.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Models; +using Octokit; + +namespace GitHub.Services +{ + /// + /// A pull request session used to display inline comments. + /// + public interface IPullRequestSession + { + /// + /// Gets a value indicating whether the pull request branch is the currently checked out branch. + /// + bool IsCheckedOut { get; } + + /// + /// Gets the current user. + /// + ActorModel User { get; } + + /// + /// Gets the pull request. + /// + PullRequestDetailModel PullRequest { get; } + + /// + /// Gets an observable that indicates that has been updated. + /// + /// + /// This notification is different to listening for a PropertyChanged event because the + /// pull request model may be updated in-place which will not result in a PropertyChanged + /// notification. + /// + IObservable PullRequestChanged { get; } + + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the owner of the repository that contains the pull request. + /// + /// + /// If the pull request is targeting then the owner will be + /// the owner of the local repository. If however the pull request targets a different fork + /// then this property describes the owner of the fork. + /// + string RepositoryOwner { get; } + + /// + /// Gets a value indicating whether the pull request has a pending review for the current + /// user. + /// + bool HasPendingReview { get; } + + /// + /// Gets the ID of the current pending pull request review for the user. + /// + string PendingReviewId { get; } + + /// + /// Gets all files touched by the pull request. + /// + /// + /// A list of the files touched by the pull request. + /// + Task> GetAllFiles(); + + /// + /// Gets a file touched by the pull request. + /// + /// The relative path to the file. + /// + /// The commit at which to get the file contents, or "HEAD" to track the pull request head. + /// + /// + /// A object or null if the file was not touched by + /// the pull request. + /// + Task GetFile(string relativePath, string commitSha = "HEAD"); + + /// + /// Gets the merge base SHA for the pull request. + /// + /// The merge base SHA. + Task GetMergeBase(); + + /// + /// Posts a new PR review comment. + /// + /// The comment body. + /// THe SHA of the commit to comment on. + /// The relative path of the file to comment on. + /// The diff between the PR head and base. + /// The line index in the diff to comment on. + Task PostReviewComment( + string body, + string commitId, + string path, + IReadOnlyList fileDiff, + int position); + + /// + /// Posts a PR review comment reply. + /// + /// The comment body. + /// The GraphQL ID of the comment to reply to. + /// + Task PostReviewComment( + string body, + string inReplyTo); + + /// + /// Starts a new pending pull request review. + /// + Task StartReview(); + + /// + /// Cancels the currently pending review. + /// + /// + /// There is no pending review. + /// + Task CancelReview(); + + /// + /// Posts the currently pending review. + /// + /// The review body. + /// The review event. + /// The review model. + Task PostReview(string body, PullRequestReviewEvent e); + + /// + /// Deletes a pull request comment. + /// + /// The number of the pull request id of the comment + /// The number of the pull request comment to delete + /// A task which completes when the session has completed updating. + Task DeleteComment(int pullRequestId, int commentDatabaseId); + + /// + /// Edit a PR review comment reply. + /// + /// The node id of the pull request comment + /// The replacement comment body. + /// A comment model. + Task EditComment(string commentNodeId, string body); + + /// + /// Refreshes the pull request session. + /// + /// A task which completes when the session has completed refreshing. + Task Refresh(); + } +} diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestSessionManager.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestSessionManager.cs new file mode 100644 index 0000000000..da9b407908 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestSessionManager.cs @@ -0,0 +1,91 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using GitHub.Models; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; + +namespace GitHub.Services +{ + /// + /// Manages pull request sessions. + /// + /// + /// If the currently checked out branch represents a pull request then + /// will return an containing the details of that pull request. + /// A session for any other pull request can also be retrieved by calling + /// . + /// + /// Calling will return an + /// which tracks both the contents of a text buffer and the + /// current session, and updates the review comments in real-time. + /// + public interface IPullRequestSessionManager : INotifyPropertyChanged + { + /// + /// Gets the current pull request session. + /// + /// + /// The current pull request session, or null if the currently checked out branch is not + /// a pull request branch. + /// + IPullRequestSession CurrentSession { get; } + + /// + /// Ensures that the service is initialized. + /// + /// A task that when completed indicates that the service is initialized. + /// + /// If you are simply monitoring changes to the then you + /// don't need to call this method: will be updated on + /// initialization. If however, you want to be sure that is + /// initialized, you can await the task returned from this method. + /// + Task EnsureInitialized(); + + /// + /// Gets an that tracks the live state of a document. + /// + /// The relative path to the file in the repository. + /// The text view that is showing the file. + /// The text buffer with the file contents. + /// An . + Task GetLiveFile( + string relativePath, + ITextView textView, + ITextBuffer textBuffer); + + /// + /// Gets the path of a document displayed in a text buffer, relative to the current + /// repository. + /// + /// The text buffer. + /// + /// The relative path, or null if the buffer does not represent a file in the repository. + /// + string GetRelativePath(ITextBuffer buffer); + + /// + /// Gets an for a pull request. + /// + /// The repository owner. + /// The repository name. + /// The pull request number. + /// An . + Task GetSession(string owner, string name, int number); + + /// + /// Gets information about the pull request that a Visual Studio text buffer is a part of. + /// + /// The text buffer. + /// + /// A or null if the pull request for the text + /// buffer could not be determined. + /// + /// + /// This method looks for a object stored in the text + /// buffer's properties. + /// + PullRequestTextBufferInfo GetTextBufferInfo(ITextBuffer buffer); + } +} diff --git a/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs b/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs index f3b7a8ec0c..791aa25734 100644 --- a/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs +++ b/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs @@ -1,6 +1,7 @@ -using System; -using System.Reactive; +using System.Threading; using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; namespace GitHub.Services { @@ -19,18 +20,62 @@ public interface IRepositoryCloneService /// Clones the specificed repository into the specified directory. /// /// The url of the repository to clone. - /// The name of the repository to clone. /// The directory that will contain the repository directory. /// /// An object through which to report progress. This must be of type - /// , but + /// System.IProgress<Microsoft.VisualStudio.Shell.ServiceProgressData>, but /// as that type is only available in VS2017+ it is typed as here. /// + /// A cancellation token. /// Task CloneRepository( string cloneUrl, - string repositoryName, string repositoryPath, - object progress = null); + object progress = null, + CancellationToken? cancellationToken = null); + + /// + /// Clones the specified repository into the specified directory or opens it if the directory already exists. + /// + /// The URL and path of the repository to clone or open. + /// + /// An object through which to report progress. This must be of type + /// System.IProgress<Microsoft.VisualStudio.Shell.ServiceProgressData>, but + /// as that type is only available in VS2017+ it is typed as here. + /// + /// + Task CloneOrOpenRepository( + CloneDialogResult cloneDialogResult, + object progress = null, + CancellationToken? cancellationToken = null); + + /// + /// Checks whether the specified destination directory already exists. + /// + /// The destination path. + /// + /// true if a directory is already present at ; otherwise false. + /// + bool DestinationDirectoryExists(string path); + + /// + /// Checks whether the specified destination directory is empty. + /// + /// The destination path. + /// + /// true if a directory is empty ; otherwise false. + /// + bool DestinationDirectoryEmpty(string path); + + /// + /// Checks whether the specified destination file already exists. + /// + /// The destination file. + /// + /// true if a file is already present at ; otherwise false. + /// + bool DestinationFileExists(string path); + + Task ReadViewerRepositories(HostAddress address, bool refresh = false); } } diff --git a/src/GitHub.Exports.Reactive/Services/IRepositoryForkService.cs b/src/GitHub.Exports.Reactive/Services/IRepositoryForkService.cs new file mode 100644 index 0000000000..8f0b12c3b9 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IRepositoryForkService.cs @@ -0,0 +1,15 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Models; +using Octokit; + +namespace GitHub.Services +{ + public interface IRepositoryForkService + { + IObservable ForkRepository(IApiClient apiClient, RepositoryModel sourceRepository, NewRepositoryFork repositoryFork, bool updateOrigin, bool addUpstream, bool trackMasterUpstream); + IObservable SwitchRemotes(RepositoryModel destinationRepository, bool updateOrigin, bool addUpstream, bool trackMasterUpstream); + } +} diff --git a/src/GitHub.Exports.Reactive/Services/IShowDialogService.cs b/src/GitHub.Exports.Reactive/Services/IShowDialogService.cs new file mode 100644 index 0000000000..474fdc7c6a --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IShowDialogService.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; + +namespace GitHub.Services +{ + /// + /// Service for displaying the GitHub for Visual Studio dialog. + /// + /// + /// This is a low-level service used by to carry out the actual + /// showing of the dialog. You probably want to use instead if + /// you want to show the dialog for login/clone etc. + /// + public interface IShowDialogService + { + /// + /// Shows a view model in the dialog. + /// + /// The view model to show. + /// + /// The value returned by the 's + /// observable, or null if the dialog was + /// canceled. + /// + Task Show(IDialogContentViewModel viewModel); + + /// + /// Shows a view model that requires a connection with specifiec scopes in the dialog. + /// + /// The view model to show. + /// The connection. + /// The required scopes. + /// + /// If the connection does not have the requested scopes, the user will be invited to log + /// out and back in. + /// + Task Show( + TViewModel viewModel, + IConnection connection, + IEnumerable scopes) + where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel; + + /// + /// Shows a view model that requires a connection in the dialog. + /// + /// The view model to show. + /// + /// The value returned by the 's + /// observable, or null if the dialog was + /// canceled. + /// + /// + /// The first existing connection will be used. If there is no existing connection, the + /// login dialog will be shown first. + /// + Task ShowWithFirstConnection(TViewModel viewModel) + where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel; + } +} diff --git a/src/GitHub.Exports.Reactive/Services/LocalRepositoriesExtensions.cs b/src/GitHub.Exports.Reactive/Services/LocalRepositoriesExtensions.cs new file mode 100644 index 0000000000..02237f87dc --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/LocalRepositoriesExtensions.cs @@ -0,0 +1,29 @@ +using System; +using GitHub.Models; +using GitHub.Primitives; +using ReactiveUI; + +namespace GitHub.Services +{ + /// + /// Implements extension methods for . + /// + public static class LocalRepositoriesExtensions + { + /// + /// Gets a derived collection that contains all known repositories with the specified + /// clone URL, ordered by name. + /// + /// The local repositories object. + /// The address. + public static IReactiveDerivedList GetRepositoriesForAddress( + this ILocalRepositories repos, + HostAddress address) + { + return repos.Repositories.CreateDerivedCollection( + x => x, + x => x.CloneUrl != null && address.Equals(HostAddress.Create(x.CloneUrl)), + OrderedComparer.OrderBy(x => x.Name).Compare); + } + } +} diff --git a/src/GitHub.Exports.Reactive/Services/ModelServiceExtensions.cs b/src/GitHub.Exports.Reactive/Services/ModelServiceExtensions.cs new file mode 100644 index 0000000000..b9413d4ab2 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/ModelServiceExtensions.cs @@ -0,0 +1,13 @@ +using System; +using GitHub.Models; + +namespace GitHub.Services +{ + public static class ModelServiceExtensions + { + public static IObservable GetPullRequest(this IModelService service, RepositoryModel repo, int number) + { + return service.GetPullRequest(repo.Owner, repo.Name, number); + } + } +} diff --git a/src/GitHub.Exports.Reactive/Services/NotificationDispatcher.cs b/src/GitHub.Exports.Reactive/Services/NotificationDispatcher.cs index b1df61806c..8a70f54d4f 100644 --- a/src/GitHub.Exports.Reactive/Services/NotificationDispatcher.cs +++ b/src/GitHub.Exports.Reactive/Services/NotificationDispatcher.cs @@ -54,18 +54,18 @@ public void ShowMessage(string message) handler?.ShowMessage(message); } - public void ShowMessage(string message, ICommand command) + public void ShowMessage(string message, ICommand command, bool showToolTips = true, Guid guid = default(Guid)) { notifications.OnNext(new Notification(message, Notification.NotificationType.Message, command)); var handler = notificationHandlers.TryPeek(); - handler?.ShowMessage(message, command); + handler?.ShowMessage(message, command, showToolTips, guid); } public void ShowWarning(string message) { notifications.OnNext(new Notification(message, Notification.NotificationType.Warning)); var handler = notificationHandlers.TryPeek(); - handler.ShowWarning(message); + handler?.ShowWarning(message); } public void ShowError(string message) @@ -75,6 +75,18 @@ public void ShowError(string message) handler?.ShowError(message); } + public void HideNotification(Guid guid) + { + var handler = notificationHandlers.TryPeek(); + handler?.HideNotification(guid); + } + + public bool IsNotificationVisible(Guid guid) + { + var handler = notificationHandlers.TryPeek(); + return handler?.IsNotificationVisible(guid) ?? false; + } + bool disposed; // To detect redundant calls void Dispose(bool disposing) { diff --git a/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs b/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs new file mode 100644 index 0000000000..05fc393f58 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs @@ -0,0 +1,65 @@ +using System; +using System.Linq; +using GitHub.Extensions; + +namespace GitHub.Services +{ + /// + /// Extension methods for . + /// + public static class PullRequestSessionExtensions + { + /// + /// Gets the head (source) branch label for a pull request, stripping the owner if the pull + /// request is not from a fork. + /// + /// The pull request session. + /// The head branch label + public static string GetHeadBranchDisplay(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + return GetBranchDisplay( + session.IsPullRequestFromFork(), + session.PullRequest?.HeadRepositoryOwner, + session.PullRequest?.HeadRefName); + } + + /// + /// Gets the head (target) branch label for a pull request, stripping the owner if the pull + /// request is not from a fork. + /// + /// The pull request session. + /// The head branch label + public static string GetBaseBranchDisplay(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + return GetBranchDisplay( + session.IsPullRequestFromFork(), + session.PullRequest?.BaseRepositoryOwner, + session.PullRequest?.BaseRefName); + } + + /// + /// Returns a value that determines whether the pull request comes from a fork. + /// + /// The pull request session. + /// True if the pull request is from a fork, otherwise false. + public static bool IsPullRequestFromFork(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + + return session.PullRequest != null && + session.PullRequest.HeadRepositoryOwner != session.PullRequest.BaseRepositoryOwner; + } + + static string GetBranchDisplay(bool fork, string owner, string label) + { + if (owner != null && label != null) + { + return fork ? owner + ':' + label : label; + } + + return "[invalid]"; + } + } +} diff --git a/src/GitHub.Exports.Reactive/Validation/ReactivePropertyValidator.cs b/src/GitHub.Exports.Reactive/Validation/ReactivePropertyValidator.cs index 6c3f603027..102a54ae21 100644 --- a/src/GitHub.Exports.Reactive/Validation/ReactivePropertyValidator.cs +++ b/src/GitHub.Exports.Reactive/Validation/ReactivePropertyValidator.cs @@ -107,7 +107,7 @@ public void Dispose() public class ReactivePropertyValidator : ReactivePropertyValidator { - readonly ReactiveCommand validateCommand; + readonly ReactiveCommand validateCommand; ValidationParameter currentValidationParameter; ObservableAsPropertyHelper validationResult; @@ -118,12 +118,12 @@ public override ReactivePropertyValidationResult ValidationResult public override Task ExecuteAsync() { - return validateCommand.ExecuteAsyncTask(currentValidationParameter); + return validateCommand.Execute(currentValidationParameter).ToTask(); } public override Task ResetAsync() { - return validateCommand.ExecuteAsync(new ValidationParameter { RequiresReset = true }) + return validateCommand.Execute(new ValidationParameter { RequiresReset = true }) .Select(_ => Unit.Default) .ToTask(); } @@ -139,9 +139,9 @@ public override bool IsValidating public ReactivePropertyValidator(IObservable propertyChangeSignal) { - validateCommand = ReactiveCommand.CreateAsyncObservable(param => + validateCommand = ReactiveCommand.CreateFromObservable((ValidationParameter param) => { - var validationParams = (ValidationParameter)param ?? new ValidationParameter(); + var validationParams = param ?? new ValidationParameter(); if (validationParams.RequiresReset) { @@ -177,7 +177,7 @@ public ReactivePropertyValidator(IObservable propertyChangeSignal) propertyChangeSignal .Select(x => new ValidationParameter { PropertyValue = x, RequiresReset = false }) .Do(validationParameter => currentValidationParameter = validationParameter) - .Subscribe(validationParameter => validateCommand.Execute(validationParameter)); + .Subscribe(validationParameter => validateCommand.Execute(validationParameter).Subscribe()); } public ReactivePropertyValidator IfTrue(Func predicate, string errorMessage) @@ -276,12 +276,21 @@ public static ReactivePropertyValidator IfNullOrEmpty(this ReactivePrope return This.IfTrue(String.IsNullOrEmpty, errorMessage); } + [SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", MessageId = "System.UriBuilder", + Justification = "We're using UriBuilder to validate the URL because Uri.TryCreate fails if no scheme specified.")] public static ReactivePropertyValidator IfNotUri(this ReactivePropertyValidator This, string errorMessage) { return This.IfFalse(s => { - Uri uri; - return Uri.TryCreate(s, UriKind.Absolute, out uri); + try + { + new UriBuilder(s); + return true; + } + catch + { + return false; + } }, errorMessage); } diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneTabViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneTabViewModel.cs new file mode 100644 index 0000000000..0eb678d828 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneTabViewModel.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.Dialog.Clone +{ + /// + /// Represents a tab in the repository clone dialog. + /// + public interface IRepositoryCloneTabViewModel : IViewModel + { + /// + /// Gets a value that indicates whether the tab is enabled. + /// + /// + /// A disabled tab will be hidden. + /// + bool IsEnabled { get; } + + /// + /// Gets the selected repository, or null if no repository has been selected. + /// + RepositoryModel Repository { get; } + + /// + /// Activates the tab. + /// + /// + /// Will be called each time the tab is selected. + /// + Task Activate(); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneViewModel.cs new file mode 100644 index 0000000000..10f3b1cb83 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneViewModel.cs @@ -0,0 +1,59 @@ +using System; +using System.Reactive; +using GitHub.Models; +using GitHub.Primitives; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog.Clone +{ + /// + /// ViewModel for the Clone Repository dialog + /// + public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel + { + /// + /// Gets the view model for the GitHub.com tab. + /// + IRepositorySelectViewModel GitHubTab { get; } + + /// + /// Gets the view model for the enterprise tab. + /// + IRepositorySelectViewModel EnterpriseTab { get; } + + /// + /// Initial URL for the dialog. + /// + UriString Url { get; set; } + + /// + /// Gets the path to clone the repository to. + /// + string Path { get; set; } + + /// + /// Gets a warning message that explains why is suspect. + /// + string PathWarning { get; } + + /// + /// Gets the index of the selected tab. + /// + /// + /// The tabs are: GitHubPage, EnterprisePage, UrlPage. + /// + int SelectedTabIndex { get; } + + /// + /// Gets the command executed when the user clicks "Browse". + /// + ReactiveCommand Browse { get; } + + /// + /// Gets the command executed when the user clicks "Clone". + /// + ReactiveCommand Clone { get; } + + ReactiveCommand LoginAsDifferentUser { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryItemViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryItemViewModel.cs new file mode 100644 index 0000000000..6bd675ebee --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryItemViewModel.cs @@ -0,0 +1,14 @@ +using System; +using GitHub.UI; + +namespace GitHub.ViewModels.Dialog.Clone +{ + public interface IRepositoryItemViewModel + { + string Caption { get; } + string Name { get; } + string Owner { get; } + Octicon Icon { get; } + Uri Url { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositorySelectViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositorySelectViewModel.cs new file mode 100644 index 0000000000..b8cf181f63 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositorySelectViewModel.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog.Clone +{ + public interface IRepositorySelectViewModel : IRepositoryCloneTabViewModel + { + Exception Error { get; } + string Filter { get; set; } + bool IsLoading { get; } + IReadOnlyList Items { get; } + ICollectionView ItemsView { get; } + IRepositoryItemViewModel SelectedItem { get; set; } + + void Initialize(IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IDialogContentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IDialogContentViewModel.cs new file mode 100644 index 0000000000..4202f71f3c --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IDialogContentViewModel.cs @@ -0,0 +1,20 @@ +using System; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents a view that can be shown in the GitHub dialog. + /// + public interface IDialogContentViewModel : IViewModel + { + /// + /// Gets a title to display in the dialog title bar. + /// + string Title { get; } + + /// + /// Gets an observable that is signalled with a return value when the dialog has completed. + /// + IObservable Done { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositoryExecuteViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositoryExecuteViewModel.cs new file mode 100644 index 0000000000..330138c3cc --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositoryExecuteViewModel.cs @@ -0,0 +1,53 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using Octokit; +using ReactiveUI; +using IConnection = GitHub.Models.IConnection; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// View model for selecting the account to fork a repository to. + /// + public interface IForkRepositoryExecuteViewModel : IDialogContentViewModel + { + RepositoryModel SourceRepository { get; } + + RepositoryModel DestinationRepository { get; } + + IAccount DestinationAccount { get; } + + /// + /// Gets a command that is executed when the user clicks the "Fork" button. + /// + ReactiveCommand CreateFork { get; } + + ReactiveCommand BackCommand { get; } + + bool ResetMasterTracking { get; set; } + + bool AddUpstream { get; set; } + + bool UpdateOrigin { get; set; } + + bool CanAddUpstream { get; } + + bool CanResetMasterTracking { get; } + + string Error { get; } + IObservable Back { get; } + + /// + /// Initializes the view model. + /// + /// The repository to fork. + /// The account to fork to. + /// The connection to use. + Task InitializeAsync( + LocalRepositoryModel sourceRepository, + IAccount destinationAccount, + IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositorySelectViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositorySelectViewModel.cs new file mode 100644 index 0000000000..03e282c14c --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositorySelectViewModel.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// View model for selecting the account to fork a repository to. + /// + public interface IForkRepositorySelectViewModel : IDialogContentViewModel + { + /// + /// Gets a list of accounts that the repository can be forked to. + /// + IReadOnlyList Accounts { get; } + + /// + /// Gets a list of existing forks for accounts that the owner has access to. + /// + IReadOnlyList ExistingForks { get; } + + /// + /// Gets a value indicating whether the view model is loading. + /// + bool IsLoading { get; } + + /// + /// Gets a command that is executed when the user selects an item in . + /// + ReactiveCommand SelectedAccount { get; } + + /// + /// Gets a command that is executed when the user selects an item in . + /// + ReactiveCommand SwitchOrigin { get; } + + /// + /// Initializes the view model. + /// + /// The repository to fork. + /// The connection to use. + Task InitializeAsync(LocalRepositoryModel repository, IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositorySwitchViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositorySwitchViewModel.cs new file mode 100644 index 0000000000..f820955bc0 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositorySwitchViewModel.cs @@ -0,0 +1,34 @@ +using System.Reactive; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// View model for selecting the fork to switch to + /// + public interface IForkRepositorySwitchViewModel : IDialogContentViewModel + { + RepositoryModel SourceRepository { get; } + + RepositoryModel DestinationRepository { get; } + + /// + /// Gets a command that is executed when the user clicks the "Fork" button. + /// + ReactiveCommand SwitchFork { get; } + + bool ResetMasterTracking { get; set; } + + bool AddUpstream { get; set; } + + bool UpdateOrigin { get; set; } + + /// + /// Initializes the view model. + /// + /// The repository to fork. + /// + void Initialize(LocalRepositoryModel sourceRepository, RemoteRepositoryModel remoteRepository); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositoryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositoryViewModel.cs new file mode 100644 index 0000000000..cb9d8c5d3d --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IForkRepositoryViewModel.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// View model for forking a repository. + /// + public interface IForkRepositoryViewModel : IDialogContentViewModel + { + /// + /// Gets the currently displayed page. + /// + IDialogContentViewModel Content { get; } + + /// + /// Initializes the view model. + /// + /// The repository to fork. + /// The connection to use. + Task InitializeAsync(LocalRepositoryModel repository, IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IGistCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGistCreationViewModel.cs similarity index 76% rename from src/GitHub.Exports.Reactive/ViewModels/IGistCreationViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/IGistCreationViewModel.cs index 39dd297ac6..e59779c9b0 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IGistCreationViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGistCreationViewModel.cs @@ -1,15 +1,16 @@ -using GitHub.Models; +using System.Reactive; +using GitHub.Models; using Octokit; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - public interface IGistCreationViewModel : IViewModel + public interface IGistCreationViewModel : IDialogContentViewModel, IConnectionInitializedViewModel { /// /// Gets the command to create a new gist. /// - IReactiveCommand CreateGist { get; } + ReactiveCommand CreateGist { get; } /// /// True if the gist should be private. diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGitHubDialogWindowViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGitHubDialogWindowViewModel.cs new file mode 100644 index 0000000000..5e66f23cc5 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGitHubDialogWindowViewModel.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the top-level view model for the GitHub dialog. + /// + public interface IGitHubDialogWindowViewModel : IDisposable + { + /// + /// Gets the content to display in the dialog. + /// + IDialogContentViewModel Content { get; } + + /// + /// Gets an observable that is signalled when when the dialog should be closed. + /// + /// + /// If the content being displayed has a return value, then this wil be returned here. + /// + IObservable Done { get; } + + /// + /// Starts displaying a view model. + /// + /// The view model to display. + void Start(IDialogContentViewModel viewModel); + + /// + /// Starts displaying a view model that requires a connection. + /// + /// The view model to display. + Task StartWithConnection(T viewModel) + where T : IDialogContentViewModel, IConnectionInitializedViewModel; + + /// + /// Starts displaying a view model that requires a connection that needs to be logged out + /// and back in. + /// + /// The view model to display. + Task StartWithLogout(T viewModel, IConnection connection) + where T : IDialogContentViewModel, IConnectionInitializedViewModel; + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogOutRequiredViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogOutRequiredViewModel.cs new file mode 100644 index 0000000000..eafa4f59ea --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogOutRequiredViewModel.cs @@ -0,0 +1,17 @@ +using System; +using System.Reactive; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the "Logout required" dialog page. + /// + public interface ILogOutRequiredViewModel : IDialogContentViewModel + { + /// + /// Gets a command that will log out the user. + /// + ReactiveCommand LogOut { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogin2FaViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogin2FaViewModel.cs new file mode 100644 index 0000000000..8d6079a9f0 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogin2FaViewModel.cs @@ -0,0 +1,36 @@ +using System; +using System.Reactive; +using GitHub.Validation; +using Octokit; +using ReactiveUI; +using ReactiveUI.Legacy; + +namespace GitHub.ViewModels.Dialog +{ + public interface ILogin2FaViewModel : IDialogContentViewModel + { + ReactiveCommand OkCommand { get; } + ReactiveCommand NavigateLearnMore { get; } + ReactiveCommand ResendCodeCommand { get; } + +#pragma warning disable CS0618 // Type or member is obsolete + IObservable Show(UserError error); +#pragma warning restore CS0618 // Type or member is obsolete + void Cancel(); + + bool IsBusy { get; } + bool IsSms { get; } + bool IsAuthenticationCodeSent { get; } + bool ShowErrorMessage { get; } + bool InvalidAuthenticationCode { get; } + string Description { get; } + string AuthenticationCode { get; set; } + TwoFactorType TwoFactorType { get; } + + /// + /// Gets the validator instance used for validating the + /// property + /// + ReactivePropertyValidator AuthenticationCodeValidator { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginCredentialsViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginCredentialsViewModel.cs new file mode 100644 index 0000000000..a1673ba919 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginCredentialsViewModel.cs @@ -0,0 +1,20 @@ +using System; + +namespace GitHub.ViewModels.Dialog +{ + [Flags] + public enum LoginMode + { + None = 0x00, + DotComOnly = 0x01, + EnterpriseOnly = 0x02, + DotComOrEnterprise = 0x03, + } + + public interface ILoginCredentialsViewModel : IDialogContentViewModel + { + LoginMode LoginMode { get; } + ILoginToGitHubViewModel GitHubLogin { get; } + ILoginToGitHubForEnterpriseViewModel EnterpriseLogin { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubForEnterpriseViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubForEnterpriseViewModel.cs new file mode 100644 index 0000000000..5d6ceeba86 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubForEnterpriseViewModel.cs @@ -0,0 +1,67 @@ +using System.Reactive; +using GitHub.Services; +using GitHub.Validation; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Details the possible values for . + /// + public enum EnterpriseProbeStatus + { + /// + /// No checking is underway. + /// + None, + + /// + /// A probe is underway to see if the URL is a valid enterprise instance. + /// + Checking, + + /// + /// A valid enterprise instance was found. + /// + Valid, + + /// + /// A valid enterprise instance was not found. + /// + Invalid + } + + /// + /// Represents a view model responsible for authenticating a user + /// against a GitHub Enterprise instance. + /// + public interface ILoginToGitHubForEnterpriseViewModel : ILoginToHostViewModel + { + /// + /// Gets or sets the URL to the GitHub Enterprise instance + /// + string EnterpriseUrl { get; set; } + + /// + /// Gets the status of the enterprise probe. + /// + EnterpriseProbeStatus ProbeStatus { get; } + + /// + /// Gets the supported login methods for the GitHub Enterprise instance at + /// . + /// + EnterpriseLoginMethods? SupportedLoginMethods { get; } + + /// + /// Gets the validator instance used for validating the + /// property + /// + ReactivePropertyValidator EnterpriseUrlValidator { get; } + + /// + /// Gets a command which, when invoked, directs the user to a learn more page + /// + ReactiveCommand NavigateLearnMore { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubViewModel.cs similarity index 81% rename from src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubViewModel.cs index b56713c654..bd1e13f52b 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubViewModel.cs @@ -1,7 +1,7 @@ using System.Reactive; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { /// /// Represents a view model responsible for authenticating a user @@ -13,6 +13,6 @@ public interface ILoginToGitHubViewModel : ILoginToHostViewModel /// Gets a command which, when invoked, directs the user to /// a GitHub.com lost password flow. /// - IReactiveCommand NavigatePricing { get; } + ReactiveCommand NavigatePricing { get; } } } \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginViewModel.cs new file mode 100644 index 0000000000..9667652cab --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginViewModel.cs @@ -0,0 +1,19 @@ +using System; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the Login dialog content. + /// + public interface ILoginViewModel : IDialogContentViewModel + { + /// + /// Gets the currently displayed login content. + /// + /// + /// The value of this property will either be a + /// or a . + /// + IDialogContentViewModel Content { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationTarget.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationTarget.cs similarity index 78% rename from src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationTarget.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationTarget.cs index 756c7e71e9..936185fd4b 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationTarget.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationTarget.cs @@ -1,7 +1,9 @@ -using System.Windows.Input; +using System.Reactive; +using System.Windows.Input; using GitHub.Validation; +using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { public interface IRepositoryCreationTarget { @@ -18,6 +20,6 @@ public interface IRepositoryCreationTarget /// /// Command that launches a dialog to browse for the directory in which to create the repository. /// - ICommand BrowseForDirectory { get; } + ReactiveCommand BrowseForDirectory { get; } } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationViewModel.cs new file mode 100644 index 0000000000..9a07bbfd0b --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationViewModel.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Reactive; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + public interface IRepositoryCreationViewModel : IDialogContentViewModel, + IConnectionInitializedViewModel, + IRepositoryForm, + IRepositoryCreationTarget + { + /// + /// Command that creates the repository. + /// + ReactiveCommand CreateRepository { get; } + + /// + /// True when creation is in progress. + /// + bool IsCreating { get; } + + GitIgnoreItem SelectedGitIgnoreTemplate { get; set; } + + LicenseItem SelectedLicense { get; set; } + + /// + /// The list of GitIgnore templates supported by repository creation + /// + IReadOnlyList GitIgnoreTemplates { get; } + + /// + /// The list of license templates supported by repository creation + /// + IReadOnlyList Licenses { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitListViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitListViewModel.cs new file mode 100644 index 0000000000..46ad9c13c6 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitListViewModel.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a list of commit summaries in a pull request timeline. + /// + public interface ICommitListViewModel : IViewModel + { + /// + /// Gets the first author of the commits in the list. + /// + ICommitActorViewModel Author { get; } + + /// + /// Gets a string to display the author login or the author name. + /// + string AuthorName { get; } + + /// + /// Gets a string to display next to the author in the view. + /// + string AuthorCaption { get; } + + /// + /// Gets the commits. + /// + IReadOnlyList Commits { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitSummaryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitSummaryViewModel.cs new file mode 100644 index 0000000000..49d046b503 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitSummaryViewModel.cs @@ -0,0 +1,28 @@ +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a one-line summary of a commit in a pull request timeline. + /// + public interface ICommitSummaryViewModel : IViewModel + { + /// + /// Gets the abbreviated OID (SHA) of the commit. + /// + string AbbreviatedOid { get; } + + /// + /// Gets the commit author. + /// + ICommitActorViewModel Author { get; } + + /// + /// Gets the commit message header. + /// + string Header { get; } + + /// + /// Gets the OID (SHA) of the commit. + /// + string Oid { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/IIssueishCommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/IIssueishCommentThreadViewModel.cs new file mode 100644 index 0000000000..d3cd6dd86e --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/IIssueishCommentThreadViewModel.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.ViewModels.Documents +{ + /// + /// A thread of issue or pull request comments. + /// + public interface IIssueishCommentThreadViewModel : ICommentThreadViewModel + { + /// + /// Called by a comment in the thread to close the issue or pull request. + /// + /// The comment requesting the close. + Task CloseOrReopen(ICommentViewModel comment); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/IPullRequestPageViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/IPullRequestPageViewModel.cs new file mode 100644 index 0000000000..5f311fcfa3 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/IPullRequestPageViewModel.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for displaying a pull request in a document window. + /// + public interface IPullRequestPageViewModel : IPullRequestViewModelBase + { + /// + /// Gets the number of commits in the pull request. + /// + int CommitCount { get; } + + /// + /// Gets the pull request's timeline. + /// + IReadOnlyList Timeline { get; } + + /// + /// Gets a command that will open a commit in Team Explorer. + /// + ReactiveCommand ShowCommit { get; } + + /// + /// Initializes the view model with data. + /// + /// The repository to which the pull request belongs. + /// The local repository. + /// The currently logged in user. + /// The pull request model. + Task InitializeAsync( + RemoteRepositoryModel repository, + LocalRepositoryModel localRepository, + ActorModel currentUser, + PullRequestDetailModel model); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListItemViewModelBase.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListItemViewModelBase.cs new file mode 100644 index 0000000000..cfb62747f7 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListItemViewModelBase.cs @@ -0,0 +1,25 @@ +using System; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Base interface for items in a issue or pull request list. + /// + public interface IIssueListItemViewModelBase : IViewModel + { + /// + /// Gets the issue or pull request number. + /// + int Number { get; } + + /// + /// Gets the issue or pull request title. + /// + string Title { get; } + + /// + /// Gets the author of the issue or pull request. + /// + IActorViewModel Author { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListViewModelBase.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListViewModelBase.cs new file mode 100644 index 0000000000..1330142fc7 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListViewModelBase.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Describes a message that should be displayed in place of a list of items in + /// an . + /// + public enum IssueListMessage + { + /// + /// No message should be displayed; the items should be displayed. + /// + None, + + /// + /// A "No Open Items" message should be displayed. + /// + NoOpenItems, + + /// + /// A "No Items Match Search Criteria" message should be displayed. + /// + NoItemsMatchCriteria, + } + + /// + /// Represents a view model which displays an issue or pull request list. + /// + public interface IIssueListViewModelBase : ISearchablePageViewModel + { + /// + /// Gets the filter view model. + /// + IUserFilterViewModel AuthorFilter { get; } + + /// + /// Gets a list consisting of the fork and parent repositories if the current repository is + /// a fork. + /// + /// + /// Returns null if the current repository is not a fork. + /// + IReadOnlyList Forks { get; } + + /// + /// Gets the list of issues or pull requests. + /// + IReadOnlyList Items { get; } + + /// + /// Gets a filtered view of based on and + /// . + /// + ICollectionView ItemsView { get; } + + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets an enum indicating a message that should be displayed in place of a list of items. + /// + IssueListMessage Message { get; } + + /// + /// Gets the remote repository. + /// + /// + /// This may differ from if is + /// a fork. + /// + RepositoryModel RemoteRepository { get; set; } + + /// + /// Gets the currently selected item in . + /// + string SelectedState { get; set; } + + /// + /// Gets a list of the available states (e.g. Open, Closed, All). + /// + IReadOnlyList States { get; } + + /// + /// Gets the caption to display as the header on the dropdown. + /// + string StateCaption { get; } + + /// + /// Gets a command which opens the item passed as a parameter. + /// + ReactiveCommand OpenItem { get; } + + /// + /// Initializes the view model. + /// + /// The local repository. + /// The connection/ + /// A task tracking the operation. + Task InitializeAsync(LocalRepositoryModel repository, IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoggedOutViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoggedOutViewModel.cs new file mode 100644 index 0000000000..058e8904d9 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoggedOutViewModel.cs @@ -0,0 +1,22 @@ +using System; +using System.Reactive; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Defines the view model for the "Sign in to GitHub" view in the GitHub pane. + /// + public interface ILoggedOutViewModel : IPanePageViewModel + { + /// + /// Gets the command executed when the user clicks the "Sign in" link. + /// + ReactiveCommand SignIn { get; } + + /// + /// Gets the command executed when the user clicks the "Create an Account" link. + /// + ReactiveCommand Register { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoginFailedViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoginFailedViewModel.cs new file mode 100644 index 0000000000..e6c72eceab --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoginFailedViewModel.cs @@ -0,0 +1,28 @@ +using System.Reactive; +using ReactiveUI; +using ReactiveUI.Legacy; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Defines the view model for the "Login Failed" view in the GitHub pane. + /// + public interface ILoginFailedViewModel : IPanePageViewModel + { + /// + /// Gets a description of the login failure. + /// + UserError LoginError { get; } + + /// + /// Gets a command which opens the Team Explorer Connect page. + /// + ReactiveCommand OpenTeamExplorer { get; } + + /// + /// Initializes the view model with an error. + /// + /// The error. + void Initialize(UserError error); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INavigationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INavigationViewModel.cs new file mode 100644 index 0000000000..c097e9989b --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INavigationViewModel.cs @@ -0,0 +1,67 @@ +using System; +using System.Reactive; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// A view model that supports back/forward navigation of an inner content page. + /// + public interface INavigationViewModel : IViewModel + { + /// + /// Gets or sets the current content page. + /// + IPanePageViewModel Content { get; } + + /// + /// Gets or sets the current index in the history list. + /// + int Index { get; set; } + + /// + /// Gets the back and forward history. + /// + IReadOnlyReactiveList History { get; } + + /// + /// Gets a command that navigates back in the history. + /// + ReactiveCommand NavigateBack { get; } + + /// + /// Gets a command that navigates forwards in the history. + /// + ReactiveCommand NavigateForward { get; } + + /// + /// Navigates back if possible. + /// + /// True if there was a page to navigate back to. + bool Back(); + + /// + /// Clears the current page and all history . + /// + void Clear(); + + /// + /// Navigates forwards if possible. + /// + /// True if there was a page to navigate forwards to. + bool Forward(); + + /// + /// Navigates to a new page. + /// + /// The page view model. + void NavigateTo(IPanePageViewModel page); + + /// + /// Removes all instances of a page from the history. + /// + /// The page to remove. + int RemoveAll(IPanePageViewModel page); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INoRemoteOriginViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INoRemoteOriginViewModel.cs new file mode 100644 index 0000000000..9d9c9b08b9 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INoRemoteOriginViewModel.cs @@ -0,0 +1,16 @@ +using System.Reactive; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Defines the view model for the "No Origin Remote" view in the GitHub pane. + /// + public interface INoRemoteOriginViewModel : IPanePageViewModel + { + /// + /// Gets a command that will allow the user to rename remotes. + /// + ReactiveCommand EditRemotes { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitHubRepositoryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitHubRepositoryViewModel.cs new file mode 100644 index 0000000000..bcb3752150 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitHubRepositoryViewModel.cs @@ -0,0 +1,16 @@ +using System.Reactive; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Defines the view model for the "Sign in to GitHub" view in the GitHub pane. + /// + public interface INotAGitHubRepositoryViewModel : IPanePageViewModel + { + /// + /// Gets the command executed when the user clicks the "Publish to GitHub" link. + /// + ReactiveCommand Publish { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitRepositoryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitRepositoryViewModel.cs new file mode 100644 index 0000000000..296673bc5d --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitRepositoryViewModel.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Defines the view model for the "Not a git repository" view in the GitHub pane. + /// + [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces")] + public interface INotAGitRepositoryViewModel : IPanePageViewModel + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPanePageViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPanePageViewModel.cs new file mode 100644 index 0000000000..3596bea2cb --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPanePageViewModel.cs @@ -0,0 +1,66 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model that represents a page in the GitHub pane. + /// + public interface IPanePageViewModel : IViewModel, IDisposable + { + /// + /// Gets an exception representing an error state to display. + /// + Exception Error { get; } + + /// + /// Gets a value indicating whether the page is busy. + /// + /// + /// When is set to true, an indeterminate progress bar will be + /// displayed at the top of the GitHub pane but the pane contents will remain visible. + /// + bool IsBusy { get; } + + /// + /// Gets a value indicating whether the page is loading. + /// + /// + /// When is set to true, a spinner will be displayed instead of the + /// pane contents. + /// + bool IsLoading { get; } + + /// + /// Gets the title to display in the pane when the page is shown. + /// + string Title { get; } + + /// + /// Gets an observable that is fired when the pane wishes to close itself. + /// + IObservable CloseRequested { get; } + + /// + /// Gets an observable that is fired with a URI when the pane wishes to navigate to another + /// pane. + /// + IObservable NavigationRequested { get; } + + /// + /// Called when the page becomes the current page in the GitHub pane. + /// + void Activated(); + + /// + /// Called when the page stops being the current page in the GitHub pane. + /// + void Deactivated(); + + /// + /// Refreshes the view model. + /// + Task Refresh(); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestAnnotationItemViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestAnnotationItemViewModel.cs new file mode 100644 index 0000000000..98e60121c4 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestAnnotationItemViewModel.cs @@ -0,0 +1,37 @@ +using System.Reactive; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// The viewmodel for a single annotation item in a list + /// + public interface IPullRequestAnnotationItemViewModel + { + /// + /// Gets the annotation model. + /// + CheckRunAnnotationModel Annotation { get; } + + /// + /// Gets a formatted descriptor of the line(s) the annotation is about. + /// + string LineDescription { get; } + + /// + /// Gets or sets a flag to control the expanded state. + /// + bool IsExpanded { get; set; } + + /// + /// Gets a flag which indicates this annotation item is from a file changed in this pull request. + /// + bool IsFileInPullRequest { get; } + + /// + /// Gets a command which opens the annotation in the diff view. + /// + ReactiveCommand OpenAnnotation { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestAnnotationsViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestAnnotationsViewModel.cs new file mode 100644 index 0000000000..9f4ca12e45 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestAnnotationsViewModel.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; +using ReactiveUI.Legacy; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A viewmodel which displays a list of annotations for a pull request's check run. + /// + public interface IPullRequestAnnotationsViewModel : IPanePageViewModel + { + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the owner of the remote repository that contains the pull request. + /// + /// + /// The remote repository may be different from the local repository if the local + /// repository is a fork and the user is viewing pull requests from the parent repository. + /// + string RemoteRepositoryOwner { get; } + + /// + /// Gets the number of the pull request. + /// + int PullRequestNumber { get; } + + /// + /// Gets the title of the pull request. + /// + string PullRequestTitle { get; } + + /// + /// Gets the id of the check run. + /// + string CheckRunId { get; } + + /// + /// Gets the name of the check run. + /// + string CheckRunName { get; } + + /// + /// Gets a command which navigates to the parent pull request. + /// + ReactiveCommand NavigateToPullRequest { get; } + + /// + /// Name of the Check Suite. + /// + string CheckSuiteName { get; } + + /// + /// Summary of the Check Run + /// + string CheckRunSummary { get; } + + /// + /// Text of the Check Run + /// + string CheckRunText { get; } + + /// + /// Gets a dictionary of annotations by file path. + /// + IReadOnlyDictionary AnnotationsDictionary { get; } + + /// + /// Initializes the view model. + /// + /// The local repository. + /// The connection to the repository host. + /// The pull request's repository owner. + /// The pull request's repository name. + /// The pull request's number. + /// The pull request's check run id. + Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber, + string checkRunId); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs new file mode 100644 index 0000000000..a47f4889d1 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs @@ -0,0 +1,15 @@ +using System; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a file or directory node in a pull request changes tree. + /// + public interface IPullRequestChangeNode + { + /// + /// Gets the path to the file or directory, relative to the root of the repository. + /// + string RelativePath { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCheckViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCheckViewModel.cs new file mode 100644 index 0000000000..d24a9c7212 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCheckViewModel.cs @@ -0,0 +1,60 @@ +using System; +using System.Reactive; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model for displaying details of a pull request Status or Check. + /// + public interface IPullRequestCheckViewModel: IViewModel + { + /// + /// The title of the Status/Check. + /// + string Title { get; } + + /// + /// The description of the Status/Check. + /// + string Description { get; } + + /// + /// The status of the Status/Check. + /// + PullRequestCheckStatus Status { get; } + + /// + /// The url where more information about the Status/Check can be found. + /// + Uri DetailsUrl { get; } + + /// + /// A command that opens the DetailsUrl in a browser. + /// + ReactiveCommand OpenDetailsUrl { get; } + + /// + /// Gets the type of check run, Status/Check. + /// + PullRequestCheckType CheckType { get; } + + /// + /// Gets the id of the check run. + /// + string CheckRunId { get; } + + /// + /// Gets a flag to show this check run has annotations. + /// + bool HasAnnotations { get; } + } + + public enum PullRequestCheckStatus + { + Pending, + Success, + Failure + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs new file mode 100644 index 0000000000..fb201f3ab7 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs @@ -0,0 +1,24 @@ +using GitHub.Models; +using System.Collections.Generic; +using GitHub.Validation; +using ReactiveUI; +using System.Threading.Tasks; +using System.Reactive; +using GitHub.Services; + +namespace GitHub.ViewModels.GitHubPane +{ + public interface IPullRequestCreationViewModel : IPanePageViewModel + { + BranchModel SourceBranch { get; set; } + BranchModel TargetBranch { get; set; } + IReadOnlyList Branches { get; } + ReactiveCommand CreatePullRequest { get; } + ReactiveCommand Cancel { get; } + string PRTitle { get; set; } + ReactivePropertyValidator TitleValidator { get; } + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + + Task InitializeAsync(LocalRepositoryModel repository, IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs new file mode 100644 index 0000000000..0e4616fb67 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using ReactiveUI.Legacy; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Holds immutable state relating to the command. + /// + public interface IPullRequestCheckoutState + { + /// + /// Gets the message to display on the checkout button. + /// + string Caption { get; } + + /// + /// Gets a value indicating whether checkout is available. + /// + bool IsEnabled { get; } + + /// + /// Gets the message to display as the checkout button's tooltip. + /// + string ToolTip { get; } + } + + /// + /// Holds immutable state relating to the and + /// commands. + /// + public interface IPullRequestUpdateState + { + /// + /// Gets the number of commits that the current branch is ahead of the PR branch. + /// + int CommitsAhead { get; } + + /// + /// Gets the number of commits that the current branch is behind the PR branch. + /// + int CommitsBehind { get; } + + /// + /// Gets a value indicating whether the current branch is up to date. + /// + bool UpToDate { get; } + + /// + /// Gets the message to display when a pull cannot be carried out. + /// + string PullToolTip { get; } + + /// + /// Gets the message to display when a push cannot be carried out. + /// + string PushToolTip { get; } + } + + /// + /// A view model which displays the details of a pull request. + /// + public interface IPullRequestDetailViewModel : IPanePageViewModel, IOpenInBrowser + { + /// + /// Gets the underlying pull request model. + /// + PullRequestDetailModel Model { get; } + + /// + /// Gets the session for the pull request. + /// + IPullRequestSession Session { get; } + + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the owner of the remote repository that contains the pull request. + /// + /// + /// The remote repository may be different from the local repository if the local + /// repository is a fork and the user is viewing pull requests from the parent repository. + /// + string RemoteRepositoryOwner { get; } + + /// + /// Gets the Pull Request number. + /// + int Number { get; } + + /// + /// Gets the Pull Request author. + /// + IActorViewModel Author { get; } + + /// + /// Gets a string describing how to display the pull request's source branch. + /// + string SourceBranchDisplayName { get; } + + /// + /// Gets a string describing how to display the pull request's target branch. + /// + string TargetBranchDisplayName { get; } + + /// + /// Gets a value indicating whether the pull request branch is checked out. + /// + bool IsCheckedOut { get; } + + /// + /// Gets a value indicating whether the pull request comes from a fork. + /// + bool IsFromFork { get; } + + /// + /// Gets the pull request body. + /// + string Body { get; } + + /// + /// Gets the latest pull request review for each user. + /// + IReadOnlyList Reviews { get; } + + /// + /// Gets the pull request's changed files. + /// + IPullRequestFilesViewModel Files { get; } + + /// + /// Gets the state associated with the command. + /// + IPullRequestCheckoutState CheckoutState { get; } + + /// + /// Gets the state associated with the and commands. + /// + IPullRequestUpdateState UpdateState { get; } + + /// + /// Gets the error message to be displayed below the checkout button. + /// + string OperationError { get; } + + /// + /// Gets a command that checks out the pull request locally. + /// + ReactiveCommand Checkout { get; } + + /// + /// Gets a command that pulls changes to the current branch. + /// + ReactiveCommand Pull { get; } + + /// + /// Gets a command that pushes changes from the current branch. + /// + ReactiveCommand Push { get; } + + /// + /// Sync submodules for PR branch. + /// + ReactiveCommand SyncSubmodules { get; } + + /// + /// Gets a command that opens the pull request conversation in a document pane. + /// + ReactiveCommand OpenConversation { get; } + + /// + /// Gets a command that opens the pull request on GitHub. + /// + ReactiveCommand OpenOnGitHub { get; } + + /// + /// Gets a command that navigates to a pull request review. + /// + ReactiveCommand ShowReview { get; } + + /// + /// Gets a command that navigates to a pull request's check run annotation list. + /// + ReactiveCommand ShowAnnotations { get; } + + /// + /// Gets the latest pull request checks & statuses. + /// + IReadOnlyList Checks { get; } + + /// + /// Initializes the view model. + /// + /// The local repository. + /// The connection to the repository host. + /// The pull request's repository owner. + /// The pull request's repository name. + /// The pull request number. + Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int number); + + /// + /// Gets the full path to a file in the working directory. + /// + /// The file. + /// The full path to the file in the working directory. + string GetLocalFilePath(IPullRequestFileNode file); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDirectoryNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDirectoryNode.cs similarity index 93% rename from src/GitHub.Exports.Reactive/ViewModels/IPullRequestDirectoryNode.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDirectoryNode.cs index 6b227c3ff9..7e2b23c1c3 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDirectoryNode.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDirectoryNode.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// Represents a directory node in a pull request changes tree. diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFileNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFileNode.cs new file mode 100644 index 0000000000..c9d1b55b8e --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFileNode.cs @@ -0,0 +1,52 @@ +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + public interface IPullRequestFileNode : IPullRequestChangeNode + { + /// + /// Gets the name of the file without path information. + /// + string FileName { get; } + + /// + /// Gets the old path of a moved/renamed file, relative to the root of the repository. + /// + string OldPath { get; } + + /// + /// Gets the SHA of the file. + /// + string Sha { get; } + + /// + /// Gets the type of change that was made to the file. + /// + PullRequestFileStatus Status { get; } + + /// + /// Gets the string to display in the [message] box next to the filename. + /// + string StatusDisplay { get; } + + /// + /// Gets the number of review comments on the file. + /// + int CommentCount { get; } + + /// + /// Gets or sets the number of annotation notices on the file. + /// + int AnnotationNoticeCount { get; } + + /// + /// Gets or sets the number of annotation errors on the file. + /// + int AnnotationWarningCount { get; } + + /// + /// Gets or sets the number of annotation failures on the file. + /// + int AnnotationFailureCount { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs new file mode 100644 index 0000000000..ebef31bf39 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using LibGit2Sharp; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a tree of changed files in a pull request. + /// + public interface IPullRequestFilesViewModel : IViewModel, IDisposable + { + /// + /// Gets the number of changed files in the pull request. + /// + int ChangedFilesCount { get; } + + /// + /// Gets the root nodes of the tree. + /// + IReadOnlyList Items { get; } + + /// + /// Gets a command that diffs an between BASE and HEAD. + /// + ReactiveCommand DiffFile { get; } + + /// + /// Gets a command that opens an as it appears in the PR. + /// + ReactiveCommand ViewFile { get; } + + /// + /// Gets a command that diffs an between the version in + /// the working directory and HEAD. + /// + ReactiveCommand DiffFileWithWorkingDirectory { get; } + + /// + /// Gets a command that opens an from disk. + /// + ReactiveCommand OpenFileInWorkingDirectory { get; } + + /// + /// Gets a command that opens the first comment for a in + /// the diff viewer. + /// + ReactiveCommand OpenFirstComment { get; } + + /// + /// Gets a command that opens the first annotation notice for a in + /// the diff viewer. + /// + ReactiveCommand OpenFirstAnnotationNotice { get; } + + /// + /// Gets a command that opens the first annotation warning for a in + /// the diff viewer. + /// + ReactiveCommand OpenFirstAnnotationWarning { get; } + + /// + /// Gets a command that opens the first annotation failure for a in + /// the diff viewer. + /// + ReactiveCommand OpenFirstAnnotationFailure { get; } + + /// + /// Initializes the view model. + /// + /// The pull request session. + /// An optional review comment filter. + Task InitializeAsync( + IPullRequestSession session, + Func commentFilter = null); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs new file mode 100644 index 0000000000..9f6758243b --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs @@ -0,0 +1,52 @@ +using System; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents an item in the pull request list. + /// + public interface IPullRequestListItemViewModel : IIssueListItemViewModelBase + { + /// + /// Gets the ID of the pull request. + /// + string Id { get; } + + /// + /// Gets the number of comments in the pull request. + /// + int CommentCount { get; } + + /// + /// Gets a value indicating whether the currently checked out branch is the pull request + /// branch. + /// + bool IsCurrent { get; } + + /// + /// Gets the last updated time of the pull request. + /// + DateTimeOffset UpdatedAt { get; } + + /// + /// Gets the pull request checks and statuses summary + /// + PullRequestChecksSummaryState ChecksSummary { get; } + + /// + /// Gets the number of pending checks and statuses + /// + int ChecksPendingCount { get; } + + /// + /// Gets the number of successful checks and statuses + /// + int ChecksSuccessCount { get; } + + /// + /// Gets the number of erroneous checks and statuses + /// + int ChecksErrorCount { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs new file mode 100644 index 0000000000..802a0083ad --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs @@ -0,0 +1,22 @@ +using System; +using System.Reactive; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model which displays a pull request list. + /// + public interface IPullRequestListViewModel : IIssueListViewModelBase, IOpenInBrowser + { + /// + /// Gets a command which navigates to the "Create Pull Request" view. + /// + ReactiveCommand CreatePullRequest { get; } + + /// + /// Gets a command that opens the pull request item on GitHub. + /// + ReactiveCommand OpenItemInBrowser { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs new file mode 100644 index 0000000000..6d6137050a --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model for displaying details of a pull request review that is being + /// authored. + /// + public interface IPullRequestReviewAuthoringViewModel : IPanePageViewModel, IDisposable + { + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the owner of the remote repository that contains the pull request. + /// + /// + /// The remote repository may be different from the local repository if the local + /// repository is a fork and the user is viewing pull requests from the parent repository. + /// + string RemoteRepositoryOwner { get; } + + /// + /// Gets the underlying pull request review model. + /// + PullRequestReviewModel Model { get; } + + /// + /// Gets the underlying pull request model. + /// + PullRequestDetailModel PullRequestModel { get; } + + /// + /// Gets or sets the body of the pull request review to be submitted. + /// + string Body { get; } + + /// + /// Gets a value indicating whether the user can approve/request changes on the pull request. + /// + bool CanApproveRequestChanges { get; } + + /// + /// Gets the pull request's changed files. + /// + IPullRequestFilesViewModel Files { get; } + + /// + /// Gets a list of the file comments in the review. + /// + IReadOnlyList FileComments { get; } + + /// + /// Gets the error message to be displayed in the action area as a result of an error submitting. + /// + string OperationError { get; } + + /// + /// Gets a command which navigates to the parent pull request. + /// + ReactiveCommand NavigateToPullRequest { get; } + + /// + /// Gets a command which submits the review as an approval. + /// + ReactiveCommand Approve { get; } + + /// + /// Gets a command which submits the review as a comment. + /// + ReactiveCommand Comment { get; } + + /// + /// Gets a command which submits the review requesting changes. + /// + ReactiveCommand RequestChanges { get; } + + /// + /// Gets a command which cancels the review. + /// + ReactiveCommand Cancel { get; } + + /// + /// Provides an AutoCompleteAdvisor. + /// + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + + /// + /// Initializes the view model for creating a new review. + /// + /// The local repository. + /// The connection to the repository host. + /// The pull request's repository owner. + /// The pull request's repository name. + /// The pull request number. + Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewFileCommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewFileCommentViewModel.cs new file mode 100644 index 0000000000..cef9b60e30 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewFileCommentViewModel.cs @@ -0,0 +1,27 @@ +using System; +using System.Reactive; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model for a file comment in an . + /// + public interface IPullRequestReviewFileCommentViewModel + { + /// + /// Gets the body of the comment. + /// + string Body { get; } + + /// + /// Gets the path to the file, relative to the root of the repository. + /// + string RelativePath { get; } + + /// + /// Gets a command which opens the comment in a diff view. + /// + ReactiveCommand Open { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewSummaryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewSummaryViewModel.cs new file mode 100644 index 0000000000..c4af548211 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewSummaryViewModel.cs @@ -0,0 +1,35 @@ +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays a short overview of a pull request review in the . + /// + public interface IPullRequestReviewSummaryViewModel + { + /// + /// Gets the ID of the pull request review. + /// + string Id { get; set; } + + /// + /// Gets the user who submitted the review. + /// + IActorViewModel User { get; set; } + + /// + /// Gets the state of the review. + /// + PullRequestReviewState State { get; set; } + + /// + /// Gets a string representing the state of the review. + /// + string StateDisplay { get; } + + /// + /// Gets the number of file comments in the review. + /// + int FileCommentCount { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewViewModel.cs new file mode 100644 index 0000000000..a211ea5d11 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewViewModel.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a view model that displays a pull request review. + /// + public interface IPullRequestReviewViewModel : IViewModel + { + /// + /// Gets the underlying pull request review model. + /// + PullRequestReviewModel Model { get; } + + /// + /// Gets the body of the review. + /// + string Body { get; } + + /// + /// Gets the state of the pull request review as a string. + /// + string StateDisplay { get; } + + /// + /// Gets a value indicating whether the pull request review should initially be expanded. + /// + bool IsExpanded { get; } + + /// + /// Gets a value indicating whether the pull request review has a body or file comments. + /// + bool HasDetails { get; } + + /// + /// Gets a list of the file comments in the review. + /// + IReadOnlyList FileComments { get; } + + /// + /// Gets a list of outdated file comments in the review. + /// + IReadOnlyList OutdatedFileComments { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestUserReviewsViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestUserReviewsViewModel.cs new file mode 100644 index 0000000000..fa21f2753d --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestUserReviewsViewModel.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Displays all reviews made by a user on a pull request. + /// + public interface IPullRequestUserReviewsViewModel : IPanePageViewModel + { + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the owner of the remote repository that contains the pull request. + /// + /// + /// The remote repository may be different from the local repository if the local + /// repository is a fork and the user is viewing pull requests from the parent repository. + /// + string RemoteRepositoryOwner { get; } + + /// + /// Gets the number of the pull request. + /// + int PullRequestNumber { get; } + + /// + /// Gets the reviews made by the . + /// + IReadOnlyList Reviews { get; } + + /// + /// Gets the title of the pull request. + /// + string PullRequestTitle { get; } + + /// + /// Gets the user whose reviews are being shown. + /// + IActorViewModel User { get; } + + /// + /// Gets a command that navigates to the parent pull request in the GitHub pane. + /// + ReactiveCommand NavigateToPullRequest { get; } + + /// + /// Initializes the view model, loading data from the API. + /// + /// The local repository. + /// The connection to the repository host. + /// The pull request's repository owner. + /// The pull request's repository name. + /// The pull request number. + /// The user's login. + Task InitializeAsync( + LocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int pullRequestNumber, + string login); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ISearchablePageViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ISearchablePageViewModel.cs new file mode 100644 index 0000000000..808bdc1e92 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ISearchablePageViewModel.cs @@ -0,0 +1,15 @@ +using System; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model that represents a searchable page in the GitHub pane. + /// + public interface ISearchablePageViewModel : IPanePageViewModel + { + /// + /// Gets or sets the current search query. + /// + string SearchQuery { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IActorViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IActorViewModel.cs new file mode 100644 index 0000000000..4f7da2000f --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IActorViewModel.cs @@ -0,0 +1,11 @@ +using System.Windows.Media.Imaging; + +namespace GitHub.ViewModels +{ + public interface IActorViewModel : IViewModel + { + BitmapSource Avatar { get; } + string AvatarUrl { get; } + string Login { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IBaseCloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IBaseCloneViewModel.cs deleted file mode 100644 index 07d5f79c2c..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IBaseCloneViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using System.Reactive; -using GitHub.Models; -using ReactiveUI; -using System.Collections.ObjectModel; - -namespace GitHub.ViewModels -{ - /// - /// ViewModel for the the Clone Repository dialog - /// - public interface IBaseCloneViewModel : IViewModel, IRepositoryCreationTarget - { - /// - /// Signals that the user clicked the clone button. - /// - IReactiveCommand CloneCommand { get; } - - IRepositoryModel SelectedRepository { get; set; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IClosable.cs b/src/GitHub.Exports.Reactive/ViewModels/IClosable.cs new file mode 100644 index 0000000000..ac6ab171c3 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IClosable.cs @@ -0,0 +1,16 @@ +using System; +using System.Reactive; + +namespace GitHub.ViewModels +{ + /// + /// Represents an entity that can be closed. + /// + public interface IClosable + { + /// + /// Gets an observable that is fired when the entity is closed. + /// + IObservable Closed { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs new file mode 100644 index 0000000000..5c520519cd --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// A comment thread. + /// + public interface ICommentThreadViewModel : IViewModel + { + /// + /// Gets the current user under whose account new comments will be created. + /// + IActorViewModel CurrentUser { get; } + + /// + /// Called by a comment in the thread to post itself as a new comment to the API. + /// + /// The comment to post. + Task PostComment(ICommentViewModel comment); + + /// + /// Called by a comment in the thread to post itself as an edit to a comment to the API. + /// + /// The comment to edit. + Task EditComment(ICommentViewModel comment); + + /// + /// Called by a comment in the thread to delete the comment on the API. + /// + /// The comment to delete. + Task DeleteComment(ICommentViewModel comment); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs new file mode 100644 index 0000000000..be6d97e13b --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs @@ -0,0 +1,129 @@ +using System; +using System.Reactive; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + public enum CommentEditState + { + None, + Editing, + Placeholder, + } + + /// + /// View model for an issue, pull request or pull request review comment. + /// + public interface ICommentViewModel : IViewModel + { + /// + /// Gets the GraphQL ID of the comment. + /// + string Id { get; } + + /// + /// Gets the Database ID of the comment. + /// + int DatabaseId { get; } + + /// + /// The pull request id of the comment + /// + int PullRequestId { get; } + + /// + /// Gets the author of the comment. + /// + IActorViewModel Author { get; } + + /// + /// Gets or sets the body of the comment. + /// + string Body { get; set; } + + /// + /// Gets any error message encountered posting or updating the comment. + /// + string ErrorMessage { get; } + + /// + /// Gets the current edit state of the comment. + /// + CommentEditState EditState { get; } + + /// + /// Gets or sets a value indicating whether the comment is read-only. + /// + bool IsReadOnly { get; set; } + + /// + /// Gets a value indicating whether the comment is currently in the process of being + /// submitted. + /// + bool IsSubmitting { get; } + + /// + /// Gets a value indicating whether the comment edit state can be canceled. + /// + bool CanCancel { get; } + + /// + /// Gets a value indicating whether the comment can be edited or deleted by the current user. + /// + bool CanDelete { get; } + + /// + /// Gets the creation date of the comment. + /// + DateTimeOffset CreatedAt { get; } + + /// + /// Gets the caption for the "Commit" button. + /// + /// + /// This will be "Comment" when editing a new comment and "Update" when editing an existing comment. + /// + string CommitCaption { get; } + + /// + /// Gets the thread that the comment is a part of. + /// + ICommentThreadViewModel Thread { get; } + + /// + /// Gets the URL of the comment on the web. + /// + Uri WebUrl { get; } + + /// + /// Gets a command which will begin editing of the comment. + /// + ReactiveCommand BeginEdit { get; } + + /// + /// Gets a command which will cancel editing of the comment. + /// + ReactiveCommand CancelEdit { get; } + + /// + /// Gets a command which will commit edits to the comment. + /// + ReactiveCommand CommitEdit { get; } + + /// + /// Gets a command to open the comment in a browser. + /// + ReactiveCommand OpenOnGitHub { get; } + + /// + /// Deletes a comment. + /// + ReactiveCommand Delete { get; } + + /// + /// Provides an AutoCompleteAdvisor. + /// + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommitActorViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommitActorViewModel.cs new file mode 100644 index 0000000000..a736aac349 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommitActorViewModel.cs @@ -0,0 +1,9 @@ +namespace GitHub.ViewModels +{ + public interface ICommitActorViewModel : IActorViewModel + { + string Email { get; } + string Name { get; } + bool HasLogin { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IInlineAnnotationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IInlineAnnotationViewModel.cs new file mode 100644 index 0000000000..1ed533c306 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IInlineAnnotationViewModel.cs @@ -0,0 +1,15 @@ +using GitHub.Models; + +namespace GitHub.ViewModels +{ + /// + /// A view model that represents a single inline annotation. + /// + public interface IInlineAnnotationViewModel + { + /// + /// Gets the inline annotation model. + /// + InlineAnnotationModel Model { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IIssueishViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IIssueishViewModel.cs new file mode 100644 index 0000000000..fed3456d09 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IIssueishViewModel.cs @@ -0,0 +1,53 @@ +using System; +using System.Reactive; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// Base interface for issue and pull request view models. + /// + public interface IIssueishViewModel : IViewModel + { + /// + /// Gets the GraphQL ID for the issue or pull request. + /// + string Id { get; } + + /// + /// Gets the issue or pull request author. + /// + IActorViewModel Author { get; } + + /// + /// Gets the issue or pull request body. + /// + string Body { get; } + + /// + /// Gets the issue or pull request number. + /// + int Number { get; } + + /// + /// Gets the repository that the issue or pull request comes from. + /// + RemoteRepositoryModel Repository { get; } + + /// + /// Gets the issue or pull request title. + /// + string Title { get; } + + /// + /// Gets the URL of the issue or pull request. + /// + Uri WebUrl { get; } + + /// + /// Gets a command which opens the issue or pull request in a browser. + /// + ReactiveCommand OpenOnGitHub { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoggedOutViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILoggedOutViewModel.cs deleted file mode 100644 index e7aed4cdfd..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoggedOutViewModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Reactive; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// Defines the view model for the "Sign in to GitHub" view in the GitHub pane. - /// - public interface ILoggedOutViewModel : IViewModel - { - /// - /// Gets the command executed when the user clicks the "Sign in" link. - /// - IReactiveCommand SignIn { get; } - - /// - /// Gets the command executed when the user clicks the "Create an Account" link. - /// - IReactiveCommand Register { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginControlViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILoginControlViewModel.cs deleted file mode 100644 index f23c583826..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginControlViewModel.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.ComponentModel; -using System.Reactive; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - public enum LoginMode - { - None = 0, - DotComOrEnterprise, - DotComOnly = 3, - EnterpriseOnly = 4, - } - - /// - /// Represents a view model responsible for providing log in to - /// either GitHub.com or a GitHub Enterprise instance. - /// - public interface ILoginControlViewModel : IReactiveObject, ILoginViewModel - { - /// - /// Gets a value indicating the currently available login modes - /// for the control. - /// - LoginMode LoginMode { get; } - - /// - /// Gets a value indicating whether this instance is currently - /// in the process of logging in. - /// - bool IsLoginInProgress { get; } - - /// - /// Gets a view model responsible for authenticating a user - /// against GitHub.com. - /// - ILoginToGitHubViewModel GitHubLogin { get; } - - /// - /// Gets a view model responsible for authenticating a user - /// against a GitHub Enterprise instance. - /// - ILoginToGitHubForEnterpriseViewModel EnterpriseLogin { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubForEnterpriseViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubForEnterpriseViewModel.cs deleted file mode 100644 index fe1352bb0a..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubForEnterpriseViewModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Reactive; -using GitHub.Validation; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// Represents a view model responsible for authenticating a user - /// against a GitHub Enterprise instance. - /// - public interface ILoginToGitHubForEnterpriseViewModel : ILoginToHostViewModel - { - /// - /// Gets or sets the URL to the GitHub Enterprise instance - /// - string EnterpriseUrl { get; set; } - - /// - /// Gets the validator instance used for validating the - /// property - /// - ReactivePropertyValidator EnterpriseUrlValidator { get; } - - /// - /// Gets a command which, when invoked, directs the user to a learn more page - /// - IReactiveCommand NavigateLearnMore { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs index 5b089502fa..9b20b22f29 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs @@ -1,7 +1,8 @@ using System.Reactive; -using GitHub.Authentication; +using GitHub.Models; using GitHub.Validation; using ReactiveUI; +using ReactiveUI.Legacy; namespace GitHub.ViewModels { @@ -33,13 +34,18 @@ public interface ILoginToHostViewModel /// Gets a command which, when invoked, performs the actual /// login procedure. /// - IReactiveCommand Login { get; } + ReactiveCommand Login { get; } + + /// + /// Gets a command which, when invoked, performs an OAuth login. + /// + ReactiveCommand LoginViaOAuth { get; } /// /// Gets a command which, when invoked, direct the user to a /// GitHub.com sign up flow /// - IReactiveCommand SignUp { get; } + ReactiveCommand SignUp { get; } /// /// Gets a value indicating whether all validators pass and we @@ -57,12 +63,26 @@ public interface ILoginToHostViewModel /// Gets a command which, when invoked, resets all properties /// and validators. /// - IReactiveCommand Reset { get; } + ReactiveCommand Reset { get; } +#pragma warning disable CS0618 // Type or member is obsolete /// /// Gets a command which, when invoked, directs the user to /// a GitHub.com lost password flow. /// IRecoveryCommand NavigateForgotPassword { get; } +#pragma warning restore CS0618 // Type or member is obsolete + +#pragma warning disable CS0618 // Type or member is obsolete + /// + /// Gets an error to display to the user. + /// + UserError Error { get; } +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Called when the login UI is hidden or dismissed. + /// + void Deactivated(); } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILogoutRequiredViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILogoutRequiredViewModel.cs deleted file mode 100644 index 6d1d4fac45..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/ILogoutRequiredViewModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GitHub.UI; -using ReactiveUI; -using System.Diagnostics.CodeAnalysis; - -namespace GitHub.ViewModels -{ - /// - /// Represents a view model for the "Log out Required" dialog.. - /// - public interface ILogoutRequiredViewModel : IReactiveViewModel - { - IReactiveCommand Logout { get; } - string LogoutRequiredMessage { get; set; } - Octicon Icon { get; set; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/INotAGitHubRepositoryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/INotAGitHubRepositoryViewModel.cs deleted file mode 100644 index 2fb562e65f..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/INotAGitHubRepositoryViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// Defines the view model for the "Sign in to GitHub" view in the GitHub pane. - /// - public interface INotAGitHubRepositoryViewModel : IViewModel - { - /// - /// Gets the command executed when the user clicks the "Publish to GitHub" link. - /// - IReactiveCommand Publish { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/INotAGitRepositoryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/INotAGitRepositoryViewModel.cs deleted file mode 100644 index 6ee8569e75..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/INotAGitRepositoryViewModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GitHub.ViewModels -{ - /// - /// Defines the view model for the "Not a git repository" view in the GitHub pane. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces")] - public interface INotAGitRepositoryViewModel : IViewModel - { - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestChangeNode.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestChangeNode.cs deleted file mode 100644 index 8a343a002c..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestChangeNode.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace GitHub.ViewModels -{ - /// - /// Represents a file or directory node in a pull request changes tree. - /// - public interface IPullRequestChangeNode - { - /// - /// Gets the path to the file (not including the filename) or directory, relative to the - /// root of the repository. - /// - string DirectoryPath { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestCreationViewModel.cs deleted file mode 100644 index be5199aab1..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestCreationViewModel.cs +++ /dev/null @@ -1,17 +0,0 @@ -using GitHub.Models; -using System.Collections.Generic; -using GitHub.Validation; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - public interface IPullRequestCreationViewModel : IReactiveViewModel - { - IBranch SourceBranch { get; set; } - IBranch TargetBranch { get; set; } - IReadOnlyList Branches { get; } - IReactiveCommand CreatePullRequest { get; } - string PRTitle { get; set; } - ReactivePropertyValidator TitleValidator { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDetailViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDetailViewModel.cs deleted file mode 100644 index 9e1fada966..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDetailViewModel.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Reactive; -using System.Threading.Tasks; -using GitHub.Models; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// Holds immutable state relating to the command. - /// - public interface IPullRequestCheckoutState - { - /// - /// Gets the message to display on the checkout button. - /// - string Caption { get; } - - /// - /// Gets a value indicating whether checkout is available. - /// - bool IsEnabled { get; } - - /// - /// Gets the message to display as the checkout button's tooltip. - /// - string ToolTip { get; } - } - - /// - /// Holds immutable state relating to the and - /// commands. - /// - public interface IPullRequestUpdateState - { - /// - /// Gets the number of commits that the current branch is ahead of the PR branch. - /// - int CommitsAhead { get; } - - /// - /// Gets the number of commits that the current branch is behind the PR branch. - /// - int CommitsBehind { get; } - - /// - /// Gets a value indicating whether the current branch is up to date. - /// - bool UpToDate { get; } - - /// - /// Gets the message to display when a pull cannot be carried out. - /// - string PullToolTip { get; } - - /// - /// Gets the message to display when a push cannot be carried out. - /// - string PushToolTip { get; } - } - - /// - /// Represents a view model for displaying details of a pull request. - /// - public interface IPullRequestDetailViewModel : IViewModel, IHasBusy - { - /// - /// Gets the underlying pull request model. - /// - IPullRequestModel Model { get; } - - /// - /// Gets a string describing how to display the pull request's source branch. - /// - string SourceBranchDisplayName { get; } - - /// - /// Gets a string describing how to display the pull request's target branch. - /// - string TargetBranchDisplayName { get; } - - /// - /// Gets a value indicating whether the pull request comes from a fork. - /// - bool IsFromFork { get; } - - /// - /// Gets the pull request body. - /// - string Body { get; } - - /// - /// Gets the changed files as a tree. - /// - IReactiveList ChangedFilesTree { get; } - - /// - /// Gets the state associated with the command. - /// - IPullRequestCheckoutState CheckoutState { get; } - - /// - /// Gets the state associated with the and commands. - /// - IPullRequestUpdateState UpdateState { get; } - - /// - /// Gets the error message to be displayed below the checkout button. - /// - string OperationError { get; } - - /// - /// Gets a command that checks out the pull request locally. - /// - ReactiveCommand Checkout { get; } - - /// - /// Gets a command that pulls changes to the current branch. - /// - ReactiveCommand Pull { get; } - - /// - /// Gets a command that pushes changes from the current branch. - /// - ReactiveCommand Push { get; } - - /// - /// Gets a command that opens the pull request on GitHub. - /// - ReactiveCommand OpenOnGitHub { get; } - - /// - /// Gets a command that opens a . - /// - ReactiveCommand OpenFile { get; } - - /// - /// Gets a command that diffs a . - /// - ReactiveCommand DiffFile { get; } - - /// - /// Gets the specified file as it appears in the pull request. - /// - /// The file or directory node. - /// The path to the extracted file. - Task ExtractFile(IPullRequestFileNode file); - - /// - /// Gets the before and after files needed for viewing a diff. - /// - /// The changed file. - /// A tuple containing the full path to the before and after files. - Task> ExtractDiffFiles(IPullRequestFileNode file); - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileNode.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileNode.cs deleted file mode 100644 index 9081727525..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileNode.cs +++ /dev/null @@ -1,32 +0,0 @@ -using GitHub.Models; - -namespace GitHub.ViewModels -{ - public interface IPullRequestFileNode : IPullRequestChangeNode - { - /// - /// Gets the name of the file without path information. - /// - string FileName { get; } - - /// - /// Gets the path to display in the "Path" column of the changed files list. - /// - string DisplayPath { get; } - - /// - /// Gets the SHA of the file. - /// - string Sha { get; } - - /// - /// Gets the type of change that was made to the file. - /// - PullRequestFileStatus Status { get; } - - /// - /// Gets the string to display in the [message] box next to the filename. - /// - string StatusDisplay { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileViewModel.cs deleted file mode 100644 index 3d1dd147bc..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileViewModel.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace GitHub.ViewModels -{ - /// - /// Describes the way in which a file is changed in a pull request. - /// - public enum FileChangeType - { - /// - /// The file contents were changed. - /// - Changed, - - /// - /// The file was added. - /// - Added, - - /// - /// The file was deleted. - /// - Removed, - } - - /// - /// Represents a file node in a pull request changes tree. - /// - public interface IPullRequestFileViewModel : IPullRequestChangeNode - { - /// - /// Gets the type of change that was made to the file. - /// - FileChangeType ChangeType { get; } - - /// - /// Gets the name of the file without path information. - /// - string FileName { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs deleted file mode 100644 index dd6ab61126..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Collections.Generic; -using GitHub.Collections; -using GitHub.Models; -using System.Windows.Input; -using ReactiveUI; -using System.Collections.ObjectModel; - -namespace GitHub.ViewModels -{ - public class PullRequestState - { - public PullRequestState() - { - } - - public PullRequestState(bool isOpen, string name) - { - IsOpen = isOpen; - Name = name; - } - - public bool? IsOpen; - public string Name; - public override string ToString() - { - return Name; - } - } - - public interface IPullRequestListViewModel : IViewModel - { - ITrackingCollection PullRequests { get; } - IPullRequestModel SelectedPullRequest { get; } - ICommand OpenPullRequest { get; } - IReadOnlyList States { get; set; } - PullRequestState SelectedState { get; set; } - ObservableCollection Authors { get; } - IAccount SelectedAuthor { get; set; } - ObservableCollection Assignees { get; } - IAccount SelectedAssignee { get; set; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs new file mode 100644 index 0000000000..b669927a07 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// A thread of pull request review comments. + /// + public interface IPullRequestReviewCommentThreadViewModel : ICommentThreadViewModel + { + /// + /// Gets the comments in the thread. + /// + IReadOnlyReactiveList Comments { get; } + + /// + /// Gets the current pull request review session. + /// + IPullRequestSession Session { get; } + + /// + /// Gets the file that the comment is on. + /// + IPullRequestSessionFile File { get; } + + /// + /// Gets the 0-based line number that the comment in on. + /// + int LineNumber { get; } + + /// + /// Gets the side of the diff that the comment is on. + /// + DiffSide Side { get; } + + /// + /// Gets a value indicating whether comment thread has been marked as resolved by a user. + /// + bool IsResolved { get; } + + /// + /// Gets a value indicating whether the thread is a new thread being authored, that is not + /// yet present on the server. + /// + bool IsNewThread { get; } + + /// + /// Gets a value indicating whether the user must commit and push their changes before + /// leaving a comment on the requested line. + /// + bool NeedsPush { get; } + + /// + /// Initializes the view model with data. + /// + /// The pull request session. + /// The file that the comment is on. + /// The thread. + /// + /// Whether to add a placeholder comment at the end of the thread. + /// + Task InitializeAsync(IPullRequestSession session, + IPullRequestSessionFile file, + IInlineCommentThreadModel thread, + bool addPlaceholder); + + /// + /// Initializes the view model as a new thread being authored. + /// + /// The pull request session. + /// The file that the comment is on. + /// The 0-based line number of the thread. + /// The side of the diff. + /// Whether to start the placeholder in edit state. + Task InitializeNewAsync(IPullRequestSession session, + IPullRequestSessionFile file, + int lineNumber, + DiffSide side, + bool isEditing); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs new file mode 100644 index 0000000000..8212fdba3e --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs @@ -0,0 +1,57 @@ +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// View model for a pull request review comment. + /// + public interface IPullRequestReviewCommentViewModel : ICommentViewModel + { + /// + /// Gets a value indicating whether the user can start a new review with this comment. + /// + bool CanStartReview { get; } + + /// + /// Gets a value indicating whether this comment is part of a pending pull request review. + /// + bool IsPending { get; } + + /// + /// Gets a command which will commit a new comment and start a review. + /// + ReactiveCommand StartReview { get; } + + /// + /// Initializes the view model with data. + /// + /// The pull request session. + /// The thread that the comment is a part of. + /// The associated pull request review. + /// The comment model. + /// The comment edit state. + Task InitializeAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + PullRequestReviewModel review, + PullRequestReviewCommentModel comment, + CommentEditState state); + + /// + /// Initializes the view model as a placeholder. + /// + /// The pull request session. + /// The thread that the comment is a part of. + /// Whether the comment thread is a pending review thread. + /// Whether to start the placeholder in edit mode. + Task InitializeAsPlaceholderAsync( + IPullRequestSession session, + ICommentThreadViewModel thread, + bool isPending, + bool isEditing); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestViewModelBase.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestViewModelBase.cs new file mode 100644 index 0000000000..ae197e1c61 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestViewModelBase.cs @@ -0,0 +1,30 @@ +using GitHub.Models; + +namespace GitHub.ViewModels +{ + /// + /// Base class for pull request view models. + /// + public interface IPullRequestViewModelBase : IIssueishViewModel + { + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the pull request state. + /// + PullRequestState State { get; } + + /// + /// Gets a the pull request's source (head) branch display. + /// + string SourceBranchDisplayName { get; } + + /// + /// Gets a the pull request's target (base) branch display. + /// + string TargetBranchDisplayName { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IReactiveViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IReactiveViewModel.cs deleted file mode 100644 index e0f666200b..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IReactiveViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ReactiveUI; - -namespace GitHub.ViewModels -{ - public interface IReactiveViewModel : IViewModel - { - IReactiveCommand CancelCommand { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCloneViewModel.cs deleted file mode 100644 index f6764fb7c2..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCloneViewModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.Reactive; -using GitHub.Models; -using ReactiveUI; -using System.Collections.ObjectModel; - -namespace GitHub.ViewModels -{ - /// - /// ViewModel for the the Clone Repository dialog - /// - public interface IRepositoryCloneViewModel : IBaseCloneViewModel, IRepositoryCreationTarget - { - - /// - /// The list of repositories the current user may clone from the specified host. - /// - ObservableCollection Repositories { get; } - - bool FilterTextIsEnabled { get; } - - /// - /// Whether or not we are currently loading repositories. - /// - bool IsLoading { get; } - - /// - /// If true, then we failed to load the repositories. - /// - bool LoadingFailed { get; } - - /// - /// Set to true if no repositories were found. - /// - bool NoRepositoriesFound { get; } - - /// - /// Set to true if a repository is selected. - /// - bool CanClone { get; } - - string FilterText { get; set; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationViewModel.cs deleted file mode 100644 index d699663a9c..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationViewModel.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Generic; -using System.Reactive; -using GitHub.Models; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - public interface IRepositoryCreationViewModel : IRepositoryForm, IRepositoryCreationTarget - { - /// - /// Command that creates the repository. - /// - IReactiveCommand CreateRepository { get; } - - /// - /// True when creation is in progress. - /// - bool IsCreating { get; } - - GitIgnoreItem SelectedGitIgnoreTemplate { get; set; } - - LicenseItem SelectedLicense { get; set; } - - /// - /// The list of GitIgnore templates supported by repository creation - /// - IReadOnlyList GitIgnoreTemplates { get; } - - /// - /// The list of license templates supported by repository creation - /// - IReadOnlyList Licenses { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryForm.cs b/src/GitHub.Exports.Reactive/ViewModels/IRepositoryForm.cs index 6047d41daf..efb15369e7 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryForm.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/IRepositoryForm.cs @@ -41,18 +41,5 @@ public interface IRepositoryForm : IViewModel /// Indicates whether the created repository should be private or not. /// bool KeepPrivate { get; set; } - - /// - /// Indicates whether the user can create a private repository. This is false if the user is not a paid - /// account or if the user has run out of repositories for their current plan. - /// - bool CanKeepPrivate { get; } - bool ShowUpgradeToMicroPlanWarning { get; } - bool ShowUpgradePlanWarning { get; } - - /// - /// Command that opens a browser to a page for upgrading the user's plan. - /// - ICommand UpgradeAccountPlan { get; } } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryPublishViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IRepositoryPublishViewModel.cs deleted file mode 100644 index 1493db2488..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryPublishViewModel.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Reactive; -using GitHub.Models; -using ReactiveUI; -using System.Collections.ObjectModel; - -namespace GitHub.ViewModels -{ - public interface IRepositoryPublishViewModel : IRepositoryForm - { - ObservableCollection Connections { get; } - - /// - /// Command that creates the repository. - /// - IReactiveCommand PublishRepository { get; } - - /// - /// True when publishing is in progress. - /// - bool IsPublishing { get; } - - /// - /// Determines whether the host combo box is visible. Only true if the user is logged into more than one host. - /// - bool IsHostComboBoxVisible { get; } - - /// - /// The selected host to publish to. - /// - IConnection SelectedConnection { get; set; } - } - - public enum ProgressState - { - Idle, - Running, - Success, - Fail - } - -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ITwoFactorDialogViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ITwoFactorDialogViewModel.cs deleted file mode 100644 index c5437c2c22..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/ITwoFactorDialogViewModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using GitHub.Validation; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - public interface ITwoFactorDialogViewModel : IReactiveViewModel - { - ReactiveCommand OkCommand { get; } - ReactiveCommand NavigateLearnMore { get; } - ReactiveCommand ResendCodeCommand { get; } - - IObservable Show(UserError error); - - bool IsBusy { get; } - bool IsSms { get; } - bool IsAuthenticationCodeSent { get; } - bool ShowErrorMessage { get; } - bool InvalidAuthenticationCode { get; } - string Description { get; } - string AuthenticationCode { get; set; } - - /// - /// Gets the validator instance used for validating the - /// property - /// - ReactivePropertyValidator AuthenticationCodeValidator { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IUserFilterViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IUserFilterViewModel.cs new file mode 100644 index 0000000000..8654cc630c --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IUserFilterViewModel.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.ComponentModel; + +namespace GitHub.ViewModels +{ + public interface IUserFilterViewModel : IViewModel + { + string Filter { get; set; } + IActorViewModel Selected { get; set; } + IReadOnlyList Users { get; } + ICollectionView UsersView { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/TeamExplorer/IRepositoryPublishViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/TeamExplorer/IRepositoryPublishViewModel.cs new file mode 100644 index 0000000000..efd0ef6e9b --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/TeamExplorer/IRepositoryPublishViewModel.cs @@ -0,0 +1,45 @@ +using System.Reactive; +using GitHub.Extensions; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.TeamExplorer +{ + public interface IRepositoryPublishViewModel : IViewModel, IRepositoryForm + { + IReadOnlyObservableCollection Connections { get; } + + /// + /// Gets the busy state of the publish. + /// + bool IsBusy { get; } + + /// + /// Command that creates the repository. + /// + ReactiveCommand PublishRepository { get; } + + /// + /// Command that shows login dialog. + /// + ReactiveCommand LoginAsDifferentUser { get; } + + /// + /// Determines whether the host combo box is visible. Only true if the user is logged into more than one host. + /// + bool IsHostComboBoxVisible { get; } + + /// + /// The selected host to publish to. + /// + IConnection SelectedConnection { get; set; } + } + + public enum ProgressState + { + Idle, + Running, + Success, + Fail + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs b/src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000000..6cae3c4e06 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs @@ -0,0 +1,29 @@ +using System; +using System.Reactive; +using GitHub.Logging; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels +{ + /// + /// Base class for view models. + /// + /// + /// A view model must inherit from this class in order for a view to be automatically + /// found by the ViewLocator. + /// + public abstract class ViewModelBase : ReactiveObject, IViewModel + { + static readonly ILogger logger = LogManager.ForContext(); + + static ViewModelBase() + { + // We don't really have a better place to hook this up as we don't want to force-load + // rx on package load. + RxApp.DefaultExceptionHandler = Observer.Create( + ex => logger.Error(ex, "Unhandled rxui error"), + ex => logger.Error(ex, "Unhandled rxui error")); + } + } +} diff --git a/src/GitHub.Exports.Reactive/packages.config b/src/GitHub.Exports.Reactive/packages.config deleted file mode 100644 index afcfb2ad2b..0000000000 --- a/src/GitHub.Exports.Reactive/packages.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.Exports/Api/ISimpleApiClient.cs b/src/GitHub.Exports/Api/ISimpleApiClient.cs index f1b3a66bc1..28004d6951 100644 --- a/src/GitHub.Exports/Api/ISimpleApiClient.cs +++ b/src/GitHub.Exports/Api/ISimpleApiClient.cs @@ -6,11 +6,12 @@ namespace GitHub.Api { public interface ISimpleApiClient { + IGitHubClient Client { get; } HostAddress HostAddress { get; } UriString OriginalUrl { get; } Task GetRepository(); bool HasWiki(); - bool IsEnterprise(); + Task IsEnterprise(); bool IsAuthenticated(); } } diff --git a/src/GitHub.Exports/Api/ISimpleApiClientFactory.cs b/src/GitHub.Exports/Api/ISimpleApiClientFactory.cs index ba4f3b92e3..67cf946cc5 100644 --- a/src/GitHub.Exports/Api/ISimpleApiClientFactory.cs +++ b/src/GitHub.Exports/Api/ISimpleApiClientFactory.cs @@ -1,10 +1,11 @@ -using GitHub.Primitives; +using System.Threading.Tasks; +using GitHub.Primitives; namespace GitHub.Api { public interface ISimpleApiClientFactory { - ISimpleApiClient Create(UriString repositoryUrl); + Task Create(UriString repositoryUrl); void ClearFromCache(ISimpleApiClient client); } } diff --git a/src/GitHub.Exports/Authentication/AuthenticationResult.cs b/src/GitHub.Exports/Authentication/AuthenticationResult.cs deleted file mode 100644 index f2623e2774..0000000000 --- a/src/GitHub.Exports/Authentication/AuthenticationResult.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace GitHub.Authentication -{ - public enum AuthenticationResult - { - /// - /// Could not authenticate using the credentials provided. - /// - CredentialFailure, - /// - /// The two factor authentication challenge failed. - /// - VerificationFailure, - /// - /// The given remote Uri is not an enterprise Uri. - /// - EnterpriseServerNotFound, - /// - /// Aaaawwww yeeeaah - /// - Success - } -} diff --git a/src/GitHub.Exports/Authentication/AuthenticationResultExtensions.cs b/src/GitHub.Exports/Authentication/AuthenticationResultExtensions.cs deleted file mode 100644 index 3a57e63c61..0000000000 --- a/src/GitHub.Exports/Authentication/AuthenticationResultExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace GitHub.Authentication -{ - - public static class AuthenticationResultExtensions - { - public static bool IsFailure(this AuthenticationResult result) - { - return result != AuthenticationResult.Success; - } - - public static bool IsSuccess(this AuthenticationResult result) - { - return result == AuthenticationResult.Success; - } - } -} diff --git a/src/GitHub.Exports/Commands/IAddConnectionCommand.cs b/src/GitHub.Exports/Commands/IAddConnectionCommand.cs new file mode 100644 index 0000000000..b863423ca6 --- /dev/null +++ b/src/GitHub.Exports/Commands/IAddConnectionCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens the login dialog to add a new connection to Team Explorer. + /// + public interface IAddConnectionCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IBlameLinkCommand.cs b/src/GitHub.Exports/Commands/IBlameLinkCommand.cs new file mode 100644 index 0000000000..aa80f45685 --- /dev/null +++ b/src/GitHub.Exports/Commands/IBlameLinkCommand.cs @@ -0,0 +1,12 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens the blame view for the currently selected text on GitHub.com or an Enterprise + /// instance. + /// + public interface IBlameLinkCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/ICopyLinkCommand.cs b/src/GitHub.Exports/Commands/ICopyLinkCommand.cs new file mode 100644 index 0000000000..605f427b03 --- /dev/null +++ b/src/GitHub.Exports/Commands/ICopyLinkCommand.cs @@ -0,0 +1,12 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Copies a link to the clipboard of the currently selected text on GitHub.com or an + /// Enterprise instance. + /// + public interface ICopyLinkCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/ICreateGistCommand.cs b/src/GitHub.Exports/Commands/ICreateGistCommand.cs new file mode 100644 index 0000000000..2cc5d11128 --- /dev/null +++ b/src/GitHub.Exports/Commands/ICreateGistCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Creates a gist from the currently selected text. + /// + public interface ICreateGistCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/ICreateGistEnterpriseCommand.cs b/src/GitHub.Exports/Commands/ICreateGistEnterpriseCommand.cs new file mode 100644 index 0000000000..702daa422a --- /dev/null +++ b/src/GitHub.Exports/Commands/ICreateGistEnterpriseCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Creates a GitHub Enterprise gist from the currently selected text. + /// + public interface ICreateGistEnterpriseCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IGoToSolutionOrPullRequestFileCommand.cs b/src/GitHub.Exports/Commands/IGoToSolutionOrPullRequestFileCommand.cs new file mode 100644 index 0000000000..1db3e75c2c --- /dev/null +++ b/src/GitHub.Exports/Commands/IGoToSolutionOrPullRequestFileCommand.cs @@ -0,0 +1,15 @@ +namespace GitHub.Commands +{ + /// + /// Navigate from a PR file to the equivalent file and location in the editor (or the reverse). + /// + /// + /// This command will do one of the following depending on context. + /// Navigate from PR file diff to the working file in the solution. + /// Navigate from the working file in the solution to the PR file diff. + /// Navigate from an editable diff (e.g. 'View Changes in Solution') to the editor view. + /// + public interface IGoToSolutionOrPullRequestFileCommand : IVsCommand + { + } +} diff --git a/src/GitHub.Exports/Commands/INextInlineCommentCommand.cs b/src/GitHub.Exports/Commands/INextInlineCommentCommand.cs new file mode 100644 index 0000000000..72aa5f544e --- /dev/null +++ b/src/GitHub.Exports/Commands/INextInlineCommentCommand.cs @@ -0,0 +1,12 @@ +using System; +using GitHub.Commands; + +namespace GitHub.Commands +{ + /// + /// Navigates to and opens the the next inline comment thread in the currently active text view. + /// + public interface INextInlineCommentCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IOpenFromClipboardCommand.cs b/src/GitHub.Exports/Commands/IOpenFromClipboardCommand.cs new file mode 100644 index 0000000000..783ac965f1 --- /dev/null +++ b/src/GitHub.Exports/Commands/IOpenFromClipboardCommand.cs @@ -0,0 +1,19 @@ +namespace GitHub.Commands +{ + /// + /// Open a file in the current repository based on a URL in the clipboard. + /// + /// + /// This command appears as `Code context > GitHub > Open from clipboard`. + /// + /// Open a working directory file at the same location as a GitHub URL in the clipboard. If the URL links to + /// a line or range of lines, these lines will be selected. If the working directory file is different to the + /// target file, the target file will be opened in the `Blame (Annotate)` view. If the URL is from a different + /// fork, it will still open the target file assuming that the target commit/branch exists. + /// Currently only GitHub `blob` URLs are supported. In a future version we can add support for `pull`, `issue`, + /// `tree`, `commits`, `blame` and any other URL types that might make sense. + /// + public interface IOpenFromClipboardCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IOpenFromUrlCommand.cs b/src/GitHub.Exports/Commands/IOpenFromUrlCommand.cs new file mode 100644 index 0000000000..af552848c3 --- /dev/null +++ b/src/GitHub.Exports/Commands/IOpenFromUrlCommand.cs @@ -0,0 +1,17 @@ +namespace GitHub.Commands +{ + /// + /// Open a repository from a URL in the clipboard. + /// + /// + /// This appears as the named command `GitHub.OpenFromUrl` and must be bound to a keyboard shortcut or executed + /// via the `Command Window`. In future it will appear on `File > Open > Open from GitHub`. + /// + /// When executed it will offer to clone, open and navigate to the file pointed to by a URL in the clipboard. + /// This spike uses Yes/No/Cancel dialogs, but the final version will use a UI to control what action is performed + /// and allow the user to override the default repository location. + /// + public interface IOpenFromUrlCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IOpenIssueishDocumentCommand.cs b/src/GitHub.Exports/Commands/IOpenIssueishDocumentCommand.cs new file mode 100644 index 0000000000..4f767cc503 --- /dev/null +++ b/src/GitHub.Exports/Commands/IOpenIssueishDocumentCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens an issue or pull request in a new document window. + /// + public interface IOpenIssueishDocumentCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IOpenLinkCommand.cs b/src/GitHub.Exports/Commands/IOpenLinkCommand.cs new file mode 100644 index 0000000000..c6495183bc --- /dev/null +++ b/src/GitHub.Exports/Commands/IOpenLinkCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens the currently selected text on GitHub.com or an Enterprise instance. + /// + public interface IOpenLinkCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IOpenPullRequestsCommand.cs b/src/GitHub.Exports/Commands/IOpenPullRequestsCommand.cs new file mode 100644 index 0000000000..b29adfc21e --- /dev/null +++ b/src/GitHub.Exports/Commands/IOpenPullRequestsCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens the GitHub pane and shows the pull request list. + /// + public interface IOpenPullRequestsCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IPreviousInlineCommentCommand.cs b/src/GitHub.Exports/Commands/IPreviousInlineCommentCommand.cs new file mode 100644 index 0000000000..571356dc25 --- /dev/null +++ b/src/GitHub.Exports/Commands/IPreviousInlineCommentCommand.cs @@ -0,0 +1,12 @@ +using System; +using GitHub.Commands; + +namespace GitHub.Commands +{ + /// + /// Navigates to and opens the the previous inline comment thread in the currently active text view. + /// + public interface IPreviousInlineCommentCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IShowCurrentPullRequestCommand.cs b/src/GitHub.Exports/Commands/IShowCurrentPullRequestCommand.cs new file mode 100644 index 0000000000..f20a6d18af --- /dev/null +++ b/src/GitHub.Exports/Commands/IShowCurrentPullRequestCommand.cs @@ -0,0 +1,14 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens the GitHub pane and shows the currently checked out pull request. + /// + /// + /// Does nothing if there is no checked out pull request. + /// + public interface IShowCurrentPullRequestCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IShowGitHubPaneCommand.cs b/src/GitHub.Exports/Commands/IShowGitHubPaneCommand.cs new file mode 100644 index 0000000000..a6c05cec32 --- /dev/null +++ b/src/GitHub.Exports/Commands/IShowGitHubPaneCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens the GitHub pane. + /// + public interface IShowGitHubPaneCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/ISyncSubmodulesCommand.cs b/src/GitHub.Exports/Commands/ISyncSubmodulesCommand.cs new file mode 100644 index 0000000000..63cd379d3f --- /dev/null +++ b/src/GitHub.Exports/Commands/ISyncSubmodulesCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.Commands +{ + /// + /// Sync submodules in local repository. + /// + public interface ISyncSubmodulesCommand : IVsCommand + { + /// + /// Sync submodules in local repository. + /// + /// Tuple with bool that is true if command completed successfully and string with + /// output from sync submodules Git command. + Task> SyncSubmodules(); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IToggleInlineCommentMarginCommand.cs b/src/GitHub.Exports/Commands/IToggleInlineCommentMarginCommand.cs new file mode 100644 index 0000000000..4ac530580f --- /dev/null +++ b/src/GitHub.Exports/Commands/IToggleInlineCommentMarginCommand.cs @@ -0,0 +1,6 @@ +namespace GitHub.Commands +{ + public interface IToggleInlineCommentMarginCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IVsCommand.cs b/src/GitHub.Exports/Commands/IVsCommand.cs new file mode 100644 index 0000000000..de2d0211c6 --- /dev/null +++ b/src/GitHub.Exports/Commands/IVsCommand.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.Commands +{ + /// + /// Represents a Visual Studio command that does not accept a parameter. + /// + public interface IVsCommand : IVsCommandBase + { + /// + /// Executes the command. + /// + /// A task that tracks the execution of the command. + Task Execute(); + } + + /// + /// Represents a Visual Studio command that accepts a parameter. + /// + public interface IVsCommand : IVsCommandBase + { + /// + /// Executes the command. + /// + /// The command parameter. + /// A task that tracks the execution of the command. + Task Execute(TParam parameter); + } +} diff --git a/src/GitHub.Exports/Commands/IVsCommandBase.cs b/src/GitHub.Exports/Commands/IVsCommandBase.cs new file mode 100644 index 0000000000..749d46e808 --- /dev/null +++ b/src/GitHub.Exports/Commands/IVsCommandBase.cs @@ -0,0 +1,21 @@ +using System; +using System.Windows.Input; + +namespace GitHub.Commands +{ + /// + /// Represents a Visual Studio command exposed as an . + /// + public interface IVsCommandBase : ICommand + { + /// + /// Gets a value indicating whether the command is enabled. + /// + bool Enabled { get; } + + /// + /// Gets a value indicating whether the command is visible. + /// + bool Visible { get; } + } +} diff --git a/src/GitHub.Exports/Commands/InlineCommentNavigationParams.cs b/src/GitHub.Exports/Commands/InlineCommentNavigationParams.cs new file mode 100644 index 0000000000..2d99c5af33 --- /dev/null +++ b/src/GitHub.Exports/Commands/InlineCommentNavigationParams.cs @@ -0,0 +1,26 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Supplies parameters to and + /// . + /// + public class InlineCommentNavigationParams + { + /// + /// Gets or sets the line that should be used as the start point for navigation. + /// + /// + /// If null, the current line will be used. If -1 then the absolute first or last + /// comment in the file will be navigated to. + /// + public int? FromLine { get; set; } + + /// + /// Gets or sets a value indicating whether the cursor will be moved to the newly opened + /// comment. + /// + public bool MoveCursor { get; set; } = true; + } +} diff --git a/src/GitHub.Exports/Commands/OpenIssueishParams.cs b/src/GitHub.Exports/Commands/OpenIssueishParams.cs new file mode 100644 index 0000000000..2c3d7e6ea2 --- /dev/null +++ b/src/GitHub.Exports/Commands/OpenIssueishParams.cs @@ -0,0 +1,23 @@ +using GitHub.Primitives; + +namespace GitHub.Commands +{ + public class OpenIssueishParams + { + public OpenIssueishParams( + HostAddress address, + string owner, + string repository, + int number) + { + Address = address; + Owner = owner; + Repository = repository; + Number = number; + } + public HostAddress Address { get; } + public string Owner { get; } + public string Repository { get; } + public int Number { get; } + } +} diff --git a/src/GitHub.Exports/ExceptionExtensions.cs b/src/GitHub.Exports/ExceptionExtensions.cs index e04bb1a0f1..4afbb3011a 100644 --- a/src/GitHub.Exports/ExceptionExtensions.cs +++ b/src/GitHub.Exports/ExceptionExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Octokit; namespace GitHub.Extensions @@ -9,7 +10,7 @@ public static class ApiExceptionExtensions public static bool IsGitHubApiException(this Exception ex) { var apiex = ex as ApiException; - return apiex?.HttpResponse?.Headers.ContainsKey(GithubHeader) ?? false; + return apiex?.HttpResponse?.Headers.Keys.Contains(GithubHeader, StringComparer.OrdinalIgnoreCase) ?? false; } } } diff --git a/src/GitHub.Exports/Exports/ExportForProcessAttribute.cs b/src/GitHub.Exports/Exports/ExportForProcessAttribute.cs new file mode 100644 index 0000000000..aa47ff4cd9 --- /dev/null +++ b/src/GitHub.Exports/Exports/ExportForProcessAttribute.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.ComponentModel.Composition; + +namespace GitHub.Exports +{ + /// + /// Only expose export when executing in specific named process. + /// + /// + /// This attribute is used to mark exports that mustn't be loaded into Blend. + /// See: https://github.com/github/VisualStudio/pull/1055 + /// + [SuppressMessage("Microsoft.Performance", "CA1813:AvoidUnsealedAttributes", Justification = "Extended by ExportForVisualStudioProcessAttribute")] + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] + public class ExportForProcessAttribute : ExportAttribute + { + // Unique name for exports that have been disabled + const string DisabledContractName = "GitHub.Disabled"; + + /// + /// Define an export that is only exposed in a specific named process. + /// + /// Name of the process to expose export from (e.g. 'devenv'). + /// The contract type to expose. + public ExportForProcessAttribute(string processName, Type contractType = null) : + base(ContractNameForProcess(processName), contractType) + { + ProcessName = processName; + } + + static string ContractNameForProcess(string processName) + { + var enabled = IsProcess(processName); + return enabled ? null : DisabledContractName; + } + + public static bool IsProcess(string processName) => Process.GetCurrentProcess().ProcessName == processName; + + /// + /// The process name export will be exposed in. + /// + public string ProcessName { get; } + } +} diff --git a/src/GitHub.Exports/Exports/ExportForVisualStudioProcessAttribute.cs b/src/GitHub.Exports/Exports/ExportForVisualStudioProcessAttribute.cs new file mode 100644 index 0000000000..4e39cd28d3 --- /dev/null +++ b/src/GitHub.Exports/Exports/ExportForVisualStudioProcessAttribute.cs @@ -0,0 +1,28 @@ +using System; + +namespace GitHub.Exports +{ + /// + /// Only expose export when executing in Visual Studio (devenv) process. + /// + /// + /// This attribute is used to mark exports that mustn't be loaded into Blend. + /// See: https://github.com/github/VisualStudio/pull/1055 + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] + public sealed class ExportForVisualStudioProcessAttribute : ExportForProcessAttribute + { + const string VisualStudioProcessName = "devenv"; + + /// + /// Define an export that is only exposed in a Visual Studio (devenv) process. + /// + /// The contract type to expose. + public ExportForVisualStudioProcessAttribute(Type contractType = null) : + base(VisualStudioProcessName, contractType) + { + } + + public static bool IsVisualStudioProcess() => IsProcess(VisualStudioProcessName); + } +} diff --git a/src/GitHub.Exports/Exports/ExportMetadata.cs b/src/GitHub.Exports/Exports/ExportMetadata.cs index 911aa7eeb1..74ecf29ba3 100644 --- a/src/GitHub.Exports/Exports/ExportMetadata.cs +++ b/src/GitHub.Exports/Exports/ExportMetadata.cs @@ -1,40 +1,10 @@ using System; using System.ComponentModel.Composition; -using GitHub.UI; -using GitHub.ViewModels; -using System.Windows.Controls; -using System.Linq; -using System.Diagnostics; -using System.Reflection; -using GitHub.VisualStudio; +using System.Diagnostics.CodeAnalysis; +using System.Windows; namespace GitHub.Exports { - /// - /// Defines the type of a view or view model. - /// - public enum UIViewType - { - None, - End, - Login, - TwoFactor, - Create, - Clone, - Publish, - Gist, - PRList, - PRDetail, - PRCreation, - LogoutRequired, - GitHubPane, - LoggedOut, - NotAGitRepository, - NotAGitHubRepository, - StartPageClone, - - } - /// /// Defines the types of global visual studio menus available. /// @@ -45,52 +15,38 @@ public enum MenuType } /// - /// A MEF export attribute that defines an export of type with - /// metadata. - /// - [MetadataAttribute] - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public sealed class ExportViewModelAttribute : ExportAttribute - { - public ExportViewModelAttribute() : base(typeof(IViewModel)) - {} - - public UIViewType ViewType { get; set; } - } - - /// - /// A MEF export attribute that defines an export of type with - /// metadata. + /// Defines the type of repository link to navigate to /// - [MetadataAttribute] - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public sealed class ExportViewAttribute : ExportAttribute + public enum LinkType { - public ExportViewAttribute() : base(typeof(IView)) - { - } - - public UIViewType ViewType { get; set; } + Unknown, + Blob, + Blame, + Repository } /// - /// A MEF export attribute that defines an export of type with - /// metadata. + /// A MEF export attribute that defines an export of type with + /// metadata. /// [MetadataAttribute] - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public sealed class ExportMenuAttribute : ExportAttribute + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + [SuppressMessage("Microsoft.Design", "CA1019:DefineAccessorsForAttributeArguments", + Justification = "Store string rather than Type as metadata")] + public sealed class ExportViewForAttribute : ExportAttribute { - public ExportMenuAttribute() : base(typeof(IMenuHandler)) + public ExportViewForAttribute(Type viewModelType) + : base(typeof(FrameworkElement)) { + ViewModelType = viewModelType.FullName; } - public MenuType MenuType { get; set; } + public string ViewModelType { get; } } /// - /// Defines a MEF metadata view that matches and - /// . + /// Defines a MEF metadata view that matches and + /// . /// /// /// For more information see the Metadata and Metadata views section at @@ -98,50 +54,7 @@ public ExportMenuAttribute() : base(typeof(IMenuHandler)) /// public interface IViewModelMetadata { - UIViewType ViewType { get; } - } - - /// - /// Defines a MEF metadata view that matches . - /// - /// - /// For more information see the Metadata and Metadata views section at - /// https://msdn.microsoft.com/en-us/library/ee155691(v=vs.110).aspx#Anchor_3 - /// - public interface IMenuMetadata - { - MenuType MenuType { get; } - } - - public static class ExportMetadataAttributeExtensions - { - public static bool IsViewType(this UserControl c, UIViewType type) - { - return c.GetType().GetCustomAttributesData().Any(attr => IsViewType(attr, type)); - } - - public static bool IsViewType(this IView c, UIViewType type) - { - return c.GetType().GetCustomAttributesData().Any(attr => IsViewType(attr, type)); - } - - static bool IsViewType(CustomAttributeData attributeData, UIViewType viewType) - { - Debug.Assert(attributeData.NamedArguments != null); - return attributeData.AttributeType == typeof(ExportViewAttribute) - && (UIViewType)attributeData.NamedArguments[0].TypedValue.Value == viewType; - } - - public static bool IsMenuType(this IMenuHandler c, MenuType type) - { - return c.GetType().GetCustomAttributesData().Any(attr => IsMenuType(attr, type)); - } - - static bool IsMenuType(CustomAttributeData attributeData, MenuType type) - { - Debug.Assert(attributeData.NamedArguments != null); - return attributeData.AttributeType == typeof(ExportMenuAttribute) - && (MenuType)attributeData.NamedArguments[0].TypedValue.Value == type; - } + [SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays")] + string[] ViewModelType { get; } } } diff --git a/src/GitHub.Exports/ExtensionInformation.cs b/src/GitHub.Exports/ExtensionInformation.cs new file mode 100644 index 0000000000..0e19dd1d41 --- /dev/null +++ b/src/GitHub.Exports/ExtensionInformation.cs @@ -0,0 +1,9 @@ +namespace GitHub +{ + public static class ExtensionInformation + { + // HACK: For some reason ThisAssembly.AssemblyFileVersion can't be referenced + // directly from inside GitHub.VisualStudio. + public const string Version = ThisAssembly.AssemblyFileVersion; + } +} diff --git a/src/GitHub.Exports/Extensions/ConnectionManagerExtensions.cs b/src/GitHub.Exports/Extensions/ConnectionManagerExtensions.cs new file mode 100644 index 0000000000..1763f728f7 --- /dev/null +++ b/src/GitHub.Exports/Extensions/ConnectionManagerExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; + +namespace GitHub.Extensions +{ + public static class ConnectionManagerExtensions + { + public static async Task IsLoggedIn(this IConnectionManager cm) + { + Guard.ArgumentNotNull(cm, nameof(cm)); + + var connections = await cm.GetLoadedConnections(); + return connections.Any(x => x.ConnectionError == null); + } + + public static async Task IsLoggedIn(this IConnectionManager cm, HostAddress address) + { + Guard.ArgumentNotNull(cm, nameof(cm)); + Guard.ArgumentNotNull(address, nameof(address)); + + var connections = await cm.GetLoadedConnections(); + return connections.Any(x => x.HostAddress == address && x.ConnectionError == null); + } + + public static async Task GetFirstLoggedInConnection(this IConnectionManager cm) + { + Guard.ArgumentNotNull(cm, nameof(cm)); + + var connections = await cm.GetLoadedConnections(); + return connections.FirstOrDefault(x => x.IsLoggedIn); + } + + public static Task GetConnection(this IConnectionManager cm, RepositoryModel repository) + { + if (repository?.CloneUrl != null) + { + var hostAddress = HostAddress.Create(repository.CloneUrl); + return cm.GetConnection(hostAddress); + } + + return null; + } + } +} diff --git a/src/GitHub.Exports/Extensions/LocalRepositoryModelExtensions.cs b/src/GitHub.Exports/Extensions/LocalRepositoryModelExtensions.cs index f065f5f33b..f11d093947 100644 --- a/src/GitHub.Exports/Extensions/LocalRepositoryModelExtensions.cs +++ b/src/GitHub.Exports/Extensions/LocalRepositoryModelExtensions.cs @@ -8,13 +8,15 @@ namespace GitHub.Extensions { public static class LocalRepositoryModelExtensions { - public static bool HasCommits(this ILocalRepositoryModel repository) + public static bool HasCommits(this LocalRepositoryModel repository) { - var repo = GitService.GitServiceHelper.GetRepository(repository.LocalPath); - return repo?.Commits.Any() ?? false; + using (var repo = GitService.GitServiceHelper.GetRepository(repository.LocalPath)) + { + return repo?.Commits.Any() ?? false; + } } - public static bool MightContainSolution(this ILocalRepositoryModel repository) + public static bool MightContainSolution(this LocalRepositoryModel repository) { var dir = new DirectoryInfo(repository.LocalPath); return dir.EnumerateFileSystemInfos("*", SearchOption.TopDirectoryOnly) diff --git a/src/GitHub.Exports/Extensions/PropertyNotifierExtensions.cs b/src/GitHub.Exports/Extensions/PropertyNotifierExtensions.cs index 899e9aa407..be3ddeef3a 100644 --- a/src/GitHub.Exports/Extensions/PropertyNotifierExtensions.cs +++ b/src/GitHub.Exports/Extensions/PropertyNotifierExtensions.cs @@ -1,5 +1,7 @@ using System.Runtime.CompilerServices; +#pragma warning disable CA1030 // Use events where appropriate + namespace GitHub.VisualStudio.Helpers { public static class PropertyNotifierExtensions diff --git a/src/GitHub.Exports/Extensions/ServiceProviderExtensions.cs b/src/GitHub.Exports/Extensions/ServiceProviderExtensions.cs index 82913fa4a2..2ec954bf6f 100644 --- a/src/GitHub.Exports/Extensions/ServiceProviderExtensions.cs +++ b/src/GitHub.Exports/Extensions/ServiceProviderExtensions.cs @@ -1,14 +1,15 @@ using System; -using System.ComponentModel.Design; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; +using GitHub.Logging; using GitHub.Services; -using Microsoft.VisualStudio.Shell; +using Serilog; namespace GitHub.Extensions { public static class IServiceProviderExtensions { + static readonly ILogger log = LogManager.ForContext(typeof(IServiceProviderExtensions)); + /// /// Safe variant of GetService that doesn't throw exceptions if the service is /// not found. @@ -40,7 +41,7 @@ public static object GetServiceSafe(this IServiceProvider serviceProvider, Type catch (Exception ex) { Debug.Print(ex.ToString()); - VisualStudio.VsOutputLogger.WriteLine("GetServiceSafe: Could not obtain instance of '{0}'", type); + log.Error(ex, "GetServiceSafe: Could not obtain instance of '{Type}'", type); } return ui?.TryGetService(type); } @@ -54,46 +55,5 @@ public static T GetServiceSafe(this IServiceProvider serviceProvider) where T { return serviceProvider.GetServiceSafe(typeof(T)) as T; } - - public static void AddCommandHandler(this IServiceProvider provider, - Guid guid, - int cmdId, - EventHandler eventHandler) - { - var mcs = provider.GetService(typeof(IMenuCommandService)) as IMenuCommandService; - Debug.Assert(mcs != null, "No IMenuCommandService? Something is wonky"); - if (mcs == null) - return; - var id = new CommandID(guid, cmdId); - var item = new MenuCommand(eventHandler, id); - mcs.AddCommand(item); - } - - public static OleMenuCommand AddCommandHandler(this IServiceProvider provider, - Guid guid, - int cmdId, - Func canEnable, - Action execute, - bool disable = false) - { - var mcs = provider.GetService(typeof(IMenuCommandService)) as IMenuCommandService; - Debug.Assert(mcs != null, "No IMenuCommandService? Something is wonky"); - if (mcs == null) - return null; - var id = new CommandID(guid, cmdId); - var item = new OleMenuCommand( - (s, e) => execute(), - (s, e) => { }, - (s, e) => - { - if (disable) - ((OleMenuCommand)s).Enabled = canEnable(); - else - ((OleMenuCommand)s).Visible = canEnable(); - }, - id); - mcs.AddCommand(item); - return item; - } } } diff --git a/src/GitHub.Exports/Factories/IUIFactory.cs b/src/GitHub.Exports/Factories/IUIFactory.cs deleted file mode 100644 index b24b153d5e..0000000000 --- a/src/GitHub.Exports/Factories/IUIFactory.cs +++ /dev/null @@ -1,10 +0,0 @@ -using GitHub.Exports; -using System; - -namespace GitHub.App.Factories -{ - public interface IUIFactory : IDisposable - { - IUIPair CreateViewAndViewModel(UIViewType viewType); - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/Factories/IUIPair.cs b/src/GitHub.Exports/Factories/IUIPair.cs deleted file mode 100644 index acdbb5ea55..0000000000 --- a/src/GitHub.Exports/Factories/IUIPair.cs +++ /dev/null @@ -1,14 +0,0 @@ -using GitHub.UI; -using GitHub.ViewModels; -using System; - -namespace GitHub.App.Factories -{ - public interface IUIPair : IDisposable - { - IView View { get; } - IViewModel ViewModel { get; } - void AddHandler(IDisposable disposable); - void ClearHandlers(); - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/Factories/IViewViewModelFactory.cs b/src/GitHub.Exports/Factories/IViewViewModelFactory.cs new file mode 100644 index 0000000000..e0db076839 --- /dev/null +++ b/src/GitHub.Exports/Factories/IViewViewModelFactory.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Windows; +using GitHub.ViewModels; + +namespace GitHub.Factories +{ + /// + /// Factory for creating views and view models. + /// + public interface IViewViewModelFactory + { + /// + /// Creates a view model based on the specified interface type. + /// + /// The view model interface type. + /// The view model. + TViewModel CreateViewModel() where TViewModel : IViewModel; + + /// + /// Creates a view based on a view model interface type. + /// + /// The view model interface type. + /// The view. + [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter")] + FrameworkElement CreateView() where TViewModel : IViewModel; + + /// + /// Creates a view based on a view model interface type. + /// + /// The view model interface type. + /// The view. + FrameworkElement CreateView(Type viewModel); + } +} diff --git a/src/GitHub.Exports/GitHub.Exports.csproj b/src/GitHub.Exports/GitHub.Exports.csproj index ad99570bc9..b6fa2fc7f5 100644 --- a/src/GitHub.Exports/GitHub.Exports.csproj +++ b/src/GitHub.Exports/GitHub.Exports.csproj @@ -1,282 +1,35 @@ - - - - + - Debug - AnyCPU - {9AEA02DB-02B5-409C-B0CA-115D05331A6B} - Library - Properties + net46 GitHub - GitHub.Exports - v4.6.1 - 512 - Internal - - - bin\$(Configuration)\ - - - - true full - false - DEBUG;TRACE - prompt - 4 - true - ..\common\GitHubVS.ruleset - true - true - - - pdbonly - true - TRACE - prompt - 4 - true - true - true - ..\common\GitHubVS.ruleset - - - ..\..\script\Key.snk - true - false + true + + + - - False - - - false - - - ..\..\packages\LibGit2Sharp.0.22.0\lib\net40\LibGit2Sharp.dll - True - - - ..\..\packages\VSSDK.ComponentModelHost.12.0.4\lib\net45\Microsoft.VisualStudio.ComponentModelHost.dll - False - - - ..\..\packages\Microsoft.VisualStudio.Shell.14.0.14.3.25407\lib\Microsoft.VisualStudio.Shell.14.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Immutable.10.0.10.0.30319\lib\net40\Microsoft.VisualStudio.Shell.Immutable.10.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.OLE.Interop.7.10.6070\lib\Microsoft.VisualStudio.OLE.Interop.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.7.10.6071\lib\Microsoft.VisualStudio.Shell.Interop.dll - True - - - True - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.10.0.10.0.30319\lib\Microsoft.VisualStudio.Shell.Interop.10.0.dll - True - - - True - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.11.0.11.0.61030\lib\Microsoft.VisualStudio.Shell.Interop.11.0.dll - True - - - True - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.12.0.12.0.30110\lib\Microsoft.VisualStudio.Shell.Interop.12.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.Shell.Interop.8.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.9.0.9.0.30729\lib\Microsoft.VisualStudio.Shell.Interop.9.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6070\lib\Microsoft.VisualStudio.TextManager.Interop.dll - True - - - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Threading.14.1.131\lib\net45\Microsoft.VisualStudio.Threading.dll - True - - - - - - - - - - - - - + + + - - Key.snk - - - Properties\settings.json - - - Services\MetricsService.cs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Properties\SolutionInfo.cs - - - - - - - - - True - True - IPackageSettings.tt - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {08dd4305-7787-4823-a53f-4d0f725a07f3} - Octokit - - - {4A84E568-CA86-4510-8CD0-90D3EF9B65F9} - Rothko - - - {6afe2e2d-6db0-4430-a2ea-f5f5388d2f78} - GitHub.Extensions - True - - - - - TextTemplatingFileGenerator - IPackageSettings.cs - + + + + - + + + + + + + + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - \ No newline at end of file + diff --git a/src/GitHub.Exports/GitHubLogicException.cs b/src/GitHub.Exports/GitHubLogicException.cs new file mode 100644 index 0000000000..d41d0520be --- /dev/null +++ b/src/GitHub.Exports/GitHubLogicException.cs @@ -0,0 +1,35 @@ +using System; +using System.Runtime.Serialization; + +namespace GitHub +{ + /// + /// An exception that signals an error in program logic. + /// + /// + /// This error is often used instead of Debug.Assert because Debug.Assert causes problems with + /// unit tests and the XAML designer. + /// + [Serializable] + public class GitHubLogicException : Exception + { + public GitHubLogicException() + { + } + + public GitHubLogicException(string message) + :base(message) + { + } + + public GitHubLogicException(string message, Exception inner) + : base(message, inner) + { + } + + protected GitHubLogicException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/GitHub.Exports/GlobalSuppressions.cs b/src/GitHub.Exports/GlobalSuppressions.cs index d5fcc9b07a..e69de29bb2 100644 Binary files a/src/GitHub.Exports/GlobalSuppressions.cs and b/src/GitHub.Exports/GlobalSuppressions.cs differ diff --git a/src/GitHub.Exports/Helpers/INotifyPropertySource.cs b/src/GitHub.Exports/Helpers/INotifyPropertySource.cs index 23d8dbe8e2..5f6b15de4a 100644 --- a/src/GitHub.Exports/Helpers/INotifyPropertySource.cs +++ b/src/GitHub.Exports/Helpers/INotifyPropertySource.cs @@ -1,4 +1,6 @@ -namespace GitHub.VisualStudio.Helpers +#pragma warning disable CA1030 // Use events where appropriate + +namespace GitHub.VisualStudio.Helpers { public interface INotifyPropertySource { diff --git a/src/GitHub.Exports/Helpers/ThreadingHelper.cs b/src/GitHub.Exports/Helpers/ThreadingHelper.cs deleted file mode 100644 index bdd82d2a16..0000000000 --- a/src/GitHub.Exports/Helpers/ThreadingHelper.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Threading.Tasks; -using ThreadHelper = Microsoft.VisualStudio.Shell.ThreadHelper; -using GitHub.Extensions; -using System.Runtime.CompilerServices; -using System; -using System.Threading; -using System.Windows; -using static Microsoft.VisualStudio.Threading.JoinableTaskFactory; -using static Microsoft.VisualStudio.Threading.AwaitExtensions; -using System.Windows.Threading; - -namespace GitHub.Helpers -{ - public interface IAwaitable - { - IAwaiter GetAwaiter(); - } - - public interface IAwaiter : INotifyCompletion - { - bool IsCompleted { get; } - void GetResult(); - } - - public static class ThreadingHelper - { - public static bool InUIThread => (!Guard.InUnitTestRunner && Application.Current.Dispatcher.CheckAccess()) || !(Guard.InUnitTestRunner); - - /// - /// Gets the Dispatcher for the main thread. - /// - public static Dispatcher MainThreadDispatcher => Application.Current.Dispatcher; - - /// - /// Switch to the UI thread using ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync - /// Auto-disables switching when running in unit test mode - /// - /// - public static IAwaitable SwitchToMainThreadAsync() - { - return Guard.InUnitTestRunner ? - new AwaitableWrapper() : - new AwaitableWrapper(ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync()); - } - - /// - /// Switch to a thread pool background thread if the current thread isn't one, otherwise does nothing - /// Auto-disables switching when running in unit test mode - /// - /// - /// - public static IAwaitable SwitchToPoolThreadAsync(TaskScheduler scheduler = null) - { - return Guard.InUnitTestRunner ? - new AwaitableWrapper() : - new AwaitableWrapper(scheduler ?? TaskScheduler.Default); - } - - class AwaitableWrapper : IAwaitable - { - Func getAwaiter; - - public AwaitableWrapper() - { - getAwaiter = () => new AwaiterWrapper(); - } - - public AwaitableWrapper(MainThreadAwaitable awaitable) - { - getAwaiter = () => new AwaiterWrapper(awaitable.GetAwaiter()); - } - - public AwaitableWrapper(TaskScheduler scheduler) - { - getAwaiter = () => new AwaiterWrapper(new TaskSchedulerAwaiter(scheduler)); - } - - public IAwaiter GetAwaiter() => getAwaiter(); - } - - class AwaiterWrapper : IAwaiter - { - Func isCompleted; - Action onCompleted; - Action getResult; - - public AwaiterWrapper() - { - isCompleted = () => true; - onCompleted = c => c(); - getResult = () => {}; - } - - public AwaiterWrapper(MainThreadAwaiter awaiter) - { - isCompleted = () => awaiter.IsCompleted; - onCompleted = c => awaiter.OnCompleted(c); - getResult = () => awaiter.GetResult(); - } - - public AwaiterWrapper(TaskSchedulerAwaiter awaiter) - { - isCompleted = () => awaiter.IsCompleted; - onCompleted = c => awaiter.OnCompleted(c); - getResult = () => awaiter.GetResult(); - } - - public bool IsCompleted => isCompleted(); - - public void OnCompleted(Action continuation) => onCompleted(continuation); - - public void GetResult() => getResult(); - } - } -} diff --git a/src/GitHub.Exports/Info/ApplicationInfo.cs b/src/GitHub.Exports/Info/ApplicationInfo.cs deleted file mode 100644 index afb8b875d5..0000000000 --- a/src/GitHub.Exports/Info/ApplicationInfo.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace GitHub.Info -{ - public static class ApplicationInfo - { -#if DEBUG - public const string ApplicationName = "GìtHūbVisualStudio"; - public const string ApplicationProvider = "GitHub"; -#else - public const string ApplicationName = "GitHubVisualStudio"; - public const string ApplicationProvider = "GitHub"; -#endif - public const string ApplicationSafeName = "GitHubVisualStudio"; - public const string ApplicationDescription = "GitHub Extension for Visual Studio"; - } -} diff --git a/src/GitHub.Exports/Models/ActorModel.cs b/src/GitHub.Exports/Models/ActorModel.cs new file mode 100644 index 0000000000..aa387747f5 --- /dev/null +++ b/src/GitHub.Exports/Models/ActorModel.cs @@ -0,0 +1,20 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Represents an actor (a User or a Bot). + /// + public class ActorModel + { + /// + /// Gets or sets the URL of the actor's avatar. + /// + public string AvatarUrl { get; set; } + + /// + /// Gets or sets the actor's login. + /// + public string Login { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/AnnotationModel.cs b/src/GitHub.Exports/Models/AnnotationModel.cs new file mode 100644 index 0000000000..8981b9d719 --- /dev/null +++ b/src/GitHub.Exports/Models/AnnotationModel.cs @@ -0,0 +1,38 @@ +namespace GitHub.Models +{ + /// + /// Model for a single check annotation. + /// + public class CheckRunAnnotationModel + { + /// + /// The starting 1-based line number (1 indexed). + /// + public int StartLine { get; set; } + + /// + /// The ending 1-based line number (1 indexed). + /// + public int EndLine { get; set; } + + /// + /// The path that this annotation was made on. + /// + public string Path { get; set; } + + /// + /// The annotation's message. + /// + public string Message { get; set; } + + /// + /// The annotation's title. + /// + public string Title { get; set; } + + /// + /// The annotation's severity level. + /// + public CheckAnnotationLevel AnnotationLevel { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/BranchModel.cs b/src/GitHub.Exports/Models/BranchModel.cs index d1ec050f79..06e0203a23 100644 --- a/src/GitHub.Exports/Models/BranchModel.cs +++ b/src/GitHub.Exports/Models/BranchModel.cs @@ -1,50 +1,42 @@ using System; using System.Globalization; +using GitHub.Collections; namespace GitHub.Models { - public class BranchModel : IBranch + public class BranchModel : ICopyable, + IEquatable, IComparable { - public BranchModel(string name, IRepositoryModel repo) + public BranchModel(string name, RepositoryModel repo, string sha, bool isTracking, string trackedSha, string trackedRemoteName) : + this(name, repo) { - Extensions.Guard.ArgumentNotEmptyString(name, nameof(name)); - Extensions.Guard.ArgumentNotNull(repo, nameof(repo)); - - Name = DisplayName = name; - Repository = repo; - Id = String.Format(CultureInfo.InvariantCulture, "{0}/{1}", Repository.Owner, Name); + IsTracking = isTracking; + Sha = sha; + TrackedSha = trackedSha; + TrackedRemoteName = trackedRemoteName; } - public BranchModel(Octokit.Branch branch, IRepositoryModel repo) + public BranchModel(string name, RepositoryModel repo) { - Extensions.Guard.ArgumentNotNull(branch, nameof(branch)); + Extensions.Guard.ArgumentNotEmptyString(name, nameof(name)); Extensions.Guard.ArgumentNotNull(repo, nameof(repo)); - Name = DisplayName = branch.Name; + Name = DisplayName = name; Repository = repo; - Id = String.Format(CultureInfo.InvariantCulture, "{0}/{1}", Repository.Owner, Name); - } - - public BranchModel(LibGit2Sharp.Branch branch, IRepositoryModel repo) - { - Extensions.Guard.ArgumentNotNull(branch, nameof(branch)); - Extensions.Guard.ArgumentNotNull(repo, nameof(repo)); - - Name = DisplayName = branch.FriendlyName; - Repository = branch.IsRemote ? new LocalRepositoryModel(branch.Remote.Url) : repo; - IsTracking = branch.IsTracking; - Id = String.Format(CultureInfo.InvariantCulture, "{0}/{1}", Repository.Owner, Name); + Id = string.Format(CultureInfo.InvariantCulture, "{0}/{1}", Repository.Owner, Name); } public string Id { get; private set; } public string Name { get; private set; } - public IRepositoryModel Repository { get; private set; } + public RepositoryModel Repository { get; private set; } public bool IsTracking { get; private set; } public string DisplayName { get; set; } - + public string Sha { get; private set; } + public string TrackedSha { get; private set; } + public string TrackedRemoteName { get; private set; } #region Equality things - public void CopyFrom(IBranch other) + public void CopyFrom(BranchModel other) { if (!Equals(other)) throw new ArgumentException("Instance to copy from doesn't match this instance. this:(" + this + ") other:(" + other + ")", nameof(other)); @@ -68,16 +60,16 @@ public override int GetHashCode() return Id.GetHashCode(); } - bool IEquatable.Equals(IBranch other) + bool IEquatable.Equals(BranchModel other) { if (ReferenceEquals(this, other)) return true; return other != null && Id == other.Id; } - public int CompareTo(IBranch other) + public int CompareTo(BranchModel other) { - return other != null ? String.Compare(Id, other.Id, StringComparison.CurrentCulture) : 1; + return other != null ? string.Compare(Id, other.Id, StringComparison.CurrentCulture) : 1; } public static bool operator >(BranchModel lhs, BranchModel rhs) diff --git a/src/GitHub.Exports/Models/CheckAnnotationLevel.cs b/src/GitHub.Exports/Models/CheckAnnotationLevel.cs new file mode 100644 index 0000000000..b0efb7e1ec --- /dev/null +++ b/src/GitHub.Exports/Models/CheckAnnotationLevel.cs @@ -0,0 +1,9 @@ +namespace GitHub.Models +{ + public enum CheckAnnotationLevel + { + Failure, + Notice, + Warning, + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/CheckConclusionState.cs b/src/GitHub.Exports/Models/CheckConclusionState.cs new file mode 100644 index 0000000000..a8f24d076b --- /dev/null +++ b/src/GitHub.Exports/Models/CheckConclusionState.cs @@ -0,0 +1,14 @@ +namespace GitHub.Models +{ + public enum CheckConclusionState + { + ActionRequired, + TimedOut, + Cancelled, + Failure, + Success, + Neutral, + Skipped, + Stale + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/CheckRunModel.cs b/src/GitHub.Exports/Models/CheckRunModel.cs new file mode 100644 index 0000000000..de6ec46d9d --- /dev/null +++ b/src/GitHub.Exports/Models/CheckRunModel.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Model for a single check run. + /// + public class CheckRunModel + { + /// + /// The id of a Check Run. + /// + public string Id { get; set; } + + /// + /// The conclusion of the check run. + /// + public CheckConclusionState? Conclusion { get; set; } + + /// + /// The current status of a Check Run. + /// + public CheckStatusState Status { get; set; } + + /// + /// Identifies the date and time when the check run was completed. + /// + public DateTimeOffset? CompletedAt { get; set; } + + /// + /// The check run's annotations. + /// + public List Annotations { get; set; } + + /// + /// The name of the check for this check run. + /// + public string Name { get; set; } + + /// + /// The URL from which to find full details of the check run on the integrator's site. + /// + public string DetailsUrl { get; set; } + + /// + /// The summary of a Check Run. + /// + public string Summary { get; set; } + + /// + /// The detail of a Check Run. + /// + public string Text { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/CheckStatusState.cs b/src/GitHub.Exports/Models/CheckStatusState.cs new file mode 100644 index 0000000000..6c7e0a2f14 --- /dev/null +++ b/src/GitHub.Exports/Models/CheckStatusState.cs @@ -0,0 +1,10 @@ +namespace GitHub.Models +{ + public enum CheckStatusState + { + Queued, + InProgress, + Completed, + Requested, + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/CheckSuiteModel.cs b/src/GitHub.Exports/Models/CheckSuiteModel.cs new file mode 100644 index 0000000000..43ad354910 --- /dev/null +++ b/src/GitHub.Exports/Models/CheckSuiteModel.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Model for a single check suite. + /// + public class CheckSuiteModel + { + /// + /// The head sha of a Check Suite. + /// + public string HeadSha { get; set; } + + /// + /// The check runs associated with a check suite. + /// + public List CheckRuns { get; set; } + + public string ApplicationName { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/CloneDialogResult.cs b/src/GitHub.Exports/Models/CloneDialogResult.cs index 05da3d10cd..f53fa2736e 100644 --- a/src/GitHub.Exports/Models/CloneDialogResult.cs +++ b/src/GitHub.Exports/Models/CloneDialogResult.cs @@ -1,5 +1,5 @@ using System; -using GitHub.Services; +using GitHub.Primitives; namespace GitHub.Models { @@ -11,22 +11,22 @@ public class CloneDialogResult /// /// Initializes a new instance of the class. /// - /// The selected base path for the clone. + /// The path to clone the repository to. /// The selected repository. - public CloneDialogResult(string basePath, IRepositoryModel repository) + public CloneDialogResult(string path, UriString cloneUrl) { - BasePath = basePath; - Repository = repository; + Path = path; + Url = cloneUrl; } /// - /// Gets the filesystem path to which the user wants to clone. + /// Gets the path to clone the repository to. /// - public string BasePath { get; } + public string Path { get; } /// - /// Gets the repository selected by the user. + /// Gets the url selected by the user. /// - public IRepositoryModel Repository { get; } + public UriString Url { get; } } } diff --git a/src/GitHub.Exports/Models/CommentModel.cs b/src/GitHub.Exports/Models/CommentModel.cs new file mode 100644 index 0000000000..902bf5263a --- /dev/null +++ b/src/GitHub.Exports/Models/CommentModel.cs @@ -0,0 +1,40 @@ +using System; + +namespace GitHub.Models +{ + /// + /// An issue or pull request review comment. + /// + public class CommentModel + { + /// + /// Gets the ID of the comment. + /// + public string Id { get; set; } + + /// + /// Gets the DatabaseId of the comment. + /// + public int DatabaseId { get; set; } + + /// + /// Gets the author of the comment. + /// + public ActorModel Author { get; set; } + + /// + /// Gets the body of the comment. + /// + public string Body { get; set; } + + /// + /// Gets the creation time of the comment. + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets the HTTP URL permalink for the comment. + /// + public string Url { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/CommitActorModel.cs b/src/GitHub.Exports/Models/CommitActorModel.cs new file mode 100644 index 0000000000..02280bfbd1 --- /dev/null +++ b/src/GitHub.Exports/Models/CommitActorModel.cs @@ -0,0 +1,23 @@ +namespace GitHub.Models +{ + /// + /// Represents a commit actor (which may or may not have an associated User or Bot). + /// + public class CommitActorModel + { + /// + /// Gets or sets the actor user + /// + public ActorModel User { get; set; } + + /// + /// Gets or sets the actor name + /// + public string Name { get; set; } + + /// + /// Gets or sets the actor email + /// + public string Email { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/CommitMessage.cs b/src/GitHub.Exports/Models/CommitMessage.cs new file mode 100644 index 0000000000..58ef8cc182 --- /dev/null +++ b/src/GitHub.Exports/Models/CommitMessage.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; + +namespace GitHub.Models +{ + public class CommitMessage : IEquatable + { + public string Summary { get; private set; } + public string Details { get; private set; } + public string FullMessage { get; private set; } + + /// + /// This is for mocking porpoises. + /// http://cl.ly/image/0q2A2W0U3O2t + /// + public CommitMessage() { } + + public CommitMessage(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) return; + + var lines = fullMessage.Replace("\r\n", "\n").Split('\n'); + Summary = lines.FirstOrDefault(); + + FullMessage = fullMessage; + var detailsLines = lines + .Skip(1) + .SkipWhile(string.IsNullOrEmpty) + .ToList(); + Details = detailsLines.Any(x => !string.IsNullOrWhiteSpace(x)) + ? string.Join(Environment.NewLine, detailsLines).Trim() + : null; + } + + public bool Equals(CommitMessage other) + { + if (ReferenceEquals(other, null)) + { + return false; + } + + return string.Equals(Summary, other.Summary, StringComparison.Ordinal) + && string.Equals(Details, other.Details, StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return Equals(obj as CommitMessage); + } + + public override int GetHashCode() + { + return Tuple.Create(Summary, Details).GetHashCode(); + } + } +} diff --git a/src/GitHub.Exports/Models/CommitModel.cs b/src/GitHub.Exports/Models/CommitModel.cs new file mode 100644 index 0000000000..a0aaad8a83 --- /dev/null +++ b/src/GitHub.Exports/Models/CommitModel.cs @@ -0,0 +1,30 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Holds the details of a commit. + /// + public class CommitModel + { + /// + /// Gets or sets the author of the commit. + /// + public CommitActorModel Author { get; set; } + + /// + /// Gets or sets the abbreviated git object ID for the commit. + /// + public string AbbreviatedOid { get; set; } + + /// + /// Gets or sets the commit headline. + /// + public string MessageHeadline { get; set; } + + /// + /// Gets or sets the git object ID for the commit. + /// + public string Oid { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/ConnectionDetails.cs b/src/GitHub.Exports/Models/ConnectionDetails.cs new file mode 100644 index 0000000000..67298d7c8b --- /dev/null +++ b/src/GitHub.Exports/Models/ConnectionDetails.cs @@ -0,0 +1,75 @@ +using System; +using GitHub.Primitives; + +namespace GitHub.Models +{ + /// + /// Represents details about a connection stored in an . + /// + public struct ConnectionDetails : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + /// The address of the host. + /// The username for the host. + public ConnectionDetails(string hostAddress, string userName) + { + HostAddress = HostAddress.Create(hostAddress); + UserName = userName; + } + + /// + /// Initializes a new instance of the struct. + /// + /// The address of the host. + /// The username for the host. + public ConnectionDetails(HostAddress hostAddress, string userName) + { + HostAddress = hostAddress; + UserName = userName; + } + + /// + /// Gets the address of the host. + /// + public HostAddress HostAddress { get; } + + /// + /// Gets the username for the host. + /// + public string UserName { get; } + + public bool Equals(ConnectionDetails other) + { + if (ReferenceEquals(this, other)) + return true; + + return HostAddress.Equals(other.HostAddress) && + string.Equals(UserName, other.UserName, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is ConnectionDetails && Equals((ConnectionDetails)obj); + } + + public override int GetHashCode() + { + unchecked + { + return (HostAddress.GetHashCode()*397) ^ StringComparer.InvariantCultureIgnoreCase.GetHashCode(UserName); + } + } + + public static bool operator ==(ConnectionDetails left, ConnectionDetails right) + { + return Equals(left, right); + } + + public static bool operator !=(ConnectionDetails left, ConnectionDetails right) + { + return !Equals(left, right); + } + } +} diff --git a/src/GitHub.Exports/Models/DiffChangeType.cs b/src/GitHub.Exports/Models/DiffChangeType.cs new file mode 100644 index 0000000000..372bd270a0 --- /dev/null +++ b/src/GitHub.Exports/Models/DiffChangeType.cs @@ -0,0 +1,12 @@ +using System; + +namespace GitHub.Models +{ + public enum DiffChangeType + { + None, + Add, + Delete, + Control + } +} diff --git a/src/GitHub.Exports/Models/DiffChunk.cs b/src/GitHub.Exports/Models/DiffChunk.cs new file mode 100644 index 0000000000..91c29b9c53 --- /dev/null +++ b/src/GitHub.Exports/Models/DiffChunk.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GitHub.Models +{ + public class DiffChunk + { + public int OldLineNumber { get; set; } + public int NewLineNumber { get; set; } + public int DiffLine { get; set; } + public IList Lines { get; } = new List(); + + public override string ToString() + { + var builder = new StringBuilder(); + + foreach (var line in Lines) + { + builder.AppendLine(line.Content); + } + + return builder.ToString(); + } + } +} diff --git a/src/GitHub.Exports/Models/DiffLine.cs b/src/GitHub.Exports/Models/DiffLine.cs new file mode 100644 index 0000000000..449ed4eb27 --- /dev/null +++ b/src/GitHub.Exports/Models/DiffLine.cs @@ -0,0 +1,34 @@ +using System; + +namespace GitHub.Models +{ + public class DiffLine + { + /// + /// Was the line added, deleted or unchanged. + /// + public DiffChangeType Type { get; set; } + + /// + /// Gets the old 1-based line number. + /// + public int OldLineNumber { get; set; } = -1; + + /// + /// Gets the new 1-based line number. + /// + public int NewLineNumber { get; set; } = -1; + + /// + /// Gets the unified diff line number where the first chunk header is line 0. + /// + public int DiffLineNumber { get; set; } = -1; + + /// + /// Gets the content of the diff line (including +, - or space). + /// + public string Content { get; set; } + + public override string ToString() => Content; + } +} diff --git a/src/GitHub.Exports/Models/DiffUtilities.cs b/src/GitHub.Exports/Models/DiffUtilities.cs new file mode 100644 index 0000000000..6d781075a1 --- /dev/null +++ b/src/GitHub.Exports/Models/DiffUtilities.cs @@ -0,0 +1,205 @@ +using System; +using System.IO; +using System.Globalization; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Diagnostics.CodeAnalysis; +using GitHub.Extensions; + +#pragma warning disable CA1034 // Nested types should not be visible + +#pragma warning disable CA1034 // Nested types should not be visible + +namespace GitHub.Models +{ + public static class DiffUtilities + { + static readonly Regex ChunkHeaderRegex = new Regex(@"^@@\s+\-(\d+),?\d*\s+\+(\d+),?\d*\s@@"); + + public static IEnumerable ParseFragment(string diff) + { + Guard.ArgumentNotNull(diff, nameof(diff)); + + var reader = new LineReader(diff); + string line; + DiffChunk chunk = null; + int diffLine = -1; + int oldLine = -1; + int newLine = -1; + + while ((line = reader.ReadLine()) != null) + { + var headerMatch = ChunkHeaderRegex.Match(line); + + if (headerMatch.Success) + { + if (chunk != null) + { + yield return chunk; + } + + if (diffLine == -1) diffLine = 0; + + chunk = new DiffChunk + { + OldLineNumber = oldLine = int.Parse(headerMatch.Groups[1].Value, CultureInfo.InvariantCulture), + NewLineNumber = newLine = int.Parse(headerMatch.Groups[2].Value, CultureInfo.InvariantCulture), + DiffLine = diffLine, + }; + } + else if (chunk != null) + { + var type = GetLineChange(line[0]); + + // This might contain info about previous line (e.g. "\ No newline at end of file"). + if (type != DiffChangeType.Control) + { + chunk.Lines.Add(new DiffLine + { + Type = type, + OldLineNumber = type != DiffChangeType.Add ? oldLine : -1, + NewLineNumber = type != DiffChangeType.Delete ? newLine : -1, + DiffLineNumber = diffLine, + Content = line, + }); + + var lineCount = 1; + lineCount += LineReader.CountCarriageReturns(line); + + switch (type) + { + case DiffChangeType.None: + oldLine += lineCount; + newLine += lineCount; + break; + case DiffChangeType.Delete: + oldLine += lineCount; + break; + case DiffChangeType.Add: + newLine += lineCount; + break; + } + } + } + + if (diffLine != -1) ++diffLine; + } + + if (chunk != null) + { + yield return chunk; + } + } + + public static DiffLine Match(IEnumerable diff, IList target) + { + if (target.Count == 0) + { + return null; // no lines to match + } + + foreach (var source in diff) + { + var matches = 0; + for (var i = source.Lines.Count - 1; i >= 0; --i) + { + if (source.Lines[i].Content == target[matches].Content) + { + matches++; + if (matches == target.Count || i == 0) + { + return source.Lines[i + matches - 1]; + } + } + else + { + i += matches; + matches = 0; + } + } + } + + return null; + } + + /// Here are some alternative implementations we tried: + /// https://gist.github.com/shana/200e4719d4f571caab9dbf5921fa5276 + /// Scanning with `text.IndexOf('\n', index)` appears to the the best compromise for average .diff files. + /// It's likely that `text.IndexOfAny(new [] {'\r', '\n'}, index)` would be faster if lines were much longer. + public class LineReader + { + readonly string text; + int index = 0; + + public LineReader(string text) + { + Guard.ArgumentNotNull(text, nameof(text)); + + this.text = text; + } + + public string ReadLine() + { + if (EndOfText) + { + if (StartOfText) + { + index = -1; + return string.Empty; + } + + return null; + } + + var startIndex = index; + index = text.IndexOf('\n', index); + var endIndex = index != -1 ? index : text.Length; + var length = endIndex - startIndex; + + if (index != -1) + { + if (index > 0 && text[index - 1] == '\r') + { + length--; + } + + index++; + } + + return text.Substring(startIndex, length); + } + + public static int CountCarriageReturns(string text) + { + Guard.ArgumentNotNull(text, nameof(text)); + + int count = 0; + int index = 0; + while ((index = text.IndexOf('\r', index)) != -1) + { + index++; + count++; + } + + return count; + } + + bool StartOfText => index == 0; + + bool EndOfText => index == -1 || index == text.Length; + } + + [SuppressMessage("Microsoft.Globalization", "CA1305:SpecifyIFormatProvider", MessageId = "System.String.Format(System.String,System.Object)")] + static DiffChangeType GetLineChange(char c) + { + switch (c) + { + case ' ': return DiffChangeType.None; + case '+': return DiffChangeType.Add; + case '-': return DiffChangeType.Delete; + case '\\': return DiffChangeType.Control; + default: throw new InvalidDataException($"Invalid diff line change char: '{c}'."); + } + } + } +} diff --git a/src/GitHub.Exports/Models/IAccount.cs b/src/GitHub.Exports/Models/IAccount.cs index 313d677944..617d770b91 100644 --- a/src/GitHub.Exports/Models/IAccount.cs +++ b/src/GitHub.Exports/Models/IAccount.cs @@ -14,6 +14,7 @@ public interface IAccount : ICopyable, string Login { get; } int OwnedPrivateRepos { get; } long PrivateReposInPlan { get; } - BitmapSource Avatar { get; } + string AvatarUrl { get; } + BitmapSource Avatar { get; } } } diff --git a/src/GitHub.Exports/Models/IBranch.cs b/src/GitHub.Exports/Models/IBranch.cs deleted file mode 100644 index 52d48bfc77..0000000000 --- a/src/GitHub.Exports/Models/IBranch.cs +++ /dev/null @@ -1,15 +0,0 @@ -using GitHub.Collections; -using System; - -namespace GitHub.Models -{ - public interface IBranch : ICopyable, - IEquatable, IComparable - { - string Id { get; } - string Name { get; } - IRepositoryModel Repository { get; } - bool IsTracking { get; } - string DisplayName { get; set; } - } -} diff --git a/src/GitHub.Exports/Models/IConnection.cs b/src/GitHub.Exports/Models/IConnection.cs index 26e3a172e1..44dddb353b 100644 --- a/src/GitHub.Exports/Models/IConnection.cs +++ b/src/GitHub.Exports/Models/IConnection.cs @@ -1,17 +1,52 @@ -using GitHub.Primitives; -using System; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.Threading.Tasks; +using System; +using System.ComponentModel; +using GitHub.Primitives; +using Octokit; namespace GitHub.Models { - public interface IConnection : IDisposable + /// + /// Represents a configured connection to a GitHub account. + /// + public interface IConnection : INotifyPropertyChanged { + /// + /// Gets the host address of the GitHub instance. + /// HostAddress HostAddress { get; } + + /// + /// Gets the username of the GitHub account. + /// string Username { get; } - IObservable Login(); - void Logout(); - ObservableCollection Repositories { get; } + + /// + /// Gets the logged in user. + /// + /// + /// This may be null if is false. + /// + User User { get; } + + /// + /// Gets the login scopes. + /// + ScopesCollection Scopes { get; } + + /// + /// Gets a value indicating whether the login of the account succeeded. + /// + bool IsLoggedIn { get; } + + /// + /// Gets a value indicating whether a login is currently being attempted on the connection. + /// + bool IsLoggingIn { get; } + + /// + /// Gets the exception that occurred when trying to log in, if is + /// false. + /// + Exception ConnectionError { get; } } } diff --git a/src/GitHub.Exports/Models/IConnectionManager.cs b/src/GitHub.Exports/Models/IConnectionManager.cs deleted file mode 100644 index ee52597117..0000000000 --- a/src/GitHub.Exports/Models/IConnectionManager.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using GitHub.Primitives; -using System.Threading.Tasks; - -namespace GitHub.Models -{ - public interface IConnectionManager - { - IConnection CreateConnection(HostAddress address, string username); - bool AddConnection(HostAddress address, string username); - bool RemoveConnection(HostAddress address); - ObservableCollection Connections { get; } - - IObservable RequestLogin(IConnection connection); - void RequestLogout(IConnection connection); - - // for telling IRepositoryHosts that we need to login from cache - [SuppressMessage("Microsoft.Design", "CA1009:DeclareEventHandlersCorrectly")] - event Func> DoLogin; - Task RefreshRepositories(); - } -} diff --git a/src/GitHub.Exports/Models/IExportFactoryProvider.cs b/src/GitHub.Exports/Models/IExportFactoryProvider.cs deleted file mode 100644 index 7ea444d020..0000000000 --- a/src/GitHub.Exports/Models/IExportFactoryProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GitHub.Exports; -using GitHub.UI; -using GitHub.ViewModels; -using System.Collections.Generic; -using System.ComponentModel.Composition; - -namespace GitHub.Models -{ - public interface IExportFactoryProvider - { - IEnumerable> ViewModelFactory { get; set; } - IEnumerable> ViewFactory { get; set; } - ExportLifetimeContext GetViewModel(UIViewType viewType); - ExportLifetimeContext GetView(UIViewType viewType); - } -} diff --git a/src/GitHub.Exports/Models/ILocalRepositoryModel.cs b/src/GitHub.Exports/Models/ILocalRepositoryModel.cs deleted file mode 100644 index 5d954327f6..0000000000 --- a/src/GitHub.Exports/Models/ILocalRepositoryModel.cs +++ /dev/null @@ -1,37 +0,0 @@ -using GitHub.Primitives; -using System.ComponentModel; -using System.Threading.Tasks; - -namespace GitHub.Models -{ - /// - /// Represents a locally cloned repository. - /// - public interface ILocalRepositoryModel : IRepositoryModel, INotifyPropertyChanged - { - /// - /// Gets the path to the repository on the filesystem. - /// - string LocalPath { get; } - - /// - /// Gets the current branch. - /// - IBranch CurrentBranch { get; } - - /// - /// Updates the url information based on the local path - /// - void Refresh(); - - /// - /// Generates a http(s) url to the repository in the remote server, optionally - /// pointing to a specific file and specific line range in it. - /// - /// The file to generate an url to. Optional. - /// A specific line, or (if specifying the as well) the start of a range - /// The end of a line range on the specified file. - /// An UriString with the generated url, or null if the repository has no remote server configured or if it can't be found locally - Task GenerateUrl(string path = null, int startLine = -1, int endLine = -1); - } -} diff --git a/src/GitHub.Exports/Models/IPullRequestFileModel.cs b/src/GitHub.Exports/Models/IPullRequestFileModel.cs deleted file mode 100644 index ad0a0fbabb..0000000000 --- a/src/GitHub.Exports/Models/IPullRequestFileModel.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace GitHub.Models -{ - public enum PullRequestFileStatus - { - Modified, - Added, - Removed, - Renamed, - } - - public interface IPullRequestFileModel - { - string FileName { get; } - string Sha { get; } - PullRequestFileStatus Status { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/IPullRequestModel.cs b/src/GitHub.Exports/Models/IPullRequestModel.cs index a4ad12dbf8..58672e3eec 100644 --- a/src/GitHub.Exports/Models/IPullRequestModel.cs +++ b/src/GitHub.Exports/Models/IPullRequestModel.cs @@ -4,13 +4,20 @@ namespace GitHub.Models { - /// TODO: A PullRequestState class already exists hence the ugly naming of this. - /// Merge the two when the maintainer workflow has been merged to master. - public enum PullRequestStateEnum + public enum PullRequestState { Open, - Merged, Closed, + Merged, + } + + public enum PullRequestChecksSummaryState + { + None, + Mixed, + Pending, + Success, + Failure } public interface IPullRequestModel : ICopyable, @@ -18,7 +25,7 @@ public interface IPullRequestModel : ICopyable, { int Number { get; } string Title { get; } - PullRequestStateEnum State { get; } + PullRequestState State { get; } int CommentCount { get; } int CommitCount { get; } bool IsOpen { get; } @@ -31,6 +38,5 @@ public interface IPullRequestModel : ICopyable, DateTimeOffset UpdatedAt { get; } IAccount Author { get; } IAccount Assignee { get; } - IReadOnlyCollection ChangedFiles { get; } } } diff --git a/src/GitHub.Exports/Models/IRemoteRepositoryModel.cs b/src/GitHub.Exports/Models/IRemoteRepositoryModel.cs deleted file mode 100644 index d6c3224cbd..0000000000 --- a/src/GitHub.Exports/Models/IRemoteRepositoryModel.cs +++ /dev/null @@ -1,47 +0,0 @@ -using GitHub.Collections; -using System; - -namespace GitHub.Models -{ - /// - /// Represents a repository read from the GitHub API. - /// - public interface IRemoteRepositoryModel : IRepositoryModel, ICopyable, - IEquatable, IComparable - { - /// - /// Gets the repository's API ID. - /// - long Id { get; } - - /// - /// Gets the account that is the ower of the repository. - /// - IAccount OwnerAccount { get; } - - /// - /// Gets the date and time at which the repository was created. - /// - DateTimeOffset CreatedAt { get; } - - /// - /// Gets the repository's last update date and time. - /// - DateTimeOffset UpdatedAt { get; } - - /// - /// Gets a value indicating whether the repository is a fork. - /// - bool IsFork { get; } - - /// - /// Gets the repository from which this repository was forked, if any. - /// - IRemoteRepositoryModel Parent { get; } - - /// - /// Gets the default branch for the repository. - /// - IBranch DefaultBranch { get; } - } -} diff --git a/src/GitHub.Exports/Models/IRepositoryModel.cs b/src/GitHub.Exports/Models/IRepositoryModel.cs deleted file mode 100644 index c3c1d577ae..0000000000 --- a/src/GitHub.Exports/Models/IRepositoryModel.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using GitHub.Primitives; -using GitHub.UI; - -namespace GitHub.Models -{ - /// - /// Represents a repository, either local or retreived via the GitHub API. - /// - public interface IRepositoryModel - { - /// - /// Gets the name of the repository. - /// - string Name { get; } - - /// - /// Gets the repository clone URL. - /// - UriString CloneUrl { get; } - - /// - /// Gets the name of the owner of the repository, taken from the clone URL. - /// - string Owner { get; } - - /// - /// Gets an icon for the repository that displays its private and fork state. - /// - Octicon Icon { get; } - - /// - /// Sets the based on a private and fork state. - /// - /// Whether the repository is a private repository. - /// Whether the repository is a fork. - void SetIcon(bool isPrivate, bool isFork); - } -} diff --git a/src/GitHub.Exports/Models/IssueishDetailModel.cs b/src/GitHub.Exports/Models/IssueishDetailModel.cs new file mode 100644 index 0000000000..39ac56562d --- /dev/null +++ b/src/GitHub.Exports/Models/IssueishDetailModel.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Base class for issue and pull request detail models. + /// + public class IssueishDetailModel + { + /// + /// Gets or sets the GraphQL ID of the issue or pull request. + /// + public string Id { get; set; } + + /// + /// Gets or sets the issue or pull request number. + /// + public int Number { get; set; } + + /// + /// Gets or sets the issue or pull request author. + /// + public ActorModel Author { get; set; } + + /// + /// Gets or sets the issue or pull request title. + /// + public string Title { get; set; } + + /// + /// Gets or sets the issue or pull request body. + /// + public string Body { get; set; } + + /// + /// Gets or sets the date/time at which the issue or pull request was last updated. + /// + public DateTimeOffset UpdatedAt { get; set; } + + /// + /// Gets or sets the comments on the issue or pull request. + /// + public IReadOnlyList Comments { get; set; } + + /// + /// Gets or sets the number of comments on the issue or pull request. + /// + public int CommentCount { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/LocalRepositoryModel.cs b/src/GitHub.Exports/Models/LocalRepositoryModel.cs index 5a5b61c3f6..f6f491c722 100644 --- a/src/GitHub.Exports/Models/LocalRepositoryModel.cs +++ b/src/GitHub.Exports/Models/LocalRepositoryModel.cs @@ -1,153 +1,35 @@ using System; using System.Diagnostics; using System.Globalization; -using System.IO; -using System.Linq; +using System.ComponentModel; +using System.Collections.Generic; using GitHub.Primitives; -using GitHub.UI; -using GitHub.Services; -using GitHub.Extensions; -using System.Threading.Tasks; namespace GitHub.Models { + /// + /// Represents a locally cloned repository. + /// /// /// A locally cloned repository. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] - public class LocalRepositoryModel : RepositoryModel, ILocalRepositoryModel, IEquatable + public class LocalRepositoryModel : RepositoryModel, INotifyPropertyChanged, IEquatable { - /// - /// Initializes a new instance of the class. - /// - /// The repository name. - /// The repository's clone URL. - /// The repository's local path. - public LocalRepositoryModel(string name, UriString cloneUrl, string localPath) - : base(name, cloneUrl) - { - Guard.ArgumentNotEmptyString(localPath, nameof(localPath)); - - LocalPath = localPath; - Icon = Octicon.repo; - } - - /// - /// Initializes a new instance of the class. - /// - /// The repository's local path. - public LocalRepositoryModel(string path) - : base(path) - { - LocalPath = path; - Icon = Octicon.repo; - } - - /// - /// Updates the clone URL from the local repository. - /// - public void Refresh() - { - if (LocalPath == null) - return; - CloneUrl = GitService.GitServiceHelper.GetUri(LocalPath); - } - - /// - /// Generates a http(s) url to the repository in the remote server, optionally - /// pointing to a specific file and specific line range in it. - /// - /// The file to generate an url to. Optional. - /// A specific line, or (if specifying the as well) the start of a range - /// The end of a line range on the specified file. - /// An UriString with the generated url, or null if the repository has no remote server configured or if it can't be found locally - public async Task GenerateUrl(string path = null, int startLine = -1, int endLine = -1) - { - if (CloneUrl == null) - return null; - - var sha = await GitService.GitServiceHelper.GetLatestPushedSha(path ?? LocalPath); - // this also incidentally checks whether the repo has a valid LocalPath - if (String.IsNullOrEmpty(sha)) - return CloneUrl.ToRepositoryUrl().AbsoluteUri; - - if (path != null && Path.IsPathRooted(path)) - { - // if the path root doesn't match the repository local path, then ignore it - if (!path.StartsWith(LocalPath, StringComparison.OrdinalIgnoreCase)) - { - Debug.Assert(false, String.Format(CultureInfo.CurrentCulture, "GenerateUrl: path {0} doesn't match repository {1}", path, LocalPath)); - path = null; - } - else - path = path.Substring(LocalPath.Length + 1); - } - - if (startLine > 0 && endLine > 0 && startLine > endLine) - { - // if startLine is greater than endLine and both are set, swap them - var temp = startLine; - startLine = endLine; - endLine = temp; - } - - if (startLine == endLine) - { - // if startLine is the same as endLine don't generate a range link - endLine = -1; - } - - return new UriString(GenerateUrl(CloneUrl.ToRepositoryUrl().AbsoluteUri, sha, path, startLine, endLine)); - } - - const string CommitFormat = "{0}/commit/{1}"; - const string BlobFormat = "{0}/blob/{1}/{2}"; - const string StartLineFormat = "{0}#L{1}"; - const string EndLineFormat = "{0}-L{1}"; - static string GenerateUrl(string basePath, string sha, string path, int startLine = -1, int endLine = -1) - { - if (sha == null) - return basePath; - - if (String.IsNullOrEmpty(path)) - return String.Format(CultureInfo.InvariantCulture, CommitFormat, basePath, sha); - - var ret = String.Format(CultureInfo.InvariantCulture, BlobFormat, basePath, sha, path.Replace(@"\", "/")); - if (startLine < 0) - return ret; - ret = String.Format(CultureInfo.InvariantCulture, StartLineFormat, ret, startLine); - if (endLine < 0) - return ret; - return String.Format(CultureInfo.InvariantCulture, EndLineFormat, ret, endLine); - } - /// /// Gets the local path of the repository. /// - public string LocalPath { get; } - - /// - /// Gets the head SHA of the repository. - /// - public string HeadSha + public string LocalPath { - get - { - var repo = GitService.GitServiceHelper.GetRepository(LocalPath); - return repo?.Commits.FirstOrDefault()?.Sha ?? String.Empty; - } + get; set; } /// - /// Gets the current branch of the repository. + /// True if repository has remotes but none are named "origin". /// - public IBranch CurrentBranch + public bool HasRemotesButNoOrigin { - get - { - var repo = GitService.GitServiceHelper.GetRepository(LocalPath); - return new BranchModel(repo?.Head, this); - } + get; set; } /// @@ -173,13 +55,13 @@ public bool Equals(LocalRepositoryModel other) if (ReferenceEquals(this, other)) return true; return other != null && - String.Equals(Name, other.Name) && - String.Equals(Owner, other.Owner) && - String.Equals(CloneUrl, other.CloneUrl) && - String.Equals(LocalPath?.TrimEnd('\\'), other.LocalPath?.TrimEnd('\\'), StringComparison.CurrentCultureIgnoreCase); + string.Equals(Name, other.Name, StringComparison.Ordinal) && + string.Equals(Owner, other.Owner, StringComparison.Ordinal) && + string.Equals(CloneUrl, other.CloneUrl, StringComparison.Ordinal) && + string.Equals(LocalPath?.TrimEnd('\\'), other.LocalPath?.TrimEnd('\\'), StringComparison.CurrentCultureIgnoreCase); } - internal string DebuggerDisplay => String.Format( + internal string DebuggerDisplay => string.Format( CultureInfo.InvariantCulture, "{4}\tOwner: {0} Name: {1} CloneUrl: {2} LocalPath: {3}", Owner, diff --git a/src/GitHub.Exports/Models/OrganizationRepositoriesModel.cs b/src/GitHub.Exports/Models/OrganizationRepositoriesModel.cs new file mode 100644 index 0000000000..dfa58be179 --- /dev/null +++ b/src/GitHub.Exports/Models/OrganizationRepositoriesModel.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + public class OrganizationRepositoriesModel + { + public ActorModel Organization { get; set; } + public IReadOnlyList Repositories { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/Page.cs b/src/GitHub.Exports/Models/Page.cs new file mode 100644 index 0000000000..b3413bf9f6 --- /dev/null +++ b/src/GitHub.Exports/Models/Page.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Represents a page in a GraphQL paged collection. + /// + /// The item type. + public class Page + { + /// + /// Gets or sets the cursor for the last item. + /// + public string EndCursor { get; set; } + + /// + /// Gets or sets a value indicating whether there are more items after this page. + /// + public bool HasNextPage { get; set; } + + /// + /// Gets or sets the total count of items in all pages. + /// + public int TotalCount { get; set; } + + /// + /// Gets or sets the items in the page. + /// + public IReadOnlyList Items { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/PullRequestCheckType.cs b/src/GitHub.Exports/Models/PullRequestCheckType.cs new file mode 100644 index 0000000000..d088320b53 --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestCheckType.cs @@ -0,0 +1,8 @@ +namespace GitHub.Models +{ + public enum PullRequestCheckType + { + StatusApi, + ChecksApi + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/PullRequestDetailModel.cs b/src/GitHub.Exports/Models/PullRequestDetailModel.cs new file mode 100644 index 0000000000..28cf560a0b --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestDetailModel.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Holds the details of a Pull Request. + /// + public class PullRequestDetailModel : IssueishDetailModel + { + /// + /// Gets or sets the pull request state (open, closed, merged). + /// + public PullRequestState State { get; set; } + + /// + /// Gets or sets the name of the base branch (e.g. "master"). + /// + public string BaseRefName { get; set; } + + /// + /// Gets or sets the SHA of the base branch. + /// + public string BaseRefSha { get; set; } + + /// + /// Gets or sets the owner login of the repository containing the base branch. + /// + public string BaseRepositoryOwner { get; set; } + + /// + /// Gets or sets the name of the head branch (e.g. "feature-branch"). + /// + public string HeadRefName { get; set; } + + /// + /// Gets or sets the SHA of the head branch. + /// + public string HeadRefSha { get; set; } + + /// + /// Gets or sets the owner login of the repository containing the head branch. + /// + public string HeadRepositoryOwner { get; set; } + + /// + /// Gets or sets a collection of files changed by the pull request. + /// + public IReadOnlyList ChangedFiles { get; set; } + + /// + /// Gets or sets a collection of pull request Checks Suites. + /// + public IReadOnlyList CheckSuites { get; set; } + + /// + /// Gets or sets a collection of pull request Statuses + /// + public IReadOnlyList Statuses { get; set; } + + /// + /// Gets or sets a collection of pull request reviews. + /// + public IReadOnlyList Reviews { get; set; } + + /// + /// Gets or sets a collection of pull request review comment threads. + /// + /// + /// The collection groups the comments in the various + /// into threads, as such each pull request review comment will appear in both collections. + /// + public IReadOnlyList Threads { get; set; } + + /// + /// Gets or sets the pull request timeline entries. + /// + public IReadOnlyList Timeline { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/PullRequestFileModel.cs b/src/GitHub.Exports/Models/PullRequestFileModel.cs new file mode 100644 index 0000000000..773be8577c --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestFileModel.cs @@ -0,0 +1,51 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Describes the possible values for . + /// + public enum PullRequestFileStatus + { + /// + /// The file was modified in the pull request. + /// + Modified, + + /// + /// The file was added by the pull request. + /// + Added, + + /// + /// The file was removed by the pull request. + /// + Removed, + + /// + /// The file was moved or renamed by the pull request. + /// + Renamed, + } + + /// + /// Holds details of a file changed by a pull request. + /// + public class PullRequestFileModel + { + /// + /// Gets or sets the path to the changed file, relative to the repository. + /// + public string FileName { get; set; } + + /// + /// Gets or sets the SHA of the changed file. + /// + public string Sha { get; set; } + + /// + /// Gets or sets the status of the changed file (modified, added, removed etc). + /// + public PullRequestFileStatus Status { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/PullRequestListItemModel.cs b/src/GitHub.Exports/Models/PullRequestListItemModel.cs new file mode 100644 index 0000000000..fa3a71fb9a --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestListItemModel.cs @@ -0,0 +1,65 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Holds an overview of a pull request for display in the PR list. + /// + public class PullRequestListItemModel + { + /// + /// Gets or sets the GraphQL ID of the pull request. + /// + public string Id { get; set; } + + /// + /// Gets or sets the pull request number. + /// + public int Number { get; set; } + + /// + /// Gets or sets the pull request author. + /// + public ActorModel Author { get; set; } + + /// + /// Gets or sets the number of comments on the pull request. + /// + public int CommentCount { get; set; } + + /// + /// Gets or sets the pull request title. + /// + public string Title { get; set; } + + /// + /// Gets or sets the pull request state (open, closed, merged). + /// + public PullRequestState State { get; set; } + + /// + /// Gets the pull request checks and statuses summary + /// + public PullRequestChecksSummaryState ChecksSummary { get; set; } + + /// + /// Gets the number of pending checks and statuses + /// + public int ChecksPendingCount { get; set; } + + /// + /// Gets the number of successful checks and statuses + /// + public int ChecksSuccessCount { get; set; } + + /// + /// Gets the number of erroneous checks and statuses + /// + public int ChecksErrorCount { get; set; } + + /// + /// Gets or sets the date/time at which the pull request was last updated. + /// + public DateTimeOffset UpdatedAt { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/PullRequestReviewCommentModel.cs b/src/GitHub.Exports/Models/PullRequestReviewCommentModel.cs new file mode 100644 index 0000000000..535429d3c5 --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestReviewCommentModel.cs @@ -0,0 +1,25 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Holds details about a pull request review comment. + /// + public class PullRequestReviewCommentModel : CommentModel + { + /// + /// Gets the PullRequestId of the comment. + /// + /// + /// The GraphQL Api does not allow for deleting of pull request comments. + /// REST Api must be used, and PullRequestId is needed to reload the pull request. + /// This field should be removed with better GraphQL support. + /// + public int PullRequestId { get; set; } + + /// + /// Gets or sets the associated thread that contains the comment. + /// + public PullRequestReviewThreadModel Thread { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/PullRequestReviewModel.cs b/src/GitHub.Exports/Models/PullRequestReviewModel.cs new file mode 100644 index 0000000000..eb0719b262 --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestReviewModel.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// The possible states of a pull request review. + /// + public enum PullRequestReviewState + { + /// + /// A review that has not yet been submitted. + /// + Pending, + + /// + /// An informational review. + /// + Commented, + + /// + /// A review allowing the pull request to merge. + /// + Approved, + + /// + /// A review blocking the pull request from merging. + /// + ChangesRequested, + + /// + /// A review that has been dismissed. + /// + Dismissed, + } + + /// + /// Holds details about a pull request review. + /// + public class PullRequestReviewModel + { + /// + /// Gets or sets the GraphQL ID of the pull request review. + /// + public string Id { get; set; } + + /// + /// Gets or sets the author of the pull request review. + /// + public ActorModel Author { get; set; } + + /// + /// Gets or sets the review's body markdown. + /// + public string Body { get; set; } + + /// + /// Gets or sets the review's state (approved, requested changes, commented etc). + /// + public PullRequestReviewState State { get; set; } + + /// + /// Gets or sets the SHA at which the review was left. + /// + public string CommitId { get; set; } + + /// + /// Gets or sets the date/time at which the review was submitted. + /// + public DateTimeOffset? SubmittedAt { get; set; } + + /// + /// Gets or sets the review comments. + /// + public IReadOnlyList Comments { get; set; } = Array.Empty(); + } +} diff --git a/src/GitHub.Exports/Models/PullRequestReviewThreadModel.cs b/src/GitHub.Exports/Models/PullRequestReviewThreadModel.cs new file mode 100644 index 0000000000..f0eb9a2d43 --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestReviewThreadModel.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Represents a thread of s. + /// + public class PullRequestReviewThreadModel + { + /// + /// Gets or sets the GraphQL ID of the thread. + /// + public string Id { get; set; } + + /// + /// Gets or sets the path to the file that the thread is on, relative to the repository. + /// + public string Path { get; set; } + + /// + /// Gets or sets the SHA of the commmit that the thread starts on. + /// + public string CommitSha { get; set; } + + /// + /// Gets or sets the diff hunk for the thread. + /// + public string DiffHunk { get; set; } + + /// + /// Gets or sets a value indicating whether the thread is outdated. + /// + public bool IsOutdated { get; set; } + + /// + /// Gets or sets a value indicating whether the thread is resolved. + /// + public bool IsResolved { get; set; } + + /// + /// Gets or sets the line position in the diff that the thread starts on. + /// + /// + /// This property reflects the updated for the current + /// . If the thread is outdated, it will return null. + /// + public int? Position { get; set; } + + /// + /// Gets or sets the line position in the diff that the thread was originally started on. + /// + /// + /// This property represents a line in the diff between the + /// and the pull request branch's merge base at which the thread was originally started. + /// + public int OriginalPosition { get; set; } + + /// + /// Gets or sets the SHA of the commmit that the thread was originally started on. + /// + public string OriginalCommitSha { get; set; } + + /// + /// Gets or sets the comments in the thread. + /// + public IReadOnlyList Comments { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/RepositoryListItemModel.cs b/src/GitHub.Exports/Models/RepositoryListItemModel.cs new file mode 100644 index 0000000000..757c0f18a6 --- /dev/null +++ b/src/GitHub.Exports/Models/RepositoryListItemModel.cs @@ -0,0 +1,13 @@ +using System; + +namespace GitHub.Models +{ + public class RepositoryListItemModel + { + public bool IsFork { get; set; } + public bool IsPrivate { get; set; } + public string Name { get; set; } + public string Owner { get; set; } + public Uri Url { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/RepositoryModel.cs b/src/GitHub.Exports/Models/RepositoryModel.cs index 00abfd7a7b..c30488cdfc 100644 --- a/src/GitHub.Exports/Models/RepositoryModel.cs +++ b/src/GitHub.Exports/Models/RepositoryModel.cs @@ -1,9 +1,7 @@ using System; using System.Diagnostics; -using System.IO; using GitHub.Extensions; using GitHub.Primitives; -using GitHub.Services; using GitHub.UI; namespace GitHub.Models @@ -12,7 +10,7 @@ namespace GitHub.Models /// The base class for local and remote repository models. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] - public class RepositoryModel : NotificationAwareObject, IRepositoryModel + public class RepositoryModel : NotificationAwareObject { UriString cloneUrl; Octicon icon; @@ -33,29 +31,14 @@ public RepositoryModel( CloneUrl = cloneUrl; } - /// - /// Initializes a new instance of the class. - /// - /// - /// The path to the local repository from which repository name and clone URL will be - /// extracted. - /// - protected RepositoryModel(string path) + protected RepositoryModel() { - Guard.ArgumentNotNull(path, nameof(path)); - - var dir = new DirectoryInfo(path); - if (!dir.Exists) - throw new ArgumentException("Path does not exist", nameof(path)); - var uri = GitService.GitServiceHelper.GetUri(path); - Name = uri?.RepositoryName ?? dir.Name; - CloneUrl = GitService.GitServiceHelper.GetUri(path); } /// /// Gets the name of the repository. /// - public string Name { get; } + public string Name { get; set; } /// /// Gets the repository clone URL. @@ -63,7 +46,7 @@ protected RepositoryModel(string path) public UriString CloneUrl { get { return cloneUrl; } - protected set + set { if (cloneUrl != value) { @@ -84,7 +67,7 @@ protected set public Octicon Icon { get { return icon; } - protected set + set { if (icon != value) { diff --git a/src/GitHub.Exports/Models/ScopesCollection.cs b/src/GitHub.Exports/Models/ScopesCollection.cs new file mode 100644 index 0000000000..a242653617 --- /dev/null +++ b/src/GitHub.Exports/Models/ScopesCollection.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace GitHub.Models +{ + /// + /// A collection of OAuth scopes. + /// + public class ScopesCollection : IReadOnlyList + { + readonly IReadOnlyList inner; + + /// + /// Initializes a new instance of the class. + /// + /// The scopes. + public ScopesCollection(IReadOnlyList scopes) + { + inner = scopes; + } + + /// + public string this[int index] => inner[index]; + + /// + public int Count => inner.Count; + + /// + public IEnumerator GetEnumerator() => inner.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => inner.GetEnumerator(); + + /// + /// Tests if received API scopes match the required API scopes. + /// + /// The required API scopes. + /// True if all required scopes are present, otherwise false. + public bool Matches(IEnumerable required) + { + foreach (var scope in required) + { + var found = inner.Contains(scope); + + if (!found && + (scope.StartsWith("read:", StringComparison.Ordinal) || + scope.StartsWith("write:", StringComparison.Ordinal))) + { + // NOTE: Scopes are actually more complex than this, for example + // `user` encompasses `read:user` and `user:email` but just use + // this simple rule for now as it works for the scopes we require. + var adminScope = scope + .Replace("read:", "admin:") + .Replace("write:", "admin:"); + found = inner.Contains(adminScope); + } + + if (!found) + { + return false; + } + } + + return true; + } + + /// + public override string ToString() => string.Join(",", inner); + } +} diff --git a/src/GitHub.Exports/Models/StatusModel.cs b/src/GitHub.Exports/Models/StatusModel.cs new file mode 100644 index 0000000000..3b0832caa0 --- /dev/null +++ b/src/GitHub.Exports/Models/StatusModel.cs @@ -0,0 +1,28 @@ +namespace GitHub.Models +{ + /// + /// Model for a single pull request Status. + /// + public class StatusModel + { + /// + /// The state of the Status + /// + public StatusState State { get; set; } + + /// + /// The Status context or title + /// + public string Context { get; set; } + + /// + /// The url where more information about the Status can be found + /// + public string TargetUrl { get; set; } + + /// + /// The descritption for the Status + /// + public string Description { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/StatusState.cs b/src/GitHub.Exports/Models/StatusState.cs new file mode 100644 index 0000000000..57830b3dd0 --- /dev/null +++ b/src/GitHub.Exports/Models/StatusState.cs @@ -0,0 +1,11 @@ +namespace GitHub.Models +{ + public enum StatusState + { + Expected, + Error, + Failure, + Pending, + Success, + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/UsageData.cs b/src/GitHub.Exports/Models/UsageData.cs new file mode 100644 index 0000000000..c95bfe1f05 --- /dev/null +++ b/src/GitHub.Exports/Models/UsageData.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Holds a collection of daily usage reports. + /// + public class UsageData + { + /// + /// Gets a list of unsent daily usage reports. + /// + public List Reports { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/UsageModel.cs b/src/GitHub.Exports/Models/UsageModel.cs index 4240eb9d05..751b443c5f 100644 --- a/src/GitHub.Exports/Models/UsageModel.cs +++ b/src/GitHub.Exports/Models/UsageModel.cs @@ -1,61 +1,101 @@ using System; +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1720 // Identifier contains type name + namespace GitHub.Models { public class UsageModel { - public bool IsGitHubUser { get; set; } - public bool IsEnterpriseUser { get; set; } - public string AppVersion { get; set; } - public string VSVersion { get; set; } - public string Lang { get; set; } - public int NumberOfStartups { get; set; } - public int NumberOfStartupsWeek { get; set; } - public int NumberOfStartupsMonth { get; set; } - public int NumberOfUpstreamPullRequests { get; set; } - public int NumberOfClones { get; set; } - public int NumberOfReposCreated { get; set; } - public int NumberOfReposPublished { get; set; } - public int NumberOfGists { get; set; } - public int NumberOfOpenInGitHub { get; set; } - public int NumberOfLinkToGitHub { get; set; } - public int NumberOfLogins { get; set; } - public int NumberOfPullRequestsOpened { get; set; } - public int NumberOfLocalPullRequestsCheckedOut { get; set; } - public int NumberOfLocalPullRequestPulls { get; set; } - public int NumberOfLocalPullRequestPushes { get; set; } - public int NumberOfForkPullRequestsCheckedOut { get; set; } - public int NumberOfForkPullRequestPulls { get; set; } - public int NumberOfForkPullRequestPushes { get; set; } + public DimensionsModel Dimensions { get; set; } = new DimensionsModel(); + public MeasuresModel Measures { get; set; } = new MeasuresModel(); + + // this should never be called by our code but it's required to be public by the serialization code + public UsageModel() { } - public UsageModel Clone(bool includeWeekly, bool includeMonthly) + public static UsageModel Create(Guid guid) { return new UsageModel { - IsGitHubUser = IsGitHubUser, - IsEnterpriseUser = IsEnterpriseUser, - AppVersion = AppVersion, - VSVersion = VSVersion, - Lang = Lang, - NumberOfStartups = NumberOfStartups, - NumberOfStartupsWeek = includeWeekly ? NumberOfStartupsWeek : 0, - NumberOfStartupsMonth = includeMonthly ? NumberOfStartupsMonth : 0, - NumberOfUpstreamPullRequests = NumberOfUpstreamPullRequests, - NumberOfClones = NumberOfClones, - NumberOfReposCreated = NumberOfReposCreated, - NumberOfReposPublished = NumberOfReposPublished, - NumberOfGists = NumberOfGists, - NumberOfOpenInGitHub = NumberOfOpenInGitHub, - NumberOfLinkToGitHub = NumberOfLinkToGitHub, - NumberOfLogins = NumberOfLogins, - NumberOfPullRequestsOpened = NumberOfPullRequestsOpened, - NumberOfLocalPullRequestsCheckedOut = NumberOfLocalPullRequestsCheckedOut, - NumberOfLocalPullRequestPulls = NumberOfLocalPullRequestPulls, - NumberOfLocalPullRequestPushes = NumberOfLocalPullRequestPushes, - NumberOfForkPullRequestsCheckedOut = NumberOfForkPullRequestsCheckedOut, - NumberOfForkPullRequestPulls = NumberOfForkPullRequestPulls, - NumberOfForkPullRequestPushes = NumberOfForkPullRequestPushes, + Dimensions = new DimensionsModel + { + Guid = guid, + Date = DateTimeOffset.Now, + } }; } + + public class DimensionsModel + { + public Guid Guid { get; set; } + public DateTimeOffset Date { get; set; } + public bool IsGitHubUser { get; set; } + public bool IsEnterpriseUser { get; set; } + public string AppVersion { get; set; } + public string VSVersion { get; set; } + public string Lang { get; set; } + public string CurrentLang { get; set; } + public string CurrentUILang { get; set; } + } + + public class MeasuresModel + { + public int NumberOfStartups { get; set; } + public int NumberOfUpstreamPullRequests { get; set; } + public int NumberOfClones { get; set; } + public int NumberOfReposCreated { get; set; } + public int NumberOfReposPublished { get; set; } + public int NumberOfGists { get; set; } + public int NumberOfOpenInGitHub { get; set; } + public int NumberOfLinkToGitHub { get; set; } + public int NumberOfLogins { get; set; } + public int NumberOfOAuthLogins { get; set; } + public int NumberOfTokenLogins { get; set; } + public int NumberOfPullRequestsOpened { get; set; } + public int NumberOfLocalPullRequestsCheckedOut { get; set; } + public int NumberOfLocalPullRequestPulls { get; set; } + public int NumberOfLocalPullRequestPushes { get; set; } + public int NumberOfForkPullRequestsCheckedOut { get; set; } + public int NumberOfForkPullRequestPulls { get; set; } + public int NumberOfForkPullRequestPushes { get; set; } + public int NumberOfSyncSubmodules { get; set; } + public int NumberOfWelcomeDocsClicks { get; set; } + public int NumberOfWelcomeTrainingClicks { get; set; } + public int NumberOfGitHubPaneHelpClicks { get; set; } + public int NumberOfPRDetailsOpenInGitHub { get; set; } + public int NumberOfPRStatusesOpenInGitHub { get; set; } + public int NumberOfPRChecksOpenInGitHub { get; set; } + public int NumberOfPRDetailsViewChanges { get; set; } + public int NumberOfPRDetailsViewFile { get; set; } + public int NumberOfPRDetailsCompareWithSolution { get; set; } + public int NumberOfPRDetailsOpenFileInSolution { get; set; } + public int NumberOfPRReviewDiffViewInlineCommentOpen { get; set; } + public int NumberOfPRReviewDiffViewInlineCommentPost { get; set; } + public int NumberOfPRReviewDiffViewInlineCommentDelete { get; set; } + public int NumberOfPRReviewDiffViewInlineCommentEdit { get; set; } + public int NumberOfPRReviewDiffViewInlineCommentStartReview { get; set; } + public int NumberOfPRReviewPosts { get; set; } + public int NumberOfShowCurrentPullRequest { get; set; } + public int NumberOfStatusBarOpenPullRequestList { get; set; } + public int NumberOfTeamExplorerHomeOpenPullRequestList { get; set; } + public int NumberOfPullRequestOpenAnnotationsList { get; set; } + public int NumberOfStartPageClones { get; set; } + public int NumberOfGitHubConnectSectionClones { get; set; } + public int NumberOfShowRepoForkDialogClicks { get; set; } + public int NumberOfReposForked { get; set; } + public int ExecuteGoToSolutionOrPullRequestFileCommand { get; set; } + public int NumberOfPRDetailsNavigateToEditor { get; set; } // Should rename to NumberOfNavigateToEditor + public int NumberOfNavigateToPullRequestFileDiff { get; set; } + public int NumberOfNavigateToCodeView { get; set; } + public int ExecuteToggleInlineCommentMarginCommand { get; set; } + public int NumberOfPullRequestFileMarginToggleInlineCommentMargin { get; set; } + public int NumberOfPullRequestFileMarginViewChanges { get; set; } + public int NumberOfGitHubClones { get; set; } + public int NumberOfEnterpriseClones { get; set; } + public int NumberOfGitHubOpens { get; set; } + public int NumberOfEnterpriseOpens { get; set; } + public int NumberOfClonesToDefaultClonePath { get; set; } + public int NumberOfPRConversationsOpened { get; set; } + } } } diff --git a/src/GitHub.Exports/Models/ViewerRepositoriesModel.cs b/src/GitHub.Exports/Models/ViewerRepositoriesModel.cs new file mode 100644 index 0000000000..7987af54d1 --- /dev/null +++ b/src/GitHub.Exports/Models/ViewerRepositoriesModel.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + public class ViewerRepositoriesModel + { + public string Owner { get; set; } + public IReadOnlyList Repositories { get; set; } + public IReadOnlyList ContributedToRepositories { get; set; } + public IDictionary> Organizations { get; set; } + } +} diff --git a/src/GitHub.Exports/Primitives/HostAddress.cs b/src/GitHub.Exports/Primitives/HostAddress.cs index 59a3c059db..e44c297716 100644 --- a/src/GitHub.Exports/Primitives/HostAddress.cs +++ b/src/GitHub.Exports/Primitives/HostAddress.cs @@ -6,7 +6,7 @@ namespace GitHub.Primitives { public class HostAddress { - public static HostAddress GitHubDotComHostAddress = new HostAddress(); + public static readonly HostAddress GitHubDotComHostAddress = new HostAddress(); static readonly Uri gistUri = new Uri("https://gist.github.com"); /// @@ -44,7 +44,7 @@ private HostAddress(Uri enterpriseUri) { WebUri = new Uri(enterpriseUri, new Uri("/", UriKind.Relative)); ApiUri = new Uri(enterpriseUri, new Uri("/api/v3/", UriKind.Relative)); - //CredentialCacheKeyHost = ApiUri.Host; + GraphQLUri = new Uri(enterpriseUri, new Uri("/api/graphql", UriKind.Relative)); CredentialCacheKeyHost = WebUri.ToString(); } @@ -52,21 +52,27 @@ public HostAddress() { WebUri = new Uri("https://github.com"); ApiUri = new Uri("https://api.github.com"); - //CredentialCacheKeyHost = "github.com"; + GraphQLUri = new Uri("https://api.github.com/graphql"); CredentialCacheKeyHost = WebUri.ToString(); } /// - /// The Base URL to the host. For example, "https://github.com" or "https://ghe.io" + /// The Base URL to the host. For example, "https://github.com" or "https://github-enterprise.com" /// public Uri WebUri { get; set; } /// /// The Base Url to the host's API endpoint. For example, "https://api.github.com" or - /// "https://ghe.io/api/v3" + /// "https://github-enterprise.com/api/v3" /// public Uri ApiUri { get; set; } + /// + /// The Base Url to the host's GraphQL API endpoint. For example, "https://api.github.com/graphql" or + /// "https://github-enterprise.com/api/graphql" + /// + public Uri GraphQLUri { get; set; } + // If the host name is "api.github.com" or "gist.github.com", we really only want "github.com", // since that's the same cache key for all the other github.com operations. public string CredentialCacheKeyHost { get; private set; } diff --git a/src/GitHub.Exports/Primitives/Paths.cs b/src/GitHub.Exports/Primitives/Paths.cs new file mode 100644 index 0000000000..9cdd4e0677 --- /dev/null +++ b/src/GitHub.Exports/Primitives/Paths.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; + +namespace GitHub.Primitives +{ + /// + /// Convert to and from Git paths. + /// + public static class Paths + { + public const char GitDirectorySeparatorChar = '/'; + + /// + /// Convert from a relative path to a Git path. + /// + /// A relative path. + /// A working directory relative path which uses the '/' directory separator. + public static string ToGitPath(string relativePath) + { + return relativePath.Replace(Path.DirectorySeparatorChar, GitDirectorySeparatorChar); + } + + /// + /// Convert from a Git path to a path that uses the Windows directory separator ('\'). + /// + /// A relative path that uses the '/' directory separator. + /// A relative path that uses the directory separator ('\' on Windows). + public static string ToWindowsPath(string gitPath) + { + return gitPath.Replace(GitDirectorySeparatorChar, Path.DirectorySeparatorChar); + } + } +} diff --git a/src/GitHub.Exports/Primitives/StringEquivalent.cs b/src/GitHub.Exports/Primitives/StringEquivalent.cs index 207b84657b..e4449296ce 100644 --- a/src/GitHub.Exports/Primitives/StringEquivalent.cs +++ b/src/GitHub.Exports/Primitives/StringEquivalent.cs @@ -10,7 +10,7 @@ namespace GitHub.Primitives [Serializable] public abstract class StringEquivalent : ISerializable, IXmlSerializable where T : StringEquivalent { - protected string Value; + protected string Value { get; set; } protected StringEquivalent(string value) { diff --git a/src/GitHub.Exports/Primitives/UriString.cs b/src/GitHub.Exports/Primitives/UriString.cs index e75db99dca..55842ccbfc 100644 --- a/src/GitHub.Exports/Primitives/UriString.cs +++ b/src/GitHub.Exports/Primitives/UriString.cs @@ -25,7 +25,6 @@ namespace GitHub.Primitives [TypeConverter(typeof(UriStringConverter))] public class UriString : StringEquivalent, IEquatable { - //static readonly NLog.Logger log = NLog.LogManager.GetCurrentClassLogger(); static readonly Regex sshRegex = new Regex(@"^.+@(?(\[.*?\]|[a-z0-9-.]+?))(:(?.*?))?(/(?.*)(\.git)?)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); readonly Uri url; @@ -44,11 +43,17 @@ public UriString(string uriString) : base(NormalizePath(uriString)) SetFilePath(uriString); } - if (RepositoryName != null) + if (Owner != null && RepositoryName != null) { - NameWithOwner = Owner != null - ? string.Format(CultureInfo.InvariantCulture, "{0}/{1}", Owner, RepositoryName) - : RepositoryName; + NameWithOwner = string.Format(CultureInfo.InvariantCulture, "{0}/{1}", Owner, RepositoryName); + } + else if (Owner != null) + { + NameWithOwner = Owner; + } + else if (RepositoryName != null) + { + NameWithOwner = RepositoryName; } } @@ -66,18 +71,16 @@ public Uri ToUri() void SetUri(Uri uri) { + var ownerSegment = FindSegment(uri.Segments, 0); + var repositorySegment = FindSegment(uri.Segments, 1); + Host = uri.Host; - if (uri.Segments.Any()) - { - RepositoryName = GetRepositoryName(uri.Segments.Last()); - } - - if (uri.Segments.Length > 2) - { - Owner = (uri.Segments[uri.Segments.Length - 2] ?? "").TrimEnd('/').ToNullIfEmpty(); - } - + Owner = ownerSegment; + RepositoryName = GetRepositoryName(repositorySegment); IsHypertextTransferProtocol = uri.IsHypertextTransferProtocol(); + + string FindSegment(string[] segments, int number) + => segments.Skip(number + 1).FirstOrDefault()?.TrimEnd("/"); } void SetFilePath(Uri uri) @@ -130,10 +133,12 @@ bool ParseScpSyntax(string scpString) public bool IsValidUri => url != null; /// - /// Attempts a best-effort to convert the remote origin to a GitHub Repository URL. + /// Attempts a best-effort to convert the remote origin to a GitHub Repository URL, + /// optionally changing the owner. /// + /// The owner to use, if null uses . /// A converted uri, or the existing one if we can't convert it (which might be null) - public Uri ToRepositoryUrl() + public Uri ToRepositoryUrl(string owner = null) { // we only want to process urls that represent network resources if (!IsScpUri && (!IsValidUri || IsFileUri)) return url; @@ -142,11 +147,15 @@ public Uri ToRepositoryUrl() ? url.Scheme : Uri.UriSchemeHttps; + var nameWithOwner = owner != null && RepositoryName != null ? + string.Format(CultureInfo.InvariantCulture, "{0}/{1}", owner, RepositoryName) : + NameWithOwner; + return new UriBuilder { Scheme = scheme, Host = Host, - Path = NameWithOwner, + Path = nameWithOwner, Port = url?.Port == 80 ? -1 : (url?.Port ?? -1) @@ -205,12 +214,37 @@ public override UriString Combine(string addition) return String.Concat(Value, addition); } + /// + /// Compare repository URLs ignoring any trailing ".git" or difference in case. + /// + /// True if URLs reference the same repository. + public static bool RepositoryUrlsAreEqual(UriString uri1, UriString uri2) + { + if (!uri1.IsHypertextTransferProtocol || !uri2.IsHypertextTransferProtocol) + { + // Not a repository URL + return false; + } + + // Normalize repository URLs + var str1 = uri1.ToRepositoryUrl().ToString(); + var str2 = uri2.ToRepositoryUrl().ToString(); + return string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase); + } + public override string ToString() { // Makes this look better in the debugger. return Value; } + /// + /// Makes a copy of the URI with the specified owner. + /// + /// The owner. + /// A new . + public UriString WithOwner(string owner) => ToUriString(ToRepositoryUrl(owner)); + protected UriString(SerializationInfo info, StreamingContext context) : this(GetSerializedValue(info)) { @@ -239,7 +273,7 @@ static string NormalizePath(string path) static string GetRepositoryName(string repositoryNameSegment) { - if (String.IsNullOrEmpty(repositoryNameSegment) + if (String.IsNullOrEmpty(repositoryNameSegment) || repositoryNameSegment.Equals("/", StringComparison.Ordinal)) { return null; diff --git a/src/GitHub.Exports/Properties/AssemblyInfo.cs b/src/GitHub.Exports/Properties/AssemblyInfo.cs deleted file mode 100644 index 6669743307..0000000000 --- a/src/GitHub.Exports/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("GitHub.Exports")] -[assembly: AssemblyDescription("GitHub interfaces for mef exports")] -[assembly: Guid("9aea02db-02b5-409c-b0ca-115d05331a6b")] diff --git a/src/GitHub.Exports/Services/Connection.cs b/src/GitHub.Exports/Services/Connection.cs index 1fc3ad53a9..9072a0ae15 100644 --- a/src/GitHub.Exports/Services/Connection.cs +++ b/src/GitHub.Exports/Services/Connection.cs @@ -1,56 +1,124 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; using GitHub.Models; using GitHub.Primitives; -using System.Threading.Tasks; -using System.Collections.ObjectModel; -using System.Collections.Specialized; namespace GitHub.Services { + /// + /// Represents a configured connection to a GitHub account. + /// public class Connection : IConnection { - readonly IConnectionManager manager; + string username; + Octokit.User user; + ScopesCollection scopes; + bool isLoggedIn; + bool isLoggingIn; + Exception connectionError; - public Connection(IConnectionManager cm, HostAddress hostAddress, string userName) + public Connection( + HostAddress hostAddress, + string username) { - manager = cm; + this.username = username; HostAddress = hostAddress; - Username = userName; - Repositories = new ObservableCollection(); + isLoggedIn = false; + isLoggingIn = true; } - public HostAddress HostAddress { get; private set; } - public string Username { get; private set; } - public ObservableCollection Repositories { get; } + public Connection( + HostAddress hostAddress, + Octokit.User user, + ScopesCollection scopes) + { + HostAddress = hostAddress; + this.user = user; + this.scopes = scopes; + isLoggedIn = true; + } + + /// + public HostAddress HostAddress { get; } - public IObservable Login() + /// + public string Username { - return manager.RequestLogin(this); + get => username; + private set => RaiseAndSetIfChanged(ref username, value); + } + /// + public Octokit.User User + { + get => user; + private set => RaiseAndSetIfChanged(ref user, value); } - public void Logout() + /// + public ScopesCollection Scopes { - manager.RequestLogout(this); + get => scopes; + private set => RaiseAndSetIfChanged(ref scopes, value); } - #region IDisposable Support - private bool disposed = false; // To detect redundant calls + /// + public bool IsLoggedIn + { + get => isLoggedIn; + private set => RaiseAndSetIfChanged(ref isLoggedIn, value); + } - protected virtual void Dispose(bool disposing) + /// + public bool IsLoggingIn { - if (!disposed) - { - if (disposing) - Repositories.Clear(); - disposed = true; - } + get => isLoggingIn; + private set => RaiseAndSetIfChanged(ref isLoggingIn, value); } - public void Dispose() + /// + public Exception ConnectionError { - Dispose(true); - GC.SuppressFinalize(this); + get => connectionError; + private set => RaiseAndSetIfChanged(ref connectionError, value); + } + + /// + public event PropertyChangedEventHandler PropertyChanged; + + public void SetLoggingIn() + { + ConnectionError = null; + IsLoggedIn = false; + IsLoggingIn = true; + User = null; + Scopes = null; + } + + public void SetError(Exception e) + { + ConnectionError = e; + IsLoggingIn = false; + IsLoggedIn = false; + Scopes = null; + } + + public void SetSuccess(Octokit.User user, ScopesCollection scopes) + { + User = user; + Scopes = scopes; + IsLoggingIn = false; + IsLoggedIn = true; + } + + void RaiseAndSetIfChanged(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (!Equals(field, value)) + { + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } } - #endregion } } diff --git a/src/GitHub.Exports/Services/EnterpriseProbeTask.cs b/src/GitHub.Exports/Services/EnterpriseProbeTask.cs deleted file mode 100644 index 2d14bef6b3..0000000000 --- a/src/GitHub.Exports/Services/EnterpriseProbeTask.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using GitHub.Extensions; -using GitHub.Models; -using Octokit; -using Octokit.Internal; - -namespace GitHub.Services -{ - [Export(typeof(IEnterpriseProbeTask))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class EnterpriseProbeTask : IEnterpriseProbeTask - { - static readonly Uri endPoint = new Uri("/site/sha", UriKind.Relative); - readonly ProductHeaderValue productHeader; - readonly IHttpClient httpClient; - - [ImportingConstructor] - public EnterpriseProbeTask(IProgram program, IHttpClient httpClient) - { - productHeader = program.ProductHeader; - this.httpClient = httpClient; - } - - public async Task ProbeAsync(Uri enterpriseBaseUrl) - { - var request = new Request - { - Method = HttpMethod.Get, - BaseAddress = enterpriseBaseUrl, - Endpoint = endPoint, - Timeout = TimeSpan.FromSeconds(3), - }; - request.Headers.Add("User-Agent", productHeader.ToString()); - - var success = false; - var ret = await httpClient - .Send(request, CancellationToken.None) - .Catch(ex => { - success = ex.IsGitHubApiException(); - return null; - }); - - if (ret == null) - return success ? EnterpriseProbeResult.Ok : EnterpriseProbeResult.Failed; - else if (ret.StatusCode == HttpStatusCode.OK) - return EnterpriseProbeResult.Ok; - return EnterpriseProbeResult.NotFound; - } - } - - public enum EnterpriseProbeResult - { - /// - /// Yep! It's an Enterprise server - /// - Ok, - - /// - /// Got a response from a server, but it wasn't an Enterprise server - /// - NotFound, - - /// - /// Request timed out or DNS failed. So it's probably the case it's not an enterprise server but - /// we can't know for sure. - /// - Failed - } -} diff --git a/src/GitHub.Exports/Services/ExportFactoryProvider.cs b/src/GitHub.Exports/Services/ExportFactoryProvider.cs deleted file mode 100644 index 2fc3f79821..0000000000 --- a/src/GitHub.Exports/Services/ExportFactoryProvider.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using GitHub.Exports; -using GitHub.UI; -using GitHub.ViewModels; -using GitHub.Models; - -namespace GitHub.Services -{ - [Export(typeof(IExportFactoryProvider))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class ExportFactoryProvider : IExportFactoryProvider - { - [ImportingConstructor] - public ExportFactoryProvider(ICompositionService cc) - { - cc.SatisfyImportsOnce(this); - } - - [ImportMany(AllowRecomposition = true)] - public IEnumerable> ViewModelFactory { get; set; } - - [ImportMany(AllowRecomposition = true)] - public IEnumerable> ViewFactory { get; set; } - - public ExportLifetimeContext GetViewModel(UIViewType viewType) - { - Debug.Assert(ViewModelFactory != null, "Attempted to obtain a view model before we imported the ViewModelFactory"); - var f = ViewModelFactory.FirstOrDefault(x => x.Metadata.ViewType == viewType); - Debug.Assert(f != null, string.Format(CultureInfo.InvariantCulture, "Could not locate view model for {0}.", viewType)); - return f.CreateExport(); - } - - public ExportLifetimeContext GetView(UIViewType viewType) - { - var f = ViewFactory.FirstOrDefault(x => x.Metadata.ViewType == viewType); - Debug.Assert(f != null, string.Format(CultureInfo.InvariantCulture, "Could not locate view for {0}.", viewType)); - return f.CreateExport(); - } - } -} diff --git a/src/GitHub.Exports/Services/GitHubContext.cs b/src/GitHub.Exports/Services/GitHubContext.cs new file mode 100644 index 0000000000..e91677db15 --- /dev/null +++ b/src/GitHub.Exports/Services/GitHubContext.cs @@ -0,0 +1,64 @@ +using GitHub.Exports; +using GitHub.Primitives; + +namespace GitHub.Services +{ + /// + /// Information used to map betwen a GitHub URL and some other context. + /// + /// + /// This might be used to navigate between a GitHub URL and the location a repository file. Alternatively it + /// might be used to map between the line in a blame view and GitHub URL. + /// + public class GitHubContext + { + /// + /// The owner of a repository. + /// + public string Owner { get; set; } + /// + /// The name of a repository. + /// + public string RepositoryName { get; set; } + /// + /// The host of a repository ("github.com" or a GitHub Enterprise host). + /// + public string Host { get; set; } + /// + /// The name of a branch stored on GitHub (not the local branch name). + /// + public string BranchName { get; set; } + /// + /// Like a tree-ish but with ':' changed to '/' (e.g. "master/src" not "master:src"). + /// + public string TreeishPath { get; set; } + /// + /// The name of a file on GitHub. + /// + public string BlobName { get; set; } + /// + /// A PR number if this context represents a PR. + /// + public int? PullRequest { get; set; } + /// + /// An issue number if this context represents an issue. + /// + public int? Issue { get; set; } + /// + /// The line number in a file. + /// + public int? Line { get; set; } + /// + /// The end line number if this context represents a range. + /// + public int? LineEnd { get; set; } + /// + /// The source Url of the context (when context originated from a URL). + /// + public UriString Url { get; set; } + /// + /// The type of context if known (blob, blame etc). + /// + public LinkType LinkType { get; set; } + } +} diff --git a/src/GitHub.Exports/Services/GitService.cs b/src/GitHub.Exports/Services/GitService.cs index 9b1cc4bc0d..2ae7d650c5 100644 --- a/src/GitHub.Exports/Services/GitService.cs +++ b/src/GitHub.Exports/Services/GitService.cs @@ -1,11 +1,13 @@ -using System.ComponentModel.Composition; -using GitHub.Primitives; -using LibGit2Sharp; -using System; +using System; +using System.IO; +using System.Linq; using System.Threading.Tasks; +using System.ComponentModel.Composition; +using GitHub.UI; using GitHub.Models; -using System.Linq; +using GitHub.Primitives; using GitHub.Extensions; +using LibGit2Sharp; namespace GitHub.Services { @@ -13,6 +15,77 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.NonShared)] public class GitService : IGitService { + readonly IRepositoryFacade repositoryFacade; + readonly CompareOptions defaultCompareOptions; + + [ImportingConstructor] + public GitService(IRepositoryFacade repositoryFacade) + { + this.repositoryFacade = repositoryFacade; + defaultCompareOptions = new CompareOptions { IndentHeuristic = true }; + } + + /// + /// Initializes a new instance of the class. + /// + /// The repository's local path. + /// A repository model. + public LocalRepositoryModel CreateLocalRepositoryModel(string localPath) + { + Guard.ArgumentNotNull(localPath, nameof(localPath)); + + var dir = new DirectoryInfo(localPath); + if (!dir.Exists) + { + throw new ArgumentException("Path does not exist", nameof(localPath)); + } + + using (var repository = GetRepository(localPath)) + { + UriString cloneUrl = null; + bool noOrigin = false; + if (repository != null) + { + cloneUrl = GetUri(repository); + noOrigin = HasRemotesButNoOrigin(repository); + } + + var name = cloneUrl?.RepositoryName ?? dir.Name; + + var model = new LocalRepositoryModel + { + LocalPath = localPath, + CloneUrl = cloneUrl, + HasRemotesButNoOrigin = noOrigin, + Name = name, + Icon = Octicon.repo + }; + + return model; + } + } + + public BranchModel GetBranch(LocalRepositoryModel model) + { + var localPath = model.LocalPath; + using (var repo = GetRepository(localPath)) + { + var branch = repo?.Head; + if (branch == null) + { + return null; + } + + return new BranchModel( + name: branch.FriendlyName, + repo: model, + sha: branch.Tip?.Sha, + isTracking: branch.IsTracking, + trackedSha: branch.TrackedBranch?.Tip?.Sha, + trackedRemoteName: branch.TrackedBranch?.RemoteName); + } + } + /// /// Returns the URL of the remote for the specified . If the repository /// is null or no remote named origin exists, this method returns null @@ -38,11 +111,14 @@ public UriString GetUri(IRepository repository, string remote = "origin") /// Returns a representing the uri of the remote normalized to a GitHub repository url or null if none found. public UriString GetUri(string path, string remote = "origin") { - return GetUri(GetRepository(path), remote); + using (var repo = GetRepository(path)) + { + return GetUri(repo, remote); + } } /// - /// Probes for a git repository and if one is found, returns a instance for the + /// Probes for a git repository and if one is found, returns a instance for the /// repository. /// /// @@ -50,11 +126,22 @@ public UriString GetUri(string path, string remote = "origin") /// walks up the parent directories until it either finds a repository, or reaches the root disk. /// /// The path to start probing - /// An instance of or null + /// An instance of or null public IRepository GetRepository(string path) { - var repoPath = Repository.Discover(path); - return repoPath == null ? null : new Repository(repoPath); + var repoPath = repositoryFacade.Discover(path); + return repoPath == null ? null : repositoryFacade.NewRepository(repoPath); + } + + /// + /// Find out if repository has remotes but none are called "origin". + /// + /// The target repository. + /// True if repository has remotes but none are called "origin". + public bool HasRemotesButNoOrigin(IRepository repo) + { + var remotes = repo.Network.Remotes; + return remotes["origin"] == null && remotes.Any(); } /// @@ -71,40 +158,169 @@ public UriString GetRemoteUri(IRepository repo, string remote = "origin") ?.Url; } - public static IGitService GitServiceHelper => VisualStudio.Services.DefaultExportProvider.GetExportedValueOrDefault() ?? new GitService(); + /// + /// Get a new instance of . + /// + /// + /// This is equivalent to creating it via MEF with + /// + public static IGitService GitServiceHelper => new GitService(new RepositoryFacade()); /// /// Finds the latest pushed commit of a file and returns the sha of that commit. Returns null when no commits have /// been found in any remote branches or the current local branch. /// /// The local path of a repository or a file inside a repository. This cannot be null. + /// The remote name to look for /// - public Task GetLatestPushedSha(string path) + public Task GetLatestPushedSha(string path, string remote = "origin") { Guard.ArgumentNotNull(path, nameof(path)); - var repo = GetRepository(path); - if (repo == null) + return Task.Run(() => + { + using (var repo = GetRepository(path)) + { + if (repo != null) + { + // This is the common case where HEAD is tracking a remote branch + var commonAncestor = repo.Head.TrackingDetails.CommonAncestor; + if (commonAncestor != null) + { + return commonAncestor.Sha; + } + + // This is the common case where a branch was forked from a local branch. + // Use CommonAncestor because we don't want to search for a commit that only exists + // locally or that has been added to the remote tracking branch since the fork. + var commonAncestorShas = repo.Branches + .Where(b => b.IsTracking) + .Select(b => b.TrackingDetails.CommonAncestor?.Sha) + .Where(s => s != null) + .ToArray(); + + var sortByTopological = new CommitFilter { SortBy = CommitSortStrategies.Topological }; + foreach (var commit in repo.Commits.QueryBy(sortByTopological)) + { + if (commonAncestorShas.Contains(commit.Sha)) + { + return commit.Sha; + } + } + + // This is a less common case where a branch was forked from a branch + // which has since had new commits added to it. + var nearestCommonAncestor = repo.Branches + .Where(b => b.IsRemote) + .Select(b => b.Tip) + .Distinct() + .Select(c => repo.ObjectDatabase.CalculateHistoryDivergence(c, repo.Head.Tip)) + .Where(hd => hd.AheadBy != null) + .OrderBy(hd => hd.BehindBy) + .Select(hd => hd.CommonAncestor) + .FirstOrDefault(); + if (nearestCommonAncestor != null) + { + return nearestCommonAncestor.Sha; + } + } + + return null; + } + }); + } + + public Task Compare( + IRepository repository, + string sha1, + string sha2, + string relativePath) + { + Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentNotEmptyString(sha1, nameof(sha1)); + Guard.ArgumentNotEmptyString(sha2, nameof(sha2)); + Guard.ArgumentIsRelativePath(relativePath, nameof(relativePath)); + + var gitPath = Paths.ToGitPath(relativePath); + return Task.Run(() => + { + var commit1 = repository.Lookup(sha1); + var commit2 = repository.Lookup(sha2); + + if (commit1 != null && commit2 != null) + { + return repository.Diff.Compare( + commit1.Tree, + commit2.Tree, + new[] { gitPath }, + defaultCompareOptions); + } + else + { + return null; + } + }); + } + + public Task CompareWith(IRepository repository, string sha1, string sha2, string relativePath, byte[] contents) + { + Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentNotEmptyString(sha1, nameof(sha1)); + Guard.ArgumentNotEmptyString(sha2, nameof(sha1)); + Guard.ArgumentIsRelativePath(relativePath, nameof(relativePath)); + + var gitPath = Paths.ToGitPath(relativePath); + return Task.Run(() => + { + var commit1 = repository.Lookup(sha1); + var commit2 = repository.Lookup(sha2); + + var treeChanges = repository.Diff.Compare(commit1.Tree, commit2.Tree, defaultCompareOptions); + var change = treeChanges.FirstOrDefault(x => x.Path == gitPath); + var oldPath = change?.OldPath; + + if (commit1 != null && oldPath != null) + { + var contentStream = contents != null ? new MemoryStream(contents) : new MemoryStream(); + var blob1 = commit1[oldPath]?.Target as Blob ?? repository.ObjectDatabase.CreateBlob(new MemoryStream()); + var blob2 = repository.ObjectDatabase.CreateBlob(contentStream, gitPath); + return repository.Diff.Compare(blob1, blob2, defaultCompareOptions); + } + return null; + }); + } - if (repo.Head.IsTracking && repo.Head.Tip.Sha == repo.Head.TrackedBranch.Tip.Sha) + public Task Compare( + IRepository repository, + string sha1, + string sha2, + bool detectRenames) + { + Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentNotEmptyString(sha1, nameof(sha1)); + Guard.ArgumentNotEmptyString(sha2, nameof(sha2)); + + return Task.Run(() => { - return Task.FromResult(repo.Head.Tip.Sha); - } + var options = new CompareOptions + { + Similarity = detectRenames ? SimilarityOptions.Renames : SimilarityOptions.None, + IndentHeuristic = defaultCompareOptions.IndentHeuristic + }; + + var commit1 = repository.Lookup(sha1); + var commit2 = repository.Lookup(sha2); - return Task.Factory.StartNew(() => - { - var remoteHeads = repo.Refs.Where(r => r.IsRemoteTrackingBranch).ToList(); - - foreach (var c in repo.Commits) - { - if (repo.Refs.ReachableFrom(remoteHeads, new[] { c }).Any()) - { - return c.Sha; - } - } - return null; - }); + if (commit1 != null && commit2 != null) + { + return repository.Diff.Compare(commit1.Tree, commit2.Tree, options); + } + else + { + return null; + } + }); } } -} +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IConnectionCache.cs b/src/GitHub.Exports/Services/IConnectionCache.cs new file mode 100644 index 0000000000..e5e262763a --- /dev/null +++ b/src/GitHub.Exports/Services/IConnectionCache.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.Services +{ + /// + /// Loads the configured connections from a cache. + /// + public interface IConnectionCache + { + /// + /// Loads the configured connections. + /// + /// A task returning the collection of configured collections. + Task> Load(); + + /// + /// Saves the configured connections. + /// + /// The collection of configured collections to save. + /// A task tracking the operation. + Task Save(IEnumerable connections); + } +} diff --git a/src/GitHub.Exports/Services/IConnectionManager.cs b/src/GitHub.Exports/Services/IConnectionManager.cs new file mode 100644 index 0000000000..fbe09de045 --- /dev/null +++ b/src/GitHub.Exports/Services/IConnectionManager.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Primitives; + +namespace GitHub.Services +{ + /// + /// Manages the configured s to GitHub instances. + /// + public interface IConnectionManager + { + /// + /// Gets a collection containing the current connections. + /// + /// + /// This collection is lazily initialized: the first time the + /// property is accessed, an async load of the configured connections is started. If you + /// want to ensure that all connections have been loaded, call + /// . + /// + IReadOnlyObservableCollection Connections { get; } + + /// + /// Gets a connection with the specified host address. + /// + /// The address. + /// + /// A task returning the requested connection, or null if the connection was not found. + /// + Task GetConnection(HostAddress address); + + /// + /// Gets the collection, after ensuring that it is fully loaded. + /// + /// + /// A task returning the fully loaded connections collection. + /// + Task> GetLoadedConnections(); + + /// + /// Attempts to login to a GitHub instance. + /// + /// The instance address. + /// The username. + /// The password. + /// + /// A connection if the login succeded. If the login fails, throws an exception. An + /// exception is also thrown if an existing connection with the same host address already + /// exists. + /// + /// + /// A connection to the host already exists. + /// + Task LogIn(HostAddress address, string username, string password); + + /// + /// Attempts to log into a GitHub server via OAuth in the browser. + /// + /// The instance address. + /// A cancellation token used to cancel the operation. + /// + /// A connection if the login succeded. If the login fails, throws an exception. An + /// exception is also thrown if an existing connection with the same host address already + /// exists. + /// + /// + /// A connection to the host already exists. + /// + Task LogInViaOAuth(HostAddress address, CancellationToken cancel); + + /// + /// Attempts to login to a GitHub instance with a token. + /// + /// The instance address. + /// The token. + /// + /// A connection if the login succeded. If the login fails, throws an exception. An + /// exception is also thrown if an existing connection with the same host address already + /// exists. + /// + /// + /// A connection to the host already exists. + /// + Task LogInWithToken(HostAddress address, string token); + + /// + /// Logs out of a GitHub instance. + /// + /// + /// A task tracking the operation. + Task LogOut(HostAddress address); + + /// + /// Retries logging in to a failed connection. + /// + /// The connection. + /// The resulting connection. + Task Retry(IConnection connection); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IDialogService.cs b/src/GitHub.Exports/Services/IDialogService.cs index d05f1abd7f..cc837dc5b4 100644 --- a/src/GitHub.Exports/Services/IDialogService.cs +++ b/src/GitHub.Exports/Services/IDialogService.cs @@ -10,27 +10,52 @@ namespace GitHub.Services public interface IDialogService { /// - /// Shows the clone dialog. + /// Shows the Clone dialog. /// - /// The connection to use. + /// + /// The connection to use. If null, the first connection will be used, or the user promted + /// to log in if there are no connections. + /// + /// + /// The URL to prepopulate URL field with or null. + /// /// /// A task that returns an instance of on success, /// or null if the dialog was cancelled. /// - Task ShowCloneDialog(IConnection connection); + Task ShowCloneDialog(IConnection connection, string url = null); /// - /// Shows the re-clone dialog. + /// Shows the Create Gist dialog. + /// + /// + /// The connection to use. If null, the first connection will be used, or the user promted + /// to log in if there are no connections. + /// + Task ShowCreateGist(IConnection connection); + + /// + /// Shows the Create Repository dialog. + /// + /// + /// The connection to use. May not be null. + /// + Task ShowCreateRepositoryDialog(IConnection connection); + + /// + /// Shows the Login dialog. /// - /// The repository to clone. /// - /// A task that returns the base path for the clone on success, or null if the dialog was - /// cancelled. + /// The created by the login, or null if the login was + /// unsuccessful. /// - /// - /// The re-clone dialog is shown from the VS2017+ start page when the user wants to check - /// out a repository that was previously checked out on another machine. - /// - Task ShowReCloneDialog(IRepositoryModel repository); + Task ShowLoginDialog(); + + /// + /// Shows the Fork Repository dialog. + /// + /// The repository to fork. + /// The connection to use. May not be null. + Task ShowForkDialog(LocalRepositoryModel repository, IConnection connection); } } diff --git a/src/GitHub.Exports/Services/IEnterpriseCapabilitiesService.cs b/src/GitHub.Exports/Services/IEnterpriseCapabilitiesService.cs new file mode 100644 index 0000000000..875916c722 --- /dev/null +++ b/src/GitHub.Exports/Services/IEnterpriseCapabilitiesService.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using Octokit; + +namespace GitHub.Services +{ + /// + /// Describes the login methods supported by an enterprise instance. + /// + [Flags] + public enum EnterpriseLoginMethods + { + Token = 0x01, + UsernameAndPassword = 0x02, + OAuth = 0x04, + } + + /// + /// Services for checking the capabilities of enterprise installations. + /// + public interface IEnterpriseCapabilitiesService + { + /// + /// Makes a request to the specified URL and returns whether or not the probe could definitively determine that a GitHub + /// Enterprise Instance exists at the specified URL. + /// + /// + /// The probe checks the absolute path /site/sha at the specified . + /// + /// The URL to test + /// An with either , + /// , or in the case the request failed + Task Probe(Uri enterpriseBaseUrl); + + /// + /// Checks the login methods supported by an enterprise instance. + /// + /// The URL to test. + /// The supported login methods. + Task ProbeLoginMethods(Uri enterpriseBaseUrl); + } +} diff --git a/src/GitHub.Exports/Services/IEnterpriseProbeTask.cs b/src/GitHub.Exports/Services/IEnterpriseProbeTask.cs deleted file mode 100644 index 2255a91a8d..0000000000 --- a/src/GitHub.Exports/Services/IEnterpriseProbeTask.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace GitHub.Services -{ - public interface IEnterpriseProbeTask - { - Task ProbeAsync(Uri enterpriseBaseUrl); - } -} diff --git a/src/GitHub.Exports/Services/IGitHubContextService.cs b/src/GitHub.Exports/Services/IGitHubContextService.cs new file mode 100644 index 0000000000..3f0536fe44 --- /dev/null +++ b/src/GitHub.Exports/Services/IGitHubContextService.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.Services +{ + /// + /// Methods for constructing a and navigating based on a . + /// + public interface IGitHubContextService + { + /// + /// Attempt to navigate to the equivalent context inside Visual Studio. + /// + /// The target repository + /// The context to open. + void TryNavigateToContext(string repositoryDir, GitHubContext context); + + /// + /// Find the context from a URL in the clipboard if any. + /// + /// The context or null if clipboard doesn't contain a GitHub URL + GitHubContext FindContextFromClipboard(); + + /// + /// Find the context from the title of the topmost browser. + /// + /// A context or null if a context can't be found. + GitHubContext FindContextFromBrowser(); + + /// + /// Convert a GitHub URL to a context object. + /// + /// A GitHub URL + /// The context from the URL or null + GitHubContext FindContextFromUrl(string url); + + /// + /// Convert a context to a repository URL. + /// + /// A browser window title. + /// A repository URL + GitHubContext FindContextFromWindowTitle(string windowTitle); + + /// + /// Find a context from a browser window title. + /// + /// + /// The context or null if none can be found + Uri ToRepositoryUrl(GitHubContext context); + + /// + /// Open a file in the working directory that corresponds to a context and navigate to a line/range. + /// + /// The working directory. + /// A context to navigate to. + /// True if navigation was successful + bool TryOpenFile(string repositoryDir, GitHubContext context); + + /// + /// Attempt to open the Blame/Annotate view for a context. + /// + /// + /// The access to the Blame/Annotate view was added in a version of Visual Studio 2017. This method will return + /// false is this functionality isn't available. + /// + /// The target repository + /// A branch in the local repository. It isn't displayed on the UI but must exist. It can be a remote or local branch. + /// The context to open. + /// True if AnnotateFile functionality is available. + Task TryAnnotateFile(string repositoryDir, string currentBranch, GitHubContext context); + + /// + /// Map from a context to a repository blob object. + /// + /// The target repository. + /// The context to map from. + /// The name of the remote to search for branches. + /// The resolved commit-ish, blob path and commit SHA for the blob. Path will be null if the commit-ish can be resolved but not the blob. + (string commitish, string path, string commitSha) ResolveBlob(string repositoryDir, GitHubContext context, string remoteName = "origin"); + + /// + /// Find the object-ish (first 8 chars of a blob SHA) from the path to historical blob created by Team Explorer. + /// + /// + /// Team Explorer creates temporary blob files in the following format: + /// C:\Users\me\AppData\Local\Temp\TFSTemp\vctmp21996_181282.IOpenFromClipboardCommand.783ac965.cs + /// The object-ish appears immediately before the file extension and the path contains the folder "TFSTemp". + /// + /// The path to a possible Team Explorer temporary blob file. + /// The target file's object-ish (blob SHA fragment) or null if the path isn't recognized as a Team Explorer blob file. + string FindObjectishForTFSTempFile(string tempFile); + + /// + /// Find a tree entry in the commit log where a blob appears and return its commit SHA and path. + /// + /// + /// Search back through the commit log for the first tree entry where a blob appears. This operation only takes + /// a fraction of a seond on the `github/VisualStudio` repository even if a tree entry casn't be found. + /// + /// The target repository directory. + /// The fragment of a blob SHA to find. + /// The commit SHA and blob path or null if the blob can't be found. + (string commitSha, string blobPath) ResolveBlobFromHistory(string repositoryDir, string objectish); + + /// + /// Check if a file in the working directory has changed since a specified commit-ish. + /// + /// + /// The commit-ish might be a commit SHA, a tag or a remote branch. + /// + /// The target repository. + /// A commit SHA, remote branch or tag. + /// The path for a blob. + /// True if the working file is different. + bool HasChangesInWorkingDirectory(string repositoryDir, string commitish, string path); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IGitHubServiceProvider.cs b/src/GitHub.Exports/Services/IGitHubServiceProvider.cs index c1d142ed7f..0d71ce8588 100644 --- a/src/GitHub.Exports/Services/IGitHubServiceProvider.cs +++ b/src/GitHub.Exports/Services/IGitHubServiceProvider.cs @@ -12,11 +12,11 @@ public interface IGitHubServiceProvider : IServiceProvider IServiceProvider GitServiceProvider { get; set; } T GetService() where T : class; - Ret GetService() where T : class - where Ret : class; + TRet GetService() where T : class + where TRet : class; object TryGetService(Type t); - object TryGetService(string typename); + object TryGetService(string typeName); T TryGetService() where T : class; void AddService(Type t, object owner, object instance); diff --git a/src/GitHub.Exports/Services/IGitHubToolWindowManager.cs b/src/GitHub.Exports/Services/IGitHubToolWindowManager.cs new file mode 100644 index 0000000000..e7ee14312d --- /dev/null +++ b/src/GitHub.Exports/Services/IGitHubToolWindowManager.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using GitHub.ViewModels.GitHubPane; +using GitHub.ViewModels.Documents; +using GitHub.Primitives; + +namespace GitHub.Services +{ + /// + /// The Visual Studio service interface for accessing the GitHub Pane. + /// + [Guid("FC9EC5B5-C297-4548-A229-F8E16365543C")] + [ComVisible(true)] + public interface IGitHubToolWindowManager + { + /// + /// Ensure that the GitHub pane is created and visible. + /// + /// The view model for the GitHub Pane. + Task ShowGitHubPane(); + + /// + /// Shows a document-like tool window pane for an issue or pull request. + /// + /// + /// The host address of the server that hosts the issue or pull request. + /// + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + /// The view model for the document pane. + Task ShowIssueishDocumentPane( + HostAddress address, + string owner, + string repository, + int number); + } +} diff --git a/src/GitHub.Exports/Services/IGitService.cs b/src/GitHub.Exports/Services/IGitService.cs index 94bd216f32..1f183ced17 100644 --- a/src/GitHub.Exports/Services/IGitService.cs +++ b/src/GitHub.Exports/Services/IGitService.cs @@ -7,11 +7,26 @@ namespace GitHub.Services { public interface IGitService { + /// + /// Initializes a new instance of the class. + /// + /// The repository's local path. + /// A repository model. + LocalRepositoryModel CreateLocalRepositoryModel(string localPath); + + /// + /// Creates a new branch model for the current branch. + /// + /// The to create a current branch model for. + /// A new branch model. + BranchModel GetBranch(LocalRepositoryModel model); + /// /// Returns the URL of the remote for the specified . If the repository /// is null or no remote exists, this method returns null /// /// The repository to look at for the remote. + /// The remote name to look for /// A representing the origin or null if none found. UriString GetUri(IRepository repository, string remote = "origin"); @@ -24,11 +39,12 @@ public interface IGitService /// walks up the parent directories until it either finds a repository, or reaches the root disk. /// /// The path to start probing + /// The remote name to look for /// A representing the origin or null if none found. UriString GetUri(string path, string remote = "origin"); - + /// - /// Probes for a git repository and if one is found, returns a instance for the + /// Probes for a git repository and if one is found, returns a instance for the /// repository. /// /// @@ -36,13 +52,14 @@ public interface IGitService /// walks up the parent directories until it either finds a repository, or reaches the root disk. /// /// The path to start probing - /// An instance of or null + /// An instance of or null IRepository GetRepository(string path); /// /// Returns a representing the uri of a remote. /// - /// + /// The repository to look at for the remote. + /// The remote name to look for /// UriString GetRemoteUri(IRepository repo, string remote = "origin"); @@ -51,7 +68,47 @@ public interface IGitService /// been found in any remote branches or the current local branch. /// /// The local path of a repository or a file inside a repository. This cannot be null. + /// The remote name to look for /// - Task GetLatestPushedSha(string path); + Task GetLatestPushedSha(string path, string remote = "origin"); + + /// + /// Compares a file in two commits. + /// + /// The repository + /// The SHA of the first commit. + /// The SHA of the second commit. + /// The relative path to the file. + /// + /// A object or null if one of the commits could not be found in the repository. + /// + Task Compare(IRepository repository, string sha1, string sha2, string path); + + /// + /// Compares a file in a commit to a string. + /// + /// The repository + /// The SHA of the first commit. + /// The SHA of the second commit. + /// The relative path to the file. + /// The contents to compare with the file. + /// + /// A object or null if the commit could not be found in the repository. + /// + /// If contains a '\'. + Task CompareWith(IRepository repository, string sha1, string sha2, string relativePath, byte[] contents); + + /// + /// Compares two commits. + /// + /// The repository + /// The SHA of the first commit. + /// The SHA of the second commit. + /// Whether to detect renames + /// + /// A object or null if one of the commits could not be found in the repository, + /// (e.g. it is from a fork). + /// + Task Compare(IRepository repository, string sha1, string sha2, bool detectRenames = false); } } \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IGlobalConnection.cs b/src/GitHub.Exports/Services/IGlobalConnection.cs new file mode 100644 index 0000000000..22bf92689b --- /dev/null +++ b/src/GitHub.Exports/Services/IGlobalConnection.cs @@ -0,0 +1,22 @@ +using System; +using GitHub.Models; + +namespace GitHub.Services +{ + /// + /// Returns the current globally registered . + /// + /// + /// Many view models require a connection with which to work. The UIController registers the + /// connection for the current flow with in its Start() + /// method for this purpose. View models wishing to retreive this value should import this + /// interface and call . + /// + public interface IGlobalConnection + { + /// + /// Gets the globally registered . + /// + IConnection Get(); + } +} diff --git a/src/GitHub.Exports/Services/ILocalRepositories.cs b/src/GitHub.Exports/Services/ILocalRepositories.cs new file mode 100644 index 0000000000..6a463f1b7f --- /dev/null +++ b/src/GitHub.Exports/Services/ILocalRepositories.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; + +namespace GitHub.Services +{ + /// + /// Stores a collection of known local repositories. + /// + public interface ILocalRepositories + { + /// + /// Gets the currently known local repositories. + /// + IReadOnlyObservableCollection Repositories { get; } + + /// + /// Updates . + /// + Task Refresh(); + } +} diff --git a/src/GitHub.Exports/Services/IMenuHandler.cs b/src/GitHub.Exports/Services/IMenuHandler.cs deleted file mode 100644 index b505fcea1f..0000000000 --- a/src/GitHub.Exports/Services/IMenuHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; - -namespace GitHub.VisualStudio -{ - /// - /// Interface for MEF exports implementing menu handlers - /// (top level context, toolbar, etc) - /// - public interface IMenuHandler - { - Guid Guid { get; } - int CmdId { get; } - void Activate(object data = null); - } - - /// - /// Interface for MEF exports implementing menu handlers - /// (top level context, toolbar, etc) - /// Allows hiding the menu (requires vsct visibility flags) - /// - public interface IDynamicMenuHandler : IMenuHandler - { - bool CanShow(); - } -} diff --git a/src/GitHub.Exports/Services/IMenuProvider.cs b/src/GitHub.Exports/Services/IMenuProvider.cs deleted file mode 100644 index 1492bfbfad..0000000000 --- a/src/GitHub.Exports/Services/IMenuProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; - -namespace GitHub.VisualStudio -{ - /// - /// Container for static and dynamic visibility menus (context, toolbar, top, etc) - /// Get a reference to this via MEF and register the menus - /// - [Guid(Guids.MenuProviderId)] - public interface IMenuProvider - { - /// - /// Registered via AddCommandHandler - /// - IReadOnlyCollection Menus { get; } - - /// - /// Registered via AddCommandHandler - /// - IReadOnlyCollection DynamicMenus { get; } - } -} diff --git a/src/GitHub.Exports/Services/IMessageDraftStore.cs b/src/GitHub.Exports/Services/IMessageDraftStore.cs new file mode 100644 index 0000000000..a7ccf5233c --- /dev/null +++ b/src/GitHub.Exports/Services/IMessageDraftStore.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace GitHub.Services +{ + /// + /// Represents a store in which drafts of messages can be held for later recall. + /// + public interface IMessageDraftStore + { + /// + /// Tries to get a draft. + /// + /// The type to deserialize. + /// The key. + /// The secondary key. + /// The draft data if it exists, otherwise null. + Task GetDraft(string key, string secondaryKey) where T : class; + + /// + /// Gets all drafts with the specified key. + /// + /// The type to deserialize. + /// The key. + /// + /// A collection of tuples describing the secondary key and data of each draft. + /// + Task> GetDrafts(string key) where T : class; + + /// + /// Updates a draft. + /// + /// The type to serialize. + /// The key. + /// The secondary key. + /// The draft data. + Task UpdateDraft(string key, string secondaryKey, T data) where T : class; + + /// + /// Removes a draft from the store. + /// + /// The key. + /// The secondary key. + Task DeleteDraft(string key, string secondaryKey); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/INotificationDispatcher.cs b/src/GitHub.Exports/Services/INotificationDispatcher.cs index 667219d8ea..03d6d14891 100644 --- a/src/GitHub.Exports/Services/INotificationDispatcher.cs +++ b/src/GitHub.Exports/Services/INotificationDispatcher.cs @@ -1,8 +1,10 @@ using System; using System.Windows.Input; +using System.Diagnostics.CodeAnalysis; namespace GitHub.Services { + [SuppressMessage("Microsoft.Performance", "CA1815:OverrideEqualsAndOperatorEqualsOnValueTypes")] public struct Notification { public enum NotificationType diff --git a/src/GitHub.Exports/Services/INotificationService.cs b/src/GitHub.Exports/Services/INotificationService.cs index ad8951e54b..b0591b7a2a 100644 --- a/src/GitHub.Exports/Services/INotificationService.cs +++ b/src/GitHub.Exports/Services/INotificationService.cs @@ -1,4 +1,6 @@ +using System; using System.Windows.Input; +#pragma warning disable CA1720 // Identifier contains type name namespace GitHub.Services { @@ -9,8 +11,10 @@ namespace GitHub.Services public interface INotificationService { void ShowMessage(string message); - void ShowMessage(string message, ICommand command); + void ShowMessage(string message, ICommand command, bool showToolTips = true, Guid guid = default(Guid)); void ShowWarning(string message); void ShowError(string message); + void HideNotification(Guid guid); + bool IsNotificationVisible(Guid guid); } } \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IRepositoryFacade.cs b/src/GitHub.Exports/Services/IRepositoryFacade.cs new file mode 100644 index 0000000000..1bc495b305 --- /dev/null +++ b/src/GitHub.Exports/Services/IRepositoryFacade.cs @@ -0,0 +1,13 @@ +using LibGit2Sharp; + +namespace GitHub.Services +{ + /// + /// Facade for static methods. + /// + public interface IRepositoryFacade + { + IRepository NewRepository(string path); + string Discover(string startingPath); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IRepositoryService.cs b/src/GitHub.Exports/Services/IRepositoryService.cs new file mode 100644 index 0000000000..c71f492e29 --- /dev/null +++ b/src/GitHub.Exports/Services/IRepositoryService.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; +using GitHub.Primitives; + +namespace GitHub.Services +{ + public interface IRepositoryService + { + /// + /// Finds the parent repository of a fork, if any. + /// + /// The host address. + /// The repository owner. + /// The repository name. + /// + /// A tuple of the parent repository's owner and name if the repository is a fork, + /// otherwise null. + /// + Task<(string owner, string name)?> FindParent(HostAddress address, string owner, string name); + } +} diff --git a/src/GitHub.Exports/Services/ITeamExplorerContext.cs b/src/GitHub.Exports/Services/ITeamExplorerContext.cs new file mode 100644 index 0000000000..e09d9aa3b4 --- /dev/null +++ b/src/GitHub.Exports/Services/ITeamExplorerContext.cs @@ -0,0 +1,33 @@ +using System; +using System.ComponentModel; +using GitHub.Models; + +namespace GitHub.Services +{ + /// + /// Responsible for watching the active repository in Team Explorer. + /// + /// + /// A event is fired when moving to a new repository. + /// A event is fired when the current branch, head SHA or tracked SHA changes. + /// + public interface ITeamExplorerContext : INotifyPropertyChanged + { + /// + /// The active Git repository in Team Explorer. + /// This will be null if no repository is active. + /// + /// + /// This property might be changed by a non-UI thread. + /// + LocalRepositoryModel ActiveRepository { get; } + + /// + /// Fired when the CurrentBranch or HeadSha changes. + /// + /// + /// This event might fire on a non-UI thread. + /// + event EventHandler StatusChanged; + } +} diff --git a/src/GitHub.Exports/Services/ITeamExplorerServiceHolder.cs b/src/GitHub.Exports/Services/ITeamExplorerServiceHolder.cs index ae60d6c8de..808c7d0eae 100644 --- a/src/GitHub.Exports/Services/ITeamExplorerServiceHolder.cs +++ b/src/GitHub.Exports/Services/ITeamExplorerServiceHolder.cs @@ -1,6 +1,7 @@ using System; using GitHub.Primitives; using GitHub.Models; +using Microsoft.VisualStudio.Threading; namespace GitHub.Services { @@ -17,6 +18,7 @@ public interface ITeamExplorerServiceHolder /// changes in the source control context. /// IServiceProvider ServiceProvider { get; set; } + /// /// Clears the current ServiceProvider if it matches the one that is passed in. /// This is usually called on Dispose, which might happen after another section @@ -25,33 +27,23 @@ public interface ITeamExplorerServiceHolder /// /// If the current ServiceProvider matches this, clear it void ClearServiceProvider(IServiceProvider provider); + /// - /// A IGitRepositoryInfo representing the currently active repository - /// - ILocalRepositoryModel ActiveRepo { get; } - /// - /// Subscribe to be notified when the active repository is set and Notify is called. + /// A service that can be used for repository changed events. /// - /// The instance that is interested in being called (or a unique key/object for that instance) - /// The handler to call when ActiveRepo is set - void Subscribe(object who, Action handler); + ITeamExplorerContext TeamExplorerContext { get; } + /// - /// Unsubscribe from notifications + /// A service for avoiding deadlocks and marshaling tasks onto the UI thread. /// - /// The instance/key that previously subscribed to notifications - void Unsubscribe(object who); + JoinableTaskContext JoinableTaskContext { get; } IGitAwareItem HomeSection { get; } - - /// - /// Refresh the information on the active repo (in case of remote url changes or other such things) - /// - void Refresh(); } public interface IGitAwareItem { - ILocalRepositoryModel ActiveRepo { get; } + LocalRepositoryModel ActiveRepo { get; } /// /// Represents the web URL of the repository on GitHub.com, even if the origin is an SSH address. diff --git a/src/GitHub.Exports/Services/ITeamExplorerServices.cs b/src/GitHub.Exports/Services/ITeamExplorerServices.cs index 8b5657d309..f25454c220 100644 --- a/src/GitHub.Exports/Services/ITeamExplorerServices.cs +++ b/src/GitHub.Exports/Services/ITeamExplorerServices.cs @@ -1,10 +1,15 @@ -using System.Windows.Input; +using System.Threading.Tasks; namespace GitHub.Services { public interface ITeamExplorerServices : INotificationService { + void ShowConnectPage(); + void ShowCommitDetails(string oid); + void ShowHomePage(); void ShowPublishSection(); + Task ShowRepositorySettingsRemotesAsync(); void ClearNotifications(); + void OpenRepository(string repositoryPath); } } \ No newline at end of file diff --git a/src/GitHub.Exports/Services/ITippingService.cs b/src/GitHub.Exports/Services/ITippingService.cs new file mode 100644 index 0000000000..a125e8ccab --- /dev/null +++ b/src/GitHub.Exports/Services/ITippingService.cs @@ -0,0 +1,28 @@ +using System; +using System.Windows; + +namespace GitHub.Services +{ + /// + /// This service is a thin wrapper around . + /// + /// + /// The interface is public, but contained within the 'Microsoft.VisualStudio.Shell.UI.Internal' assembly. + /// To avoid a direct dependency on 'Microsoft.VisualStudio.Shell.UI.Internal', we use reflection to call this service. + /// + public interface ITippingService + { + /// + /// Show a call-out notification with the option to execute a command. + /// + /// A unique id for the callout so that is can be permanently dismissed. + /// A clickable title for the callout. + /// A plain text message for that callout that will automatically wrap. + /// True for an option to never show again. + /// A UI element for the callout to appear above which must be visible. + /// The group of the command to execute when title is clicked. + /// The ID of the command to execute when title is clicked. + void RequestCalloutDisplay(Guid calloutId, string title, string message, + bool isPermanentlyDismissible, FrameworkElement targetElement, Guid vsCommandGroupId, uint vsCommandId); + } +} diff --git a/src/GitHub.Exports/Services/IUIProvider.cs b/src/GitHub.Exports/Services/IUIProvider.cs deleted file mode 100644 index 34a138eb29..0000000000 --- a/src/GitHub.Exports/Services/IUIProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -using GitHub.Exports; -using GitHub.Models; -using GitHub.UI; -using GitHub.VisualStudio; -using System; -using System.Runtime.InteropServices; - -namespace GitHub.Services -{ - [Guid(Guids.UIProviderId)] - public interface IUIProvider - { - IUIController Configure(UIControllerFlow flow, IConnection connection = null, ViewWithData data = null); - IUIController Run(UIControllerFlow flow); - void RunInDialog(UIControllerFlow flow, IConnection connection = null); - void RunInDialog(IUIController controller); - IView GetView(UIViewType which, ViewWithData data = null); - void StopUI(IUIController controller); - void Run(IUIController controller); - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IUsageService.cs b/src/GitHub.Exports/Services/IUsageService.cs new file mode 100644 index 0000000000..54fc9729a8 --- /dev/null +++ b/src/GitHub.Exports/Services/IUsageService.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.Services +{ + /// + /// Provides services for . + /// + public interface IUsageService + { + /// + /// Gets a GUID that anonymously represents the user. + /// + Task GetUserGuid(); + + /// + /// Starts a timer. + /// + /// The callback to call when the timer ticks. + /// The timespan after which the callback will be called the first time. + /// The timespan after which the callback will be called subsequent times. + /// A disposable used to cancel the timer. + IDisposable StartTimer(Func callback, TimeSpan dueTime, TimeSpan period); + + /// + /// Reads the local usage data from disk. + /// + /// A task returning a object. + Task ReadLocalData(); + + /// + /// Writes the local usage data to disk. + /// + Task WriteLocalData(UsageData data); + } +} diff --git a/src/GitHub.Exports/Services/IUsageTracker.cs b/src/GitHub.Exports/Services/IUsageTracker.cs index a736e161c4..98e8416d8d 100644 --- a/src/GitHub.Exports/Services/IUsageTracker.cs +++ b/src/GitHub.Exports/Services/IUsageTracker.cs @@ -1,23 +1,15 @@ using GitHub.VisualStudio; using System.Runtime.InteropServices; using System.Threading.Tasks; +using System; +using System.Linq.Expressions; +using GitHub.Models; namespace GitHub.Services { [Guid(Guids.UsageTrackerId)] public interface IUsageTracker { - Task IncrementLaunchCount(); - Task IncrementCloneCount(); - Task IncrementCreateCount(); - Task IncrementPublishCount(); - Task IncrementOpenInGitHubCount(); - Task IncrementLinkToGitHubCount(); - Task IncrementCreateGistCount(); - Task IncrementUpstreamPullRequestCount(); - Task IncrementLoginCount(); - Task IncrementPullRequestCheckOutCount(bool fork); - Task IncrementPullRequestPullCount(bool fork); - Task IncrementPullRequestPushCount(bool fork); + Task IncrementCounter(Expression> counter); } } diff --git a/src/GitHub.Exports/Services/IVSGitExt.cs b/src/GitHub.Exports/Services/IVSGitExt.cs new file mode 100644 index 0000000000..076191f0a3 --- /dev/null +++ b/src/GitHub.Exports/Services/IVSGitExt.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using GitHub.Models; + +namespace GitHub.Services +{ + public interface IVSGitExt + { + IReadOnlyList ActiveRepositories { get; } + event Action ActiveRepositoriesChanged; + void RefreshActiveRepositories(); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IVSGitServices.cs b/src/GitHub.Exports/Services/IVSGitServices.cs index ad56579d84..a2b0b5fd10 100644 --- a/src/GitHub.Exports/Services/IVSGitServices.cs +++ b/src/GitHub.Exports/Services/IVSGitServices.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using GitHub.Models; @@ -17,18 +18,22 @@ public interface IVSGitServices /// Whether to recursively clone submodules. /// /// An object through which to report progress. This must be of type - /// , but + /// System.IProgress<Microsoft.VisualStudio.Shell.ServiceProgressData>, but /// as that type is only available in VS2017+ it is typed as here. /// + /// A cancellation token. + /// + /// Task Clone( string cloneUrl, string clonePath, bool recurseSubmodules, - object progress = null); + object progress = null, + CancellationToken? cancellationToken = null); string GetActiveRepoPath(); LibGit2Sharp.IRepository GetActiveRepo(); - IEnumerable GetKnownRepositories(); + IEnumerable GetKnownRepositories(); string SetDefaultProjectPath(string path); } } \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IVSServices.cs b/src/GitHub.Exports/Services/IVSServices.cs index 0ed713d131..d0171ada58 100644 --- a/src/GitHub.Exports/Services/IVSServices.cs +++ b/src/GitHub.Exports/Services/IVSServices.cs @@ -1,15 +1,30 @@ -using System.Collections.Generic; -using GitHub.Models; +using Microsoft.VisualStudio; namespace GitHub.Services { public interface IVSServices { + /// + /// Get the full Visual Studio version from `VisualStudio\14.0_Config\SplashInfo|EnvVersion` on Visual Studoi 2015 + /// or `SetupConfiguration.GetInstanceForCurrentProcess()` on on Visual Studoi 2017. + /// string VSVersion { get; } - void ActivityLogMessage(string message); - void ActivityLogWarning(string message); - void ActivityLogError(string message); + /// Open a repository in Team Explorer + /// + /// There doesn't appear to be a command that directly opens a target repo. + /// Our workaround is to create, open and delete a solution in the repo directory. + /// This triggers an event that causes the target repo to open. ;) + /// + /// The path to the repository to open + /// True if a transient solution was successfully created in target directory (which should trigger opening of repository). bool TryOpenRepository(string directory); + + /// + /// Displays a message box with the specified message. + /// + /// The message to display + /// The result. + VSConstants.MessageBoxResult ShowMessageBoxInfo(string message); } } \ No newline at end of file diff --git a/src/GitHub.Exports/Services/IVSUIContextFactory.cs b/src/GitHub.Exports/Services/IVSUIContextFactory.cs new file mode 100644 index 0000000000..57c6542d9b --- /dev/null +++ b/src/GitHub.Exports/Services/IVSUIContextFactory.cs @@ -0,0 +1,25 @@ +using System; + +namespace GitHub.Services +{ + public interface IVSUIContextFactory + { + IVSUIContext GetUIContext(Guid contextGuid); + } + + public sealed class VSUIContextChangedEventArgs + { + public VSUIContextChangedEventArgs(bool activated) + { + Activated = activated; + } + + public bool Activated { get; } + } + + public interface IVSUIContext + { + bool IsActive { get; } + void WhenActivated(Action action); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/Logger.cs b/src/GitHub.Exports/Services/Logger.cs deleted file mode 100644 index b0e8fbfe7b..0000000000 --- a/src/GitHub.Exports/Services/Logger.cs +++ /dev/null @@ -1,91 +0,0 @@ -using GitHub.Info; -using System; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Text; - -namespace GitHub.VisualStudio -{ - public class VSTraceListener : TraceListener - { - public override void Write(string message) - { - VsOutputLogger.Write(message); - } - - public override void WriteLine(string message) - { - VsOutputLogger.WriteLine(message); - } - } - - public static class VsOutputLogger - { - static Lazy> logger = new Lazy>(() => DefaultLogger); - static readonly string defaultLogPath; - static readonly object fileLock = new object(); - - static Action Logger - { - get { return logger.Value; } - } - - static VsOutputLogger() - { - //Debug.Listeners.Add(new VSTraceListener()); - var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ApplicationInfo.ApplicationName); - defaultLogPath = Path.Combine(dir, ApplicationInfo.ApplicationName + "-te.log"); - try - { - Directory.CreateDirectory(dir); - } - catch { } - } - - public static void SetLogger(Action log) - { - logger = new Lazy>(() => log); - } - - public static void Write(string format, params object[] args) - { - var message = string.Format(CultureInfo.CurrentCulture, format, args); - Write(message); - } - - public static void Write(string message) - { - Logger(message); - } - - public static void WriteLine(string format, params object[] args) - { - var message = string.Format(CultureInfo.CurrentCulture, format, args); - WriteLine(message); - } - - public static void WriteLine(string message) - { - Logger(message + Environment.NewLine); - } - - static async void DefaultLogger(string msg) - { - // this codepath is called multiple times on loading - // doing a little delay so that calling code doesn't get slowed down by this - await System.Threading.Tasks.Task.Delay(500); - lock(fileLock) - { - // TODO: Fix this properly - try - { - File.AppendAllText(defaultLogPath, msg, Encoding.UTF8); - } - catch(Exception) - { - } - } - } - } -} diff --git a/src/GitHub.Exports/Services/MetricsService.cs b/src/GitHub.Exports/Services/MetricsService.cs new file mode 100644 index 0000000000..9f1e1231b7 --- /dev/null +++ b/src/GitHub.Exports/Services/MetricsService.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using Octokit; +using Octokit.Internal; + +namespace GitHub.Services +{ + [Export(typeof(IMetricsService))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class MetricsService : IMetricsService + { +#if DEBUG + [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields", Justification = "We have conditional compilation")] + static readonly Uri centralUri = new Uri("http://localhost:4000/", UriKind.Absolute); +#else + static readonly Uri centralUri = new Uri("https://central.github.com/", UriKind.Absolute); +#endif + + [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields", Justification = "We have conditional compilation")] + readonly Lazy httpClient; + + [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields", Justification = "We have conditional compilation")] + readonly ProductHeaderValue productHeader; + + [ImportingConstructor] + public MetricsService(Lazy httpClient, IProgram program) + { + this.httpClient = httpClient; + this.productHeader = program.ProductHeader; + } + +#if DEBUG && !SEND_DEBUG_METRICS + public Task PostUsage(UsageModel model) + { + return Task.CompletedTask; + } +#else + public async Task PostUsage(UsageModel model) + { + var request = new Request + { + Method = HttpMethod.Post, + BaseAddress = centralUri, + Endpoint = new Uri("api/usage/visualstudio", UriKind.Relative), + }; + + request.Headers.Add("User-Agent", productHeader.ToString()); + + request.Body = SerializeRequest(model); + request.ContentType = "application/json"; + + await httpClient.Value.Send(request); + } +#endif + + public Task SendOptOut() + { + // Temporarily disabled until https://github.com/github/central/issues/213 gets resolved + return Task.FromResult(0); + + /* + var request = new Request + { + Method = HttpMethod.Post, + AllowAutoRedirect = true, + Endpoint = new Uri(centralUri, new Uri("api/usage/visualstudio?optout=1", UriKind.Relative)), + }; + request.Headers.Add("User-Agent", productHeader.ToString()); + request.Body = new StringContent(""); + request.ContentType = "application/json"; + await httpClient.Value.Send((IRequest)request, cancellationToken); + */ + } + + public Task SendOptIn() + { + // Temporarily disabled until https://github.com/github/central/issues/213 gets resolved + return Task.FromResult(0); + + /* + var request = new Request + { + Method = HttpMethod.Post, + AllowAutoRedirect = true, + Endpoint = new Uri(centralUri, new Uri("api/usage/visualstudio?optin=1", UriKind.Relative)), + }; + request.Headers.Add("User-Agent", productHeader.ToString()); + request.Body = new StringContent(""); + request.ContentType = "application/json"; + return Observable.FromAsync(cancellationToken => httpClient.Value.Send((IRequest)request, cancellationToken)) + .AsCompletion(); + */ + } + + public static StringContent SerializeRequest(UsageModel model) + { + var serializer = new SimpleJsonSerializer(); + var dictionary = new Dictionary + { + {ToJsonPropertyName("Dimensions"), ToModelDictionary(model.Dimensions) }, + {ToJsonPropertyName("Measures"), ToModelDictionary(model.Measures) } + }; + + return new StringContent(serializer.Serialize(dictionary), Encoding.UTF8, "application/json"); + } + + static Dictionary ToModelDictionary(object model) + { + var dict = new Dictionary(); + var type = model.GetType(); + + foreach (var prop in type.GetProperties()) + { + if (prop.PropertyType.IsValueType || prop.PropertyType == typeof(string)) + { + dict.Add(ToJsonPropertyName(prop.Name), prop.GetValue(model)); + } + else + { + var value = prop.GetValue(model); + + if (value == null) + { + dict.Add(ToJsonPropertyName(prop.Name), value); + } + else + { + dict.Add(ToJsonPropertyName(prop.Name), ToModelDictionary(value)); + } + } + } + + return dict; + } + + + /// + /// Convert from PascalCase to camelCase. + /// + [SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase")] + static string ToJsonPropertyName(string propertyName) + { + if (propertyName.Length < 2) + { + return propertyName.ToLowerInvariant(); + } + + return propertyName.Substring(0, 1).ToLowerInvariant() + propertyName.Substring(1); + } + } +} diff --git a/src/GitHub.Exports/Services/RepositoryFacade.cs b/src/GitHub.Exports/Services/RepositoryFacade.cs new file mode 100644 index 0000000000..4b70bfb6fa --- /dev/null +++ b/src/GitHub.Exports/Services/RepositoryFacade.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.Composition; +using LibGit2Sharp; + +namespace GitHub.Services +{ + /// + /// Facade for static methods. + /// + [Export(typeof(IRepositoryFacade))] + public class RepositoryFacade : IRepositoryFacade + { + public IRepository NewRepository(string path) + { + return new Repository(path); + } + + public string Discover(string startingPath) + { + return Repository.Discover(startingPath); + } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Services/StatusBarNotificationService.cs b/src/GitHub.Exports/Services/StatusBarNotificationService.cs index 9773c097fb..0913a9ca74 100644 --- a/src/GitHub.Exports/Services/StatusBarNotificationService.cs +++ b/src/GitHub.Exports/Services/StatusBarNotificationService.cs @@ -20,6 +20,17 @@ public StatusBarNotificationService([Import(typeof(SVsServiceProvider))] IServic this.serviceProvider = serviceProvider; } + public void HideNotification(Guid guid) + { + // status bar only shows text, this is a noop + } + + public bool IsNotificationVisible(Guid guid) + { + // it's only text, there's no way of checking + return false; + } + public void ShowError(string message) { ShowText(message); @@ -30,7 +41,7 @@ public void ShowMessage(string message) ShowText(message); } - public void ShowMessage(string message, ICommand command) + public void ShowMessage(string message, ICommand command, bool showToolTips = true, Guid guid = default(Guid)) { ShowText(message); } diff --git a/src/GitHub.Exports/Services/VSServices.cs b/src/GitHub.Exports/Services/VSServices.cs index 18d600a7f0..1bb1920cba 100644 --- a/src/GitHub.Exports/Services/VSServices.cs +++ b/src/GitHub.Exports/Services/VSServices.cs @@ -1,13 +1,15 @@ using System; -using System.IO; -using System.Linq; using System.ComponentModel.Composition; using System.Globalization; -using GitHub.VisualStudio; +using System.IO; +using GitHub.Logging; using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Setup.Configuration; +using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; -using DTE = EnvDTE.DTE; using Rothko; +using Serilog; +using EnvDTE; namespace GitHub.Services { @@ -15,19 +17,26 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.Shared)] public class VSServices : IVSServices { + readonly ILogger log; readonly IGitHubServiceProvider serviceProvider; // Use a prefix (~$) that is defined in the default VS gitignore. public const string TempSolutionName = "~$GitHubVSTemp$~"; - [ImportingConstructor] - public VSServices(IGitHubServiceProvider serviceProvider) + public VSServices(IGitHubServiceProvider serviceProvider) : + this(serviceProvider, LogManager.ForContext()) + { + } + + public VSServices(IGitHubServiceProvider serviceProvider, ILogger log) { this.serviceProvider = serviceProvider; + this.log = log; } string vsVersion; + /// public string VSVersion { get @@ -38,39 +47,11 @@ public string VSVersion } } - - public void ActivityLogMessage(string message) - { - var log = serviceProvider.GetActivityLog(); - if (log != null) - { - if (!ErrorHandler.Succeeded(log.LogEntry((UInt32)__ACTIVITYLOG_ENTRYTYPE.ALE_INFORMATION, - Info.ApplicationInfo.ApplicationSafeName, message))) - Console.WriteLine(string.Format(CultureInfo.CurrentCulture, "Could not log message to activity log: {0}", message)); - } - } - - public void ActivityLogError(string message) + /// + public VSConstants.MessageBoxResult ShowMessageBoxInfo(string message) { - var log = serviceProvider.GetActivityLog(); - if (log != null) - { - - if (!ErrorHandler.Succeeded(log.LogEntry((UInt32)__ACTIVITYLOG_ENTRYTYPE.ALE_ERROR, - Info.ApplicationInfo.ApplicationSafeName, message))) - Console.WriteLine(string.Format(CultureInfo.CurrentCulture, "Could not log error to activity log: {0}", message)); - } - } - - public void ActivityLogWarning(string message) - { - var log = serviceProvider.GetActivityLog(); - if (log != null) - { - if (!ErrorHandler.Succeeded(log.LogEntry((UInt32)__ACTIVITYLOG_ENTRYTYPE.ALE_WARNING, - Info.ApplicationInfo.ApplicationSafeName, message))) - Console.WriteLine(string.Format(CultureInfo.CurrentCulture, "Could not log warning to activity log: {0}", message)); - } + return (VSConstants.MessageBoxResult)VsShellUtilities.ShowMessageBox(serviceProvider, message, null, + OLEMSGICON.OLEMSGICON_INFO, OLEMSGBUTTON.OLEMSGBUTTON_OK, OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); } /// Open a repository in Team Explorer @@ -86,19 +67,20 @@ public bool TryOpenRepository(string repoPath) var os = serviceProvider.TryGetService(); if (os == null) { - VsOutputLogger.WriteLine("TryOpenRepository couldn't find IOperatingSystem service."); + log.Error("TryOpenRepository couldn't find IOperatingSystem service"); return false; } var dte = serviceProvider.TryGetService(); if (dte == null) { - VsOutputLogger.WriteLine("TryOpenRepository couldn't find DTE service."); + log.Error("TryOpenRepository couldn't find DTE service"); return false; } - var repoDir = os.Directory.GetDirectory(repoPath); - if(!repoDir.Exists) + var gitPath = Path.Combine(repoPath, ".git"); + var gitDir = os.Directory.GetDirectory(gitPath); + if (!gitDir.Exists) { return false; } @@ -113,7 +95,7 @@ public bool TryOpenRepository(string repoPath) } catch (Exception e) { - VsOutputLogger.WriteLine("Error opening repository. {0}", e); + log.Error(e, "Error opening repository"); } finally { @@ -136,32 +118,38 @@ void TryCleanupSolutionUserFiles(IOperatingSystem os, string repoPath, string sl } catch (Exception e) { - VsOutputLogger.WriteLine("Couldn't clean up {0}. {1}", vsTempPath, e); + log.Error(e, "Couldn't clean up {TempPath}", vsTempPath); } } const string RegistryRootKey = @"Software\Microsoft\VisualStudio"; const string EnvVersionKey = "EnvVersion"; + const string InstallationNamePrefix = "VisualStudio/"; string GetVSVersion() { var version = typeof(Microsoft.VisualStudio.Shell.ActivityLog).Assembly.GetName().Version; var keyPath = String.Format(CultureInfo.InvariantCulture, "{0}\\{1}.{2}_Config\\SplashInfo", RegistryRootKey, version.Major, version.Minor); try { - using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(keyPath)) + if (version.Major == 14) + { + using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(keyPath)) + { + var value = (string)key.GetValue(EnvVersionKey, String.Empty); + if (!String.IsNullOrEmpty(value)) + return value; + } + } + else { - var value = (string)key.GetValue(EnvVersionKey, String.Empty); - if (!String.IsNullOrEmpty(value)) - return value; + var setupConfiguration = new SetupConfiguration(); + var setupInstance = setupConfiguration.GetInstanceForCurrentProcess(); + return setupInstance.GetInstallationName().TrimPrefix(InstallationNamePrefix); } - // fallback to poking the CommonIDE assembly, which most closely follows the advertised version. - var asm = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.FullName.StartsWith("Microsoft.VisualStudio.CommonIDE", StringComparison.OrdinalIgnoreCase)); - if (asm != null) - return asm.GetName().Version.ToString(); } - catch(Exception ex) + catch (Exception ex) { - VsOutputLogger.WriteLine(string.Format(CultureInfo.CurrentCulture, "Error getting the Visual Studio version '{0}'", ex)); + log.Error(ex, "Error getting the Visual Studio version"); } return version.ToString(); } diff --git a/src/GitHub.Exports/Settings/Guids.cs b/src/GitHub.Exports/Settings/Guids.cs index b75e4e4d42..0c64b37fef 100644 --- a/src/GitHub.Exports/Settings/Guids.cs +++ b/src/GitHub.Exports/Settings/Guids.cs @@ -1,19 +1,48 @@ using System; +#pragma warning disable CA1707 // Identifiers should not contain underscores + namespace GitHub.VisualStudio { public static class Guids { public const string PackageId = "c3d3dc68-c977-411f-b3e8-03b0dccf7dfc"; public const string ImagesId = "27841f47-070a-46d6-90be-a5cbbfc724ac"; - public const string MenuProviderId = "36FA083F-E0BE-418E-B42F-CB7623C55A00"; public const string UsageTrackerId = "9362DD38-7E49-4B5D-9DE1-E843D4155716"; public const string UIProviderId = "304F2186-17C4-4C66-8A54-5C96F9353A28"; public const string GitHubServiceProviderId = "76909E1A-9D58-41AB-8957-C26B9550787B"; public const string StartPagePackageId = "3b764d23-faf7-486f-94c7-b3accc44a70e"; public const string CodeContainerProviderId = "6CE146CB-EF57-4F2C-A93F-5BA685317660"; + public const string InlineReviewsPackageId = "248325BE-4A2D-4111-B122-E7D59BF73A35"; + public const string PullRequestStatusPackageId = "5121BEC6-1088-4553-8453-0DDC7C8E2238"; + public const string GitHubPanePackageId = "0A40459D-6B6D-4110-B6CE-EC83C0BC6A09"; + public const string TeamExplorerWelcomeMessage = "C529627F-8AA6-4FDB-82EB-4BFB7DB753C3"; + public const string LoginManagerId = "7BA2071A-790A-4F95-BE4A-0EEAA5928AAF"; // VisualStudio IDs public const string GitSccProviderId = "11B8E6D7-C08B-4385-B321-321078CDD1F8"; + public const string TeamExplorerInstall3rdPartyGitTools = "DF785C7C-8454-4836-9686-D1C4A01D0BB9"; + + // UIContexts + public const string GitContextPkgString = "565515AD-F4C1-4D59-BC14-AE77396DDDD7"; + + // Guids defined in GitHub.VisualStudio.vsct + public const string guidGitHubPkgString = "c3d3dc68-c977-411f-b3e8-03b0dccf7dfc"; + public const string guidAssemblyResolverPkgString = "a6424dba-34cb-360d-a4de-1b0b0411e57d"; + public const string guidGitHubCmdSetString = "c4c91892-8881-4588-a5d9-b41e8f540f5a"; + public const string guidGitHubToolbarCmdSetString = "C5F1193E-F300-41B3-B4C4-5A703DD3C1C6"; + public const string guidContextMenuSetString = "31057D08-8C3C-4C5B-9F91-8682EA08EC27"; + public const string guidImageMonikerString = "27841f47-070a-46d6-90be-a5cbbfc724ac"; + public static readonly Guid guidGitHubCmdSet = new Guid(guidGitHubCmdSetString); + public static readonly Guid guidGitHubToolbarCmdSet = new Guid(guidGitHubToolbarCmdSetString); + public static readonly Guid guidContextMenuSet = new Guid(guidContextMenuSetString); + public static readonly Guid guidImageMoniker = new Guid(guidImageMonikerString); + + // Guids defined in InlineReviewsPackage.vsct + public const string CommandSetString = "C5F1193E-F300-41B3-B4C4-5A703DD3C1C6"; + public static readonly Guid CommandSetGuid = new Guid(CommandSetString); + + // Callout notification IDs + public static readonly Guid NoRemoteOriginCalloutId = new Guid("B5679412-58A1-49CD-96E9-8F093FE3DC79"); } } diff --git a/src/GitHub.Exports/Settings/PkgCmdID.cs b/src/GitHub.Exports/Settings/PkgCmdID.cs new file mode 100644 index 0000000000..64160c738e --- /dev/null +++ b/src/GitHub.Exports/Settings/PkgCmdID.cs @@ -0,0 +1,36 @@ +// PkgCmdID.cs +// MUST match PkgCmdID.h +namespace GitHub.VisualStudio +{ + public static class PkgCmdIDList + { + // IDs defined in GitHub.VisualStudio.vsct + public const int addConnectionCommand = 0x110; + public const int idGitHubToolbar = 0x1120; + public const int showGitHubPaneCommand = 0x200; + public const int openPullRequestsCommand = 0x201; + public const int showCurrentPullRequestCommand = 0x202; + public const int syncSubmodulesCommand = 0x203; + public const int openFromUrlCommand = 0x204; + public const int openFromClipboardCommand = 0x205; + public const int showIssueishDocumentCommand = 0x206; + + public const int backCommand = 0x300; + public const int forwardCommand = 0x301; + public const int refreshCommand = 0x302; + public const int pullRequestCommand = 0x310; + public const int createGistCommand = 0x400; + public const int createGistEnterpriseCommand = 0x401; + public const int openLinkCommand = 0x100; + public const int copyLinkCommand = 0x101; + public const int goToSolutionOrPullRequestFileCommand = 0x102; + public const int githubCommand = 0x320; + public const int helpCommand = 0x321; + public const int blameCommand = 0x500; + + // IDs defined in InlineReviewsPackage.vsct + public const int NextInlineCommentId = 0x1001; + public const int PreviousInlineCommentId = 0x1002; + public const int ToggleInlineCommentMarginId = 0x1003; + }; +} \ No newline at end of file diff --git a/src/GitHub.Exports/Settings/generated/IPackageSettings.cs b/src/GitHub.Exports/Settings/generated/IPackageSettings.cs index c2057457de..81e7309778 100644 --- a/src/GitHub.Exports/Settings/generated/IPackageSettings.cs +++ b/src/GitHub.Exports/Settings/generated/IPackageSettings.cs @@ -1,19 +1,29 @@ -// This is an automatically generated file, based on settings.json and PackageSettingsGen.tt +// This is an automatically generated file, based on settings.json and PackageSettingsGen.tt /* settings.json content: { - "settings": [ + "settings": [ { - "name": "CollectMetrics", - "type": "bool", - "default": 'true' + "name": "CollectMetrics", + "type": "bool", + "default": 'true' }, { - "name": "UIState", - "type": "object", - "typename": "UIState", - "default": 'null' + "name": "UIState", + "type": "object", + "typename": "UIState", + "default": "null" + }, + { + "name": "HideTeamExplorerWelcomeMessage", + "type": "bool", + "default": "false" + }, + { + "name": "EnableTraceLogging", + "type": "bool", + "default": "false" } - ] + ] } */ @@ -26,5 +36,7 @@ public interface IPackageSettings : INotifyPropertyChanged void Save(); bool CollectMetrics { get; set; } UIState UIState { get; set; } + bool HideTeamExplorerWelcomeMessage { get; set; } + bool EnableTraceLogging { get; set; } } -} +} \ No newline at end of file diff --git a/src/GitHub.Exports/SimpleJson.cs b/src/GitHub.Exports/SimpleJson.cs index f59c702bc8..82360321e7 100644 --- a/src/GitHub.Exports/SimpleJson.cs +++ b/src/GitHub.Exports/SimpleJson.cs @@ -67,6 +67,7 @@ using System.Runtime.Serialization; using System.Text; using GitHub.Reflection; +using System.Diagnostics; // ReSharper disable LoopCanBeConvertedToQuery // ReSharper disable RedundantExplicitArrayCreation @@ -1838,7 +1839,13 @@ public static ConstructorDelegate GetConstructorByReflection(ConstructorInfo con public static ConstructorDelegate GetConstructorByReflection(Type type, params Type[] argsType) { ConstructorInfo constructorInfo = GetConstructorInfo(type, argsType); - return constructorInfo == null ? null : GetConstructorByReflection(constructorInfo); + // if it's a value type (i.e., struct), it won't have a default constructor, so use Activator instead + return constructorInfo == null ? (type.IsValueType ? GetConstructorForValueType(type) : null) : GetConstructorByReflection(constructorInfo); + } + + static ConstructorDelegate GetConstructorForValueType(Type type) + { + return delegate (object[] args) { return Activator.CreateInstance(type); }; } #if !SIMPLE_JSON_NO_LINQ_EXPRESSION @@ -1865,7 +1872,8 @@ public static ConstructorDelegate GetConstructorByExpression(ConstructorInfo con public static ConstructorDelegate GetConstructorByExpression(Type type, params Type[] argsType) { ConstructorInfo constructorInfo = GetConstructorInfo(type, argsType); - return constructorInfo == null ? null : GetConstructorByExpression(constructorInfo); + // if it's a value type (i.e., struct), it won't have a default constructor, so use Activator instead + return constructorInfo == null ? (type.IsValueType ? GetConstructorForValueType(type) : null) : GetConstructorByExpression(constructorInfo); } #endif @@ -1925,6 +1933,9 @@ public static SetDelegate GetSetMethod(PropertyInfo propertyInfo) #if SIMPLE_JSON_NO_LINQ_EXPRESSION return GetSetMethodByReflection(propertyInfo); #else + // if it's a struct, we want to use reflection, as linq expressions modify copies of the object and not the real thing + if (propertyInfo.DeclaringType.IsValueType) + return GetSetMethodByReflection(propertyInfo); return GetSetMethodByExpression(propertyInfo); #endif } @@ -1934,6 +1945,9 @@ public static SetDelegate GetSetMethod(FieldInfo fieldInfo) #if SIMPLE_JSON_NO_LINQ_EXPRESSION return GetSetMethodByReflection(fieldInfo); #else + // if it's a struct, we want to use reflection, as linq expressions modify copies of the object and not the real thing + if (fieldInfo.DeclaringType.IsValueType) + return GetSetMethodByReflection(fieldInfo); return GetSetMethodByExpression(fieldInfo); #endif } diff --git a/src/GitHub.Exports/UI/IUIController.cs b/src/GitHub.Exports/UI/IUIController.cs deleted file mode 100644 index e2377612d6..0000000000 --- a/src/GitHub.Exports/UI/IUIController.cs +++ /dev/null @@ -1,85 +0,0 @@ -using GitHub.Exports; -using GitHub.Models; -using System; - -namespace GitHub.UI -{ - public interface IUIController : IDisposable - { - /// - /// Allows listening to the completion state of the ui flow - whether - /// it was completed because it was cancelled or whether it succeeded. - /// - /// true for success, false for cancel - IObservable ListenToCompletionState(); - void Start(); - void Stop(); - bool IsStopped { get; } - UIControllerFlow CurrentFlow { get; } - UIControllerFlow SelectedFlow { get; } - IObservable TransitionSignal { get; } - - IObservable Configure(UIControllerFlow choice, IConnection connection = null, ViewWithData parameters = null); - void Reload(); - } - - public enum UIControllerFlow - { - None = 0, - Authentication, - Create, - Clone, - Publish, - Gist, - LogoutRequired, - Home, - ReClone, - PullRequestList, - PullRequestDetail, - PullRequestCreation, - } - - public class ViewWithData - { - public UIControllerFlow ActiveFlow; - public UIControllerFlow MainFlow; - public UIViewType ViewType; - public object Data; - - public ViewWithData() {} - public ViewWithData(UIControllerFlow flow) - { - ActiveFlow = flow; - MainFlow = flow; - } - } - - public struct LoadData - { - public UIControllerFlow Flow; - public IView View; - public ViewWithData Data; - - public override int GetHashCode() - { - return 17 * (23 + Flow.GetHashCode()) * (23 + (View?.GetHashCode() ?? 0)) * (23 + (Data?.GetHashCode() ?? 0)); - } - - public override bool Equals(object obj) - { - if (obj is LoadData) - return GetHashCode() == obj.GetHashCode(); - return base.Equals(obj); - } - - public static bool operator==(LoadData lhs, LoadData rhs) - { - return lhs.Equals(rhs); - } - - public static bool operator !=(LoadData lhs, LoadData rhs) - { - return !lhs.Equals(rhs); - } - } -} diff --git a/src/GitHub.Exports/UI/IView.cs b/src/GitHub.Exports/UI/IView.cs deleted file mode 100644 index 7ca6545bb6..0000000000 --- a/src/GitHub.Exports/UI/IView.cs +++ /dev/null @@ -1,22 +0,0 @@ -using GitHub.ViewModels; -using System; -using System.Windows.Input; - -namespace GitHub.UI -{ - public interface IView - { - IViewModel ViewModel { get; } - IObservable Done { get; } - IObservable Cancel { get; } - IObservable IsBusy { get; } - // necessary for WPF to trigger binding events - object DataContext { get; set; } - - } - - public interface ICanLoad - { - IObservable Load { get; } - } -} diff --git a/src/GitHub.Exports/UI/Octicon.cs b/src/GitHub.Exports/UI/Octicon.cs index 0064141067..1084c3f881 100644 --- a/src/GitHub.Exports/UI/Octicon.cs +++ b/src/GitHub.Exports/UI/Octicon.cs @@ -1,5 +1,7 @@ using System; +#pragma warning disable CA1707 // Identifiers should not contain underscores + namespace GitHub.UI { public enum Octicon diff --git a/src/GitHub.Exports/ViewModels/Documents/IIssueishPaneViewModel.cs b/src/GitHub.Exports/ViewModels/Documents/IIssueishPaneViewModel.cs new file mode 100644 index 0000000000..60d6a6ddea --- /dev/null +++ b/src/GitHub.Exports/ViewModels/Documents/IIssueishPaneViewModel.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for an issue or pull request document pane. + /// + public interface IIssueishPaneViewModel : IPaneViewModel + { + /// + /// Gets the content to display in the document pane. + /// + IViewModel Content { get; } + + /// + /// Gets a value indicating whether + /// has been called on the view model. + /// + bool IsInitialized { get; } + + /// + /// Loads an issue or pull request into the view model. + /// + /// The connection to use. + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + /// A task that will complete when the load has finished. + Task Load( + IConnection connection, + string owner, + string name, + int number); + } +} diff --git a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs new file mode 100644 index 0000000000..69eb5c1c2f --- /dev/null +++ b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Describes the ways that the display of can + /// be overridden. + /// + public enum ContentOverride + { + /// + /// No override, display the content. + /// + None, + + /// + /// Display a spinner instead of the content. + /// + Spinner, + + /// + /// Display an error instead of the content. + /// + Error + } + + /// + /// The view model for the GitHub Pane. + /// + public interface IGitHubPaneViewModel : IViewModel + { + /// + /// Gets the connection to the current repository. + /// + IConnection Connection { get; } + + /// + /// Gets the content to display in the GitHub pane. + /// + IViewModel Content { get; } + + /// + /// Gets a value describing whether to display the or to override + /// it with another view. + /// + ContentOverride ContentOverride { get; } + + /// + /// Gets a value indicating whether search is available on the current page. + /// + bool IsSearchEnabled { get; } + + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets or sets the search query for the current page. + /// + string SearchQuery { get; set; } + + /// + /// Gets the title to display in the GitHub pane header. + /// + string Title { get; } + + /// + /// Initializes the view model. + /// + Task InitializeAsync(IServiceProvider paneServiceProvider); + + /// + /// Navigates to a GitHub Pane URL. + /// + /// The URL. + Task NavigateTo(Uri uri); + + /// + /// Shows the pull reqest list in the GitHub pane. + /// + Task ShowPullRequests(); + + /// + /// Shows the details for a pull request in the GitHub pane. + /// + /// The repository owner. + /// The repository name. + /// The pull rqeuest number. + Task ShowPullRequest(string owner, string repo, int number); + + /// + /// Shows the details for a pull request's check run in the GitHub pane. + /// + /// The repository owner. + /// The repository name. + /// The pull rqeuest number. + /// The check run id. + Task ShowPullRequestCheckRun(string owner, string repo, int number, string checkRunId); + + /// + /// Shows the pull requests reviews authored by a user. + /// + /// The repository owner. + /// The repository name. + /// The pull rqeuest number. + /// The user login. + Task ShowPullRequestReviews(string owner, string repo, int number, string login); + + /// + /// Shows a pane authoring a pull request review. + /// + /// The repository owner. + /// The repository name. + /// The pull rqeuest number. + Task ShowPullRequestReviewAuthoring(string owner, string repo, int number); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/IConnectionInitializedViewModel.cs b/src/GitHub.Exports/ViewModels/IConnectionInitializedViewModel.cs new file mode 100644 index 0000000000..4889067b7f --- /dev/null +++ b/src/GitHub.Exports/ViewModels/IConnectionInitializedViewModel.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels +{ + /// + /// Represents a view model that requires initialization with a connection. + /// + public interface IConnectionInitializedViewModel : IViewModel + { + /// + /// Initializes the view model with the specified connection. + /// + /// The connection. + /// A task tracking the initialization. + Task InitializeAsync(IConnection connection); + } +} diff --git a/src/GitHub.Exports/ViewModels/IGitHubConnectSection.cs b/src/GitHub.Exports/ViewModels/IGitHubConnectSection.cs index f4a01060a5..b07a96f587 100644 --- a/src/GitHub.Exports/ViewModels/IGitHubConnectSection.cs +++ b/src/GitHub.Exports/ViewModels/IGitHubConnectSection.cs @@ -8,8 +8,14 @@ public interface IGitHubConnectSection void DoCreate(); void SignOut(); void Login(); + void Retry(); bool OpenRepository(); + string ErrorMessage { get; } IConnection SectionConnection { get; } + bool IsLoggingIn { get; } + bool ShowLogin { get; } + bool ShowLogout { get; } + bool ShowRetry { get; } ICommand Clone { get; } } } diff --git a/src/GitHub.Exports/ViewModels/IGitHubPaneViewModel.cs b/src/GitHub.Exports/ViewModels/IGitHubPaneViewModel.cs deleted file mode 100644 index 94cb7efcbe..0000000000 --- a/src/GitHub.Exports/ViewModels/IGitHubPaneViewModel.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.ObjectModel; -using System.Windows.Input; -using GitHub.UI; - -namespace GitHub.ViewModels -{ - public interface IGitHubPaneViewModel : IViewModel - { - string ActiveRepoName { get; } - IView Control { get; } - string Message { get; } - MessageType MessageType { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/IHasBusy.cs b/src/GitHub.Exports/ViewModels/IHasBusy.cs deleted file mode 100644 index 5583448a73..0000000000 --- a/src/GitHub.Exports/ViewModels/IHasBusy.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace GitHub.ViewModels -{ - /// - /// Interface for view models that have a busy state. - /// - public interface IHasBusy - { - bool IsBusy { get; } - } -} diff --git a/src/GitHub.Exports/ViewModels/IInfoPanel.cs b/src/GitHub.Exports/ViewModels/IInfoPanel.cs deleted file mode 100644 index 4b8972df5a..0000000000 --- a/src/GitHub.Exports/ViewModels/IInfoPanel.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace GitHub.ViewModels -{ - public interface IInfoPanel - { - string Message { get; set; } - MessageType MessageType { get; set; } - } - - public enum MessageType - { - Information, - Warning - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/ILoginViewModel.cs b/src/GitHub.Exports/ViewModels/ILoginViewModel.cs deleted file mode 100644 index a94233d556..0000000000 --- a/src/GitHub.Exports/ViewModels/ILoginViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using GitHub.Authentication; - -namespace GitHub.ViewModels -{ - public interface ILoginViewModel : IViewModel - { - /// - /// Gets an observable sequence which produces an authentication - /// result every time a log in attempt through this control success - /// or fails. - /// - IObservable AuthenticationResults { get; } - } -} diff --git a/src/GitHub.Exports/ViewModels/IOpenInBrowser.cs b/src/GitHub.Exports/ViewModels/IOpenInBrowser.cs new file mode 100644 index 0000000000..7eaaeb4828 --- /dev/null +++ b/src/GitHub.Exports/ViewModels/IOpenInBrowser.cs @@ -0,0 +1,15 @@ +using System; + +namespace GitHub.ViewModels +{ + /// + /// Represents a view model with a URL that can be opened in the system web browser. + /// + public interface IOpenInBrowser + { + /// + /// Gets the URL. + /// + Uri WebUrl { get; } + } +} diff --git a/src/GitHub.Exports/ViewModels/IPaneViewModel.cs b/src/GitHub.Exports/ViewModels/IPaneViewModel.cs new file mode 100644 index 0000000000..142ada1f77 --- /dev/null +++ b/src/GitHub.Exports/ViewModels/IPaneViewModel.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.ViewModels +{ + /// + /// Represents the top-level content in a Visual Studio ToolWindowPane. + /// + public interface IPaneViewModel : IViewModel + { + /// + /// Gets the caption for the tool window. + /// + string PaneCaption { get; } + + /// + /// Initializes the view model. + /// + /// + /// The service provider for the containing ToolWindowPane. + /// + Task InitializeAsync(IServiceProvider paneServiceProvider); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs b/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs index 33a1c6ac44..b207a198ad 100644 --- a/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs +++ b/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs @@ -1,5 +1,4 @@ -using GitHub.UI; -using System; +using System; namespace GitHub.ViewModels { @@ -7,9 +6,4 @@ public interface IServiceProviderAware { void Initialize(IServiceProvider serviceProvider); } - - public interface IViewHost - { - void ShowView(ViewWithData data); - } } diff --git a/src/GitHub.Exports/ViewModels/ISpinnerViewModel.cs b/src/GitHub.Exports/ViewModels/ISpinnerViewModel.cs new file mode 100644 index 0000000000..2478ed572b --- /dev/null +++ b/src/GitHub.Exports/ViewModels/ISpinnerViewModel.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.ViewModels +{ + /// + /// View model which displays a spinner. + /// + public interface ISpinnerViewModel : IViewModel + { + } +} diff --git a/src/GitHub.Exports/ViewModels/IViewModel.cs b/src/GitHub.Exports/ViewModels/IViewModel.cs index 8e038639f1..1137370652 100644 --- a/src/GitHub.Exports/ViewModels/IViewModel.cs +++ b/src/GitHub.Exports/ViewModels/IViewModel.cs @@ -1,13 +1,14 @@ -using GitHub.UI; -using System.Windows.Input; +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; namespace GitHub.ViewModels { - public interface IViewModel + /// + /// Base interface for all view models. + /// + [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces")] + public interface IViewModel : INotifyPropertyChanged { - string Title { get; } - ICommand Cancel { get; } - bool IsShowing { get; } - void Initialize(ViewWithData data); } -} \ No newline at end of file +} diff --git a/src/GitHub.Exports/packages.config b/src/GitHub.Exports/packages.config deleted file mode 100644 index a4b24b48fd..0000000000 --- a/src/GitHub.Exports/packages.config +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.Extensions.Reactive/FodyWeavers.xml b/src/GitHub.Extensions.Reactive/FodyWeavers.xml deleted file mode 100644 index 9321cb912f..0000000000 --- a/src/GitHub.Extensions.Reactive/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/GitHub.Extensions.Reactive/GitHub.Extensions.Reactive.csproj b/src/GitHub.Extensions.Reactive/GitHub.Extensions.Reactive.csproj index 3a19c11227..72e5ac52bc 100644 --- a/src/GitHub.Extensions.Reactive/GitHub.Extensions.Reactive.csproj +++ b/src/GitHub.Extensions.Reactive/GitHub.Extensions.Reactive.csproj @@ -1,132 +1,21 @@ - - - + - Debug - AnyCPU - {6559E128-8B40-49A5-85A8-05565ED0C7E3} - Library - Properties - GitHub.Extensions.Reactive - GitHub.Extensions.Reactive - v4.6.1 - 512 - - - 4 - true - ..\common\GitHubVS.ruleset - true - Internal - bin\$(Configuration)\ - - - - true + net46 + GitHub full - false - DEBUG;TRACE - prompt - 4 - true - ..\common\GitHubVS.ruleset - true - true - - - pdbonly - true - TRACE - prompt - 4 - true - true - true - ..\common\GitHubVS.ruleset - - - ..\..\script\Key.snk - true - false + true + + + - - ..\..\packages\NullGuard.Fody.1.4.6\Lib\dotnet\NullGuard.dll - False - - - - - - - ..\..\packages\Rx-Core.2.2.5-custom\lib\net45\System.Reactive.Core.dll - True - - - ..\..\packages\Rx-Interfaces.2.2.5-custom\lib\net45\System.Reactive.Interfaces.dll - True - - - ..\..\packages\Rx-Linq.2.2.5-custom\lib\net45\System.Reactive.Linq.dll - True - - - ..\..\packages\Rx-PlatformServices.2.2.5-custom\lib\net45\System.Reactive.PlatformServices.dll - True - - - ..\..\packages\Rx-XAML.2.2.5-custom\lib\net45\System.Reactive.Windows.Threading.dll - True - - - - - - Key.snk - - - - Properties\SolutionInfo.cs - - - - - - - - - - - - + + + + + - - {1ce2d235-8072-4649-ba5a-cfb1af8776e0} - ReactiveUI_Net45 - - - {252ce1c2-027a-4445-a3c2-e4d6c80a935a} - Splat-Net45 - - - {6afe2e2d-6db0-4430-a2ea-f5f5388d2f78} - GitHub.Extensions - + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - \ No newline at end of file + diff --git a/src/GitHub.Extensions.Reactive/ObservableExtensions.cs b/src/GitHub.Extensions.Reactive/ObservableExtensions.cs index 99a5b09a5c..71b820b896 100644 --- a/src/GitHub.Extensions.Reactive/ObservableExtensions.cs +++ b/src/GitHub.Extensions.Reactive/ObservableExtensions.cs @@ -52,6 +52,17 @@ public static IObservable ContinueAfter(this IObservable obser return observable.AsCompletion().SelectMany(_ => selector()); } + /// + /// Helper method to transform an IObservable{Unit} to IObservable{object} with a null value. + /// + /// + /// + /// + public static IObservable SelectNull(this IObservable observable) + { + return observable.Select(_ => (object)null); + } + /// /// Helper method to transform an IObservable{T} to IObservable{Unit}. /// diff --git a/src/GitHub.Extensions.Reactive/Properties/AssemblyInfo.cs b/src/GitHub.Extensions.Reactive/Properties/AssemblyInfo.cs deleted file mode 100644 index 7cae481efd..0000000000 --- a/src/GitHub.Extensions.Reactive/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("GitHub.Extensions.Reactive")] -[assembly: AssemblyDescription("Provides useful Rx based extension and utility methods common to the needs of GitHub applications")] -[assembly: Guid("73e49b11-0bd0-4984-b9a8-3e7edceb071e")] diff --git a/src/GitHub.Extensions.Reactive/ReactiveUIExtensions.cs b/src/GitHub.Extensions.Reactive/ReactiveUIExtensions.cs deleted file mode 100644 index aaf013b92d..0000000000 --- a/src/GitHub.Extensions.Reactive/ReactiveUIExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Linq.Expressions; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Windows.Input; -using ReactiveUI; - -namespace GitHub.Extensions -{ - public static class ReactiveUIExtensions - { - /// - /// Syntactic sugar/convenience overload for the real RxUI onewaybind allowing one to specify - /// conversion hint as a BooleanVisibilityHint without specifying it as a named parameter. - /// - public static IDisposable OneWayBind( - this TView view, - TViewModel viewModel, - Expression> vmProperty, - Expression> viewProperty, - BooleanToVisibilityHint conversionHint) - where TViewModel : class - where TView : IViewFor - { - return BindingMixins.OneWayBind(view, viewModel, vmProperty, viewProperty, conversionHint: conversionHint); - } - - public static void WhenActivated(this ISupportsActivation This, params Func>[] blocks) - { - ViewForMixins.WhenActivated(This, () => blocks.SelectMany(x => x())); - } - - public static bool TryExecute(this ICommand command, object parameter = null) - { - if (!command.CanExecute(parameter)) - { - return false; - } - - command.Execute(parameter); - return true; - } - } -} diff --git a/src/GitHub.Extensions.Reactive/RecoveryCommandWithIcon.cs b/src/GitHub.Extensions.Reactive/RecoveryCommandWithIcon.cs index 2a7e0689d6..c2b0c2d9af 100644 --- a/src/GitHub.Extensions.Reactive/RecoveryCommandWithIcon.cs +++ b/src/GitHub.Extensions.Reactive/RecoveryCommandWithIcon.cs @@ -1,8 +1,10 @@ using System; using ReactiveUI; +using ReactiveUI.Legacy; namespace GitHub.Extensions { +#pragma warning disable CS0618 // Type or member is obsolete public class RecoveryCommandWithIcon : RecoveryCommand { public string Icon { get; private set; } @@ -12,4 +14,5 @@ public RecoveryCommandWithIcon(string commandName, string icon, Func(this IEnumerable This, Action block) + { + foreach (var v in This) { + block(v); + } + } + } +} diff --git a/src/GitHub.Extensions.Reactive/RxuiLegacy/Errors.cs b/src/GitHub.Extensions.Reactive/RxuiLegacy/Errors.cs new file mode 100644 index 0000000000..17e8ed6b81 --- /dev/null +++ b/src/GitHub.Extensions.Reactive/RxuiLegacy/Errors.cs @@ -0,0 +1,423 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MS-PL license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; + +namespace ReactiveUI.Legacy +{ + /// + /// Describes a stock error icon situation - it is up to the UI to decide + /// how to interpret these icons. + /// + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using user interactions instead.")] + public enum StockUserErrorIcon { + Critical, + Error, + Question, + Warning, + Notice, + }; + + /// + /// A command that represents a recovery from an error. These commands + /// will typically be displayed as buttons in the error dialog. + /// + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using user interactions instead.")] + public interface IRecoveryCommand : IReactiveCommand + { + /// + /// The command name, typically displayed as the button text. + /// + string CommandName { get; } + + /// + /// When the command is invoked and a result is determined, the + /// command should set the recovery result to indicate the action the + /// throwing code should take. + /// + RecoveryOptionResult? RecoveryResult { get; } + } + + /// + /// RecoveryOptionResult describes to the code throwing the UserError what + /// to do once the error is resolved. + /// + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using user interactions instead.")] + public enum RecoveryOptionResult { + + /// + /// The operation should be cancelled, but it is no longer an error. + /// + CancelOperation, + + /// + /// The operation should be retried with the same parameters. + /// + RetryOperation, + + /// + /// Recovery failed or not possible, you should rethrow as an + /// Exception. + /// + FailOperation, + }; + + /// + /// User Errors are similar to Exceptions, except that they are intended + /// to be displayed to the user. As such, your error messages should be + /// phrased in a friendly way. When a UserError is thrown, code higher up + /// in the stack has a chance to resolve the UserError via a user + /// interaction. + /// + /// Code can also add "Recovery Options" which resolve user errors: for + /// example an "Out of Disk Space" error might have an "Open Explorer" + /// recovery option. + /// + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using user interactions instead.")] + public class UserError : ReactiveObject + { + public UserError( + string errorMessage, + string errorCauseOrResolution = null, + IEnumerable recoveryOptions = null, + Dictionary contextInfo = null, + Exception innerException = null) + { + RecoveryOptions = new ReactiveList(recoveryOptions ?? Enumerable.Empty()); + + ErrorCauseOrResolution = errorCauseOrResolution; + ContextInfo = contextInfo ?? new Dictionary(); + UserErrorIcon = StockUserErrorIcon.Warning; + InnerException = innerException; + ErrorMessage = errorMessage; + } + + /// + /// A Dictionary that allows UserErrors to contain arbitrary + /// application data. + /// + public Dictionary ContextInfo { get; protected set; } + + ReactiveList _RecoveryOptions; + + /// + /// The list of available Recovery Options that will be presented to + /// the user to resolve the issue - these usually correspond to + /// buttons in the dialog. + /// + public ReactiveList RecoveryOptions { + get { return _RecoveryOptions; } + protected set { this.RaiseAndSetIfChanged(ref _RecoveryOptions, value); } + } + + /// + /// The "Newspaper Headline" of the message being conveyed to the + /// user. This should be one line, short, and informative. + /// + public string ErrorMessage { get; set; } + + /// + /// Additional optional information to describe what is happening, or + /// the resolution to an information-only error (i.e. a dialog to tell + /// the user that something has happened) + /// + public string ErrorCauseOrResolution { get; set; } + + /// + /// This object is either a custom icon (usually an ImageSource), or + /// it can also be a StockUserErrorIcon. It can also be an + /// application-defined type that the handlers know to interpret. + /// + public object UserErrorIcon { get; set; } + + /// + /// Optionally, The actual Exception that warranted throwing the + /// UserError. + /// + public Exception InnerException { get; protected set; } + + + // + // Static API + // + + [ThreadStatic] static Func> overriddenRegisteredUserErrorHandlers; + static readonly List>> registeredUserErrorHandlers = new List>>(); + + /// + /// Initiate a user interaction (i.e. "Throw the error to the user to + /// deal with") - this method is the simplest way to prompt the user + /// that an error has occurred. + /// + /// The message to show to the user. The + /// upper level handlers registered with RegisterHandler are + /// ultimately responsible for displaying this information. + /// The Exception that was thrown, if + /// relevant - this will *not* ever be shown to the user. + /// An Observable representing the action the code should + /// attempt to take, if any. + public static IObservable Throw(string errorMessage, Exception innerException = null) + { + return Throw(new UserError(errorMessage, innerException: innerException)); + } + + /// + /// Initiate a user interaction (i.e. "Throw the error to the user to + /// deal with"). + /// + /// The UserError to show to the user. The + /// upper level handlers registered with RegisterHandler are + /// ultimately responsible for displaying this information. + /// + public static IObservable Throw(UserError error) + { + var handlers = (overriddenRegisteredUserErrorHandlers != null) ? + new[] { overriddenRegisteredUserErrorHandlers } : + registeredUserErrorHandlers.ToArray().Reverse(); + + // NB: This is a little complicated - here's the idea: we have a + // list of handlers that we're running down *in order*. If we find + // one that doesn't return null, we're going to return this as an + // Observable with one item (the result). + // + // If *none* of the handlers are interested in this UserError, we're + // going to OnError the Observable. + var handler = handlers.Select(x => x(error)).FirstOrDefault(x => x != null) ?? Observable.Empty() + .Concat(Observable.Throw(new UnhandledUserErrorException(error))); + + var ret = handler.Take(1).PublishLast(); + ret.Connect(); + + return ret; + } + + /// + /// Register code to handle a UserError. Registered handlers are + /// called in reverse order to their registration (i.e. the newest + /// handler is called first), and they each have a chance to handle a + /// UserError. + /// + /// If a Handler cannot resolve a UserError, it should return null + /// instead of an Observable result. + /// + /// A method that can handle a UserError, + /// usually by presenting it to the user. If the handler cannot handle + /// the error, it should return null. + /// An IDisposable which will unregister the handler. + public static IDisposable RegisterHandler(Func> errorHandler) + { + registeredUserErrorHandlers.Add(errorHandler); + + return Disposable.Create(() => registeredUserErrorHandlers.Remove(errorHandler)); + } + + /// + /// Register code to handle a specific type of UserError. Registered + /// handlers are called in reverse order to their registration (i.e. + /// the newest handler is called first), and they each have a chance + /// to handle a UserError. + /// + /// If a Handler cannot resolve a UserError, it should return null + /// instead of an Observable result. + /// + /// A method that can handle a UserError, + /// usually by presenting it to the user. If the handler cannot handle + /// the error, it should return null. + /// An IDisposable which will unregister the handler. + public static IDisposable RegisterHandler(Func> errorHandler) + where TException : UserError + { + return RegisterHandler(x => { + if (!(x is TException)) { + return null; + } + + return errorHandler((TException) x); + }); + } + + /// + /// Register code to handle a UserError. Registered handlers are + /// called in reverse order to their registration (i.e. the newest + /// handler is called first), and they each have a chance to handle a + /// UserError. + /// + /// If a Handler cannot resolve a UserError, it should return null + /// instead of an Observable result. + /// + /// A method that can handle a UserError, + /// usually by presenting it to the user. If the handler cannot handle + /// the error, it should return null. + /// An IDisposable which will unregister the handler. + public static IDisposable RegisterHandler(Func> errorHandler) + { + return RegisterHandler(x => errorHandler(x).ToObservable()); + } + + /// + /// Register code to handle a specific type of UserError. Registered + /// handlers are called in reverse order to their registration (i.e. + /// the newest handler is called first), and they each have a chance + /// to handle a UserError. + /// + /// If a Handler cannot resolve a UserError, it should return null + /// instead of an Observable result. + /// + /// A method that can handle a UserError, + /// usually by presenting it to the user. If the handler cannot handle + /// the error, it should return null. + /// An IDisposable which will unregister the handler. + public static IDisposable RegisterHandler(Func> errorHandler) + where TException : UserError + { + return RegisterHandler(x => { + if (!(x is TException)) { + return null; + } + + return errorHandler((TException)x).ToObservable(); + }); + } + + + /// + /// This method is a convenience wrapper around RegisterHandler that + /// adds the specified RecoveryCommand to any UserErrors that match + /// its filter. + /// + /// The RecoveryCommand to add. + /// An optional filter to determine which + /// UserErrors to add the command to. + /// An IDisposable which will unregister the handler. + public static IDisposable AddRecoveryOption(IRecoveryCommand command, Func filter = null) + { + return RegisterHandler(x => { + if (filter != null && !filter(x)) { + return null; + } + + if (!x.RecoveryOptions.Contains(command)) { + x.RecoveryOptions.Add(command); + } + + return Observable.Empty(); + }); + } + + /// + /// This method replaces *all* UserError handlers with the specified + /// handler. Use it for testing code that may throw UserErrors. + /// + /// The replacement UserError handler. + /// An IDisposable which will unregister the test handler. + public static IDisposable OverrideHandlersForTesting(Func> errorHandler) + { + overriddenRegisteredUserErrorHandlers = errorHandler; + return Disposable.Create(() => overriddenRegisteredUserErrorHandlers = null); + } + + /// + /// This method replaces *all* UserError handlers with the specified + /// handler. Use it for testing code that may throw UserErrors. + /// + /// The replacement UserError handler. + /// An IDisposable which will unregister the test handler. + public static IDisposable OverrideHandlersForTesting(Func errorHandler) + { + return OverrideHandlersForTesting(x => Observable.Return(errorHandler(x))); + } + } + +#pragma warning disable 618 +#pragma warning disable CA1032 // Implement standard exception constructors +#pragma warning disable CA2237 // Mark ISerializable types with serializable + /// + /// This Exception will be thrown when a UserError is not handled by any + /// of the registered handlers. + /// + public class UnhandledUserErrorException : Exception + { + public UnhandledUserErrorException(UserError error) : base(error.ErrorMessage, error.InnerException) + { + ReportedError = error; + } + + public UserError ReportedError { get; protected set; } + } +#pragma warning restore CA2237 // Mark ISerializable types with serializable +#pragma warning restore CA1032 // Implement standard exception constructors +#pragma warning restore 618 + + /// + /// RecoveryCommand is a straightforward implementation of a recovery + /// command - this class represents a command presented to the user + /// (usually in the form of a button) that will help resolve or mitigate a + /// UserError. + /// + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using user interactions instead.")] + public class RecoveryCommand : ReactiveCommand, IRecoveryCommand + { + public bool IsDefault { get; set; } + public bool IsCancel { get; set; } + public string CommandName { get; protected set; } + public RecoveryOptionResult? RecoveryResult { get; set; } + + /// + /// Constructs a RecoveryCommand. + /// + /// The user-visible name of this Command. + /// A convenience handler - equivalent to + /// Subscribing to the command and setting the RecoveryResult. + public RecoveryCommand(string commandName, Func handler = null) + : base(Observable.Return(true), _ => Observable.Return(Unit.Default)) + { + CommandName = commandName; + + if (handler != null) { + this.Subscribe(x => RecoveryResult = handler(x)); + } + } + + /// + /// A default command whose caption is "Ok" + /// + /// RetryOperation + public static IRecoveryCommand Ok { + get { var ret = new RecoveryCommand("Ok") { IsDefault = true }; ret.Subscribe(_ => ret.RecoveryResult = RecoveryOptionResult.RetryOperation); return ret; } + } + + /// + /// A default command whose caption is "Cancel" + /// + /// FailOperation + public static IRecoveryCommand Cancel { + get { var ret = new RecoveryCommand("Cancel") { IsCancel = true }; ret.Subscribe(_ => ret.RecoveryResult = RecoveryOptionResult.FailOperation); return ret; } + } + + /// + /// A default command whose caption is "Yes" + /// + /// RetryOperation + public static IRecoveryCommand Yes { + get { var ret = new RecoveryCommand("Yes") { IsDefault = true }; ret.Subscribe(_ => ret.RecoveryResult = RecoveryOptionResult.RetryOperation); return ret; } + } + + /// + /// A default command whose caption is "No" + /// + /// FailOperation + public static IRecoveryCommand No { + get { var ret = new RecoveryCommand("No") { IsCancel = true }; ret.Subscribe(_ => ret.RecoveryResult = RecoveryOptionResult.FailOperation); return ret; } + } + } +} + +// vim: tw=120 ts=4 sw=4 et : \ No newline at end of file diff --git a/src/GitHub.Extensions.Reactive/RxuiLegacy/ReactiveCommand.cs b/src/GitHub.Extensions.Reactive/RxuiLegacy/ReactiveCommand.cs new file mode 100644 index 0000000000..b04585a970 --- /dev/null +++ b/src/GitHub.Extensions.Reactive/RxuiLegacy/ReactiveCommand.cs @@ -0,0 +1,612 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MS-PL license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using Splat; + +#pragma warning disable CA1030 // Use events where appropriate +#pragma warning disable CA1063 // Implement IDisposable Correctly +#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace ReactiveUI.Legacy +{ + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using ReactiveUI.ReactiveCommand instead.")] + public interface IReactiveCommand : IHandleObservableErrors, ICommand, IDisposable, IEnableLogger + { + /// + /// Gets a value indicating whether this instance can execute observable. + /// + /// true if this instance can execute observable; otherwise, false. + IObservable CanExecuteObservable { get; } + + /// + /// Gets a value indicating whether this instance is executing. This + /// Observable is guaranteed to always return a value immediately (i.e. + /// it is backed by a BehaviorSubject), meaning it is safe to determine + /// the current state of the command via IsExecuting.First() + /// + /// true if this instance is executing; otherwise, false. + IObservable IsExecuting { get; } + } + + /// + /// IReactiveCommand represents an ICommand which also notifies when it is + /// executed (i.e. when Execute is called) via IObservable. Conceptually, + /// this represents an Event, so as a result this IObservable should never + /// OnComplete or OnError. + /// + /// In previous versions of ReactiveUI, this interface was split into two + /// separate interfaces, one to handle async methods and one for "standard" + /// commands, but these have now been merged - every ReactiveCommand is now + /// a ReactiveAsyncCommand. + /// + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using ReactiveUI.ReactiveCommand instead.")] + public interface IReactiveCommand : IObservable, IReactiveCommand + { + IObservable ExecuteAsync(object parameter = null); + } + + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using ReactiveUI.ReactiveCommand instead.")] + public static class ReactiveCommand + { + /// + /// Creates a default ReactiveCommand that has no background action. This + /// is probably what you want if you were calling the constructor in + /// previous versions of ReactiveUI + /// + /// An Observable that determines when the + /// Command can Execute. WhenAny is a great way to create this! + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand whose ExecuteAsync just returns the + /// CommandParameter immediately. Which you should ignore! + public static ReactiveCommand Create(IObservable canExecute = null, IScheduler scheduler = null) + { + canExecute = canExecute ?? Observable.Return(true); + return new ReactiveCommand(canExecute, x => Observable.Return(x), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Observable + /// method. Use this method if your background method returns IObservable. + /// + /// An Observable that determines when the + /// Command can Execute. WhenAny is a great way to create this! + /// Method to call that creates an Observable + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Observable completes. If this + /// Observable terminates with OnError, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncObservable(IObservable canExecute, Func> executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(canExecute, executeAsync, scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Observable + /// method. Use this method if your background method returns IObservable. + /// + /// Method to call that creates an Observable + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Observable completes. If this + /// Observable terminates with OnError, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncObservable(Func> executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(Observable.Return(true), executeAsync, scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method. Use this method if your background method returns Task or uses + /// async/await. + /// + /// An Observable that determines when the + /// Command can Execute. WhenAny is a great way to create this! + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(IObservable canExecute, Func> executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(canExecute, x => executeAsync(x).ToObservable(), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method. Use this method if your background method returns Task or uses + /// async/await. + /// + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(Func> executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(Observable.Return(true), x => executeAsync(x).ToObservable(), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method. Use this method if your background method returns Task or uses + /// async/await. + /// + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(Func executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(Observable.Return(true), x => executeAsync(x).ToObservable(), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method. Use this method if your background method returns Task or uses + /// async/await. + /// + /// An Observable that determines when the + /// Command can Execute. WhenAny is a great way to create this! + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(IObservable canExecute, Func executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(canExecute, x => executeAsync(x).ToObservable(), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method that supports cancellation. Use this method if your background + /// method returns Task or uses async/await. + /// + /// An Observable that determines when the + /// Command can Execute. WhenAny is a great way to create this! + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(IObservable canExecute, Func> executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(canExecute, x => Observable.StartAsync(ct => executeAsync(x, ct)), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method that supports cancellation. Use this method if your background + /// method returns Task or uses async/await. + /// + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(Func> executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(Observable.Return(true), x => Observable.StartAsync(ct => executeAsync(x, ct)), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method that supports cancellation. Use this method if your background + /// method returns Task or uses async/await. + /// + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(Func executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(Observable.Return(true), x => Observable.StartAsync(ct => executeAsync(x, ct)), scheduler); + } + + /// + /// Creates a ReactiveCommand typed to the given executeAsync Task-based + /// method that supports cancellation. Use this method if your background + /// method returns Task or uses async/await. + /// + /// An Observable that determines when the + /// Command can Execute. WhenAny is a great way to create this! + /// Method to call that creates a Task + /// representing an operation to execute in the background. The Command's + /// CanExecute will be false until this Task completes. If this + /// Task terminates with an Exception, the Exception is marshaled to + /// ThrownExceptions. + /// The scheduler to deliver events on. + /// Defaults to RxApp.MainThreadScheduler. + /// A ReactiveCommand which returns all items that are created via + /// calling executeAsync as a single stream. + public static ReactiveCommand CreateAsyncTask(IObservable canExecute, Func executeAsync, IScheduler scheduler = null) + { + return new ReactiveCommand(canExecute, x => Observable.StartAsync(ct => executeAsync(x, ct)), scheduler); + } + + /// + /// This creates a ReactiveCommand that calls several child + /// ReactiveCommands when invoked. Its CanExecute will match the + /// combined result of the child CanExecutes (i.e. if any child + /// commands cannot execute, neither can the parent) + /// + /// An Observable that determines whether the + /// parent command can execute + /// The commands to combine. + public static ReactiveCommand CreateCombined(IObservable canExecute, params IReactiveCommand[] commands) + { + var childrenCanExecute = commands + .Select(x => x.CanExecuteObservable) + .CombineLatest(latestCanExecute => latestCanExecute.All(x => x != false)); + + var canExecuteSum = Observable.CombineLatest( + canExecute.StartWith(true), + childrenCanExecute, + (parent, child) => parent && child); + + var ret = ReactiveCommand.Create(canExecuteSum); + ret.Subscribe(x => commands.ForEach(cmd => cmd.Execute(x))); + return ret; + } + + /// + /// This creates a ReactiveCommand that calls several child + /// ReactiveCommands when invoked. Its CanExecute will match the + /// combined result of the child CanExecutes (i.e. if any child + /// commands cannot execute, neither can the parent) + /// + /// The commands to combine. + public static ReactiveCommand CreateCombined(params IReactiveCommand[] commands) + { + return CreateCombined(Observable.Return(true), commands); + } + } + + /// + /// This class represents a Command that can optionally do a background task. + /// The results of the background task (or a signal that the Command has been + /// invoked) are delivered by Subscribing to the command itself, since + /// ReactiveCommand is itself an Observable. The results of individual + /// invocations can be retrieved via the ExecuteAsync method. + /// + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using ReactiveUI.ReactiveCommand instead.")] + public class ReactiveCommand : IReactiveCommand, IReactiveCommand + { + public event EventHandler CanExecuteChanged; + + protected virtual void raiseCanExecuteChanged(EventArgs args) + { + var handler = this.CanExecuteChanged; + if (handler != null) { + handler(this, args); + } + } + + readonly Subject executeResults = new Subject(); + readonly Subject isExecuting = new Subject(); + readonly Func> executeAsync; + readonly IScheduler scheduler; + readonly ScheduledSubject exceptions; + + IConnectableObservable canExecute; + bool canExecuteLatest = false; + IDisposable canExecuteDisp; + int inflightCount = 0; + + /// + /// Don't use this, use ReactiveCommand.CreateXYZ instead + /// + public ReactiveCommand(IObservable canExecute, Func> executeAsync, IScheduler scheduler = null) + { + this.scheduler = scheduler ?? RxApp.MainThreadScheduler; + this.executeAsync = executeAsync; + + this.canExecute = canExecute.CombineLatest(isExecuting.StartWith(false), (ce, ie) => ce && !ie) + .Catch(ex => { + exceptions.OnNext(ex); + return Observable.Return(false); + }) + .Do(x => { + var fireCanExecuteChanged = (canExecuteLatest != x); + canExecuteLatest = x; + + if (fireCanExecuteChanged) { + this.raiseCanExecuteChanged(EventArgs.Empty); + } + }) + .Publish(); + + if (ModeDetector.InUnitTestRunner()) { + this.canExecute.Connect(); + } + + ThrownExceptions = exceptions = new ScheduledSubject(CurrentThreadScheduler.Instance, RxApp.DefaultExceptionHandler); + } + + /// + /// Executes a Command and returns the result asynchronously. This method + /// makes it *much* easier to test ReactiveCommand, as well as create + /// ReactiveCommands who invoke inferior commands and wait on their results. + /// + /// Note that you **must** Subscribe to the Observable returned by + /// ExecuteAsync or else nothing will happen (i.e. ExecuteAsync is lazy) + /// + /// Note also that the command will be executed, irrespective of the current value + /// of the command's canExecute observable. + /// + /// An Observable representing a single invocation of the Command. + /// Don't use this. + public IObservable ExecuteAsync(object parameter = null) + { + var ret = Observable.Create(subj => { + if (Interlocked.Increment(ref inflightCount) == 1) { + isExecuting.OnNext(true); + } + + var decrement = new SerialDisposable() + { + Disposable = Disposable.Create(() => { + if (Interlocked.Decrement(ref inflightCount) == 0) { + isExecuting.OnNext(false); + } + }) + }; + + var disp = executeAsync(parameter) + .ObserveOn(scheduler) + .Do( + _ => { }, + e => decrement.Disposable = Disposable.Empty, + () => decrement.Disposable = Disposable.Empty) + .Do(executeResults.OnNext, exceptions.OnNext) + .Subscribe(subj); + + return new CompositeDisposable(disp, decrement); + }); + + return ret.Publish().RefCount(); + } + + + /// + /// Executes a Command and returns the result as a Task. This method + /// makes it *much* easier to test ReactiveCommand, as well as create + /// ReactiveCommands who invoke inferior commands and wait on their results. + /// + /// A Task representing a single invocation of the Command. + /// Don't use this. + /// An optional token that can cancel the operation, if + /// the operation supports it. + public Task ExecuteAsyncTask(object parameter = null, CancellationToken ct = default(CancellationToken)) + { + return ExecuteAsync(parameter).ToTask(ct); + } + + /// + /// Fires whenever an exception would normally terminate ReactiveUI + /// internal state. + /// + /// The thrown exceptions. + public IObservable ThrownExceptions { get; protected set; } + + /// + /// Returns a BehaviorSubject (i.e. an Observable which is guaranteed to + /// return at least one value immediately) representing the CanExecute + /// state. + /// + public IObservable CanExecuteObservable + { + get + { + var ret = canExecute.StartWith(canExecuteLatest).DistinctUntilChanged(); + + if (canExecuteDisp != null) return ret; + + return Observable.Create(subj => { + var disp = ret.Subscribe(subj); + + // NB: We intentionally leak the CanExecute disconnect, it's + // cleaned up by the global Dispose. This is kind of a + // "Lazy Subscription" to CanExecute by the command itself. + canExecuteDisp = canExecute.Connect(); + return disp; + }); + } + } + + public IObservable IsExecuting + { + get { return isExecuting.StartWith(inflightCount > 0); } + } + + public IDisposable Subscribe(IObserver observer) + { + return executeResults.Subscribe(observer); + } + + public bool CanExecute(object parameter) + { + if (canExecuteDisp == null) canExecuteDisp = canExecute.Connect(); + return canExecuteLatest; + } + + /// + /// Executes a Command. Note that the command will be executed, irrespective of the current value + /// of the command's canExecute observable. + /// + public void Execute(object parameter) + { + ExecuteAsync(parameter).Catch(Observable.Empty()).Subscribe(); + } + + public virtual void Dispose() + { + var disp = Interlocked.Exchange(ref canExecuteDisp, null); + if (disp != null) disp.Dispose(); + } + } + + [Obsolete("This type is obsolete and will be removed in a future version of ReactiveUI. Please switch to using ReactiveUI.ReactiveCommand instead.")] + public static class ReactiveCommandMixins + { + /// + /// ToCommand is a convenience method for returning a new + /// ReactiveCommand based on an existing Observable chain. + /// + /// The scheduler to publish events on - default + /// is RxApp.MainThreadScheduler. + /// A new ReactiveCommand whose CanExecute Observable is the + /// current object. + public static ReactiveCommand ToCommand(this IObservable This, IScheduler scheduler = null) + { + return ReactiveCommand.Create(This, scheduler); + } + + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called) + /// + /// The source observable to pipe into the command + /// The command to be executed. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable @this, ICommand command) + { + return @this.Throttle(x => Observable.FromEventPattern(h => command.CanExecuteChanged += h, h => command.CanExecuteChanged -= h) + .Select(_ => Unit.Default) + .StartWith(Unit.Default) + .Where(_ => command.CanExecute(x))) + .Subscribe(x => { + command.Execute(x); + }); + } + + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called) + /// + /// The source observable to pipe into the command + /// The command to be executed. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable @this, IReactiveCommand command) + { + return @this.Throttle(x => command.CanExecuteObservable.StartWith(command.CanExecute(x)).Where(b => b)) + .Select(x => command.ExecuteAsync(x).Catch(Observable.Empty())) + .Switch() + .Subscribe(); + } + + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called) + /// + /// The source observable to pipe into the command + /// The root object which has the Command. + /// The expression to reference the Command. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable @this, TTarget target, Expression> commandProperty) + { + return @this.CombineLatest(target.WhenAnyValue(commandProperty), (val, cmd) => new { val, cmd }) + .Throttle(x => Observable.FromEventPattern(h => x.cmd.CanExecuteChanged += h, h => x.cmd.CanExecuteChanged -= h) + .Select(_ => Unit.Default) + .StartWith(Unit.Default) + .Where(_ => x.cmd.CanExecute(x.val))) + .Subscribe(x => { + x.cmd.Execute(x.val); + }); + } + + /// + /// A utility method that will pipe an Observable to an ICommand (i.e. + /// it will first call its CanExecute with the provided value, then if + /// the command can be executed, Execute() will be called) + /// + /// The source observable to pipe into the command + /// The root object which has the Command. + /// The expression to reference the Command. + /// An object that when disposes, disconnects the Observable + /// from the command. + public static IDisposable InvokeCommand(this IObservable @this, TTarget target, Expression>> commandProperty) + { + return @this.CombineLatest(target.WhenAnyValue(commandProperty), (val, cmd) => new { val, cmd }) + .Throttle(x => x.cmd.CanExecuteObservable.StartWith(x.cmd.CanExecute(x.val)).Where(b => b)) + .Select(x => x.cmd.ExecuteAsync(x.val).Catch(Observable.Empty())) + .Switch() + .Subscribe(); + } + + /// + /// A convenience method for subscribing and creating ReactiveCommands + /// in the same call. Equivalent to Subscribing to the command, except + /// there's no way to release your Subscription but that's probably fine. + /// + public static ReactiveCommand OnExecuteCompleted(this ReactiveCommand @this, Action onNext, Action onError = null) + { + if (onError != null) { + @this.Subscribe(onNext, onError); + return @this; + } else { + @this.Subscribe(onNext); + return @this; + } + } + } +} diff --git a/src/GitHub.Extensions.Reactive/packages.config b/src/GitHub.Extensions.Reactive/packages.config deleted file mode 100644 index 7699548b3f..0000000000 --- a/src/GitHub.Extensions.Reactive/packages.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.Extensions/EnumerableExtensions.cs b/src/GitHub.Extensions/EnumerableExtensions.cs index 852320a4ea..1f188193a6 100644 --- a/src/GitHub.Extensions/EnumerableExtensions.cs +++ b/src/GitHub.Extensions/EnumerableExtensions.cs @@ -1,5 +1,4 @@ -using NullGuard; -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -24,7 +23,6 @@ public static IEnumerable Except( public static class StackExtensions { - [return: AllowNull] public static T TryPeek(this Stack stack) where T : class { if (stack.Count > 0) diff --git a/src/GitHub.Extensions/ExceptionExtensions.cs b/src/GitHub.Extensions/ExceptionExtensions.cs index 14df36a2c2..ee8c1d0a6b 100644 --- a/src/GitHub.Extensions/ExceptionExtensions.cs +++ b/src/GitHub.Extensions/ExceptionExtensions.cs @@ -14,7 +14,7 @@ public static bool IsCriticalException(this Exception exception) { if (exception == null) { - throw new ArgumentNullException("exception"); + throw new ArgumentNullException(nameof(exception)); } return exception.IsFatalException() @@ -36,7 +36,7 @@ public static bool IsFatalException(this Exception exception) { if (exception == null) { - throw new ArgumentNullException("exception"); + throw new ArgumentNullException(nameof(exception)); } return exception is StackOverflowException diff --git a/src/GitHub.Extensions/FodyWeavers.xml b/src/GitHub.Extensions/FodyWeavers.xml deleted file mode 100644 index 9321cb912f..0000000000 --- a/src/GitHub.Extensions/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/GitHub.Extensions/GitHub.Extensions.csproj b/src/GitHub.Extensions/GitHub.Extensions.csproj index 533d98795b..8548343196 100644 --- a/src/GitHub.Extensions/GitHub.Extensions.csproj +++ b/src/GitHub.Extensions/GitHub.Extensions.csproj @@ -1,63 +1,14 @@ - - - + - Debug - AnyCPU - {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78} - Library - Properties - GitHub.Extensions - GitHub.Extensions - v4.6.1 - 512 - - - 4 - true - ..\common\GitHubVS.ruleset - true - Internal - bin\$(Configuration)\ - - - - true + net46 + GitHub full - false - DEBUG;TRACE - prompt - 4 - true - ..\common\GitHubVS.ruleset - true - true - - - pdbonly - true - TRACE - prompt - 4 - true - true - true - ..\common\GitHubVS.ruleset - - - ..\..\script\Key.snk - true - false + true + + + - - ..\..\packages\Microsoft.VisualStudio.Shell.Immutable.10.0.10.0.30319\lib\net40\Microsoft.VisualStudio.Shell.Immutable.10.0.dll - True - - - ..\..\packages\NullGuard.Fody.1.4.6\Lib\dotnet\NullGuard.dll - False - @@ -67,52 +18,13 @@ + - - Key.snk - - - - - Properties\SolutionInfo.cs - - - - - - - - - - - - - - Designer - - - - + + + - - {252ce1c2-027a-4445-a3c2-e4d6c80a935a} - Splat-Net45 - + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - \ No newline at end of file + diff --git a/src/GitHub.Extensions/GlobalSuppressions.cs b/src/GitHub.Extensions/GlobalSuppressions.cs new file mode 100644 index 0000000000..1747e54f03 --- /dev/null +++ b/src/GitHub.Extensions/GlobalSuppressions.cs @@ -0,0 +1,10 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Design", "CA1054:Uri parameters should not be strings")] +[assembly: SuppressMessage("Design", "CA1056:Uri properties should not be strings")] +[assembly: SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Discouraged for VSSDK projects.")] diff --git a/src/GitHub.Extensions/Guard.cs b/src/GitHub.Extensions/Guard.cs index 123e16e1f5..82bf95c509 100644 --- a/src/GitHub.Extensions/Guard.cs +++ b/src/GitHub.Extensions/Guard.cs @@ -1,24 +1,26 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; +using System.IO; using System.Globalization; using System.Linq; namespace GitHub.Extensions { - [NullGuard.NullGuard(NullGuard.ValidationFlags.None)] public static class Guard { + public static void ArgumentIsRelativePath(string value, string name) + { + ArgumentNotNull(value, name); + + if (Path.IsPathRooted(value)) + { + throw new ArgumentException($"The value '{value}' must not be rooted", name); + } + } + public static void ArgumentNotNull(object value, string name) { if (value != null) return; string message = String.Format(CultureInfo.InvariantCulture, "Failed Null Check on '{0}'", name); -#if DEBUG - if (!InUnitTestRunner) - { - Debug.Fail(message); - } -#endif throw new ArgumentNullException(name, message); } @@ -27,12 +29,6 @@ public static void ArgumentNonNegative(int value, string name) if (value > -1) return; var message = String.Format(CultureInfo.InvariantCulture, "The value for '{0}' must be non-negative", name); -#if DEBUG - if (!InUnitTestRunner) - { - Debug.Fail(message); - } -#endif throw new ArgumentException(message, name); } @@ -45,12 +41,6 @@ public static void ArgumentNotEmptyString(string value, string name) { if (value?.Length > 0) return; string message = String.Format(CultureInfo.InvariantCulture, "The value for '{0}' must not be empty", name); -#if DEBUG - if (!InUnitTestRunner) - { - Debug.Fail(message); - } -#endif throw new ArgumentException(message, name); } @@ -62,12 +52,6 @@ public static void ArgumentInRange(int value, int minValue, string name) value, name, minValue); -#if DEBUG - if (!InUnitTestRunner) - { - Debug.Fail(message); - } -#endif throw new ArgumentOutOfRangeException(name, message); } @@ -80,17 +64,11 @@ public static void ArgumentInRange(int value, int minValue, int maxValue, string name, minValue, maxValue); -#if DEBUG - if (!InUnitTestRunner) - { - Debug.Fail(message); - } -#endif throw new ArgumentOutOfRangeException(name, message); } // Borrowed from Splat. - public static bool InUnitTestRunner { get; } = Splat.ModeDetector.InUnitTestRunner(); + public static bool InUnitTestRunner => Splat.ModeDetector.InUnitTestRunner(); } } diff --git a/src/GitHub.Extensions/IReadOnlyObservableCollection.cs b/src/GitHub.Extensions/IReadOnlyObservableCollection.cs new file mode 100644 index 0000000000..847512ce92 --- /dev/null +++ b/src/GitHub.Extensions/IReadOnlyObservableCollection.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace GitHub.Extensions +{ + /// + /// Represents a read-only interface to an + /// + /// The type of elements in the collection. + public interface IReadOnlyObservableCollection : IReadOnlyList, + INotifyCollectionChanged, + INotifyPropertyChanged + { + } +} \ No newline at end of file diff --git a/src/GitHub.Extensions/LambdaComparer.cs b/src/GitHub.Extensions/LambdaComparer.cs index d9070cc4f9..289ddb2117 100644 --- a/src/GitHub.Extensions/LambdaComparer.cs +++ b/src/GitHub.Extensions/LambdaComparer.cs @@ -1,6 +1,6 @@ -using NullGuard; using System; using System.Collections.Generic; +using GitHub.Extensions; namespace GitHub.Collections { @@ -14,23 +14,25 @@ public LambdaComparer(Func lambdaComparer) : { } - public LambdaComparer(Func lambdaComparer, [AllowNull] Func lambdaHash) + public LambdaComparer(Func lambdaComparer, Func lambdaHash) { + Guard.ArgumentNotNull(lambdaComparer, nameof(lambdaComparer)); + this.lambdaComparer = lambdaComparer; this.lambdaHash = lambdaHash; } - public int Compare([AllowNull] T x, [AllowNull] T y) + public int Compare(T x, T y) { return lambdaComparer(x, y); } - public bool Equals([AllowNull] T x, [AllowNull] T y) + public bool Equals(T x, T y) { return lambdaComparer(x, y) == 0; } - public int GetHashCode([AllowNull] T obj) + public int GetHashCode(T obj) { return lambdaHash != null ? lambdaHash(obj) diff --git a/src/GitHub.Extensions/ObservableCollectionEx.cs b/src/GitHub.Extensions/ObservableCollectionEx.cs new file mode 100644 index 0000000000..dc23b14cdb --- /dev/null +++ b/src/GitHub.Extensions/ObservableCollectionEx.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace GitHub.Extensions +{ + /// + /// A non-braindead way to expose a read-only view on an . + /// + /// The type of elements in the collection. + /// + /// fails in its only purpose by Not. Freaking. + /// Exposing. INotifyCollectionChanged. We define our own + /// type and use this class to expose it. Seriously. + /// + public class ObservableCollectionEx : ObservableCollection, IReadOnlyObservableCollection + { + /// + /// Initializes a new instance of the class. + /// + public ObservableCollectionEx() + { + } + + /// + /// Initializes a new instance of the class that + /// contains elements copied from the specified list. + /// + public ObservableCollectionEx(List list) + : base(list) + { + } + + /// + /// Initializes a new instance of the class that + /// contains elements copied from the specified collection. + /// + public ObservableCollectionEx(IEnumerable collection) + : base(collection) + { + } + + /// + /// Adds the elements of the specified collection to the end of the list. + /// + /// The items to add. + public void AddRange(IEnumerable items) + { + foreach (var item in items) Add(item); + } + } +} \ No newline at end of file diff --git a/src/GitHub.Extensions/ObservableCollectionExtensions.cs b/src/GitHub.Extensions/ObservableCollectionExtensions.cs new file mode 100644 index 0000000000..0a761eb256 --- /dev/null +++ b/src/GitHub.Extensions/ObservableCollectionExtensions.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections; +using System.Collections.Specialized; + +namespace GitHub.Extensions +{ + public static class ObservableCollectionExtensions + { + /// + /// Invokes an action for each item in a collection and subsequently each item added or + /// removed from the collection. + /// + /// The type of the collection items. + /// The collection. + /// + /// An action called initially for each item in the collection and subsequently for each + /// item added to the collection. + /// + /// + /// An action called for each item removed from the collection. + /// + /// + /// An action called when the collection is reset. This will be followed by calls to + /// for each item present in the collection after the reset. + /// + /// A disposable used to terminate the subscription. + public static IDisposable ForEachItem( + this IReadOnlyObservableCollection collection, + Action added, + Action removed, + Action reset) + { + Action add = items => + { + foreach (T item in items) + { + added(item); + } + }; + + Action remove = items => + { + for (var i = items.Count - 1; i >= 0; --i) + { + removed((T)items[i]); + } + }; + + NotifyCollectionChangedEventHandler handler = (_, e) => + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + add(e.NewItems); + break; + + case NotifyCollectionChangedAction.Move: + case NotifyCollectionChangedAction.Replace: + remove(e.OldItems); + add(e.NewItems); + break; + + case NotifyCollectionChangedAction.Remove: + remove(e.OldItems); + break; + + case NotifyCollectionChangedAction.Reset: + if (reset == null) + { + throw new InvalidOperationException( + "Reset called on collection without reset handler."); + } + + reset(); + add((IList)collection); + break; + } + }; + + add((IList)collection); + collection.CollectionChanged += handler; + + return new ActionDisposable(() => collection.CollectionChanged -= handler); + } + + class ActionDisposable : IDisposable + { + Action dispose; + + public ActionDisposable(Action dispose) + { + this.dispose = dispose; + } + + public void Dispose() + { + dispose(); + } + } + } +} diff --git a/src/GitHub.Extensions/Properties/AssemblyInfo.cs b/src/GitHub.Extensions/Properties/AssemblyInfo.cs deleted file mode 100644 index 0ca1c011c4..0000000000 --- a/src/GitHub.Extensions/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("GitHub.Extensions")] -[assembly: AssemblyDescription("Provides useful extension and utility methods common to the needs of GitHub applications")] -[assembly: Guid("3bf91177-3d16-425d-9c62-50a86cf26298")] diff --git a/src/GitHub.Extensions/ReflectionExtensions.cs b/src/GitHub.Extensions/ReflectionExtensions.cs index dd72f690bc..df10dba2f4 100644 --- a/src/GitHub.Extensions/ReflectionExtensions.cs +++ b/src/GitHub.Extensions/ReflectionExtensions.cs @@ -1,10 +1,10 @@ -using NullGuard; -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Reflection; +using System.Runtime.Serialization; namespace GitHub.Extensions { @@ -12,6 +12,8 @@ public static class ReflectionExtensions { public static IEnumerable GetLoadableTypes(this Assembly assembly) { + Guard.ArgumentNotNull(assembly, nameof(assembly)); + try { return assembly.GetTypes(); @@ -24,6 +26,9 @@ public static IEnumerable GetLoadableTypes(this Assembly assembly) public static bool HasInterface(this Type type, Type targetInterface) { + Guard.ArgumentNotNull(type, nameof(type)); + Guard.ArgumentNotNull(targetInterface, nameof(targetInterface)); + if (targetInterface.IsAssignableFrom(type)) return true; return type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == targetInterface); @@ -32,6 +37,9 @@ public static bool HasInterface(this Type type, Type targetInterface) [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter")] public static string GetCustomAttributeValue(this Assembly assembly, string propertyName) where T : Attribute { + Guard.ArgumentNotNull(assembly, nameof(assembly)); + Guard.ArgumentNotEmptyString(propertyName, nameof(propertyName)); + if (assembly == null || string.IsNullOrEmpty(propertyName)) return string.Empty; object[] attributes = assembly.GetCustomAttributes(typeof(T), false); @@ -47,27 +55,19 @@ public static string GetCustomAttributeValue(this Assembly assembly, string p return value.ToString(); } - [return: AllowNull] - public static object GetValueForProperty(this Type type, object instance, string propName) + public static T CreateUninitialized() { - var prop = type.GetProperty(propName); - Debug.Assert(prop != null, string.Format(CultureInfo.InvariantCulture, "'{0}' {1} not found in assembly '{2}'. Check if it's been moved or mistyped.", - propName, "property", type.Assembly.GetCustomAttributeValue("Version"))); - if (prop == null) - return null; - var getm = prop.GetGetMethod(); - Debug.Assert(prop != null, string.Format(CultureInfo.InvariantCulture, "'{0}' {1} not found in assembly '{2}'. Check if it's been moved or mistyped.", - propName, "getter", type.Assembly.GetCustomAttributeValue("Version"))); - if (getm == null) - return null; - try { - return getm.Invoke(instance, null); - } - catch (Exception ex) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "'{0}' {1} in assembly '{2}' threw an exception. {3}.", - propName, "getter", type.Assembly.GetCustomAttributeValue("Version"), ex)); - } + // WARNING: THIS METHOD IS PURE EVIL! + // Only use this in cases where T is sealed and has an internal ctor and + // you're SURE the API you're passing it into won't do anything interesting with it. + // Even then, consider refactoring. + return (T)FormatterServices.GetUninitializedObject(typeof(T)); + } + + public static void Invoke(object obj, string methodName, params object[] parameters) + { + var method = obj.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + method.Invoke(obj, parameters); } } } diff --git a/src/GitHub.Extensions/StringExtensions.cs b/src/GitHub.Extensions/StringExtensions.cs index e9fa468dc8..be59463011 100644 --- a/src/GitHub.Extensions/StringExtensions.cs +++ b/src/GitHub.Extensions/StringExtensions.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text; -using NullGuard; +using System.Text.RegularExpressions; +using GitHub.Logging; +using Splat; namespace GitHub.Extensions { @@ -12,35 +16,40 @@ public static class StringExtensions { public static bool Contains(this string s, string expectedSubstring, StringComparison comparison) { + Guard.ArgumentNotNull(s, nameof(s)); + Guard.ArgumentNotNull(expectedSubstring, nameof(expectedSubstring)); + return s.IndexOf(expectedSubstring, comparison) > -1; } public static bool ContainsAny(this string s, IEnumerable characters) { + Guard.ArgumentNotNull(s, nameof(s)); + return s.IndexOfAny(characters.ToArray()) > -1; } - public static string DebugRepresentation([AllowNull]this string s) + public static string DebugRepresentation(this string s) { s = s ?? "(null)"; return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", s); } - [return: AllowNull] - public static string ToNullIfEmpty([AllowNull]this string s) + public static string ToNullIfEmpty(this string s) { return String.IsNullOrEmpty(s) ? null : s; } - public static bool StartsWith([AllowNull]this string s, char c) + public static bool StartsWith(this string s, char c) { if (String.IsNullOrEmpty(s)) return false; return s.First() == c; } - [return: AllowNull] - public static string RightAfter([AllowNull]this string s, string search) + public static string RightAfter(this string s, string search) { + Guard.ArgumentNotNull(search, nameof(search)); + if (s == null) return null; int lastIndex = s.IndexOf(search, StringComparison.OrdinalIgnoreCase); if (lastIndex < 0) @@ -49,9 +58,10 @@ public static string RightAfter([AllowNull]this string s, string search) return s.Substring(lastIndex + search.Length); } - [return: AllowNull] public static string RightAfterLast(this string s, string search) { + Guard.ArgumentNotNull(search, nameof(search)); + if (s == null) return null; int lastIndex = s.LastIndexOf(search, StringComparison.OrdinalIgnoreCase); if (lastIndex < 0) @@ -60,9 +70,10 @@ public static string RightAfterLast(this string s, string search) return s.Substring(lastIndex + search.Length); } - [return: AllowNull] - public static string LeftBeforeLast([AllowNull]this string s, string search) + public static string LeftBeforeLast(this string s, string search) { + Guard.ArgumentNotNull(search, nameof(search)); + if (s == null) return null; int lastIndex = s.LastIndexOf(search, StringComparison.OrdinalIgnoreCase); if (lastIndex < 0) @@ -72,47 +83,63 @@ public static string LeftBeforeLast([AllowNull]this string s, string search) } // Returns a file name even if the path is FUBAR. - [return: AllowNull] - public static string ParseFileName([AllowNull]this string path) + public static string ParseFileName(this string path) { if (path == null) return null; return path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).RightAfterLast(Path.DirectorySeparatorChar + ""); } // Returns the parent directory even if the path is FUBAR. - [return: AllowNull] - public static string ParseParentDirectory([AllowNull]this string path) + public static string ParseParentDirectory(this string path) { if (path == null) return null; return path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).LeftBeforeLast(Path.DirectorySeparatorChar + ""); } - [return: AllowNull] - public static string EnsureStartsWith([AllowNull]this string s, char c) + public static string EnsureStartsWith(this string s, char c) { if (s == null) return null; return c + s.TrimStart(c); } // Ensures the string ends with the specified character. - [return: AllowNull] - public static string EnsureEndsWith([AllowNull]this string s, char c) + public static string EnsureEndsWith(this string s, char c) { if (s == null) return null; return s.TrimEnd(c) + c; } - [return: AllowNull] - public static string NormalizePath([AllowNull]this string path) + public static string EnsureValidPath(this string path) + { + if (string.IsNullOrEmpty(path)) return null; + + var components = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var result = new StringBuilder(); + + foreach (var component in components) + { + if (result.Length > 0) + { + result.Append(Path.DirectorySeparatorChar); + } + + result.Append(CoerceValidFileName(component)); + } + + return result.ToString(); + } + + public static string NormalizePath(this string path) { if (String.IsNullOrEmpty(path)) return null; return path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); } - [return: AllowNull] - public static string TrimEnd([AllowNull]this string s, string suffix) + public static string TrimEnd(this string s, string suffix) { + Guard.ArgumentNotNull(suffix, nameof(suffix)); + if (s == null) return null; if (!s.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return s; @@ -122,6 +149,8 @@ public static string TrimEnd([AllowNull]this string s, string suffix) public static string RemoveSurroundingQuotes(this string s) { + Guard.ArgumentNotNull(s, nameof(s)); + if (s.Length < 2) return s; @@ -138,6 +167,8 @@ public static string RemoveSurroundingQuotes(this string s) public static Int32 ToInt32(this string s) { + Guard.ArgumentNotNull(s, nameof(s)); + Int32 val; return Int32.TryParse(s, out val) ? val : 0; } @@ -150,6 +181,8 @@ public static Int32 ToInt32(this string s) /// A wrapped string using the platform's default newline character. This string will end in a newline. public static string Wrap(this string text, int maxLength = 72) { + Guard.ArgumentNotNull(text, nameof(text)); + if (text.Length == 0) return string.Empty; var sb = new StringBuilder(); @@ -182,9 +215,81 @@ public static string Wrap(this string text, int maxLength = 72) public static Uri ToUriSafe(this string url) { + Guard.ArgumentNotNull(url, nameof(url)); + Uri uri; Uri.TryCreate(url, UriKind.Absolute, out uri); return uri; } + + /// + /// Returns an alphanumeric sentence cased string with dashes and underscores as spaces. + /// + /// The string to format. + [SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase")] + public static string Humanize(this string s) + { + if (String.IsNullOrWhiteSpace(s)) + { + return s; + } + + var matches = Regex.Matches(s, @"[a-zA-Z\d]{1,}", RegexOptions.None); + + if (matches.Count == 0) + { + return s; + } + + var result = matches.Cast().Select(match => match.Value.ToLower(CultureInfo.InvariantCulture)); + var combined = String.Join(" ", result); + return Char.ToUpper(combined[0], CultureInfo.InvariantCulture) + combined.Substring(1); + } + + /// + /// Generates a SHA256 hash for a string. + /// + /// The input string. + /// The SHA256 hash. + public static string GetSha256Hash(this string input) + { + Guard.ArgumentNotNull(input, nameof(input)); + + using (var sha256 = SHA256.Create()) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha256.ComputeHash(bytes); + + return string.Join("", hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); + } + } + + /// + /// Strip illegal chars and reserved words from a candidate filename (should not include the directory path) + /// + /// + /// http://stackoverflow.com/questions/309485/c-sharp-sanitize-file-name + /// + static string CoerceValidFileName(string filename) + { + var invalidChars = Regex.Escape(new string(Path.GetInvalidFileNameChars())); + var invalidReStr = string.Format(CultureInfo.InvariantCulture, @"[{0}]+", invalidChars); + + var reservedWords = new[] + { + "CON", "PRN", "AUX", "CLOCK$", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", + "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", + "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + var sanitisedNamePart = Regex.Replace(filename, invalidReStr, "_"); + foreach (var reservedWord in reservedWords) + { + var reservedWordPattern = string.Format(CultureInfo.InvariantCulture, "^{0}\\.", reservedWord); + sanitisedNamePart = Regex.Replace(sanitisedNamePart, reservedWordPattern, "_reservedWord_.", RegexOptions.IgnoreCase); + } + + return sanitisedNamePart; + } } } diff --git a/src/GitHub.Extensions/TaskExtensions.cs b/src/GitHub.Extensions/TaskExtensions.cs index ca111af36c..a878b8ae28 100644 --- a/src/GitHub.Extensions/TaskExtensions.cs +++ b/src/GitHub.Extensions/TaskExtensions.cs @@ -1,14 +1,18 @@ using System; using System.Threading.Tasks; -using NullGuard; +using GitHub.Logging; +using Serilog; namespace GitHub.Extensions { public static class TaskExtensions { - [return: AllowNull] + static readonly ILogger log = LogManager.ForContext(typeof(TaskExtensions)); + public static async Task Catch(this Task source, Func handler = null) { + Guard.ArgumentNotNull(source, nameof(source)); + try { return await source; @@ -21,9 +25,10 @@ public static async Task Catch(this Task source, Func han } } - [return: AllowNull] public static async Task Catch(this Task source, Action handler = null) { + Guard.ArgumentNotNull(source, nameof(source)); + try { await source; @@ -35,9 +40,37 @@ public static async Task Catch(this Task source, Action handler = nul } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA1801:ReviewUnusedParameters", MessageId = "task")] - public static void Forget([AllowNull] this Task task) + /// + /// Allow task to run and log any exceptions. + /// + /// The to log exceptions from. + /// An error message to log if the task throws. + public static void Forget(this Task task, string errorMessage = "") + { + task.ContinueWith(t => + { + if (t.IsFaulted) + { + log.Error(t.Exception, errorMessage); + } + }); + } + + /// + /// Allow task to run and log any exceptions. + /// + /// The task to log exceptions from. + /// The logger to use. + /// The error message to log if the task throws. + public static void Forget(this Task task, ILogger log, string errorMessage = "") { + task.ContinueWith(t => + { + if (t.IsFaulted) + { + log.Error(t.Exception, errorMessage); + } + }); } } } diff --git a/src/GitHub.Extensions/packages.config b/src/GitHub.Extensions/packages.config deleted file mode 100644 index c8d7b8bb26..0000000000 --- a/src/GitHub.Extensions/packages.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/GitHub.InlineReviews/Commands/InlineCommentNavigationCommand.cs b/src/GitHub.InlineReviews/Commands/InlineCommentNavigationCommand.cs new file mode 100644 index 0000000000..58d43b4545 --- /dev/null +++ b/src/GitHub.InlineReviews/Commands/InlineCommentNavigationCommand.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using GitHub.Commands; +using GitHub.InlineReviews.Services; +using GitHub.InlineReviews.Tags; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using GitHub.Services.Vssdk.Commands; +using Microsoft; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.ComponentModelHost; +using Microsoft.VisualStudio.Editor; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Differencing; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.TextManager.Interop; +using Serilog; + +namespace GitHub.InlineReviews.Commands +{ + /// + /// Base class for commands that navigate between inline comments. + /// + abstract class InlineCommentNavigationCommand : VsCommand + { + static readonly ILogger log = LogManager.ForContext(); + readonly IGitHubServiceProvider serviceProvider; + readonly IViewTagAggregatorFactoryService tagAggregatorFactory; + readonly IInlineCommentPeekService peekService; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The tag aggregator factory. + /// The peek service. + /// The GUID of the group the command belongs to. + /// The numeric identifier of the command. + protected InlineCommentNavigationCommand( + IGitHubServiceProvider serviceProvider, + IViewTagAggregatorFactoryService tagAggregatorFactory, + IInlineCommentPeekService peekService, + Guid commandSet, + int commandId) + : base(commandSet, commandId) + { + this.serviceProvider = serviceProvider; + this.tagAggregatorFactory = tagAggregatorFactory; + this.peekService = peekService; + BeforeQueryStatus += QueryStatus; + } + + /// + /// Gets the text buffer position for the line specified in the parameters or from the + /// cursor point if no line is specified or is null. + /// + /// The parameters. + /// The text view. + /// + protected int GetCursorPoint(ITextView textView, InlineCommentNavigationParams parameter) + { + if (parameter?.FromLine != null) + { + return parameter.FromLine > -1 ? GetCursorPoint(textView, parameter.FromLine.Value) : -1; + } + else + { + return textView.Caret.Position.BufferPosition.Position; + } + } + + /// + /// Gets the text buffer position for the specified line. + /// + /// The text view containing the buffer + /// The 0-based line number. + /// + protected static int GetCursorPoint(ITextView textView, int lineNumber) + { + lineNumber = Math.Max(0, Math.Min(lineNumber, textView.TextSnapshot.LineCount - 1)); + return textView.TextSnapshot.GetLineFromLineNumber(lineNumber).Start.Position; + } + + /// + /// Gets the currently active text view(s) from Visual Studio. + /// + /// + /// Zero, one or two active objects. + /// + /// + /// This method will return a single text view for a normal code window, or a pair of text + /// views if the currently active text view is a difference view in side by side mode, with + /// the first item being the side that currently has focus. If there is no active text view, + /// an empty collection will be returned. + /// + protected IEnumerable GetCurrentTextViews() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var result = new List(); + + try + { + var monitorSelection = (IVsMonitorSelection)serviceProvider.GetService(typeof(SVsShellMonitorSelection)); + if (monitorSelection == null) + { + return result; + } + + object curDocument; + if (ErrorHandler.Failed(monitorSelection.GetCurrentElementValue((uint)VSConstants.VSSELELEMID.SEID_DocumentFrame, out curDocument))) + { + return result; + } + + IVsWindowFrame frame = curDocument as IVsWindowFrame; + if (frame == null) + { + return result; + } + + object docView = null; + if (ErrorHandler.Failed(frame.GetProperty((int)__VSFPROPID.VSFPROPID_DocView, out docView))) + { + return result; + } + + if (docView is IVsDifferenceCodeWindow) + { + var diffWindow = (IVsDifferenceCodeWindow)docView; + + switch (diffWindow.DifferenceViewer.ViewMode) + { + case DifferenceViewMode.Inline: + result.Add(diffWindow.DifferenceViewer.InlineView); + break; + case DifferenceViewMode.SideBySide: + switch (diffWindow.DifferenceViewer.ActiveViewType) + { + case DifferenceViewType.LeftView: + result.Add(diffWindow.DifferenceViewer.LeftView); + result.Add(diffWindow.DifferenceViewer.RightView); + break; + case DifferenceViewType.RightView: + result.Add(diffWindow.DifferenceViewer.RightView); + result.Add(diffWindow.DifferenceViewer.LeftView); + break; + } + result.Add(diffWindow.DifferenceViewer.LeftView); + break; + case DifferenceViewMode.RightViewOnly: + result.Add(diffWindow.DifferenceViewer.RightView); + break; + } + } + else if (docView is IVsCodeWindow) + { + IVsTextView textView; + if (ErrorHandler.Failed(((IVsCodeWindow)docView).GetPrimaryView(out textView))) + { + return result; + } + + var model = (IComponentModel)serviceProvider.GetService(typeof(SComponentModel)); + Assumes.Present(model); + + var adapterFactory = model.GetService(); + var wpfTextView = adapterFactory.GetWpfTextView(textView); + result.Add(wpfTextView); + } + } + catch (Exception e) + { + log.Error(e, "Exception in InlineCommentNavigationCommand.GetCurrentTextViews()"); + } + + return result; + } + + /// + /// Creates a tag aggregator for the specified text view. + /// + /// The text view. + /// The tag aggregator + protected ITagAggregator CreateTagAggregator(ITextView textView) + { + return tagAggregatorFactory.CreateTagAggregator(textView); + } + + /// + /// Gets the s for the specified text view. + /// + /// The active text views. + /// A collection of objects, ordered by line. + protected IReadOnlyList GetTags(IEnumerable textViews) + { + var result = new List(); + + foreach (var textView in textViews) + { + var tagAggregator = CreateTagAggregator(textView); + var span = new SnapshotSpan(textView.TextSnapshot, 0, textView.TextSnapshot.Length); + var mappingSpan = textView.BufferGraph.CreateMappingSpan(span, SpanTrackingMode.EdgeExclusive); + var tags = tagAggregator.GetTags(mappingSpan) + .Select(x => new TagInfo + { + TextView = textView, + Point = Map(x.Span.Start, textView.TextSnapshot), + Tag = x.Tag as ShowInlineCommentTag, + }) + .Where(x => x.Tag != null && x.Point.HasValue); + result.AddRange(tags); + } + + result.Sort(TagInfoComparer.Instance); + return result; + } + + /// + /// Shows the inline comments for the specified tag in a peek view. + /// + /// The text view containing the tag + /// The inline comment tag + /// The navigation parameter detailing a search from the specified tag + /// The full list of text views + protected void ShowPeekComments( + InlineCommentNavigationParams parameter, + ITextView textView, + ShowInlineCommentTag tag, + IEnumerable allTextViews) + { + foreach (var other in allTextViews) + { + if (other != textView) + { + peekService.Hide(other); + } + } + + var side = tag.DiffChangeType == DiffChangeType.Delete ? DiffSide.Left : DiffSide.Right; + var point = peekService.Show(textView, side, tag.LineNumber); + + if (parameter?.MoveCursor != false) + { + var caretPoint = textView.BufferGraph.MapUpToSnapshot( + point.GetPoint(point.TextBuffer.CurrentSnapshot), + PointTrackingMode.Negative, + PositionAffinity.Successor, + textView.TextSnapshot); + + if (caretPoint.HasValue) + { + (textView as FrameworkElement)?.Focus(); + textView.Caret.MoveTo(caretPoint.Value); + } + } + } + + static SnapshotPoint? Map(IMappingPoint p, ITextSnapshot textSnapshot) + { + return p.GetPoint(textSnapshot.TextBuffer, PositionAffinity.Predecessor); + } + + void QueryStatus(object sender, EventArgs e) + { + var tags = GetTags(GetCurrentTextViews()); + Enabled = tags.Count > 0; + } + + protected interface ITagInfo + { + ITextView TextView { get; } + ShowInlineCommentTag Tag { get; } + SnapshotPoint Point { get; } + } + + class TagInfo : ITagInfo + { + public ITextView TextView { get; set; } + public ShowInlineCommentTag Tag { get; set; } + public SnapshotPoint? Point { get; set; } + + SnapshotPoint ITagInfo.Point => Point.Value; + } + + class TagInfoComparer : IComparer + { + public static readonly TagInfoComparer Instance = new TagInfoComparer(); + public int Compare(ITagInfo x, ITagInfo y) => x.Point.Position - y.Point.Position; + } + } +} diff --git a/src/GitHub.InlineReviews/Commands/NextInlineCommentCommand.cs b/src/GitHub.InlineReviews/Commands/NextInlineCommentCommand.cs new file mode 100644 index 0000000000..42ea92d69a --- /dev/null +++ b/src/GitHub.InlineReviews/Commands/NextInlineCommentCommand.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.VisualStudio; +using GitHub.InlineReviews.Services; +using Microsoft.VisualStudio.Text.Tagging; +using GitHub.Commands; +using GitHub.Services; + +namespace GitHub.InlineReviews.Commands +{ + /// + /// Navigates to and opens the the next inline comment thread in the currently active text view. + /// + [Export(typeof(INextInlineCommentCommand))] + class NextInlineCommentCommand : InlineCommentNavigationCommand, INextInlineCommentCommand + { + /// + /// Gets the GUID of the group the command belongs to. + /// + public static readonly Guid CommandSet = Guids.CommandSetGuid; + + /// + /// Gets the numeric identifier of the command. + /// + public const int CommandId = PkgCmdIDList.NextInlineCommentId; + + /// + /// Initializes a new instance of the class. + /// + /// The GitHub service provider. + /// The tag aggregator factory. + /// The peek service. + [ImportingConstructor] + protected NextInlineCommentCommand( + IGitHubServiceProvider serviceProvider, + IViewTagAggregatorFactoryService tagAggregatorFactory, + IInlineCommentPeekService peekService) + : base(serviceProvider, tagAggregatorFactory, peekService, CommandSet, CommandId) + { + } + + /// + /// Executes the command. + /// + /// A task that tracks the execution of the command. + public override Task Execute(InlineCommentNavigationParams parameter) + { + var textViews = GetCurrentTextViews().ToList(); + var tags = GetTags(textViews); + + if (tags.Count > 0) + { + var cursorPoint = GetCursorPoint(textViews[0], parameter); + var next = tags.FirstOrDefault(x => x.Point > cursorPoint) ?? tags.First(); + ShowPeekComments(parameter, next.TextView, next.Tag, textViews); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.InlineReviews/Commands/PreviousInlineCommentCommand.cs b/src/GitHub.InlineReviews/Commands/PreviousInlineCommentCommand.cs new file mode 100644 index 0000000000..782d838f00 --- /dev/null +++ b/src/GitHub.InlineReviews/Commands/PreviousInlineCommentCommand.cs @@ -0,0 +1,63 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Text.Tagging; +using GitHub.VisualStudio; +using GitHub.InlineReviews.Services; +using GitHub.Commands; +using GitHub.Services; + +namespace GitHub.InlineReviews.Commands +{ + /// + /// Navigates to and opens the the previous inline comment thread in the currently active text view. + /// + [Export(typeof(IPreviousInlineCommentCommand))] + class PreviousInlineCommentCommand : InlineCommentNavigationCommand, IPreviousInlineCommentCommand + { + /// + /// Gets the GUID of the group the command belongs to. + /// + public static readonly Guid CommandSet = Guids.CommandSetGuid; + + /// + /// Gets the numeric identifier of the command. + /// + public const int CommandId = PkgCmdIDList.PreviousInlineCommentId; + + /// + /// Initializes a new instance of the class. + /// + /// The GitHub service provider. + /// The tag aggregator factory. + /// The peek service. + [ImportingConstructor] + protected PreviousInlineCommentCommand( + IGitHubServiceProvider serviceProvider, + IViewTagAggregatorFactoryService tagAggregatorFactory, + IInlineCommentPeekService peekService) + : base(serviceProvider, tagAggregatorFactory, peekService, CommandSet, CommandId) + { + } + + /// + /// Executes the command. + /// + /// A task that tracks the execution of the command. + public override Task Execute(InlineCommentNavigationParams parameter) + { + var textViews = GetCurrentTextViews().ToList(); + var tags = GetTags(textViews); + + if (tags.Count > 0) + { + var cursorPoint = GetCursorPoint(textViews[0], parameter); + var next = tags.LastOrDefault(x => x.Point < cursorPoint) ?? tags.Last(); + ShowPeekComments(parameter, next.TextView, next.Tag, textViews); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.InlineReviews/Commands/ToggleInlineCommentMarginCommand.cs b/src/GitHub.InlineReviews/Commands/ToggleInlineCommentMarginCommand.cs new file mode 100644 index 0000000000..b01ad42f4c --- /dev/null +++ b/src/GitHub.InlineReviews/Commands/ToggleInlineCommentMarginCommand.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using System.ComponentModel.Composition; +using GitHub.Commands; +using GitHub.Services; +using GitHub.Extensions; +using GitHub.VisualStudio; +using GitHub.InlineReviews.Margins; +using GitHub.Services.Vssdk.Commands; +using Microsoft.VisualStudio.TextManager.Interop; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Editor; + +namespace GitHub.InlineReviews.Commands +{ + [Export(typeof(IToggleInlineCommentMarginCommand))] + public class ToggleInlineCommentMarginCommand : VsCommand, IToggleInlineCommentMarginCommand + { + /// + /// Gets the GUID of the group the command belongs to. + /// + public static readonly Guid CommandSet = Guids.CommandSetGuid; + + /// + /// Gets the numeric identifier of the command. + /// + public const int CommandId = PkgCmdIDList.ToggleInlineCommentMarginId; + + readonly Lazy textManager; + readonly Lazy editorAdapter; + readonly Lazy usageTracker; + + [ImportingConstructor] + public ToggleInlineCommentMarginCommand( + IGitHubServiceProvider serviceProvider, + Lazy editorAdapter, + Lazy usageTracker) : base(CommandSet, CommandId) + { + textManager = new Lazy(() => serviceProvider.GetService()); + this.editorAdapter = editorAdapter; + this.usageTracker = usageTracker; + } + + public override Task Execute() + { + usageTracker.Value.IncrementCounter(x => x.ExecuteToggleInlineCommentMarginCommand).Forget(); + + IVsTextView activeView = null; + if (textManager.Value.GetActiveView(1, null, out activeView) == VSConstants.S_OK) + { + var wpfTextView = editorAdapter.Value.GetWpfTextView(activeView); + var options = wpfTextView.Options; + var enabled = options.GetOptionValue(InlineCommentTextViewOptions.MarginEnabledId); + options.SetOptionValue(InlineCommentTextViewOptions.MarginEnabledId, !enabled); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj b/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj new file mode 100644 index 0000000000..12ca598f0e --- /dev/null +++ b/src/GitHub.InlineReviews/GitHub.InlineReviews.csproj @@ -0,0 +1,314 @@ + + + + + $(VisualStudioVersion) + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + true + + + + + Debug + AnyCPU + 2.0 + {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + {7F5ED78B-74A3-4406-A299-70CFB5885B8B} + Library + Properties + GitHub.InlineReviews + GitHub.InlineReviews + 7.3 + v4.6 + true + true + true + true + true + true + true + False + False + full + true + + + false + TRACE;DEBUG + prompt + 4 + false + bin\Debug\ + + + true + TRACE + prompt + 4 + false + bin\Release\ + + + + + Properties\SolutionInfo.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ShowInlineAnnotationGlyph.xaml + + + ShowInlineCommentAnnotationGlyph.xaml + + + + + GlyphMarginGrid.xaml + + + InlineCommentPeekView.xaml + + + + + + AddInlineCommentGlyph.xaml + + + ShowInlineCommentGlyph.xaml + + + + + + + + PullRequestStatusView.xaml + + + + + + {3321ce72-26ed-4d1e-a8f5-6901fb783007} + Octokit.GraphQL.Core + + + {791b408c-0abc-465b-9eb1-a2422d67f418} + Octokit.GraphQL + + + {08dd4305-7787-4823-a53f-4d0f725a07f3} + Octokit + + + {e899b03c-6e8e-4375-ab65-fc925d721d8b} + ReactiveUI.Wpf + + + {a4f579f3-77d3-450a-aacc-f2653ef11e69} + ReactiveUI + + + {ad0306b7-f88e-44a4-ab36-1d04822e9234} + Splat + + + {b389adaf-62cc-486e-85b4-2d8b078df763} + GitHub.Api + + + {1A1DA411-8D1F-4578-80A6-04576BEA2DC5} + GitHub.App + + + {e4ed0537-d1d9-44b6-9212-3096d7c3f7a1} + GitHub.Exports.Reactive + + + {9aea02db-02b5-409c-b0ca-115d05331a6b} + GitHub.Exports + + + {6559E128-8B40-49A5-85A8-05565ED0C7E3} + GitHub.Extensions.Reactive + + + {6AFE2E2D-6DB0-4430-A2EA-F5F5388D2F78} + GitHub.Extensions + + + {8d73575a-a89f-47cc-b153-b47dd06837f0} + GitHub.Logging + + + {54e8d71a-aabb-4698-95fe-7f11612b8e59} + GitHub.Resources + + + {2d3d2834-33be-45ca-b3cc-12f853557d7b} + GitHub.Services.Vssdk + + + {158b05e8-fdbc-4d71-b871-c96e28d5adf5} + GitHub.UI.Reactive + + + {346384dd-2445-4a28-af22-b45f3957bd89} + GitHub.UI + + + {d1dfbb0c-b570-4302-8f1e-2e3a19c41961} + GitHub.VisualStudio.UI + + + + + + + False + + + + + + + + + + + + + + + + + + + + Menus.ctmenu + Designer + + + + + true + VSPackage + Designer + + + + + MSBuild:Compile + Designer + true + + + MSBuild:Compile + Designer + + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + MSBuild:Compile + Designer + + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + + + + + + + + + 0.2.1 + + + 15.8.36 + + + 15.8.192 + + + 15.8.3252 + runtime; build; native; contentfiles; analyzers + all + + + 2.5.0 + + + 0.12.0 + + + 4.0.0 + + + 4.5.0 + + + 12.0.4 + + + + + + + \ No newline at end of file diff --git a/src/GitHub.InlineReviews/GlobalSuppressions.cs b/src/GitHub.InlineReviews/GlobalSuppressions.cs new file mode 100644 index 0000000000..2c35e4d608 --- /dev/null +++ b/src/GitHub.InlineReviews/GlobalSuppressions.cs @@ -0,0 +1,9 @@ + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Style", "VSTHRD200:Use \"Async\" suffix for async methods")] +[assembly: SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Discouraged for VSSDK projects.")] \ No newline at end of file diff --git a/src/GitHub.InlineReviews/Glyph/GlyphData.cs b/src/GitHub.InlineReviews/Glyph/GlyphData.cs new file mode 100644 index 0000000000..8364b320e9 --- /dev/null +++ b/src/GitHub.InlineReviews/Glyph/GlyphData.cs @@ -0,0 +1,46 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using Microsoft.VisualStudio.Text; + +namespace GitHub.InlineReviews.Glyph.Implementation +{ + /// + /// Information about the position of a glyph. + /// + /// The type of glyph tag we're dealing with. + internal class GlyphData + { + double deltaY; + + public GlyphData(SnapshotSpan visualSpan, TGlyphTag tag, UIElement element) + { + VisualSpan = visualSpan; + GlyphType = tag.GetType(); + Glyph = element; + + deltaY = Canvas.GetTop(element); + if (double.IsNaN(deltaY)) + { + deltaY = 0.0; + } + } + + public void SetSnapshot(ITextSnapshot snapshot) + { + VisualSpan = VisualSpan.Value.TranslateTo(snapshot, SpanTrackingMode.EdgeInclusive); + } + + public void SetTop(double top) + { + Canvas.SetTop(Glyph, top + deltaY); + } + + public UIElement Glyph { get; } + + public Type GlyphType { get; } + + public SnapshotSpan? VisualSpan { get; private set; } + } +} + diff --git a/src/GitHub.InlineReviews/Glyph/GlyphMargin.cs b/src/GitHub.InlineReviews/Glyph/GlyphMargin.cs new file mode 100644 index 0000000000..7f9df1c80b --- /dev/null +++ b/src/GitHub.InlineReviews/Glyph/GlyphMargin.cs @@ -0,0 +1,154 @@ +using System; +using System.Reactive.Linq; +using System.Windows.Media; +using System.Windows.Controls; +using System.Collections.Generic; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Text.Formatting; +using Microsoft.VisualStudio.Text.Classification; +using Microsoft.VisualStudio.Text.Editor; +using GitHub.InlineReviews.Glyph.Implementation; +using ReactiveUI; + +namespace GitHub.InlineReviews.Glyph +{ + /// + /// Responsibe for updating the margin when tags change. + /// + /// The type of glyph tag we're managing. + public sealed class GlyphMargin : IDisposable where TGlyphTag : ITag + { + readonly IWpfTextView textView; + readonly Grid marginGrid; + readonly IViewTagAggregatorFactoryService tagAggregatorFactory; + readonly GlyphMarginVisualManager visualManager; + + IDisposable visibleSubscription; + bool refreshAllGlyphs; + ITagAggregator tagAggregator; + bool disposed; + + public GlyphMargin( + IWpfTextView textView, + IGlyphFactory glyphFactory, + Grid marginGrid, + IViewTagAggregatorFactoryService tagAggregatorFactory, + IEditorFormatMap editorFormatMap, + string marginPropertiesName) + { + this.textView = textView; + this.marginGrid = marginGrid; + this.tagAggregatorFactory = tagAggregatorFactory; + visualManager = new GlyphMarginVisualManager(textView, glyphFactory, marginGrid, editorFormatMap, marginPropertiesName); + + // Initialize when first visible + visibleSubscription = marginGrid.WhenAnyValue(x => x.IsVisible).Distinct().Where(x => x).Subscribe(_ => Initialize()); + } + + public void Dispose() + { + if (!disposed) + { + disposed = true; + + textView.LayoutChanged -= OnLayoutChanged; + textView.ZoomLevelChanged -= OnZoomLevelChanged; + + tagAggregator?.Dispose(); + tagAggregator = null; + + visibleSubscription?.Dispose(); + visibleSubscription = null; + } + } + + void Initialize() + { + tagAggregator = tagAggregatorFactory.CreateTagAggregator(textView); + tagAggregator.BatchedTagsChanged += OnBatchedTagsChanged; + textView.LayoutChanged += OnLayoutChanged; + textView.ZoomLevelChanged += OnZoomLevelChanged; + + if (textView.InLayout) + { + refreshAllGlyphs = true; + } + else + { + foreach (var line in textView.TextViewLines) + { + RefreshGlyphsOver(line); + } + } + + marginGrid.LayoutTransform = new ScaleTransform(textView.ZoomLevel / 100.0, textView.ZoomLevel / 100.0); + marginGrid.LayoutTransform.Freeze(); + } + + void OnBatchedTagsChanged(object sender, BatchedTagsChangedEventArgs e) + { + if (!textView.IsClosed) + { + var list = new List(); + foreach (var span in e.Spans) + { + list.AddRange(span.GetSpans(textView.TextSnapshot)); + } + + if (list.Count > 0) + { + var span = list[0]; + int start = span.Start; + int end = span.End; + for (int i = 1; i < list.Count; i++) + { + span = list[i]; + start = Math.Min(start, span.Start); + end = Math.Max(end, span.End); + } + + var rangeSpan = new SnapshotSpan(textView.TextSnapshot, start, end - start); + visualManager.RemoveGlyphsByVisualSpan(rangeSpan); + foreach (var line in textView.TextViewLines.GetTextViewLinesIntersectingSpan(rangeSpan)) + { + RefreshGlyphsOver(line); + } + } + } + } + + void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) + { + visualManager.SetSnapshotAndUpdate(textView.TextSnapshot, e.NewOrReformattedLines, e.VerticalTranslation ? (IList)textView.TextViewLines : e.TranslatedLines); + + var lines = refreshAllGlyphs ? (IList)textView.TextViewLines : e.NewOrReformattedLines; + foreach (var line in lines) + { + visualManager.RemoveGlyphsByVisualSpan(line.Extent); + RefreshGlyphsOver(line); + } + + refreshAllGlyphs = false; + } + + void OnZoomLevelChanged(object sender, ZoomLevelChangedEventArgs e) + { + refreshAllGlyphs = true; + marginGrid.LayoutTransform = e.ZoomTransform; + } + + void RefreshGlyphsOver(ITextViewLine textViewLine) + { + foreach (IMappingTagSpan span in tagAggregator.GetTags(textViewLine.ExtentAsMappingSpan)) + { + NormalizedSnapshotSpanCollection spans; + if (span.Span.Start.GetPoint(textView.VisualSnapshot.TextBuffer, PositionAffinity.Predecessor).HasValue && + ((spans = span.Span.GetSpans(textView.TextSnapshot)).Count > 0)) + { + visualManager.AddGlyph(span.Tag, spans[0]); + } + } + } + } +} diff --git a/src/GitHub.InlineReviews/Glyph/GlyphMarginVisualManager.cs b/src/GitHub.InlineReviews/Glyph/GlyphMarginVisualManager.cs new file mode 100644 index 0000000000..bc5444919b --- /dev/null +++ b/src/GitHub.InlineReviews/Glyph/GlyphMarginVisualManager.cs @@ -0,0 +1,199 @@ +using System; +using System.Windows; +using System.Windows.Media; +using System.Windows.Controls; +using System.Collections.Generic; +using Microsoft.VisualStudio.PlatformUI; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Text.Formatting; +using Microsoft.VisualStudio.Text.Classification; + +namespace GitHub.InlineReviews.Glyph.Implementation +{ + /// + /// Manage the MarginVisual element. + /// + /// The type of tag we're managing. + internal class GlyphMarginVisualManager where TGlyphTag : ITag + { + readonly IEditorFormatMap editorFormatMap; + readonly IGlyphFactory glyphFactory; + readonly Grid glyphMarginGrid; + readonly string marginPropertiesName; + readonly IWpfTextView textView; + readonly Dictionary visuals; + + Dictionary> glyphs; + + public GlyphMarginVisualManager(IWpfTextView textView, IGlyphFactory glyphFactory, Grid glyphMarginGrid, + IEditorFormatMap editorFormatMap, string marginPropertiesName) + { + this.textView = textView; + this.marginPropertiesName = marginPropertiesName; + this.editorFormatMap = editorFormatMap; + this.editorFormatMap.FormatMappingChanged += OnFormatMappingChanged; + this.textView.Closed += new EventHandler(OnTextViewClosed); + this.glyphFactory = glyphFactory; + this.glyphMarginGrid = glyphMarginGrid; + UpdateBackgroundColor(); + + glyphs = new Dictionary>(); + visuals = new Dictionary(); + + foreach (Type type in glyphFactory.GetTagTypes()) + { + if (!visuals.ContainsKey(type)) + { + var element = new Canvas(); + element.ClipToBounds = true; + glyphMarginGrid.Children.Add(element); + visuals[type] = element; + } + } + } + + public void AddGlyph(TGlyphTag tag, SnapshotSpan span) + { + var textViewLines = textView.TextViewLines; + var glyphType = tag.GetType(); + if (textView.TextViewLines.IntersectsBufferSpan(span)) + { + var startingLine = GetStartingLine(textViewLines, span) as IWpfTextViewLine; + if (startingLine != null) + { + var element = (FrameworkElement)glyphFactory.GenerateGlyph(startingLine, tag); + if (element != null) + { + var data = new GlyphData(span, tag, element); + element.Width = glyphMarginGrid.Width; + + // draw where text is + element.Height = startingLine.TextHeight + 1; // HACK: +1 to fill gaps + data.SetTop(startingLine.TextTop - textView.ViewportTop); + + glyphs[element] = data; + visuals[glyphType].Children.Add(element); + } + } + } + } + + public void RemoveGlyphsByVisualSpan(SnapshotSpan span) + { + var list = new List(); + foreach (var pair in glyphs) + { + var data = pair.Value; + if (data.VisualSpan.HasValue && span.IntersectsWith(data.VisualSpan.Value)) + { + list.Add(pair.Key); + visuals[data.GlyphType].Children.Remove(data.Glyph); + } + } + + foreach (var element in list) + { + glyphs.Remove(element); + } + } + + public void SetSnapshotAndUpdate(ITextSnapshot snapshot, IList newOrReformattedLines, IList translatedLines) + { + if (glyphs.Count > 0) + { + var dictionary = new Dictionary>(glyphs.Count); + foreach (var pair in glyphs) + { + var data = pair.Value; + if (!data.VisualSpan.HasValue) + { + dictionary[pair.Key] = data; + continue; + } + + data.SetSnapshot(snapshot); + SnapshotSpan bufferSpan = data.VisualSpan.Value; + if (!textView.TextViewLines.IntersectsBufferSpan(bufferSpan) || GetStartingLine(newOrReformattedLines, bufferSpan) != null) + { + visuals[data.GlyphType].Children.Remove(data.Glyph); + continue; + } + + dictionary[data.Glyph] = data; + var startingLine = GetStartingLine(translatedLines, bufferSpan); + if (startingLine != null) + { + data.SetTop(startingLine.TextTop - textView.ViewportTop); + } + } + + glyphs = dictionary; + } + } + + static ITextViewLine GetStartingLine(IList lines, Span span) + { + if (lines.Count > 0) + { + int num = 0; + int count = lines.Count; + while (num < count) + { + int middle = (num + count) / 2; + var middleLine = lines[middle]; + if (span.Start < middleLine.Start) + { + count = middle; + } + else + { + if (span.Start >= middleLine.EndIncludingLineBreak) + { + num = middle + 1; + continue; + } + + return middleLine; + } + } + + var line = lines[lines.Count - 1]; + if (line.EndIncludingLineBreak == line.Snapshot.Length && span.Start == line.EndIncludingLineBreak) + { + return line; + } + } + + return null; + } + + void OnFormatMappingChanged(object sender, FormatItemsEventArgs e) + { + if (e.ChangedItems.Contains(marginPropertiesName)) + { + UpdateBackgroundColor(); + } + } + + void OnTextViewClosed(object sender, EventArgs e) + { + editorFormatMap.FormatMappingChanged -= OnFormatMappingChanged; + } + + void UpdateBackgroundColor() + { + // set background color for children + var properties = editorFormatMap.GetProperties(marginPropertiesName); + if (properties.Contains("BackgroundColor")) + { + var backgroundColor = (Color)properties["BackgroundColor"]; + ImageThemingUtilities.SetImageBackgroundColor(glyphMarginGrid, backgroundColor); + } + } + + public FrameworkElement MarginVisual => glyphMarginGrid; + } +} + diff --git a/src/GitHub.InlineReviews/Glyph/IGlyphFactory.cs b/src/GitHub.InlineReviews/Glyph/IGlyphFactory.cs new file mode 100644 index 0000000000..6ec57355c4 --- /dev/null +++ b/src/GitHub.InlineReviews/Glyph/IGlyphFactory.cs @@ -0,0 +1,29 @@ +using System; +using System.Windows; +using System.Collections.Generic; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Text.Formatting; + +namespace GitHub.InlineReviews.Glyph +{ + /// + /// A factory for a type of tag (or multiple subtypes). + /// + /// + public interface IGlyphFactory where TGlyphTag : ITag + { + /// + /// Create a glyph element for a particular line and tag. + /// + /// The line. + /// The tag. + /// + UIElement GenerateGlyph(IWpfTextViewLine line, TGlyphTag tag); + + /// + /// A list of tag subtypes this is a factory for. + /// + /// + IEnumerable GetTagTypes(); + } +} diff --git a/src/GitHub.InlineReviews/InlineReviewsPackage.cs b/src/GitHub.InlineReviews/InlineReviewsPackage.cs new file mode 100644 index 0000000000..f52aea41c2 --- /dev/null +++ b/src/GitHub.InlineReviews/InlineReviewsPackage.cs @@ -0,0 +1,67 @@ +using System; +using System.ComponentModel.Design; +using System.Runtime.InteropServices; +using System.Threading; +using GitHub.Exports; +using GitHub.Logging; +using GitHub.Commands; +using GitHub.Services.Vssdk.Commands; +using GitHub.VisualStudio; +using Microsoft.VisualStudio.ComponentModelHost; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using Serilog; +using Task = System.Threading.Tasks.Task; +using Microsoft; + +namespace GitHub.InlineReviews +{ + [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] + [Guid(Guids.InlineReviewsPackageId)] + [ProvideAutoLoad(Guids.GitContextPkgString, PackageAutoLoadFlags.BackgroundLoad)] + [ProvideMenuResource("Menus.ctmenu", 1)] + public class InlineReviewsPackage : AsyncPackage + { + static readonly ILogger log = LogManager.ForContext(); + + protected override async Task InitializeAsync( + CancellationToken cancellationToken, + IProgress progress) + { + var componentModel = (IComponentModel)(await GetServiceAsync(typeof(SComponentModel))); + Assumes.Present(componentModel); + + var exports = componentModel.DefaultExportProvider; + + // Avoid delays when there is ongoing UI activity. + // See: https://github.com/github/VisualStudio/issues/1537 + await JoinableTaskFactory.RunAsync(VsTaskRunContext.UIThreadNormalPriority, InitializeMenus); + } + + async Task InitializeMenus() + { + if (!ExportForVisualStudioProcessAttribute.IsVisualStudioProcess()) + { + log.Warning("Don't initialize menus for non-Visual Studio process"); + return; + } + + var componentModel = (IComponentModel)(await GetServiceAsync(typeof(SComponentModel))); + Assumes.Present(componentModel); + + var exports = componentModel.DefaultExportProvider; + var commands = new IVsCommandBase[] + { + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue() + }; + + await JoinableTaskFactory.SwitchToMainThreadAsync(); + var menuService = (IMenuCommandService)(await GetServiceAsync(typeof(IMenuCommandService))); + Assumes.Present(menuService); + + menuService.AddCommands(commands); + } + } +} diff --git a/src/GitHub.InlineReviews/InlineReviewsPackage.vsct b/src/GitHub.InlineReviews/InlineReviewsPackage.vsct new file mode 100644 index 0000000000..d9e99ccb84 --- /dev/null +++ b/src/GitHub.InlineReviews/InlineReviewsPackage.vsct @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Margins/InlineCommentMargin.cs b/src/GitHub.InlineReviews/Margins/InlineCommentMargin.cs new file mode 100644 index 0000000000..6736d3f855 --- /dev/null +++ b/src/GitHub.InlineReviews/Margins/InlineCommentMargin.cs @@ -0,0 +1,141 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.Extensions; +using GitHub.InlineReviews.Tags; +using GitHub.InlineReviews.Views; +using GitHub.InlineReviews.Glyph; +using GitHub.InlineReviews.Services; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Text.Classification; +using ReactiveUI; + +namespace GitHub.InlineReviews.Margins +{ + public sealed class InlineCommentMargin : IWpfTextViewMargin + { + public const string MarginName = "InlineComment"; + const string MarginPropertiesName = "Indicator Margin"; // Same background color as Glyph margin + + readonly IWpfTextView textView; + readonly IPullRequestSessionManager sessionManager; + readonly Grid marginGrid; + + GlyphMargin glyphMargin; + IDisposable currentSessionSubscription; + IDisposable visibleSubscription; + bool hasChanges; + bool hasInfo; + + public InlineCommentMargin( + IWpfTextViewHost wpfTextViewHost, + IInlineCommentPeekService peekService, + IEditorFormatMapService editorFormatMapService, + IViewTagAggregatorFactoryService tagAggregatorFactory, + Lazy sessionManager) + { + textView = wpfTextViewHost.TextView; + this.sessionManager = sessionManager.Value; + + // Default to not show comment margin + textView.Options.SetOptionValue(InlineCommentTextViewOptions.MarginEnabledId, false); + + marginGrid = new GlyphMarginGrid { Width = 17.0 }; + var glyphFactory = new InlineCommentGlyphFactory(peekService, textView); + var editorFormatMap = editorFormatMapService.GetEditorFormatMap(textView); + + glyphMargin = new GlyphMargin(textView, glyphFactory, marginGrid, tagAggregatorFactory, + editorFormatMap, MarginPropertiesName); + + if (IsDiffView()) + { + TrackCommentGlyph(wpfTextViewHost, marginGrid); + } + + currentSessionSubscription = this.sessionManager.WhenAnyValue(x => x.CurrentSession) + .Subscribe(x => RefreshCurrentSession().Forget()); + + visibleSubscription = marginGrid.WhenAnyValue(x => x.IsVisible) + .Subscribe(x => textView.Options.SetOptionValue(InlineCommentTextViewOptions.MarginVisibleId, x)); + + textView.Options.OptionChanged += (s, e) => RefreshMarginVisibility(); + } + + async Task RefreshCurrentSession() + { + var sessionFile = await FindSessionFile(); + hasChanges = sessionFile?.Diff != null && sessionFile.Diff.Count > 0; + + await Task.Yield(); // HACK: Give diff view a chance to initialize. + var info = sessionManager.GetTextBufferInfo(textView.TextBuffer); + hasInfo = info != null; + + RefreshMarginVisibility(); + } + + public ITextViewMargin GetTextViewMargin(string name) + { + return (name == MarginName) ? this : null; + } + + public void Dispose() + { + visibleSubscription?.Dispose(); + visibleSubscription = null; + + currentSessionSubscription?.Dispose(); + currentSessionSubscription = null; + + glyphMargin?.Dispose(); + glyphMargin = null; + } + + public FrameworkElement VisualElement => marginGrid; + + public double MarginSize => marginGrid.Width; + + public bool Enabled => IsMarginVisible(); + + async Task FindSessionFile() + { + await sessionManager.EnsureInitialized(); + + var session = sessionManager.CurrentSession; + if (session == null) + { + return null; + } + + var relativePath = sessionManager.GetRelativePath(textView.TextBuffer); + if (relativePath == null) + { + return null; + } + + return await session.GetFile(relativePath); + } + + bool IsDiffView() => textView.Roles.Contains("DIFF"); + + void TrackCommentGlyph(IWpfTextViewHost host, UIElement marginElement) + { + var router = new MouseEnterAndLeaveEventRouter(); + router.Add(host.HostControl, marginElement); + } + + void RefreshMarginVisibility() + { + marginGrid.Visibility = IsMarginVisible() ? Visibility.Visible : Visibility.Collapsed; + } + + bool IsMarginVisible() + { + var enabled = textView.Options.GetOptionValue(InlineCommentTextViewOptions.MarginEnabledId); + return hasInfo || (enabled && hasChanges); + } + } +} diff --git a/src/GitHub.InlineReviews/Margins/InlineCommentMarginEnabled.cs b/src/GitHub.InlineReviews/Margins/InlineCommentMarginEnabled.cs new file mode 100644 index 0000000000..3249371eff --- /dev/null +++ b/src/GitHub.InlineReviews/Margins/InlineCommentMarginEnabled.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Text.Editor; + +namespace GitHub.InlineReviews.Margins +{ + [Export(typeof(EditorOptionDefinition))] + public class InlineCommentMarginEnabled : ViewOptionDefinition + { + public override bool Default => false; + + public override EditorOptionKey Key => InlineCommentTextViewOptions.MarginEnabledId; + } +} diff --git a/src/GitHub.InlineReviews/Margins/InlineCommentMarginProvider.cs b/src/GitHub.InlineReviews/Margins/InlineCommentMarginProvider.cs new file mode 100644 index 0000000000..fb91f8817b --- /dev/null +++ b/src/GitHub.InlineReviews/Margins/InlineCommentMarginProvider.cs @@ -0,0 +1,60 @@ +using System; +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Utilities; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Text.Classification; +using GitHub.Services; +using GitHub.VisualStudio; + +namespace GitHub.InlineReviews.Margins +{ + [Export(typeof(IWpfTextViewMarginProvider))] + [Name(InlineCommentMargin.MarginName)] + [Order(After = PredefinedMarginNames.Glyph)] + [MarginContainer(PredefinedMarginNames.Left)] + [ContentType("text")] + [TextViewRole("LEFTDIFF")] + [TextViewRole("RIGHTDIFF")] + [TextViewRole("INLINEDIFF")] + internal sealed class InlineCommentMarginProvider : IWpfTextViewMarginProvider + { + readonly Lazy editorFormatMapService; + readonly Lazy tagAggregatorFactory; + readonly Lazy peekService; + readonly Lazy sessionManager; + readonly UIContext uiContext; + + [ImportingConstructor] + public InlineCommentMarginProvider( + Lazy sessionManager, + Lazy editorFormatMapService, + Lazy tagAggregatorFactory, + Lazy peekService) + { + this.sessionManager = sessionManager; + this.editorFormatMapService = editorFormatMapService; + this.tagAggregatorFactory = tagAggregatorFactory; + this.peekService = peekService; + + uiContext = UIContext.FromUIContextGuid(new Guid(Guids.GitContextPkgString)); + } + + public IWpfTextViewMargin CreateMargin(IWpfTextViewHost wpfTextViewHost, IWpfTextViewMargin parent) + { + if (!uiContext.IsActive) + { + // Only create margin when in the context of a Git repository + return null; + } + + return new InlineCommentMargin( + wpfTextViewHost, + peekService.Value, + editorFormatMapService.Value, + tagAggregatorFactory.Value, + sessionManager); + } + } +} diff --git a/src/GitHub.InlineReviews/Margins/InlineCommentMarginVisible.cs b/src/GitHub.InlineReviews/Margins/InlineCommentMarginVisible.cs new file mode 100644 index 0000000000..967a9654f8 --- /dev/null +++ b/src/GitHub.InlineReviews/Margins/InlineCommentMarginVisible.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Text.Editor; + +namespace GitHub.InlineReviews.Margins +{ + [Export(typeof(EditorOptionDefinition))] + public class InlineCommentMarginVisible : ViewOptionDefinition + { + public override bool Default => false; + + public override EditorOptionKey Key => InlineCommentTextViewOptions.MarginVisibleId; + } +} diff --git a/src/GitHub.InlineReviews/Margins/InlineCommentTextViewOptions.cs b/src/GitHub.InlineReviews/Margins/InlineCommentTextViewOptions.cs new file mode 100644 index 0000000000..c6cd4cd85b --- /dev/null +++ b/src/GitHub.InlineReviews/Margins/InlineCommentTextViewOptions.cs @@ -0,0 +1,11 @@ +using Microsoft.VisualStudio.Text.Editor; + +namespace GitHub.InlineReviews.Margins +{ + public static class InlineCommentTextViewOptions + { + public static readonly EditorOptionKey MarginVisibleId = new EditorOptionKey("TextViewHost/InlineCommentMarginVisible"); + + public static readonly EditorOptionKey MarginEnabledId = new EditorOptionKey("TextViewHost/InlineCommentMarginEnabled"); + } +} diff --git a/src/GitHub.InlineReviews/Models/InlineCommentThreadModel.cs b/src/GitHub.InlineReviews/Models/InlineCommentThreadModel.cs new file mode 100644 index 0000000000..c47b0adf6f --- /dev/null +++ b/src/GitHub.InlineReviews/Models/InlineCommentThreadModel.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GitHub.Extensions; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.InlineReviews.Models +{ + /// + /// Represents a thread of inline comments on an . + /// + class InlineCommentThreadModel : ReactiveObject, IInlineCommentThreadModel + { + bool isStale; + int lineNumber = -1; + + /// + /// Initializes a new instance of the class. + /// + /// The relative path to the file that the thread is on. + /// The SHA of the commit that the thread appears on. + /// + /// The last five lines of the thread's diff hunk, in reverse order. + /// + /// The comments in the thread + public InlineCommentThreadModel( + string relativePath, + string commitSha, + IList diffMatch, + IEnumerable comments, + bool isResolved) + { + Guard.ArgumentNotNull(relativePath, nameof(relativePath)); + Guard.ArgumentNotNull(commitSha, nameof(commitSha)); + Guard.ArgumentNotNull(diffMatch, nameof(diffMatch)); + + Comments = comments.ToList(); + DiffMatch = diffMatch; + DiffLineType = diffMatch[0].Type; + CommitSha = commitSha; + RelativePath = relativePath; + IsResolved = isResolved; + + foreach (var comment in comments) + { + comment.Thread = this; + } + } + + /// + public IReadOnlyList Comments { get; } + + /// + public IList DiffMatch { get; } + + /// + public DiffChangeType DiffLineType { get; } + + /// + public bool IsStale + { + get { return isStale; } + set { this.RaiseAndSetIfChanged(ref isStale, value); } + } + + /// + public int LineNumber + { + get { return lineNumber; } + set { this.RaiseAndSetIfChanged(ref lineNumber, value); } + } + + /// + public string CommitSha { get; } + + /// + public string RelativePath { get; } + + /// + public bool IsResolved { get; } + } +} diff --git a/src/GitHub.InlineReviews/Models/PullRequestSessionFile.cs b/src/GitHub.InlineReviews/Models/PullRequestSessionFile.cs new file mode 100644 index 0000000000..8cfa924372 --- /dev/null +++ b/src/GitHub.InlineReviews/Models/PullRequestSessionFile.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Subjects; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.InlineReviews.Models +{ + /// + /// A file in a pull request session. + /// + /// + /// A holds the review comments for a file in a pull + /// request together with associated information such as the commit SHA of the file and the + /// diff with the file's merge base. + /// + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", + Justification = "linesChanged is shared and shouldn't be disposed")] + public class PullRequestSessionFile : ReactiveObject, IPullRequestSessionFile + { + readonly Subject>> linesChanged = new Subject>>(); + IReadOnlyList diff; + string commitSha; + IReadOnlyList inlineCommentThreads; + IReadOnlyList inlineAnnotations; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The relative path to the file in the repository. + /// + /// + /// The commit to pin the file to, or "HEAD" to follow the pull request head. + /// + public PullRequestSessionFile(string relativePath, string commitSha = "HEAD") + { + RelativePath = relativePath; + this.commitSha = commitSha; + IsTrackingHead = commitSha == "HEAD"; + } + + /// + public string RelativePath { get; } + + /// + public IReadOnlyList Diff + { + get { return diff; } + internal set { this.RaiseAndSetIfChanged(ref diff, value); } + } + + /// + public string BaseSha { get; internal set; } + + /// + public string CommitSha + { + get { return commitSha; } + internal set + { + if (value != commitSha) + { + if (!IsTrackingHead) + { + throw new GitHubLogicException( + "Cannot change the CommitSha of a PullRequestSessionFile that is not tracking HEAD."); + } + + this.RaiseAndSetIfChanged(ref commitSha, value); + } + } + } + + /// + public bool IsTrackingHead { get; } + + /// + public IReadOnlyList InlineCommentThreads + { + get { return inlineCommentThreads; } + set + { + var lines = (inlineCommentThreads ?? Enumerable.Empty())? + .Concat(value ?? Enumerable.Empty()) + .Select(x => Tuple.Create(x.LineNumber, x.DiffLineType == DiffChangeType.Delete ? DiffSide.Left : DiffSide.Right)) + .Where(x => x.Item1 >= 0) + .Distinct() + .ToList(); + this.RaisePropertyChanging(); + inlineCommentThreads = value; + this.RaisePropertyChanged(); + NotifyLinesChanged(lines); + } + } + + /// + public IObservable>> LinesChanged => linesChanged; + + /// + public IReadOnlyList InlineAnnotations + { + get + { + return inlineAnnotations; + } + set + { + var lines = (inlineAnnotations ?? Enumerable.Empty())? + .Concat(value ?? Enumerable.Empty()) + .Select(x => Tuple.Create(x.StartLine, DiffSide.Right)) + .Where(x => x.Item1 >= 0) + .Distinct() + .ToList(); + + this.RaisePropertyChanging(); + inlineAnnotations = value; + this.RaisePropertyChanged(); + NotifyLinesChanged(lines); + } + } + + /// + /// Raises the signal. + /// + /// The lines that have changed. + public void NotifyLinesChanged(IReadOnlyList> lines) => linesChanged.OnNext(lines); + } +} diff --git a/src/GitHub.InlineReviews/Models/PullRequestSessionLiveFile.cs b/src/GitHub.InlineReviews/Models/PullRequestSessionLiveFile.cs new file mode 100644 index 0000000000..f9df506ae7 --- /dev/null +++ b/src/GitHub.InlineReviews/Models/PullRequestSessionLiveFile.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Subjects; +using GitHub.Models; +using Microsoft.VisualStudio.Text; + +namespace GitHub.InlineReviews.Models +{ + /// + /// A file in a pull request session that tracks editor content. + /// + /// + /// A live session file extends to update the file's + /// review comments in real time, based on the contents of an editor and + /// . + /// + public sealed class PullRequestSessionLiveFile : PullRequestSessionFile, IDisposable + { + bool disposed = false; + + public PullRequestSessionLiveFile( + string relativePath, + ITextBuffer textBuffer, + ISubject rebuild) + : base(relativePath) + { + TextBuffer = textBuffer; + Rebuild = rebuild; + } + + /// + /// Gets the VS text buffer that the file is associated with. + /// + public ITextBuffer TextBuffer { get; } + + /// + /// Gets a resource to dispose. + /// + public IDisposable ToDispose { get; internal set; } + + /// + /// Gets a dictionary mapping review comments to tracking points in the . + /// + public IDictionary TrackingPoints { get; internal set; } + + /// + /// Gets an observable raised when the review comments for the file should be rebuilt. + /// + public ISubject Rebuild { get; } + + public void Dispose() + { + Dispose(true); + } + + /// + /// Disposes of the resources in . + /// + void Dispose(bool disposing) + { + if (!disposed) + { + disposed = true; + + if (disposing) + { + ToDispose?.Dispose(); + } + } + } + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekRelationship.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekRelationship.cs new file mode 100644 index 0000000000..826b0002aa --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekRelationship.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.VisualStudio.Language.Intellisense; + +namespace GitHub.InlineReviews.Peek +{ + class InlineCommentPeekRelationship : IPeekRelationship + { + static InlineCommentPeekRelationship instance; + + private InlineCommentPeekRelationship() + { + } + + public static InlineCommentPeekRelationship Instance + { + get + { + if (instance == null) + { + instance = new InlineCommentPeekRelationship(); + } + + return instance; + } + } + + public string DisplayName => "GitHub Code Review"; + public string Name => "GitHubCodeReview"; + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekResult.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekResult.cs new file mode 100644 index 0000000000..ac9394b78b --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekResult.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.VisualStudio.Language.Intellisense; +using GitHub.Extensions; +using GitHub.InlineReviews.ViewModels; + +namespace GitHub.InlineReviews.Peek +{ + sealed class InlineCommentPeekResult : IPeekResult + { + public InlineCommentPeekResult(InlineCommentPeekViewModel viewModel) + { + Guard.ArgumentNotNull(viewModel, nameof(viewModel)); + + this.ViewModel = viewModel; + } + + public bool CanNavigateTo => true; + public InlineCommentPeekViewModel ViewModel { get; } + + public IPeekResultDisplayInfo DisplayInfo { get; } + = new PeekResultDisplayInfo("Review", null, "GitHub Review", "GitHub Review"); + + public Action PostNavigationCallback => null; + + public event EventHandler Disposed; + + public void Dispose() + { + Disposed?.Invoke(this, EventArgs.Empty); + } + + public void NavigateTo(object data) + { + } + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekResultPresentation.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekResultPresentation.cs new file mode 100644 index 0000000000..3aefe50256 --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekResultPresentation.cs @@ -0,0 +1,105 @@ +using System; +using System.Windows; +using Microsoft.VisualStudio.Language.Intellisense; +using GitHub.InlineReviews.ViewModels; +using GitHub.InlineReviews.Views; + +namespace GitHub.InlineReviews.Peek +{ + class InlineCommentPeekResultPresentation : IPeekResultPresentation, IDesiredHeightProvider + { + const double PeekBorders = 28.0; + readonly InlineCommentPeekViewModel viewModel; + InlineCommentPeekView view; + double desiredHeight; + + public bool IsDirty => false; + public bool IsReadOnly => true; + + public InlineCommentPeekResultPresentation(InlineCommentPeekViewModel viewModel) + { + this.viewModel = viewModel; + } + + public double ZoomLevel + { + get { return 1.0; } + set { } + } + + public double DesiredHeight + { + get { return desiredHeight; } + private set + { + if (desiredHeight != value && DesiredHeightChanged != null) + { + desiredHeight = value; + DesiredHeightChanged(this, EventArgs.Empty); + } + } + } + + public event EventHandler IsDirtyChanged + { + add { } + remove { } + } + + public event EventHandler IsReadOnlyChanged + { + add { } + remove { } + } + + public event EventHandler RecreateContent = delegate { }; + public event EventHandler DesiredHeightChanged; + + public bool CanSave(out string defaultPath) + { + defaultPath = null; + return false; + } + + public IPeekResultScrollState CaptureScrollState() + { + return null; + } + + public void Close() + { + } + + public UIElement Create(IPeekSession session, IPeekResultScrollState scrollState) + { + view = new InlineCommentPeekView(); + view.DataContext = viewModel; + + // Report the desired size back to the peek view. Unfortunately the peek view + // helpfully assigns this desired size to the control that also contains the tab at + // the top of the peek view, so we need to put in a fudge factor. Using a const + // value for the moment, as there's no easy way to get the size of the control. + view.DesiredHeight.Subscribe(x => DesiredHeight = x + PeekBorders); + + return view; + } + + public void Dispose() + { + } + + public void ScrollIntoView(IPeekResultScrollState scrollState) + { + } + + public void SetKeyboardFocus() + { + } + + public bool TryOpen(IPeekResult otherResult) => false; + + public bool TryPrepareToClose() => true; + + public bool TrySave(bool saveAs) => true; + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekResultPresenter.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekResultPresenter.cs new file mode 100644 index 0000000000..2c0ee81087 --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekResultPresenter.cs @@ -0,0 +1,20 @@ +using System; +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Utilities; + +namespace GitHub.InlineReviews.Peek +{ + [Export(typeof(IPeekResultPresenter))] + [Name("GitHub Inline Comments Peek Presenter")] + class InlineCommentPeekResultPresenter : IPeekResultPresenter + { + public IPeekResultPresentation TryCreatePeekResultPresentation(IPeekResult result) + { + var review = result as InlineCommentPeekResult; + return review != null ? + new InlineCommentPeekResultPresentation(review.ViewModel) : + null; + } + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItem.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItem.cs new file mode 100644 index 0000000000..8adb814546 --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItem.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.Language.Intellisense; +using GitHub.InlineReviews.ViewModels; +using GitHub.ViewModels; +using System.Reactive; + +namespace GitHub.InlineReviews.Peek +{ + class InlineCommentPeekableItem : IPeekableItem, IClosable + { + public InlineCommentPeekableItem(InlineCommentPeekViewModel viewModel) + { + ViewModel = viewModel; + } + + public string DisplayName => "GitHub Code Review"; + public InlineCommentPeekViewModel ViewModel { get; } + + public IEnumerable Relationships => new[] { InlineCommentPeekRelationship.Instance }; + + public IObservable Closed => ViewModel.Close; + + public IPeekResultSource GetOrCreateResultSource(string relationshipName) + { + return new InlineCommentPeekableResultSource(ViewModel); + } + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSource.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSource.cs new file mode 100644 index 0000000000..5cb05a60df --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSource.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using GitHub.Commands; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.InlineReviews.Commands; +using GitHub.InlineReviews.Services; +using GitHub.InlineReviews.ViewModels; +using GitHub.Services; +using Microsoft.VisualStudio.Language.Intellisense; + +namespace GitHub.InlineReviews.Peek +{ + class InlineCommentPeekableItemSource : IPeekableItemSource + { + readonly IInlineCommentPeekService peekService; + readonly IPullRequestSessionManager sessionManager; + readonly INextInlineCommentCommand nextCommentCommand; + readonly IPreviousInlineCommentCommand previousCommentCommand; + readonly IViewViewModelFactory factory; + + public InlineCommentPeekableItemSource(IInlineCommentPeekService peekService, + IPullRequestSessionManager sessionManager, + INextInlineCommentCommand nextCommentCommand, + IPreviousInlineCommentCommand previousCommentCommand, + IViewViewModelFactory factory) + { + this.peekService = peekService; + this.sessionManager = sessionManager; + this.nextCommentCommand = nextCommentCommand; + this.previousCommentCommand = previousCommentCommand; + this.factory = factory; + } + + public void AugmentPeekSession(IPeekSession session, IList peekableItems) + { + if (session.RelationshipName == InlineCommentPeekRelationship.Instance.Name) + { + var viewModel = new InlineCommentPeekViewModel( + peekService, + session, + sessionManager, + nextCommentCommand, + previousCommentCommand, + factory); + viewModel.Initialize().Forget(); + peekableItems.Add(new InlineCommentPeekableItem(viewModel)); + } + } + + public void Dispose() + { + } + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSourceProvider.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSourceProvider.cs new file mode 100644 index 0000000000..79f390814d --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableItemSourceProvider.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.Composition; +using GitHub.Commands; +using GitHub.Factories; +using GitHub.InlineReviews.Services; +using GitHub.Services; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Utilities; + +namespace GitHub.InlineReviews.Peek +{ + [Export(typeof(IPeekableItemSourceProvider))] + [ContentType("text")] + [Name("GitHub Inline Comments Peekable Item Source")] + class InlineCommentPeekableItemSourceProvider : IPeekableItemSourceProvider + { + readonly IInlineCommentPeekService peekService; + readonly IPullRequestSessionManager sessionManager; + readonly INextInlineCommentCommand nextCommentCommand; + readonly IPreviousInlineCommentCommand previousCommentCommand; + readonly IViewViewModelFactory factory; + + [ImportingConstructor] + public InlineCommentPeekableItemSourceProvider( + IInlineCommentPeekService peekService, + IPullRequestSessionManager sessionManager, + INextInlineCommentCommand nextCommentCommand, + IPreviousInlineCommentCommand previousCommentCommand, + IViewViewModelFactory factory) + { + this.peekService = peekService; + this.sessionManager = sessionManager; + this.nextCommentCommand = nextCommentCommand; + this.previousCommentCommand = previousCommentCommand; + this.factory = factory; + } + + public IPeekableItemSource TryCreatePeekableItemSource(ITextBuffer textBuffer) + { + return new InlineCommentPeekableItemSource( + peekService, + sessionManager, + nextCommentCommand, + previousCommentCommand, + factory); + } + } +} diff --git a/src/GitHub.InlineReviews/Peek/InlineCommentPeekableResultSource.cs b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableResultSource.cs new file mode 100644 index 0000000000..b15dddbb60 --- /dev/null +++ b/src/GitHub.InlineReviews/Peek/InlineCommentPeekableResultSource.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using Microsoft.VisualStudio.Language.Intellisense; +using GitHub.InlineReviews.ViewModels; + +namespace GitHub.InlineReviews.Peek +{ + class InlineCommentPeekableResultSource : IPeekResultSource + { + readonly InlineCommentPeekViewModel viewModel; + + public InlineCommentPeekableResultSource(InlineCommentPeekViewModel viewModel) + { + this.viewModel = viewModel; + } + + public void FindResults(string relationshipName, IPeekResultCollection resultCollection, CancellationToken cancellationToken, IFindPeekResultsCallback callback) + { + resultCollection.Add(new InlineCommentPeekResult(viewModel)); + } + } +} diff --git a/src/GitHub.InlineReviews/Properties/AssemblyInfo.cs b/src/GitHub.InlineReviews/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..f4657e8520 --- /dev/null +++ b/src/GitHub.InlineReviews/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("GitHub.InlineReviews")] +[assembly: AssemblyDescription("Provides inline viewing of PR review comments")] +[assembly: Guid("3bf91177-3d16-425d-9c62-50a86cf26298")] diff --git a/src/GitHub.InlineReviews/Properties/DesignTimeResources.xaml b/src/GitHub.InlineReviews/Properties/DesignTimeResources.xaml new file mode 100644 index 0000000000..c10aae3cd5 --- /dev/null +++ b/src/GitHub.InlineReviews/Properties/DesignTimeResources.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/PullRequestStatusBarPackage.cs b/src/GitHub.InlineReviews/PullRequestStatusBarPackage.cs new file mode 100644 index 0000000000..04ad51a9d3 --- /dev/null +++ b/src/GitHub.InlineReviews/PullRequestStatusBarPackage.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading; +using System.Runtime.InteropServices; +using GitHub.VisualStudio; +using GitHub.InlineReviews.Services; +using Microsoft.VisualStudio.Shell; +using Task = System.Threading.Tasks.Task; +using Microsoft.VisualStudio.Threading; +using Microsoft.VisualStudio.ComponentModelHost; + +namespace GitHub.InlineReviews +{ + [Guid(Guids.PullRequestStatusPackageId)] + [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] + [ProvideAutoLoad(Guids.GitContextPkgString, PackageAutoLoadFlags.BackgroundLoad)] + public class PullRequestStatusBarPackage : AsyncPackage + { + /// + /// Initialize the PR status UI on Visual Studio's status bar. + /// + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) + { + // Avoid delays when there is ongoing UI activity. + // See: https://github.com/github/VisualStudio/issues/1537 + await JoinableTaskFactory.RunAsync(VsTaskRunContext.UIThreadNormalPriority, InitializeStatusBar); + } + + async Task InitializeStatusBar() + { + var componentModel = (IComponentModel)(await GetServiceAsync(typeof(SComponentModel))); + var exports = componentModel.DefaultExportProvider; + var barManager = exports.GetExportedValue(); + + await JoinableTaskFactory.SwitchToMainThreadAsync(); + barManager.StartShowingStatus(); + } + } +} diff --git a/src/GitHub.InlineReviews/Services/DiffService.cs b/src/GitHub.InlineReviews/Services/DiffService.cs new file mode 100644 index 0000000000..1dccd7120e --- /dev/null +++ b/src/GitHub.InlineReviews/Services/DiffService.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using LibGit2Sharp; + +namespace GitHub.InlineReviews.Services +{ + /// + /// Service for generating parsed diffs. + /// + [Export(typeof(IDiffService))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class DiffService : IDiffService + { + readonly IGitService gitService; + + [ImportingConstructor] + public DiffService(IGitService gitService) + { + this.gitService = gitService; + } + + /// + public async Task> Diff( + IRepository repo, + string baseSha, + string headSha, + string relativePath) + { + var patch = await gitService.Compare(repo, baseSha, headSha, relativePath); + + if (patch != null) + { + return DiffUtilities.ParseFragment(patch).ToList(); + } + else + { + return Array.Empty(); + } + } + + /// + public async Task> Diff( + IRepository repo, + string baseSha, + string headSha, + string path, + byte[] contents) + { + var changes = await gitService.CompareWith(repo, baseSha, headSha, path, contents); + + if (changes?.Patch != null) + { + return DiffUtilities.ParseFragment(changes.Patch).ToList(); + } + else + { + return Array.Empty(); + } + } + } +} diff --git a/src/GitHub.InlineReviews/Services/IDiffService.cs b/src/GitHub.InlineReviews/Services/IDiffService.cs new file mode 100644 index 0000000000..c8c76aeeed --- /dev/null +++ b/src/GitHub.InlineReviews/Services/IDiffService.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Models; +using LibGit2Sharp; + +namespace GitHub.InlineReviews.Services +{ + /// + /// Service for generating parsed diffs. + /// + public interface IDiffService + { + /// + /// Calculates the diff of a file in a repository between two commits. + /// + /// The repository + /// The base commit SHA. + /// The head commit SHA. + /// The path to the file in the repository. + /// + /// A collection of s containing the parsed diff. + /// + Task> Diff(IRepository repo, string baseSha, string headSha, string relativePath); + + /// + /// Calculates the diff of a file in a repository between a base commit and a byte arrat. + /// + /// The repository + /// The base commit SHA. + /// The head commit SHA. + /// The path to the file in the repository. + /// The byte array to compare with the base SHA. + /// + /// A collection of s containing the parsed diff. + /// + /// + /// Note that even though the comparison is done between and + /// , the still needs to be provided in order + /// to track renames. + /// + Task> Diff(IRepository repo, string baseSha, string headSha, string relativePath, byte[] contents); + } +} \ No newline at end of file diff --git a/src/GitHub.InlineReviews/Services/IPullRequestSessionService.cs b/src/GitHub.InlineReviews/Services/IPullRequestSessionService.cs new file mode 100644 index 0000000000..608ccf0932 --- /dev/null +++ b/src/GitHub.InlineReviews/Services/IPullRequestSessionService.cs @@ -0,0 +1,367 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; +using Microsoft.VisualStudio.Text; +using Octokit; + +namespace GitHub.InlineReviews.Services +{ + /// + /// Provides a common interface for services required by . + /// + public interface IPullRequestSessionService + { + /// + /// Carries out a diff of a file between two commits. + /// + /// The repository. + /// The commit to use as the base. + /// The commit to use as the head. + /// The relative path to the file. + /// + Task> Diff( + LocalRepositoryModel repository, + string baseSha, + string headSha, + string relativePath); + + /// + /// Carries out a diff between a file at a commit and the current file contents. + /// + /// The repository. + /// The commit to use as the base. + /// The commit to use as the head. + /// The relative path to the file. + /// The contents of the file. + /// + Task> Diff( + LocalRepositoryModel repository, + string baseSha, + string headSha, + string relativePath, + byte[] contents); + + + + /// + /// Builds a set of annotation models for a file based on a pull request model + /// + /// The pull request session. + /// The relative path to the file. + /// + /// A collection of objects. + /// + IReadOnlyList BuildAnnotations( + PullRequestDetailModel pullRequest, + string relativePath); + + /// + /// Builds a set of comment thread models for a file based on a pull request model and a diff. + /// + /// The pull request session. + /// The relative path to the file. + /// The diff. + /// The SHA of the HEAD. + /// + /// A collection of objects with updated line numbers. + /// + IReadOnlyList BuildCommentThreads( + PullRequestDetailModel pullRequest, + string relativePath, + IReadOnlyList diff, + string headSha); + + /// + /// Updates a set of comment thread models for a file based on a new diff. + /// + /// The theads to update. + /// The diff. + /// + /// A collection of updated line numbers. + /// + IReadOnlyList> UpdateCommentThreads( + IReadOnlyList threads, + IReadOnlyList diff); + + /// + /// Tests whether the contents of a file represent a commit that is pushed to origin. + /// + /// The repository. + /// The relative path to the file. + /// The contents of the file. + /// + /// A task returning true if the file is unmodified with respect to the latest commit + /// pushed to origin; otherwise false. + /// + Task IsUnmodifiedAndPushed( + LocalRepositoryModel repository, + string relativePath, + byte[] contents); + + /// + /// Extracts a file at a specified commit from the repository. + /// + /// The repository. + /// The pull request number + /// The SHA of the commit. + /// The path to the file, relative to the repository. + /// + /// The contents of the file, or null if the file was not found at the specified commit. + /// + Task ExtractFileFromGit( + LocalRepositoryModel repository, + int pullRequestNumber, + string sha, + string relativePath); + + /// + /// Gets the associated for an . + /// + /// The buffer. + /// + /// The associated document, or null if not found. + /// + ITextDocument GetDocument(ITextBuffer buffer); + + /// + /// Gets the contents of an using the buffer's current encoding. + /// + /// The buffer. + /// The contents of the buffer. + byte[] GetContents(ITextBuffer buffer); + + /// + /// Gets the SHA of the tip of the current branch. + /// + /// The repository. + /// The tip SHA. + Task GetTipSha(LocalRepositoryModel repository); + + /// + /// Asynchronously reads the contents of a file. + /// + /// The full path to the file. + /// + /// A task returning the contents of the file, or null if the file was not found. + /// + Task ReadFileAsync(string path); + + /// + /// Reads a for a specified pull request. + /// + /// The host address. + /// The repository owner. + /// The repository name. + /// The pull request number. + /// Whether the data should be refreshed instead of read from the cache. + /// A task returning the pull request model. + Task ReadPullRequestDetail( + HostAddress address, + string owner, + string name, + int number, + bool refresh = false); + + /// + /// Reads the current viewer for the specified address.. + /// + /// The host address. + /// A task returning the viewer. + /// + /// A "Viewer" is the GraphQL term for the currently authenticated user. + /// + Task ReadViewer(HostAddress address); + + /// + /// Find the merge base for a pull request. + /// + /// The repository. + /// The pull request. + /// + /// The merge base SHA for the PR. + /// + Task GetPullRequestMergeBase(LocalRepositoryModel repository, PullRequestDetailModel pullRequest); + + /// + /// Gets the GraphQL ID for a pull request. + /// + /// The local repository. + /// The owner of the remote fork. + /// The pull request number. + /// + Task GetGraphQLPullRequestId( + LocalRepositoryModel localRepository, + string repositoryOwner, + int number); + + /// + /// Creates a rebuild signal subject for a . + /// + /// + /// A subject which is used to signal a rebuild of a live file. + /// + /// + /// The creation of the rebuild signal for a is + /// abstracted out into this service for unit testing. The default behavior of this subject + /// is to throttle the signal for 500ms so that the live file is not updated continuously + /// while the user is typing. + /// + ISubject CreateRebuildSignal(); + + /// + /// Creates a new pending review on the server. + /// + /// The local repository. + /// The user posting the review. + /// The GraphQL ID of the pull request. + /// The updated state of the pull request. + Task CreatePendingReview( + LocalRepositoryModel localRepository, + string pullRequestId); + + /// + /// Cancels a pending review on the server. + /// + /// The local repository. + /// The GraphQL ID of the review. + /// The updated state of the pull request. + Task CancelPendingReview( + LocalRepositoryModel localRepository, + string reviewId); + + /// + /// Posts PR review with no comments. + /// + /// The local repository. + /// The GraphQL ID of the pull request. + /// The SHA of the commit being reviewed. + /// The review body. + /// The review event. + /// The updated state of the pull request. + Task PostReview( + LocalRepositoryModel localRepository, + string pullRequestId, + string commitId, + string body, + PullRequestReviewEvent e); + + /// + /// Submits a pending PR review. + /// + /// The local repository. + /// The GraphQL ID of the pending review. + /// The review body. + /// The review event. + /// The updated state of the pull request. + Task SubmitPendingReview( + LocalRepositoryModel localRepository, + string pendingReviewId, + string body, + PullRequestReviewEvent e); + + /// + /// Posts a new pending PR review comment. + /// + /// The local repository. + /// The GraphQL ID of the pending review. + /// The comment body. + /// THe SHA of the commit to comment on. + /// The relative path of the file to comment on. + /// The line index in the diff to comment on. + /// The updated state of the pull request. + /// + /// This method posts a new pull request comment to a pending review started by + /// . + /// + Task PostPendingReviewComment( + LocalRepositoryModel localRepository, + string pendingReviewId, + string body, + string commitId, + string path, + int position); + + /// + /// Posts a new pending PR review comment reply. + /// + /// The local repository. + /// The GraphQL ID of the pending review. + /// The comment body. + /// The GraphQL ID of the comment to reply to. + /// The updated state of the pull request. + /// + /// The method posts a new pull request comment to a pending review started by + /// . + /// + Task PostPendingReviewCommentReply( + LocalRepositoryModel localRepository, + string pendingReviewId, + string body, + string inReplyTo); + + /// + /// Posts a new standalone PR review comment. + /// + /// The local repository. + /// The GraphQL ID of the pull request. + /// The comment body. + /// THe SHA of the commit to comment on. + /// The relative path of the file to comment on. + /// The line index in the diff to comment on. + /// The updated state of the pull request. + /// + /// The method posts a new standalone pull request comment that is not attached to a pending + /// pull request review. + /// + Task PostStandaloneReviewComment( + LocalRepositoryModel localRepository, + string pullRequestId, + string body, + string commitId, + string path, + int position); + + /// + /// Posts a PR review comment reply. + /// + /// The local repository. + /// The GraphQL ID of the pull request. + /// The comment body. + /// The GraphQL ID of the comment to reply to. + /// The updated state of the pull request. + Task PostStandaloneReviewCommentReply( + LocalRepositoryModel localRepository, + string pullRequestId, + string body, + string inReplyTo); + + /// + /// Delete a PR review comment. + /// + /// The local repository. + /// The owner of the repository. + /// The pull request id of the comment + /// The pull request comment number. + /// The updated state of the pull request. + Task DeleteComment(LocalRepositoryModel localRepository, + string remoteRepositoryOwner, + int pullRequestId, + int commentDatabaseId); + + /// + /// Edit a PR review comment. + /// + /// The local repository. + /// The owner of the repository. + /// The pull request comment node id. + /// The replacement comment body. + /// The updated state of the pull request. + Task EditComment(LocalRepositoryModel localRepository, + string remoteRepositoryOwner, + string commentNodeId, + string body); + } +} diff --git a/src/GitHub.InlineReviews/Services/PullRequestSession.cs b/src/GitHub.InlineReviews/Services/PullRequestSession.cs new file mode 100644 index 0000000000..275780d8dd --- /dev/null +++ b/src/GitHub.InlineReviews/Services/PullRequestSession.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.InlineReviews.Models; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; +using System.Threading; +using System.Reactive.Subjects; +using static System.FormattableString; +using GitHub.Primitives; + +namespace GitHub.InlineReviews.Services +{ + /// + /// A pull request session used to display inline reviews. + /// + /// + /// A pull request session represents the real-time state of a pull request in the IDE. + /// It takes the pull request model and updates according to the current state of the + /// repository on disk and in the editor. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", + Justification = "PullRequestSession is shared and shouldn't be disposed")] + public class PullRequestSession : ReactiveObject, IPullRequestSession + { + readonly IPullRequestSessionService service; + readonly Dictionary fileIndex = new Dictionary(); + readonly SemaphoreSlim getFilesLock = new SemaphoreSlim(1); + bool isCheckedOut; + string mergeBase; + IReadOnlyList files; + PullRequestDetailModel pullRequest; + string pullRequestNodeId; + Subject pullRequestChanged = new Subject(); + bool hasPendingReview; + + public PullRequestSession( + IPullRequestSessionService service, + ActorModel user, + PullRequestDetailModel pullRequest, + LocalRepositoryModel localRepository, + string repositoryOwner, + bool isCheckedOut) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(user, nameof(user)); + Guard.ArgumentNotNull(pullRequest, nameof(pullRequest)); + Guard.ArgumentNotNull(localRepository, nameof(localRepository)); + + this.service = service; + this.isCheckedOut = isCheckedOut; + this.pullRequest = pullRequest; + User = user; + LocalRepository = localRepository; + RepositoryOwner = repositoryOwner; + UpdatePendingReview(); + } + + /// + public async Task> GetAllFiles() + { + if (files == null) + { + files = await CreateAllFiles(); + } + + return files; + } + + /// + public async Task GetFile( + string relativePath, + string commitSha = "HEAD") + { + await getFilesLock.WaitAsync(); + + try + { + PullRequestSessionFile file; + var gitPath = Paths.ToGitPath(relativePath); + var key = gitPath + '@' + commitSha; + + if (!fileIndex.TryGetValue(key, out file)) + { + file = new PullRequestSessionFile(relativePath, commitSha); + await UpdateFile(file); + fileIndex.Add(key, file); + } + + return file; + } + finally + { + getFilesLock.Release(); + } + } + + /// + public async Task GetMergeBase() + { + if (mergeBase == null) + { + mergeBase = await service.GetPullRequestMergeBase(LocalRepository, PullRequest); + } + + return mergeBase; + } + + /// + public async Task PostReviewComment( + string body, + string commitId, + string path, + IReadOnlyList diff, + int position) + { + if (!HasPendingReview) + { + var model = await service.PostStandaloneReviewComment( + LocalRepository, + PullRequest.Id, + body, + commitId, + path, + position); + await Update(model); + } + else + { + var model = await service.PostPendingReviewComment( + LocalRepository, + PendingReviewId, + body, + commitId, + path, + position); + await Update(model); + } + } + + /// + public async Task DeleteComment(int pullRequestId, int commentDatabaseId) + { + var model = await service.DeleteComment( + LocalRepository, + RepositoryOwner, + pullRequestId, + commentDatabaseId); + + await Update(model); + } + + /// + public async Task EditComment(string commentNodeId, string body) + { + var model = await service.EditComment( + LocalRepository, + RepositoryOwner, + commentNodeId, + body); + + await Update(model); + } + + /// + public async Task PostReviewComment( + string body, + string inReplyTo) + { + if (!HasPendingReview) + { + var model = await service.PostStandaloneReviewCommentReply( + LocalRepository, + PullRequest.Id, + body, + inReplyTo); + await Update(model); + } + else + { + var model = await service.PostPendingReviewCommentReply( + LocalRepository, + PendingReviewId, + body, + inReplyTo); + await Update(model); + } + } + + /// + public async Task StartReview() + { + if (HasPendingReview) + { + throw new InvalidOperationException("A pending review is already underway."); + } + + var model = await service.CreatePendingReview( + LocalRepository, + await GetPullRequestNodeId()); + + await Update(model); + } + + /// + public async Task CancelReview() + { + if (!HasPendingReview) + { + throw new InvalidOperationException("There is no pending review to cancel."); + } + + var pullRequest = await service.CancelPendingReview(LocalRepository, PendingReviewId); + await Update(pullRequest); + } + + /// + public async Task PostReview(string body, Octokit.PullRequestReviewEvent e) + { + PullRequestDetailModel model; + + if (PendingReviewId == null) + { + model = await service.PostReview( + LocalRepository, + PullRequest.Id, + PullRequest.HeadRefSha, + body, + e); + } + else + { + model = await service.SubmitPendingReview( + LocalRepository, + PendingReviewId, + body, + e); + } + + await Update(model); + } + + /// + public async Task Refresh() + { + var address = HostAddress.Create(LocalRepository.CloneUrl); + var model = await service.ReadPullRequestDetail( + address, + RepositoryOwner, + LocalRepository.Name, + PullRequest.Number, + true); + await Update(model); + } + + /// + async Task Update(PullRequestDetailModel pullRequestModel) + { + PullRequest = pullRequestModel; + mergeBase = null; + + foreach (var file in this.fileIndex.Values.ToList()) + { + await UpdateFile(file); + } + + UpdatePendingReview(); + pullRequestChanged.OnNext(pullRequestModel); + } + + async Task AddComment(PullRequestReviewCommentModel comment) + { + var review = PullRequest.Reviews.FirstOrDefault(x => x.Id == PendingReviewId); + + if (review == null) + { + throw new KeyNotFoundException("Could not find pending review."); + } + + review.Comments = review.Comments + .Concat(new[] { comment }) + .ToList(); + await Update(PullRequest); + } + + async Task UpdateFile(PullRequestSessionFile file) + { + var mergeBaseSha = await GetMergeBase(); + file.BaseSha = PullRequest.BaseRefSha; + file.CommitSha = file.IsTrackingHead ? PullRequest.HeadRefSha : file.CommitSha; + file.Diff = await service.Diff(LocalRepository, mergeBaseSha, file.CommitSha, file.RelativePath); + file.InlineCommentThreads = service.BuildCommentThreads(PullRequest, file.RelativePath, file.Diff, file.CommitSha); + file.InlineAnnotations = service.BuildAnnotations(PullRequest, file.RelativePath); + } + + void UpdatePendingReview() + { + var pendingReview = PullRequest.Reviews + .FirstOrDefault(x => x.State == PullRequestReviewState.Pending && x.Author.Login == User.Login); + + if (pendingReview != null) + { + HasPendingReview = true; + PendingReviewId = pendingReview.Id; + } + else + { + HasPendingReview = false; + PendingReviewId = null; + } + } + + async Task> CreateAllFiles() + { + var result = new List(); + + foreach (var path in FilePaths) + { + var file = await GetFile(path); + result.Add(file); + } + + return result; + } + + string GetFullPath(string relativePath) + { + return Path.Combine(LocalRepository.LocalPath, relativePath); + } + + async Task GetPullRequestNodeId() + { + if (pullRequestNodeId == null) + { + pullRequestNodeId = await service.GetGraphQLPullRequestId( + LocalRepository, + RepositoryOwner, + PullRequest.Number); + } + + return pullRequestNodeId; + } + + static string BuildDiffHunk(IReadOnlyList diff, int position) + { + var lines = diff.SelectMany(x => x.Lines).Reverse(); + var context = lines.SkipWhile(x => x.DiffLineNumber != position).Take(5).Reverse().ToList(); + var oldLineNumber = context.Select(x => x.OldLineNumber).Where(x => x != -1).FirstOrDefault(); + var newLineNumber = context.Select(x => x.NewLineNumber).Where(x => x != -1).FirstOrDefault(); + var header = Invariant($"@@ -{oldLineNumber},5 +{newLineNumber},5 @@"); + return header + '\n' + string.Join("\n", context); + } + + /// + public bool IsCheckedOut + { + get { return isCheckedOut; } + internal set { this.RaiseAndSetIfChanged(ref isCheckedOut, value); } + } + + /// + public ActorModel User { get; } + + /// + public PullRequestDetailModel PullRequest + { + get { return pullRequest; } + private set + { + // PullRequestModel overrides Equals such that two PRs with the same number are + // considered equal. This was causing the PullRequest not to be updated on refresh: + // we need to use ReferenceEquals. + if (!ReferenceEquals(pullRequest, value)) + { + this.RaisePropertyChanging(nameof(PullRequest)); + pullRequest = value; + this.RaisePropertyChanged(nameof(PullRequest)); + } + } + } + + /// + public IObservable PullRequestChanged => pullRequestChanged; + + /// + public LocalRepositoryModel LocalRepository { get; } + + /// + public string RepositoryOwner { get; } + + /// + public bool HasPendingReview + { + get { return hasPendingReview; } + private set { this.RaiseAndSetIfChanged(ref hasPendingReview, value); } + } + + /// + public string PendingReviewId { get; private set; } + + IEnumerable FilePaths + { + get { return PullRequest.ChangedFiles.Select(x => x.FileName); } + } + } +} diff --git a/src/GitHub.InlineReviews/Services/PullRequestSessionManager.cs b/src/GitHub.InlineReviews/Services/PullRequestSessionManager.cs new file mode 100644 index 0000000000..5c5e816173 --- /dev/null +++ b/src/GitHub.InlineReviews/Services/PullRequestSessionManager.cs @@ -0,0 +1,427 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.InlineReviews.Models; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Projection; +using ReactiveUI; +using Serilog; + +#pragma warning disable CA1308 // Normalize strings to uppercase + +namespace GitHub.InlineReviews.Services +{ + /// + /// Manages pull request sessions. + /// + [Export(typeof(IPullRequestSessionManager))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class PullRequestSessionManager : ReactiveObject, IPullRequestSessionManager + { + static readonly ILogger log = LogManager.ForContext(); + readonly IPullRequestService service; + readonly IPullRequestSessionService sessionService; + readonly Dictionary, WeakReference> sessions = + new Dictionary, WeakReference>(); + TaskCompletionSource initialized; + IPullRequestSession currentSession; + LocalRepositoryModel repository; + + /// + /// Initializes a new instance of the class. + /// + /// The PR service to use. + /// The PR session service to use. + /// The team explorer context to use. + [ImportingConstructor] + public PullRequestSessionManager( + IPullRequestService service, + IPullRequestSessionService sessionService, + ITeamExplorerContext teamExplorerContext) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(sessionService, nameof(sessionService)); + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + + this.service = service; + this.sessionService = sessionService; + initialized = new TaskCompletionSource(null); + + Observable.FromEventPattern(teamExplorerContext, nameof(teamExplorerContext.StatusChanged)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(_ => StatusChanged().Forget(log)); + + teamExplorerContext.WhenAnyValue(x => x.ActiveRepository) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => RepoChanged(x).Forget(log)); + } + + /// + public IPullRequestSession CurrentSession + { + get { return currentSession; } + private set { this.RaiseAndSetIfChanged(ref currentSession, value); } + } + + /// + public Task EnsureInitialized() => initialized.Task; + + /// + public async Task GetLiveFile( + string relativePath, + ITextView textView, + ITextBuffer textBuffer) + { + PullRequestSessionLiveFile result; + + if (!textBuffer.Properties.TryGetProperty( + typeof(IPullRequestSessionFile), + out result)) + { + var dispose = new CompositeDisposable(); + + result = new PullRequestSessionLiveFile( + relativePath, + textBuffer, + sessionService.CreateRebuildSignal()); + + textBuffer.Properties.AddProperty( + typeof(IPullRequestSessionFile), + result); + + await UpdateLiveFile(result, true); + + textBuffer.Changed += TextBufferChanged; + textView.Closed += TextViewClosed; + + dispose.Add(Disposable.Create(() => + { + textView.TextBuffer.Changed -= TextBufferChanged; + textView.Closed -= TextViewClosed; + })); + + dispose.Add(result.Rebuild.Subscribe(x => UpdateLiveFile(result, x).Forget())); + + dispose.Add(this.WhenAnyValue(x => x.CurrentSession) + .Skip(1) + .Subscribe(_ => UpdateLiveFile(result, true).Forget())); + dispose.Add(this.WhenAnyObservable(x => x.CurrentSession.PullRequestChanged) + .Subscribe(_ => UpdateLiveFile(result, true).Forget())); + + result.ToDispose = dispose; + } + + return result; + } + + /// + public string GetRelativePath(ITextBuffer buffer) + { + var document = sessionService.GetDocument(buffer); + var path = document?.FilePath; + + if (!string.IsNullOrWhiteSpace(path) && Path.IsPathRooted(path) && repository != null) + { + var basePath = repository.LocalPath; + + if (path.StartsWith(basePath, StringComparison.OrdinalIgnoreCase) && path.Length > basePath.Length + 1) + { + return path.Substring(basePath.Length + 1); + } + } + + return null; + } + + /// + public async Task GetSession(string owner, string name, int number) + { + var session = await GetSessionInternal(owner, name, number); + + if (await service.EnsureLocalBranchesAreMarkedAsPullRequests(repository, session.PullRequest)) + { + // The branch for the PR was not previously marked with the PR number in the git + // config so we didn't pick up that the current branch is a PR branch. That has + // now been corrected, so call StatusChanged to make sure everything is up-to-date. + await StatusChanged(); + } + + return session; + } + + /// + public PullRequestTextBufferInfo GetTextBufferInfo(ITextBuffer buffer) + { + var projectionBuffer = buffer as IProjectionBuffer; + PullRequestTextBufferInfo result; + + if (buffer.Properties.TryGetProperty(typeof(PullRequestTextBufferInfo), out result)) + { + return result; + } + + if (projectionBuffer != null) + { + foreach (var sourceBuffer in projectionBuffer.SourceBuffers) + { + var sourceBufferInfo = GetTextBufferInfo(sourceBuffer); + if (sourceBufferInfo != null) return sourceBufferInfo; + } + } + + return null; + } + + async Task RepoChanged(LocalRepositoryModel localRepositoryModel) + { + repository = localRepositoryModel; + CurrentSession = null; + sessions.Clear(); + + if (localRepositoryModel != null) + { + await StatusChanged(); + } + else + { + initialized.TrySetResult(null); + } + } + + async Task StatusChanged() + { + var session = CurrentSession; + + var pr = await service.GetPullRequestForCurrentBranch(repository).FirstOrDefaultAsync(); + if (pr != default) + { + var changePR = + pr.Item1 != (session?.PullRequest.BaseRepositoryOwner) || + pr.Item2 != (session?.PullRequest.Number); + + if (changePR) + { + var newSession = await GetSessionInternal(pr.Item1, repository.Name, pr.Item2); + if (newSession != null) newSession.IsCheckedOut = true; + session = newSession; + } + } + else + { + session = null; + } + + CurrentSession = session; + initialized.TrySetResult(null); + } + + async Task GetSessionInternal(string owner, string name, int number) + { + var cloneUrl = repository.CloneUrl; + if (cloneUrl == null) + { + // Can't create a session from a repository with no origin + return null; + } + + PullRequestSession session = null; + WeakReference weakSession; + var key = Tuple.Create(owner.ToLowerInvariant(), number); + + if (sessions.TryGetValue(key, out weakSession)) + { + weakSession.TryGetTarget(out session); + } + + if (session == null) + { + var address = HostAddress.Create(cloneUrl); + var pullRequest = await sessionService.ReadPullRequestDetail(address, owner, name, number); + + session = new PullRequestSession( + sessionService, + await sessionService.ReadViewer(address), + pullRequest, + repository, + key.Item1, + false); + sessions[key] = new WeakReference(session); + } + + return session; + } + + async Task UpdateLiveFile(PullRequestSessionLiveFile file, bool rebuildThreads) + { + var session = CurrentSession; + + if (session != null) + { + var mergeBase = await session.GetMergeBase(); + var contents = sessionService.GetContents(file.TextBuffer); + file.BaseSha = session.PullRequest.BaseRefSha; + file.CommitSha = await CalculateCommitSha(session, file, contents); + file.Diff = await sessionService.Diff( + session.LocalRepository, + mergeBase, + session.PullRequest.HeadRefSha, + file.RelativePath, + contents); + + if (rebuildThreads) + { + file.InlineCommentThreads = sessionService.BuildCommentThreads( + session.PullRequest, + file.RelativePath, + file.Diff, + session.PullRequest.HeadRefSha); + } + else + { + var changedLines = sessionService.UpdateCommentThreads( + file.InlineCommentThreads, + file.Diff); + + if (changedLines.Count > 0) + { + file.NotifyLinesChanged(changedLines); + } + } + + file.TrackingPoints = BuildTrackingPoints( + file.TextBuffer.CurrentSnapshot, + file.InlineCommentThreads); + } + else + { + file.BaseSha = null; + file.CommitSha = null; + file.Diff = null; + file.InlineCommentThreads = null; + file.TrackingPoints = null; + } + } + + async Task UpdateLiveFile(PullRequestSessionLiveFile file, ITextSnapshot snapshot) + { + if (file.TextBuffer.CurrentSnapshot == snapshot) + { + await UpdateLiveFile(file, false); + } + } + + void InvalidateLiveThreads(PullRequestSessionLiveFile file, ITextSnapshot snapshot) + { + if (file.TrackingPoints != null) + { + var linesChanged = new List>(); + + foreach (var thread in file.InlineCommentThreads) + { + ITrackingPoint trackingPoint; + + if (file.TrackingPoints.TryGetValue(thread, out trackingPoint)) + { + var position = trackingPoint.GetPosition(snapshot); + var lineNumber = snapshot.GetLineNumberFromPosition(position); + + if (thread.DiffLineType != DiffChangeType.Delete && lineNumber != thread.LineNumber) + { + linesChanged.Add(Tuple.Create(lineNumber, DiffSide.Right)); + linesChanged.Add(Tuple.Create(thread.LineNumber, DiffSide.Right)); + thread.LineNumber = lineNumber; + thread.IsStale = true; + } + } + } + + linesChanged = linesChanged + .Where(x => x.Item1 >= 0) + .Distinct() + .ToList(); + + if (linesChanged.Count > 0) + { + file.NotifyLinesChanged(linesChanged); + } + } + } + + private IDictionary BuildTrackingPoints( + ITextSnapshot snapshot, + IReadOnlyList threads) + { + var result = new Dictionary(); + + foreach (var thread in threads) + { + if (thread.LineNumber >= 0 && thread.DiffLineType != DiffChangeType.Delete) + { + var line = snapshot.GetLineFromLineNumber(thread.LineNumber); + var p = snapshot.CreateTrackingPoint(line.Start, PointTrackingMode.Positive); + result.Add(thread, p); + } + } + + return result; + } + + async Task CalculateCommitSha( + IPullRequestSession session, + IPullRequestSessionFile file, + byte[] content) + { + var repo = session.LocalRepository; + return await sessionService.IsUnmodifiedAndPushed(repo, file.RelativePath, content) ? + await sessionService.GetTipSha(repo) : null; + } + + private void CloseLiveFiles(ITextBuffer textBuffer) + { + PullRequestSessionLiveFile file; + + if (textBuffer.Properties.TryGetProperty( + typeof(IPullRequestSessionFile), + out file)) + { + file.Dispose(); + } + + var projection = textBuffer as IProjectionBuffer; + + if (projection != null) + { + foreach (var source in projection.SourceBuffers) + { + CloseLiveFiles(source); + } + } + } + + void TextBufferChanged(object sender, TextContentChangedEventArgs e) + { + var textBuffer = (ITextBuffer)sender; + var file = textBuffer.Properties.GetProperty(typeof(IPullRequestSessionFile)); + InvalidateLiveThreads(file, e.After); + file.Rebuild.OnNext(textBuffer.CurrentSnapshot); + } + + void TextViewClosed(object sender, EventArgs e) + { + var textView = (ITextView)sender; + CloseLiveFiles(textView.TextBuffer); + } + } +} diff --git a/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs b/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs new file mode 100644 index 0000000000..7b60f9bb29 --- /dev/null +++ b/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs @@ -0,0 +1,1213 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Factories; +using GitHub.InlineReviews.Models; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Logging; +using GitHub.Services; +using LibGit2Sharp; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Projection; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using ReactiveUI; +using Serilog; +using PullRequestReviewEvent = Octokit.PullRequestReviewEvent; +using static Octokit.GraphQL.Variable; +using DraftPullRequestReviewComment = Octokit.GraphQL.Model.DraftPullRequestReviewComment; +using FileMode = System.IO.FileMode; +using NotFoundException = LibGit2Sharp.NotFoundException; + +// GraphQL DatabaseId field are marked as deprecated, but we need them for interop with REST. +#pragma warning disable CS0618 + +namespace GitHub.InlineReviews.Services +{ + /// + /// Provides a common interface for services required by . + /// + [Export(typeof(IPullRequestSessionService))] + public class PullRequestSessionService : IPullRequestSessionService + { + static readonly ILogger log = LogManager.ForContext(); + static ICompiledQuery readPullRequestWithResolved; + static ICompiledQuery readPullRequestWithoutResolved; + static ICompiledQuery> readCommitStatuses; + static ICompiledQuery> readCommitStatusesEnterprise; + static ICompiledQuery readViewer; + + readonly IGitService gitService; + readonly IGitClient gitClient; + readonly IDiffService diffService; + readonly IApiClientFactory apiClientFactory; + readonly IGraphQLClientFactory graphqlFactory; + readonly IUsageTracker usageTracker; + readonly IDictionary, string> mergeBaseCache; + + [ImportingConstructor] + public PullRequestSessionService( + IGitService gitService, + IGitClient gitClient, + IDiffService diffService, + IApiClientFactory apiClientFactory, + IGraphQLClientFactory graphqlFactory, + IUsageTracker usageTracker) + { + this.gitService = gitService; + this.gitClient = gitClient; + this.diffService = diffService; + this.apiClientFactory = apiClientFactory; + this.graphqlFactory = graphqlFactory; + this.usageTracker = usageTracker; + + mergeBaseCache = new Dictionary, string>(); + } + + /// + public virtual async Task> Diff(LocalRepositoryModel repository, string baseSha, string headSha, string relativePath) + { + using (var repo = await GetRepository(repository)) + { + return await diffService.Diff(repo, baseSha, headSha, relativePath); + } + } + + /// + public virtual async Task> Diff(LocalRepositoryModel repository, string baseSha, string headSha, string relativePath, byte[] contents) + { + using (var repo = await GetRepository(repository)) + { + return await diffService.Diff(repo, baseSha, headSha, relativePath, contents); + } + } + + /// + public IReadOnlyList BuildAnnotations( + PullRequestDetailModel pullRequest, + string relativePath) + { + var gitPath = Paths.ToGitPath(relativePath); + + return pullRequest.CheckSuites + ?.SelectMany(checkSuite => checkSuite.CheckRuns.Select(checkRun => new { checkSuite, checkRun })) + .SelectMany(arg => + arg.checkRun.Annotations + .Where(annotation => annotation.Path == gitPath) + .Select(annotation => new InlineAnnotationModel(arg.checkSuite, arg.checkRun, annotation))) + .OrderBy(tuple => tuple.StartLine) + .ToArray(); + } + + /// + public IReadOnlyList BuildCommentThreads( + PullRequestDetailModel pullRequest, + string relativePath, + IReadOnlyList diff, + string headSha) + { + var gitPath = Paths.ToGitPath(relativePath); + + var threadsByPosition = pullRequest.Threads + .Where(x => x.Path == gitPath) + .OrderBy(x => x.Id) + .GroupBy(x => Tuple.Create(x.OriginalCommitSha, x.OriginalPosition)); + var threads = new List(); + + foreach (var thread in threadsByPosition) + { + var reviewThread = thread.First(); + var hunk = reviewThread.DiffHunk; + var chunks = DiffUtilities.ParseFragment(hunk); + var chunk = chunks.Last(); + var diffLines = chunk.Lines.Reverse().Take(5).ToList(); + var firstLine = diffLines.FirstOrDefault(); + if (firstLine == null) + { + log.Warning("Ignoring in-line comment in {RelativePath} with no diff line context", relativePath); + continue; + } + + var inlineThread = new InlineCommentThreadModel( + relativePath, + headSha, + diffLines, + thread.SelectMany(t => t.Comments.Select(c => new InlineCommentModel + { + Comment = c, + Review = pullRequest.Reviews.FirstOrDefault(x => x.Comments.Contains(c)), + })), + reviewThread.IsResolved); + threads.Add(inlineThread); + } + + UpdateCommentThreads(threads, diff); + return threads; + } + + /// + public IReadOnlyList> UpdateCommentThreads( + IReadOnlyList threads, + IReadOnlyList diff) + { + var changedLines = new List>(); + + foreach (var thread in threads) + { + var oldLineNumber = thread.LineNumber; + var newLineNumber = GetUpdatedLineNumber(thread, diff); + var changed = false; + + if (thread.IsStale) + { + thread.IsStale = false; + changed = true; + } + + if (newLineNumber != thread.LineNumber) + { + thread.LineNumber = newLineNumber; + thread.IsStale = false; + changed = true; + } + + if (changed) + { + var side = thread.DiffLineType == DiffChangeType.Delete ? GitHub.Models.DiffSide.Left : GitHub.Models.DiffSide.Right; + if (oldLineNumber != -1) changedLines.Add(Tuple.Create(oldLineNumber, side)); + if (newLineNumber != -1 && newLineNumber != oldLineNumber) changedLines.Add(Tuple.Create(newLineNumber, side)); + } + } + + return changedLines; + } + + /// + public byte[] GetContents(ITextBuffer buffer) + { + var encoding = GetDocument(buffer)?.Encoding ?? Encoding.Default; + var content = encoding.GetBytes(buffer.CurrentSnapshot.GetText()); + + var preamble = encoding.GetPreamble(); + if (preamble.Length == 0) return content; + + var completeContent = new byte[preamble.Length + content.Length]; + Buffer.BlockCopy(preamble, 0, completeContent, 0, preamble.Length); + Buffer.BlockCopy(content, 0, completeContent, preamble.Length, content.Length); + + return completeContent; + } + + /// + public ITextDocument GetDocument(ITextBuffer buffer) + { + ITextDocument result; + + if (buffer.Properties.TryGetProperty(typeof(ITextDocument), out result)) + return result; + + var projection = buffer as IProjectionBuffer; + + if (projection != null) + { + foreach (var source in projection.SourceBuffers) + { + if ((result = GetDocument(source)) != null) + return result; + } + } + + return null; + } + + /// + public virtual async Task GetTipSha(LocalRepositoryModel repository) + { + using (var repo = await GetRepository(repository)) + { + return repo.Head.Tip.Sha; + } + } + + /// + public async Task IsUnmodifiedAndPushed(LocalRepositoryModel repository, string relativePath, byte[] contents) + { + using (var repo = await GetRepository(repository)) + { + var modified = await gitClient.IsModified(repo, relativePath, contents); + var pushed = await gitClient.IsHeadPushed(repo); + + return !modified && pushed; + } + } + + public async Task ExtractFileFromGit( + LocalRepositoryModel repository, + int pullRequestNumber, + string sha, + string relativePath) + { + using (var repo = await GetRepository(repository)) + { + try + { + return await gitClient.ExtractFileBinary(repo, sha, relativePath); + } + catch (FileNotFoundException) + { + var pullHeadRef = $"refs/pull/{pullRequestNumber}/head"; + await gitClient.Fetch(repo, "origin", sha, pullHeadRef); + return await gitClient.ExtractFileBinary(repo, sha, relativePath); + } + } + } + + /// + public async Task ReadFileAsync(string path) + { + if (File.Exists(path)) + { + try + { + using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true)) + { + var buffer = new MemoryStream(); + await file.CopyToAsync(buffer); + return buffer.ToArray(); + } + } + catch { } + } + + return null; + } + + public virtual Task ReadPullRequestDetail(HostAddress address, string owner, string name, int number, bool refresh = false) + { + // The reviewThreads/isResolved field is only guaranteed to be available on github.com + if (address.IsGitHubDotCom()) + { + return ReadPullRequestDetailWithResolved(address, owner, name, number, refresh); + } + else + { + return ReadPullRequestDetailWithoutResolved(address, owner, name, number, refresh); + } + } + + async Task ReadPullRequestDetailWithResolved(HostAddress address, string owner, + string name, int number, bool refresh) + { + var itemTypes = new[] { PullRequestTimelineItemsItemType.IssueComment, PullRequestTimelineItemsItemType.PullRequestCommit }; + + if (readPullRequestWithResolved == null) + { + readPullRequestWithResolved = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .PullRequest(number: Var(nameof(number))) + .Select(pr => new PullRequestDetailModel + { + Id = pr.Id.Value, + Number = pr.Number, + Author = new ActorModel + { + Login = pr.Author.Login, + AvatarUrl = pr.Author.AvatarUrl(null), + }, + Title = pr.Title, + Body = pr.Body, + BaseRefSha = pr.BaseRefOid, + BaseRefName = pr.BaseRefName, + BaseRepositoryOwner = pr.Repository.Owner.Login, + HeadRefName = pr.HeadRefName, + HeadRefSha = pr.HeadRefOid, + HeadRepositoryOwner = pr.HeadRepositoryOwner != null ? pr.HeadRepositoryOwner.Login : null, + State = pr.State.FromGraphQl(), + UpdatedAt = pr.UpdatedAt, + CommentCount = pr.Comments(0, null, null, null).TotalCount, + Comments = pr.Comments(null, null, null, null).AllPages().Select(comment => new CommentModel + { + Id = comment.Id.Value, + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Url = comment.Url, + }).ToList(), + Threads = pr.ReviewThreads(null, null, null, null).AllPages().Select(thread => new PullRequestReviewThreadModel + { + Comments = thread.Comments(null, null, null, null, null).AllPages().Select(comment => new CommentAdapter + { + Id = comment.Id.Value, + PullRequestId = comment.PullRequest.Number, + DatabaseId = comment.DatabaseId.Value, + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + Path = comment.Path, + CommitSha = comment.Commit.Oid, + DiffHunk = comment.DiffHunk, + Position = comment.Position, + OriginalPosition = comment.OriginalPosition, + OriginalCommitId = comment.OriginalCommit.Oid, + ReplyTo = comment.ReplyTo != null ? comment.ReplyTo.Id.Value : null, + PullRequestReviewId = comment.PullRequestReview != null ? comment.PullRequestReview.Id.Value : null, + CreatedAt = comment.CreatedAt, + Url = comment.Url + }).ToList(), + IsResolved = thread.IsResolved + }).ToList(), + Reviews = pr.Reviews(null, null, null, null, null, null).AllPages().Select(review => new PullRequestReviewModel + { + Id = review.Id.Value, + Body = review.Body, + CommitId = review.Commit.Oid, + State = review.State.FromGraphQl(), + SubmittedAt = review.SubmittedAt, + Author = new ActorModel + { + Login = review.Author.Login, + AvatarUrl = review.Author.AvatarUrl(null) + } + }).ToList(), + Timeline = pr.TimelineItems(null, null, null, null, itemTypes, null, null).AllPages().Select(item => item.Switch(when => + when.PullRequestCommit(commit => new CommitModel + { + AbbreviatedOid = commit.Commit.AbbreviatedOid, + Author = new CommitActorModel + { + Name = commit.Commit.Author.Name, + Email = commit.Commit.Author.Email, + User = commit.Commit.Author.User != null ? new ActorModel + { + Login = commit.Commit.Author.User.Login, + AvatarUrl = commit.Commit.Author.User.AvatarUrl(null), + } : null + }, + MessageHeadline = commit.Commit.MessageHeadline, + Oid = commit.Commit.Oid, + }).IssueComment(comment => new CommentModel + { + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Id = comment.Id.Value, + Url = comment.Url, + }))).ToList() + }).Compile(); + } + + var vars = new Dictionary + { + { nameof(owner), owner }, + { nameof(name), name }, + { nameof(number), number }, + }; + + var connection = await graphqlFactory.CreateConnection(address); + var result = await connection.Run(readPullRequestWithResolved, vars, refresh); + + var apiClient = await apiClientFactory.Create(address); + + var files = await log.TimeAsync(nameof(apiClient.GetPullRequestFiles), + async () => await apiClient.GetPullRequestFiles(owner, name, number).ToList()); + + var lastCommitModel = await log.TimeAsync(nameof(GetPullRequestLastCommitAdapter), + () => GetPullRequestLastCommitAdapter(address, owner, name, number, refresh)); + + result.Statuses = (IReadOnlyList)lastCommitModel.Statuses ?? Array.Empty(); + + if (lastCommitModel.CheckSuites == null) + { + result.CheckSuites = Array.Empty(); + } + else + { + result.CheckSuites = lastCommitModel.CheckSuites; + foreach (var checkSuite in result.CheckSuites) + { + checkSuite.HeadSha = lastCommitModel.HeadSha; + } + } + + result.ChangedFiles = files.Select(file => new PullRequestFileModel + { + FileName = file.FileName, + Sha = file.Sha, + Status = (PullRequestFileStatus)Enum.Parse(typeof(PullRequestFileStatus), file.Status, true), + }).ToList(); + + foreach (var thread in result.Threads) + { + if (thread.Comments.Count > 0 && thread.Comments[0] is CommentAdapter adapter) + { + thread.CommitSha = adapter.CommitSha; + thread.DiffHunk = adapter.DiffHunk; + thread.Id = adapter.Id; + thread.IsOutdated = adapter.Position == null; + thread.OriginalCommitSha = adapter.OriginalCommitId; + thread.OriginalPosition = adapter.OriginalPosition; + thread.Path = adapter.Path; + thread.Position = adapter.Position; + + foreach (var comment in thread.Comments) + { + comment.Thread = thread; + } + } + } + + foreach (var review in result.Reviews) + { + review.Comments = result.Threads + .SelectMany(t => t.Comments) + .Cast() + .Where(c => c.PullRequestReviewId == review.Id) + .ToList(); + } + + return result; + } + + async Task ReadPullRequestDetailWithoutResolved(HostAddress address, string owner, + string name, int number, bool refresh) + { + var itemTypes = new[] { PullRequestTimelineItemsItemType.IssueComment, PullRequestTimelineItemsItemType.PullRequestCommit }; + + if (readPullRequestWithoutResolved == null) + { + readPullRequestWithoutResolved = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .PullRequest(number: Var(nameof(number))) + .Select(pr => new PullRequestDetailModel + { + Id = pr.Id.Value, + Number = pr.Number, + Author = new ActorModel + { + Login = pr.Author.Login, + AvatarUrl = pr.Author.AvatarUrl(null), + }, + Title = pr.Title, + Body = pr.Body, + BaseRefSha = pr.BaseRefOid, + BaseRefName = pr.BaseRefName, + BaseRepositoryOwner = pr.Repository.Owner.Login, + HeadRefName = pr.HeadRefName, + HeadRefSha = pr.HeadRefOid, + HeadRepositoryOwner = pr.HeadRepositoryOwner != null ? pr.HeadRepositoryOwner.Login : null, + State = pr.State.FromGraphQl(), + UpdatedAt = pr.UpdatedAt, + CommentCount = pr.Comments(0, null, null, null).TotalCount, + Comments = pr.Comments(null, null, null, null).AllPages().Select(comment => new CommentModel + { + Id = comment.Id.Value, + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Url = comment.Url, + }).ToList(), + Reviews = pr.Reviews(null, null, null, null, null, null).AllPages().Select(review => new PullRequestReviewModel + { + Id = review.Id.Value, + Body = review.Body, + CommitId = review.Commit.Oid, + State = review.State.FromGraphQl(), + SubmittedAt = review.SubmittedAt, + Author = new ActorModel + { + Login = review.Author.Login, + AvatarUrl = review.Author.AvatarUrl(null), + }, + Comments = review.Comments(null, null, null, null).AllPages().Select(comment => new CommentAdapter + { + Id = comment.Id.Value, + PullRequestId = comment.PullRequest.Number, + DatabaseId = comment.DatabaseId.Value, + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + Path = comment.Path, + CommitSha = comment.Commit.Oid, + DiffHunk = comment.DiffHunk, + Position = comment.Position, + OriginalPosition = comment.OriginalPosition, + OriginalCommitId = comment.OriginalCommit.Oid, + ReplyTo = comment.ReplyTo != null ? comment.ReplyTo.Id.Value : null, + CreatedAt = comment.CreatedAt, + Url = comment.Url, + }).ToList(), + }).ToList(), + Timeline = pr.TimelineItems(null, null, null, null, itemTypes, null, null).AllPages().Select(item => item.Switch(when => + when.PullRequestCommit(commit => new CommitModel + { + AbbreviatedOid = commit.Commit.AbbreviatedOid, + Author = new CommitActorModel { + Name = commit.Commit.Author.Name, + Email = commit.Commit.Author.Email, + User = commit.Commit.Author.User != null ? new ActorModel + { + Login = commit.Commit.Author.User.Login, + AvatarUrl = commit.Commit.Author.User.AvatarUrl(null), + } : null + }, + MessageHeadline = commit.Commit.MessageHeadline, + Oid = commit.Commit.Oid, + }).IssueComment(comment => new CommentModel + { + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Id = comment.Id.Value, + Url = comment.Url, + }))).ToList() + }).Compile(); + } + + var vars = new Dictionary + { + { nameof(owner), owner }, + { nameof(name), name }, + { nameof(number), number }, + }; + + var connection = await graphqlFactory.CreateConnection(address); + var result = await connection.Run(readPullRequestWithoutResolved, vars, refresh); + + var apiClient = await apiClientFactory.Create(address); + + var files = await log.TimeAsync(nameof(apiClient.GetPullRequestFiles), + async () => await apiClient.GetPullRequestFiles(owner, name, number).ToList()); + + var lastCommitModel = await log.TimeAsync(nameof(GetPullRequestLastCommitAdapter), + () => GetPullRequestLastCommitAdapter(address, owner, name, number, refresh)); + + result.Statuses = (IReadOnlyList)lastCommitModel.Statuses ?? Array.Empty(); + + if (lastCommitModel.CheckSuites == null) + { + result.CheckSuites = Array.Empty(); + } + else + { + result.CheckSuites = lastCommitModel.CheckSuites; + foreach (var checkSuite in result.CheckSuites) + { + checkSuite.HeadSha = lastCommitModel.HeadSha; + } + } + + result.ChangedFiles = files.Select(file => new PullRequestFileModel + { + FileName = file.FileName, + Sha = file.Sha, + Status = (PullRequestFileStatus)Enum.Parse(typeof(PullRequestFileStatus), file.Status, true), + }).ToList(); + + // Build pull request threads + var commentsByReplyId = new Dictionary>(); + + // Get all comments that are not replies. + foreach (CommentAdapter comment in result.Reviews.SelectMany(x => x.Comments)) + { + if (comment.ReplyTo == null) + { + commentsByReplyId.Add(comment.Id, new List { comment }); + } + } + + // Get the comments that are replies and place them into the relevant list. + foreach (CommentAdapter comment in result.Reviews.SelectMany(x => x.Comments).OrderBy(x => x.CreatedAt)) + { + if (comment.ReplyTo != null) + { + List thread = null; + + if (commentsByReplyId.TryGetValue(comment.ReplyTo, out thread)) + { + thread.Add(comment); + } + } + } + + // Build a collection of threads for the information collected above. + var threads = new List(); + + foreach (var threadSource in commentsByReplyId) + { + var adapter = threadSource.Value[0]; + + var thread = new PullRequestReviewThreadModel + { + Comments = threadSource.Value, + CommitSha = adapter.CommitSha, + DiffHunk = adapter.DiffHunk, + Id = adapter.Id, + IsOutdated = adapter.Position == null, + OriginalCommitSha = adapter.OriginalCommitId, + OriginalPosition = adapter.OriginalPosition, + Path = adapter.Path, + Position = adapter.Position, + }; + + // Set a reference to the thread in the comment. + foreach (var comment in threadSource.Value) + { + comment.Thread = thread; + } + + threads.Add(thread); + } + + result.Threads = threads; + + return result; + } + + public virtual async Task ReadViewer(HostAddress address) + { + if (readViewer == null) + { + readViewer = new Query() + .Viewer + .Select(x => new ActorModel + { + Login = x.Login, + AvatarUrl = x.AvatarUrl(null), + }).Compile(); + } + + var connection = await graphqlFactory.CreateConnection(address); + return await connection.Run(readViewer, cacheDuration: TimeSpan.FromMinutes(10)); + } + + public async Task GetGraphQLPullRequestId( + LocalRepositoryModel localRepository, + string repositoryOwner, + int number) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var query = new Query() + .Repository(owner: repositoryOwner, name: localRepository.Name) + .PullRequest(number) + .Select(x => x.Id); + + return (await graphql.Run(query)).Value; + } + + /// + public virtual async Task GetPullRequestMergeBase(LocalRepositoryModel repository, PullRequestDetailModel pullRequest) + { + var baseSha = pullRequest.BaseRefSha; + var headSha = pullRequest.HeadRefSha; + var key = new Tuple(baseSha, headSha); + + string mergeBase; + if (mergeBaseCache.TryGetValue(key, out mergeBase)) + { + return mergeBase; + } + + using (var repo = await GetRepository(repository)) + { + var targetUrl = repository.CloneUrl.WithOwner(pullRequest.BaseRepositoryOwner); + var headUrl = repository.CloneUrl.WithOwner(pullRequest.HeadRepositoryOwner); + var baseRef = pullRequest.BaseRefName; + var pullNumber = pullRequest.Number; + try + { + mergeBase = await gitClient.GetPullRequestMergeBase(repo, targetUrl, baseSha, headSha, baseRef, pullNumber); + } + catch (NotFoundException ex) + { + throw new NotFoundException("The Pull Request failed to load. Please check your network connection and click refresh to try again. If this issue persists, please let us know at support@github.com", ex); + } + + return mergeBaseCache[key] = mergeBase; + } + } + + /// + public virtual ISubject CreateRebuildSignal() + { + var input = new Subject(); + var output = Observable.Create(x => input + .Throttle(TimeSpan.FromMilliseconds(500)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x)); + return Subject.Create(input, output); + } + + /// + public async Task CreatePendingReview( + LocalRepositoryModel localRepository, + string pullRequestId) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + var (_, owner, number) = await CreatePendingReviewCore(localRepository, pullRequestId); + var detail = await ReadPullRequestDetail(address, owner, localRepository.Name, number, true); + + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentStartReview); + + return detail; + } + + /// + public async Task CancelPendingReview( + LocalRepositoryModel localRepository, + string reviewId) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var delete = new DeletePullRequestReviewInput + { + PullRequestReviewId = new ID(reviewId), + }; + + var mutation = new Mutation() + .DeletePullRequestReview(delete) + .Select(x => new + { + x.PullRequestReview.Repository.Owner.Login, + x.PullRequestReview.PullRequest.Number + }); + + var result = await graphql.Run(mutation); + return await ReadPullRequestDetail(address, result.Login, localRepository.Name, result.Number, true); + } + + /// + public async Task PostReview( + LocalRepositoryModel localRepository, + string pullRequestId, + string commitId, + string body, + PullRequestReviewEvent e) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var addReview = new AddPullRequestReviewInput + { + Body = body, + CommitOID = commitId, + Event = ToGraphQl(e), + PullRequestId = new ID(pullRequestId), + }; + + var mutation = new Mutation() + .AddPullRequestReview(addReview) + .Select(x => new + { + x.PullRequestReview.Repository.Owner.Login, + x.PullRequestReview.PullRequest.Number + }); + + var result = await graphql.Run(mutation); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewPosts); + return await ReadPullRequestDetail(address, result.Login, localRepository.Name, result.Number, true); + } + + public async Task SubmitPendingReview( + LocalRepositoryModel localRepository, + string pendingReviewId, + string body, + PullRequestReviewEvent e) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var submit = new SubmitPullRequestReviewInput + { + Body = body, + Event = ToGraphQl(e), + PullRequestReviewId = new ID(pendingReviewId), + }; + + var mutation = new Mutation() + .SubmitPullRequestReview(submit) + .Select(x => new + { + x.PullRequestReview.Repository.Owner.Login, + x.PullRequestReview.PullRequest.Number + }); + + var result = await graphql.Run(mutation); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewPosts); + return await ReadPullRequestDetail(address, result.Login, localRepository.Name, result.Number, true); + } + + /// + public async Task PostPendingReviewComment( + LocalRepositoryModel localRepository, + string pendingReviewId, + string body, + string commitId, + string path, + int position) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var comment = new AddPullRequestReviewCommentInput + { + Body = body, + CommitOID = commitId, + Path = path, + Position = position, + PullRequestReviewId = new ID(pendingReviewId), + }; + + var addComment = new Mutation() + .AddPullRequestReviewComment(comment) + .Select(x => new + { + x.Comment.Repository.Owner.Login, + x.Comment.PullRequest.Number + }); + + var result = await graphql.Run(addComment); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentPost); + return await ReadPullRequestDetail(address, result.Login, localRepository.Name, result.Number, true); + } + + /// + public async Task PostPendingReviewCommentReply( + LocalRepositoryModel localRepository, + string pendingReviewId, + string body, + string inReplyTo) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var comment = new AddPullRequestReviewCommentInput + { + Body = body, + InReplyTo = new ID(inReplyTo), + PullRequestReviewId = new ID(pendingReviewId), + }; + + var addComment = new Mutation() + .AddPullRequestReviewComment(comment) + .Select(x => new + { + x.Comment.Repository.Owner.Login, + x.Comment.PullRequest.Number + }); + + var result = await graphql.Run(addComment); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentPost); + return await ReadPullRequestDetail(address, result.Login, localRepository.Name, result.Number, true); + } + + /// + public async Task PostStandaloneReviewComment( + LocalRepositoryModel localRepository, + string pullRequestId, + string body, + string commitId, + string path, + int position) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var addReview = new AddPullRequestReviewInput + { + CommitOID = commitId, + Event = Octokit.GraphQL.Model.PullRequestReviewEvent.Comment, + PullRequestId = new ID(pullRequestId), + Comments = new[] + { + new DraftPullRequestReviewComment + { + Body = body, + Path = path, + Position = position, + }, + }, + }; + + var mutation = new Mutation() + .AddPullRequestReview(addReview) + .Select(x => new + { + x.PullRequestReview.Repository.Owner.Login, + x.PullRequestReview.PullRequest.Number + }); + + var result = await graphql.Run(mutation); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentPost); + return await ReadPullRequestDetail(address, result.Login, localRepository.Name, result.Number, true); + } + + /// + public async Task PostStandaloneReviewCommentReply( + LocalRepositoryModel localRepository, + string pullRequestId, + string body, + string inReplyTo) + { + var (id, _, _) = await CreatePendingReviewCore(localRepository, pullRequestId); + var comment = await PostPendingReviewCommentReply(localRepository, id, body, inReplyTo); + return await SubmitPendingReview(localRepository, id, null, PullRequestReviewEvent.Comment); + } + + /// + public async Task DeleteComment( + LocalRepositoryModel localRepository, + string remoteRepositoryOwner, + int pullRequestId, + int commentDatabaseId) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var apiClient = await apiClientFactory.Create(address); + + await apiClient.DeletePullRequestReviewComment( + remoteRepositoryOwner, + localRepository.Name, + commentDatabaseId); + + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentDelete); + return await ReadPullRequestDetail(address, remoteRepositoryOwner, localRepository.Name, pullRequestId, true); + } + + /// + public async Task EditComment(LocalRepositoryModel localRepository, + string remoteRepositoryOwner, + string commentNodeId, + string body) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var updatePullRequestReviewCommentInput = new UpdatePullRequestReviewCommentInput + { + Body = body, + PullRequestReviewCommentId = new ID(commentNodeId), + }; + + var editComment = new Mutation().UpdatePullRequestReviewComment(updatePullRequestReviewCommentInput) + .Select(x => new + { + x.PullRequestReviewComment.Repository.Owner.Login, + x.PullRequestReviewComment.PullRequest.Number + }); + + var result = await graphql.Run(editComment); + await usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentPost); + return await ReadPullRequestDetail(address, result.Login, localRepository.Name, result.Number, true); + } + + async Task<(string id, string owner, int number)> CreatePendingReviewCore(LocalRepositoryModel localRepository, string pullRequestId) + { + var address = HostAddress.Create(localRepository.CloneUrl); + var graphql = await graphqlFactory.CreateConnection(address); + + var input = new AddPullRequestReviewInput + { + PullRequestId = new ID(pullRequestId), + }; + + var mutation = new Mutation() + .AddPullRequestReview(input) + .Select(x => new + { + Id = x.PullRequestReview.Id.Value, + Owner = x.PullRequestReview.Repository.Owner.Login, + x.PullRequestReview.PullRequest.Number + }); + + var result = await graphql.Run(mutation); + return (result.Id, result.Owner, result.Number); + } + + int GetUpdatedLineNumber(IInlineCommentThreadModel thread, IEnumerable diff) + { + var line = DiffUtilities.Match(diff, thread.DiffMatch); + + if (line != null) + { + return (thread.DiffLineType == DiffChangeType.Delete) ? + line.OldLineNumber - 1 : + line.NewLineNumber - 1; + } + + return -1; + } + + Task GetRepository(LocalRepositoryModel repository) + { + return Task.Run(() => gitService.GetRepository(repository.LocalPath)); + } + + async Task GetPullRequestLastCommitAdapter(HostAddress address, string owner, string name, int number, bool refresh) + { + ICompiledQuery> query; + if (address.IsGitHubDotCom()) + { + if (readCommitStatuses == null) + { + readCommitStatuses = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .PullRequest(number: Var(nameof(number))).Commits(last: 1).Nodes.Select( + commit => new LastCommitAdapter + { + HeadSha = commit.Commit.Oid, + CheckSuites = commit.Commit.CheckSuites(null, null, null, null, null).AllPages(10) + .Select(suite => new CheckSuiteModel + { + CheckRuns = suite.CheckRuns(null, null, null, null, null).AllPages(10) + .Select(run => new CheckRunModel + { + Id = run.Id.Value, + Conclusion = run.Conclusion.FromGraphQl(), + Status = run.Status.FromGraphQl(), + Name = run.Name, + DetailsUrl = run.Permalink, + Summary = run.Summary, + Text = run.Text, + Annotations = run.Annotations(null, null, null, null).AllPages() + .Select(annotation => new CheckRunAnnotationModel + { + Title = annotation.Title, + Message = annotation.Message, + Path = annotation.Path, + AnnotationLevel = annotation.AnnotationLevel.Value.FromGraphQl(), + StartLine = annotation.Location.Start.Line, + EndLine = annotation.Location.End.Line, + }).ToList() + }).ToList(), + ApplicationName = suite.App != null ? suite.App.Name : "Private App" + }).ToList(), + Statuses = commit.Commit.Status + .Select(context => + context.Contexts.Select(statusContext => new StatusModel + { + State = statusContext.State.FromGraphQl(), + Context = statusContext.Context, + TargetUrl = statusContext.TargetUrl, + Description = statusContext.Description + }).ToList() + ).SingleOrDefault() + } + ).Compile(); + } + + query = readCommitStatuses; + } + else + { + if (readCommitStatusesEnterprise == null) + { + readCommitStatusesEnterprise = new Query() + .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .PullRequest(number: Var(nameof(number))).Commits(last: 1).Nodes.Select( + commit => new LastCommitAdapter + { + Statuses = commit.Commit.Status == null ? null : commit.Commit.Status + .Select(context => context == null + ? null + : context.Contexts + .Select(statusContext => new StatusModel + { + State = statusContext.State.FromGraphQl(), + Context = statusContext.Context, + TargetUrl = statusContext.TargetUrl, + Description = statusContext.Description, + }).ToList() + ).SingleOrDefault() + } + ).Compile(); + } + + query = readCommitStatusesEnterprise; + } + + var vars = new Dictionary + { + { nameof(owner), owner }, + { nameof(name), name }, + { nameof(number), number }, + }; + + var connection = await graphqlFactory.CreateConnection(address); + var result = await connection.Run(query, vars, refresh); + return result.First(); + } + + static Octokit.GraphQL.Model.PullRequestReviewEvent ToGraphQl(Octokit.PullRequestReviewEvent e) + { + switch (e) + { + case Octokit.PullRequestReviewEvent.Approve: + return Octokit.GraphQL.Model.PullRequestReviewEvent.Approve; + case Octokit.PullRequestReviewEvent.Comment: + return Octokit.GraphQL.Model.PullRequestReviewEvent.Comment; + case Octokit.PullRequestReviewEvent.RequestChanges: + return Octokit.GraphQL.Model.PullRequestReviewEvent.RequestChanges; + default: + throw new NotSupportedException(); + } + } + + class CommentAdapter : PullRequestReviewCommentModel + { + public string Path { get; set; } + public string CommitSha { get; set; } + public string DiffHunk { get; set; } + public int? Position { get; set; } + public int OriginalPosition { get; set; } + public string OriginalCommitId { get; set; } + public string ReplyTo { get; set; } + public string PullRequestReviewId { get; set; } + } + + class LastCommitAdapter + { + public List CheckSuites { get; set; } + + public List Statuses { get; set; } + + public string HeadSha { get; set; } + } + } +} diff --git a/src/GitHub.InlineReviews/Services/PullRequestStatusBarManager.cs b/src/GitHub.InlineReviews/Services/PullRequestStatusBarManager.cs new file mode 100644 index 0000000000..0c5b4acf7f --- /dev/null +++ b/src/GitHub.InlineReviews/Services/PullRequestStatusBarManager.cs @@ -0,0 +1,211 @@ +using System; +using System.Windows; +using System.Windows.Input; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Commands; +using GitHub.Extensions; +using GitHub.Primitives; +using GitHub.VisualStudio; +using GitHub.InlineReviews.Views; +using GitHub.InlineReviews.ViewModels; +using GitHub.Services; +using GitHub.Models; +using GitHub.Logging; +using Serilog; +using ReactiveUI; + +namespace GitHub.InlineReviews.Services +{ + /// + /// Manage the UI that shows the PR for the current branch. + /// + [Export(typeof(PullRequestStatusBarManager))] + public class PullRequestStatusBarManager + { + static readonly ILogger log = LogManager.ForContext(); + const string StatusBarPartName = "PART_SccStatusBarHost"; + + readonly ICommand openPullRequestsCommand; + readonly ICommand showCurrentPullRequestCommand; + + // At the moment these must be constructed on the main thread. + // TeamExplorerContext needs to retrieve DTE using GetService. + readonly Lazy pullRequestSessionManager; + readonly Lazy teamExplorerContext; + readonly Lazy connectionManager; + readonly Lazy tippingService; + + [ImportingConstructor] + public PullRequestStatusBarManager( + Lazy usageTracker, + IOpenPullRequestsCommand openPullRequestsCommand, + IShowCurrentPullRequestCommand showCurrentPullRequestCommand, + Lazy pullRequestSessionManager, + Lazy teamExplorerContext, + Lazy connectionManager, + Lazy tippingService) + { + this.openPullRequestsCommand = new UsageTrackingCommand(usageTracker, + x => x.NumberOfStatusBarOpenPullRequestList, openPullRequestsCommand); + this.showCurrentPullRequestCommand = new UsageTrackingCommand(usageTracker, + x => x.NumberOfShowCurrentPullRequest, showCurrentPullRequestCommand); + + this.pullRequestSessionManager = pullRequestSessionManager; + this.teamExplorerContext = teamExplorerContext; + this.connectionManager = connectionManager; + this.tippingService = tippingService; + } + + /// + /// Start showing the PR for the active branch on the status bar. + /// + /// + /// This must be called from the Main thread. + /// + public void StartShowingStatus() + { + try + { + var activeReposities = teamExplorerContext.Value.WhenAnyValue(x => x.ActiveRepository); + var sessions = pullRequestSessionManager.Value.WhenAnyValue(x => x.CurrentSession); + activeReposities + .CombineLatest(sessions, (r, s) => (r, s)) + .Throttle(TimeSpan.FromSeconds(1)) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => RefreshCurrentSession(x.r, x.s).Forget(log)); + } + catch (Exception e) + { + log.Error(e, "Error initializing"); + } + } + + async Task RefreshCurrentSession(LocalRepositoryModel repository, IPullRequestSession session) + { + if (repository != null && repository.HasRemotesButNoOrigin) + { + NoRemoteOriginCallout(); + } + + var showStatus = await IsDotComOrEnterpriseRepository(repository); + if (!showStatus) + { + ShowStatus(null); + return; + } + + var viewModel = CreatePullRequestStatusViewModel(repository, session); + ShowStatus(viewModel); + } + + [STAThread] + void NoRemoteOriginCallout() + { + try + { + var view = FindSccStatusBar(Application.Current.MainWindow); + if (view == null) + { + log.Warning("Couldn't find SccStatusBar"); + return; + } + + tippingService.Value.RequestCalloutDisplay( + calloutId: Guids.NoRemoteOriginCalloutId, + title: Resources.CantFindGitHubUrlForRepository, + message: Resources.RepositoriesMustHaveRemoteOrigin, + isPermanentlyDismissible: true, + targetElement: view, + vsCommandGroupId: Guids.guidGitHubCmdSet, + vsCommandId: PkgCmdIDList.showGitHubPaneCommand); + } + catch (Exception e) + { + log.Error(e, nameof(NoRemoteOriginCallout)); + } + } + + async Task IsDotComOrEnterpriseRepository(LocalRepositoryModel repository) + { + var cloneUrl = repository?.CloneUrl; + if (cloneUrl == null) + { + // No active repository or remote + return false; + } + + var isDotCom = HostAddress.IsGitHubDotComUri(cloneUrl.ToRepositoryUrl()); + if (isDotCom) + { + // This is a github.com repository + return true; + } + + var connection = await connectionManager.Value.GetConnection(repository); + if (connection != null) + { + // This is an enterprise repository + return true; + } + + return false; + } + + PullRequestStatusViewModel CreatePullRequestStatusViewModel(LocalRepositoryModel repository, IPullRequestSession session) + { + return new PullRequestStatusViewModel(openPullRequestsCommand, showCurrentPullRequestCommand) + { + Number = session?.PullRequest?.Number, + Title = session?.PullRequest?.Title, + RepositoryName = repository?.Name, + RepositoryOwner = repository?.Owner, + }; + } + + PullRequestStatusView ShowStatus(PullRequestStatusViewModel pullRequestStatusViewModel = null) + { + var statusBar = FindSccStatusBar(Application.Current.MainWindow); + if (statusBar != null) + { + var githubStatusBar = Find(statusBar); + if (githubStatusBar != null) + { + // Replace to ensure status shows up. + statusBar.Items.Remove(githubStatusBar); + } + + if (pullRequestStatusViewModel != null) + { + githubStatusBar = new PullRequestStatusView { DataContext = pullRequestStatusViewModel }; + statusBar.Items.Insert(0, githubStatusBar); + return githubStatusBar; + } + } + + return null; + } + + static T Find(StatusBar statusBar) + { + foreach (var item in statusBar.Items) + { + if (item is T) + { + return (T)item; + } + } + + return default(T); + } + + StatusBar FindSccStatusBar(Window mainWindow) + { + var contentControl = mainWindow?.Template?.FindName(StatusBarPartName, mainWindow) as ContentControl; + return contentControl?.Content as StatusBar; + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/AddInlineCommentGlyph.xaml b/src/GitHub.InlineReviews/Tags/AddInlineCommentGlyph.xaml new file mode 100644 index 0000000000..bc2a7cff7b --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/AddInlineCommentGlyph.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Tags/AddInlineCommentGlyph.xaml.cs b/src/GitHub.InlineReviews/Tags/AddInlineCommentGlyph.xaml.cs new file mode 100644 index 0000000000..f3d7bc33cc --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/AddInlineCommentGlyph.xaml.cs @@ -0,0 +1,18 @@ +using System; +using System.Windows; +using System.Windows.Controls; + +namespace GitHub.InlineReviews.Tags +{ + public partial class AddInlineCommentGlyph : UserControl + { + public AddInlineCommentGlyph() + { + InitializeComponent(); + + AddViewbox.Visibility = Visibility.Hidden; + MouseEnter += (s, e) => AddViewbox.Visibility = Visibility.Visible; + MouseLeave += (s, e) => AddViewbox.Visibility = Visibility.Hidden; + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/AddInlineCommentTag.cs b/src/GitHub.InlineReviews/Tags/AddInlineCommentTag.cs new file mode 100644 index 0000000000..2e64f6ebf8 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/AddInlineCommentTag.cs @@ -0,0 +1,57 @@ +using System; +using GitHub.Services; +using GitHub.Models; + +namespace GitHub.InlineReviews.Tags +{ + /// + /// A tag which marks a line in an editor where a new review comment can be added. + /// + public class AddInlineCommentTag : InlineCommentTag + { + /// + /// Initializes a new instance of the class. + /// + /// The pull request session. + /// + /// The SHA of the commit to which a new comment should be added. May be null if the tag + /// represents trying to add a comment to a line that hasn't yet been pushed. + /// + /// The path to the file. + /// The line in the diff that the line relates to. + /// The line in the file. + /// The type of represented by the diff line. + public AddInlineCommentTag( + IPullRequestSession session, + string commitSha, + string filePath, + int diffLine, + int lineNumber, + DiffChangeType diffChangeType) + : base(session, lineNumber, diffChangeType) + { + CommitSha = commitSha; + DiffLine = diffLine; + FilePath = filePath; + } + + /// + /// Gets the SHA of the commit to which a new comment should be added. + /// + /// + /// May be null if the tag represents trying to add a comment to a line that hasn't yet been + /// pushed. + /// + public string CommitSha { get; } + + /// + /// Gets the line in the diff that the line relates to. + /// + public int DiffLine { get; } + + /// + /// Gets the path to the file. + /// + public string FilePath { get; } + } +} diff --git a/src/GitHub.InlineReviews/Tags/InlineCommentGlyphFactory.cs b/src/GitHub.InlineReviews/Tags/InlineCommentGlyphFactory.cs new file mode 100644 index 0000000000..4dc28ea4b6 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/InlineCommentGlyphFactory.cs @@ -0,0 +1,104 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using GitHub.InlineReviews.Glyph; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Formatting; +using GitHub.InlineReviews.Services; +using GitHub.Models; +using GitHub.Services; + +namespace GitHub.InlineReviews.Tags +{ + class InlineCommentGlyphFactory : IGlyphFactory + { + readonly IInlineCommentPeekService peekService; + readonly ITextView textView; + + public InlineCommentGlyphFactory( + IInlineCommentPeekService peekService, + ITextView textView) + { + this.peekService = peekService; + this.textView = textView; + } + + public UIElement GenerateGlyph(IWpfTextViewLine line, InlineCommentTag tag) + { + var glyph = CreateGlyph(tag); + glyph.DataContext = tag; + glyph.MouseLeftButtonUp += (s, e) => + { + if (OpenThreadView(tag)) e.Handled = true; + }; + + return glyph; + } + + public IEnumerable GetTagTypes() + { + return new[] + { + typeof(AddInlineCommentTag), + typeof(ShowInlineCommentTag) + }; + } + + [SuppressMessage("Microsoft.Globalization", "CA1305:SpecifyIFormatProvider", MessageId = "System.String.Format(System.String,System.Object)")] + static UserControl CreateGlyph(InlineCommentTag tag) + { + var addTag = tag as AddInlineCommentTag; + var showTag = tag as ShowInlineCommentTag; + + if (addTag != null) + { + return new AddInlineCommentGlyph(); + } + + if (showTag != null) + { + if (showTag.Thread != null && showTag.Annotations != null) + { + return new ShowInlineCommentAnnotationGlyph(); + } + + if (showTag.Thread != null) + { + return new ShowInlineCommentGlyph(); + } + + if (showTag.Annotations != null) + { + return new ShowInlineAnnotationGlyph(); + } + + throw new ArgumentException($"{nameof(showTag)} does not have a thread or annotations"); + } + + throw new ArgumentException($"Unknown 'InlineCommentTag' type '{tag}'"); + } + + bool OpenThreadView(InlineCommentTag tag) + { + var addTag = tag as AddInlineCommentTag; + var showTag = tag as ShowInlineCommentTag; + + if (addTag != null) + { + var side = addTag.DiffChangeType == DiffChangeType.Delete ? DiffSide.Left : DiffSide.Right; + peekService.Show(textView, side, addTag.LineNumber); + return true; + } + else if (showTag != null) + { + var side = showTag.DiffChangeType == DiffChangeType.Delete ? DiffSide.Left : DiffSide.Right; + peekService.Show(textView, side, showTag.LineNumber); + return true; + } + + return false; + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/InlineCommentTag.cs b/src/GitHub.InlineReviews/Tags/InlineCommentTag.cs new file mode 100644 index 0000000000..bdf46a062e --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/InlineCommentTag.cs @@ -0,0 +1,31 @@ +using GitHub.Extensions; +using GitHub.Services; +using GitHub.Models; +using Microsoft.VisualStudio.Text.Tagging; + +namespace GitHub.InlineReviews.Tags +{ + /// + /// Base class for inline comment tags. + /// + /// + /// + public abstract class InlineCommentTag : ITag + { + protected InlineCommentTag( + IPullRequestSession session, + int lineNumber, + DiffChangeType diffChangeType) + { + Guard.ArgumentNotNull(session, nameof(session)); + + LineNumber = lineNumber; + Session = session; + DiffChangeType = diffChangeType; + } + + public int LineNumber { get; } + public IPullRequestSession Session { get; } + public DiffChangeType DiffChangeType { get; } + } +} diff --git a/src/GitHub.InlineReviews/Tags/InlineCommentTagger.cs b/src/GitHub.InlineReviews/Tags/InlineCommentTagger.cs new file mode 100644 index 0000000000..643433c982 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/InlineCommentTagger.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using GitHub.InlineReviews.Margins; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using ReactiveUI; +using Serilog; + +namespace GitHub.InlineReviews.Tags +{ + /// + /// Creates tags in an for inline comment threads. + /// + public sealed class InlineCommentTagger : ITagger, IDisposable + { + static readonly ILogger log = LogManager.ForContext(); + static readonly IReadOnlyList> EmptyTags = new ITagSpan[0]; + readonly ITextBuffer buffer; + readonly ITextView view; + readonly IPullRequestSessionManager sessionManager; + bool needsInitialize = true; + string relativePath; + DiffSide side; + IPullRequestSession session; + IPullRequestSessionFile file; + IDisposable fileSubscription; + IDisposable sessionManagerSubscription; + IDisposable visibleSubscription; + + public InlineCommentTagger( + ITextView view, + ITextBuffer buffer, + IPullRequestSessionManager sessionManager) + { + Guard.ArgumentNotNull(buffer, nameof(buffer)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + + this.buffer = buffer; + this.view = view; + this.sessionManager = sessionManager; + } + + public bool ShowMargin => file?.Diff?.Count > 0; + + public event EventHandler TagsChanged; + + public void Dispose() + { + sessionManagerSubscription?.Dispose(); + sessionManagerSubscription = null; + fileSubscription?.Dispose(); + fileSubscription = null; + visibleSubscription?.Dispose(); + visibleSubscription = null; + } + + public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) + { + if (needsInitialize) + { + // Sucessful initialization will call NotifyTagsChanged, causing this method to be re-called. + ForgetWithLogging(Initialize()); + return EmptyTags; + } + else if (file?.InlineCommentThreads != null) + { + var result = new List>(); + var currentSession = session ?? sessionManager.CurrentSession; + + if (currentSession == null) + return EmptyTags; + + foreach (var span in spans) + { + var startLine = span.Start.GetContainingLine().LineNumber; + var endLine = span.End.GetContainingLine().LineNumber; + var linesWithTags = new BitArray((endLine - startLine) + 1); + var spanThreads = file.InlineCommentThreads.Where(x => + x.LineNumber >= startLine && + x.LineNumber <= endLine) + .ToArray(); + + var spanThreadsByLine = spanThreads.ToDictionary(model => model.LineNumber); + + Dictionary spanAnnotationsByLine = null; + if (side == DiffSide.Right) + { + var spanAnnotations = file.InlineAnnotations?.Where(x => + x.EndLine - 1 >= startLine && + x.EndLine - 1 <= endLine); + + spanAnnotationsByLine = spanAnnotations?.GroupBy(model => model.EndLine) + .ToDictionary(models => models.Key - 1, models => models.ToArray()); + } + + var lines = spanThreadsByLine.Keys.Union(spanAnnotationsByLine?.Keys ?? Enumerable.Empty()); + foreach (var line in lines) + { + var snapshot = span.Snapshot; + var snapshotLine = snapshot.GetLineFromLineNumber(line); + + if (spanThreadsByLine.TryGetValue(line, out var thread)) + { + var isThreadDeleteSide = thread.DiffLineType == DiffChangeType.Delete; + var sidesMatch = side == DiffSide.Left && isThreadDeleteSide || side == DiffSide.Right && !isThreadDeleteSide; + if (!sidesMatch) + { + thread = null; + } + } + + InlineAnnotationModel[] annotations = null; + spanAnnotationsByLine?.TryGetValue(line, out annotations); + + if (thread != null || annotations != null) + { + linesWithTags[line - startLine] = true; + + CheckAnnotationLevel? summaryAnnotationLevel = null; + if (annotations != null) + { + var hasFailure = annotations.Any(model => model.AnnotationLevel == CheckAnnotationLevel.Failure); + if (hasFailure) + { + summaryAnnotationLevel = CheckAnnotationLevel.Failure; + } + else + { + var hasWarning = annotations.Any(model => model.AnnotationLevel == CheckAnnotationLevel.Warning); + summaryAnnotationLevel = hasWarning ? CheckAnnotationLevel.Warning : CheckAnnotationLevel.Notice; + } + } + + var showInlineTag = new ShowInlineCommentTag(currentSession, line, thread?.DiffLineType ?? DiffChangeType.Add) + { + Thread = thread, + Annotations = annotations, + SummaryAnnotationLevel = summaryAnnotationLevel, + }; + + result.Add(new TagSpan( + new SnapshotSpan(snapshotLine.Start, snapshotLine.End), + showInlineTag)); + } + } + + foreach (var chunk in file.Diff) + { + foreach (var line in chunk.Lines) + { + var lineNumber = (side == DiffSide.Left ? line.OldLineNumber : line.NewLineNumber) - 1; + + if (lineNumber >= startLine && + lineNumber <= endLine && + !linesWithTags[lineNumber - startLine] + && (side == DiffSide.Right || line.Type == DiffChangeType.Delete)) + { + var snapshotLine = span.Snapshot.GetLineFromLineNumber(lineNumber); + result.Add(new TagSpan( + new SnapshotSpan(snapshotLine.Start, snapshotLine.End), + new AddInlineCommentTag(currentSession, file.CommitSha, relativePath, line.DiffLineNumber, lineNumber, line.Type))); + } + } + } + } + + return result; + } + else + { + return EmptyTags; + } + } + + async Task Initialize() + { + needsInitialize = false; + + var bufferInfo = sessionManager.GetTextBufferInfo(buffer); + + if (bufferInfo != null) + { + var commitSha = bufferInfo.Side == DiffSide.Left ? "HEAD" : bufferInfo.CommitSha; + session = bufferInfo.Session; + relativePath = bufferInfo.RelativePath; + file = await session.GetFile(relativePath, commitSha); + fileSubscription = file.LinesChanged.Subscribe(LinesChanged); + side = bufferInfo.Side ?? DiffSide.Right; + NotifyTagsChanged(); + } + else + { + side = DiffSide.Right; + await InitializeLiveFile(); + sessionManagerSubscription = sessionManager + .WhenAnyValue(x => x.CurrentSession) + .Skip(1) + .Subscribe(_ => ForgetWithLogging(InitializeLiveFile())); + } + } + + async Task InitializeLiveFile() + { + fileSubscription?.Dispose(); + fileSubscription = null; + + relativePath = sessionManager.GetRelativePath(buffer); + + if (relativePath != null) + { + file = await sessionManager.GetLiveFile(relativePath, view, buffer); + + var options = view.Options; + visibleSubscription = + Observable.FromEventPattern(options, nameof(options.OptionChanged)) + .Select(_ => Unit.Default) + .StartWith(Unit.Default) + .Select(x => options.GetOptionValue(InlineCommentTextViewOptions.MarginVisibleId)) + .DistinctUntilChanged() + .Subscribe(VisibleChanged); + } + else + { + file = null; + } + + NotifyTagsChanged(); + } + + void VisibleChanged(bool enabled) + { + if (enabled) + { + fileSubscription = fileSubscription ?? file.LinesChanged.Subscribe(LinesChanged); + } + else + { + fileSubscription?.Dispose(); + fileSubscription = null; + } + } + + static void ForgetWithLogging(Task task) + { + task.Catch(e => log.Error(e, "Exception caught while executing background task")).Forget(); + } + + void LinesChanged(IReadOnlyList> lines) + { + NotifyTagsChanged(lines.Where(x => x.Item2 == side).Select(x => x.Item1)); + } + + void NotifyTagsChanged() + { + var entireFile = new SnapshotSpan(buffer.CurrentSnapshot, 0, buffer.CurrentSnapshot.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(entireFile)); + } + + void NotifyTagsChanged(int lineNumber) + { + var line = buffer.CurrentSnapshot.GetLineFromLineNumber(lineNumber); + var span = new SnapshotSpan(buffer.CurrentSnapshot, line.Start, line.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(span)); + } + + void NotifyTagsChanged(IEnumerable lineNumbers) + { + foreach (var lineNumber in lineNumbers) + { + NotifyTagsChanged(lineNumber); + } + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/InlineCommentTaggerProvider.cs b/src/GitHub.InlineReviews/Tags/InlineCommentTaggerProvider.cs new file mode 100644 index 0000000000..2aec8df74e --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/InlineCommentTaggerProvider.cs @@ -0,0 +1,55 @@ +using System; +using System.ComponentModel.Composition; +using GitHub.Extensions; +using GitHub.Services; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Differencing; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; + +namespace GitHub.InlineReviews.Tags +{ + /// + /// Factory class for s. + /// + [Export(typeof(IViewTaggerProvider))] + [ContentType("text")] + [TagType(typeof(ShowInlineCommentTag))] + [TextViewRole("LEFTDIFF")] + [TextViewRole("RIGHTDIFF")] + [TextViewRole("INLINEDIFF")] + class InlineCommentTaggerProvider : IViewTaggerProvider + { + readonly IPullRequestSessionManager sessionManager; + + [ImportingConstructor] + public InlineCommentTaggerProvider( + IPullRequestSessionManager sessionManager) + { + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + + this.sessionManager = sessionManager; + } + + public ITagger CreateTagger(ITextView view, ITextBuffer buffer) where T : ITag + { + if (view.TextViewModel is IDifferenceTextViewModel model) + { + if (buffer == model.Viewer.DifferenceBuffer.BaseLeftBuffer) + { + return view.Properties.GetOrCreateSingletonProperty("InlineTaggerForLeftBuffer", + () => new InlineCommentTagger(view, buffer, sessionManager) as ITagger); + } + + if (buffer == model.Viewer.DifferenceBuffer.BaseRightBuffer) + { + return view.Properties.GetOrCreateSingletonProperty("InlineTaggerForRightBuffer", + () => new InlineCommentTagger(view, buffer, sessionManager) as ITagger); + } + } + + return null; + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/MouseEnterAndLeaveEventRouter.cs b/src/GitHub.InlineReviews/Tags/MouseEnterAndLeaveEventRouter.cs new file mode 100644 index 0000000000..103b399115 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/MouseEnterAndLeaveEventRouter.cs @@ -0,0 +1,105 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace GitHub.InlineReviews.Tags +{ + class MouseEnterAndLeaveEventRouter where T : FrameworkElement + { + T previousMouseOverElement; + + public void Add(UIElement sourceElement, UIElement targetElement) + { + sourceElement.MouseMove += (t, e) => MouseMove(targetElement, e); + sourceElement.MouseLeave += (t, e) => MouseLeave(targetElement, e); + } + + void MouseMove(object target, MouseEventArgs e) + { + T mouseOverElement = null; + Action visitAction = element => + { + mouseOverElement = element; + }; + + var visitor = new Visitor(e, visitAction); + visitor.Visit(target); + + if (mouseOverElement != previousMouseOverElement) + { + MouseLeave(previousMouseOverElement, e); + MouseEnter(mouseOverElement, e); + } + } + + void MouseLeave(object target, MouseEventArgs e) + { + MouseLeave(previousMouseOverElement, e); + } + + void MouseEnter(T element, MouseEventArgs e) + { + element?.RaiseEvent(new MouseEventArgs(e.MouseDevice, e.Timestamp) + { + RoutedEvent = Mouse.MouseEnterEvent, + }); + + previousMouseOverElement = element; + } + + void MouseLeave(T element, MouseEventArgs e) + { + element?.RaiseEvent(new MouseEventArgs(e.MouseDevice, e.Timestamp) + { + RoutedEvent = Mouse.MouseLeaveEvent, + }); + + previousMouseOverElement = null; + } + + class Visitor + { + MouseEventArgs mouseEventArgs; + Action action; + + internal Visitor(MouseEventArgs mouseEventArgs, Action action) + { + this.mouseEventArgs = mouseEventArgs; + this.action = action; + } + + internal void Visit(object obj) + { + if (obj is Panel) + { + Visit((Panel)obj); + return; + } + + if (obj is T) + { + Visit((T)obj); + return; + } + } + + internal void Visit(Panel panel) + { + foreach (var child in panel.Children) + { + Visit(child); + } + } + + internal void Visit(T element) + { + var point = mouseEventArgs.GetPosition(element); + if (point.Y >= 0 && point.Y < element.ActualHeight) + { + action(element); + } + } + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineAnnotationGlyph.xaml b/src/GitHub.InlineReviews/Tags/ShowInlineAnnotationGlyph.xaml new file mode 100644 index 0000000000..1eae97fa11 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineAnnotationGlyph.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineAnnotationGlyph.xaml.cs b/src/GitHub.InlineReviews/Tags/ShowInlineAnnotationGlyph.xaml.cs new file mode 100644 index 0000000000..08f998aaf0 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineAnnotationGlyph.xaml.cs @@ -0,0 +1,13 @@ +using System; +using System.Windows.Controls; + +namespace GitHub.InlineReviews.Tags +{ + public partial class ShowInlineAnnotationGlyph : UserControl + { + public ShowInlineAnnotationGlyph() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineCommentAnnotationGlyph.xaml b/src/GitHub.InlineReviews/Tags/ShowInlineCommentAnnotationGlyph.xaml new file mode 100644 index 0000000000..5d18e67537 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineCommentAnnotationGlyph.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineCommentAnnotationGlyph.xaml.cs b/src/GitHub.InlineReviews/Tags/ShowInlineCommentAnnotationGlyph.xaml.cs new file mode 100644 index 0000000000..1523f19280 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineCommentAnnotationGlyph.xaml.cs @@ -0,0 +1,13 @@ +using System; +using System.Windows.Controls; + +namespace GitHub.InlineReviews.Tags +{ + public partial class ShowInlineCommentAnnotationGlyph : UserControl + { + public ShowInlineCommentAnnotationGlyph() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineCommentGlyph.xaml b/src/GitHub.InlineReviews/Tags/ShowInlineCommentGlyph.xaml new file mode 100644 index 0000000000..b147cfab84 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineCommentGlyph.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineCommentGlyph.xaml.cs b/src/GitHub.InlineReviews/Tags/ShowInlineCommentGlyph.xaml.cs new file mode 100644 index 0000000000..0bae7c2d9b --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineCommentGlyph.xaml.cs @@ -0,0 +1,13 @@ +using System; +using System.Windows.Controls; + +namespace GitHub.InlineReviews.Tags +{ + public partial class ShowInlineCommentGlyph : UserControl + { + public ShowInlineCommentGlyph() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineCommentTag.cs b/src/GitHub.InlineReviews/Tags/ShowInlineCommentTag.cs new file mode 100644 index 0000000000..6c384ad014 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineCommentTag.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; + +namespace GitHub.InlineReviews.Tags +{ + /// + /// A tag which marks a line where inline review comments are present. + /// + public class ShowInlineCommentTag : InlineCommentTag + { + /// + /// Initializes a new instance of the class. + /// + /// The pull request session. + /// 0-based index of the inline tag + /// The diff type for the inline comment + public ShowInlineCommentTag(IPullRequestSession session, int lineNumber, DiffChangeType diffLineType) + : base(session, lineNumber, diffLineType) + { + } + + /// + /// Gets a model holding details of the thread at the tagged line. + /// + public IInlineCommentThreadModel Thread { get; set; } + + /// + /// Gets a list of models holding details of the annotations at the tagged line. + /// + public IReadOnlyList Annotations { get; set; } + + /// + /// Gets a summary annotation level is Annotations are present + /// + public CheckAnnotationLevel? SummaryAnnotationLevel { get; set; } + } +} diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineGlyph.xaml b/src/GitHub.InlineReviews/Tags/ShowInlineGlyph.xaml new file mode 100644 index 0000000000..cf5726b99d --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineGlyph.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Tags/ShowInlineGlyph.xaml.cs b/src/GitHub.InlineReviews/Tags/ShowInlineGlyph.xaml.cs new file mode 100644 index 0000000000..e8b5ac4a68 --- /dev/null +++ b/src/GitHub.InlineReviews/Tags/ShowInlineGlyph.xaml.cs @@ -0,0 +1,14 @@ +using System; +using System.Windows.Controls; + +namespace GitHub.InlineReviews.Tags +{ + public partial class ShowInlineGlyph : UserControl + { + public ShowInlineGlyph() + { + InitializeComponent(); + } + + } +} diff --git a/src/GitHub.InlineReviews/VSPackage.resx b/src/GitHub.InlineReviews/VSPackage.resx new file mode 100644 index 0000000000..1af7de150c --- /dev/null +++ b/src/GitHub.InlineReviews/VSPackage.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs b/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs new file mode 100644 index 0000000000..2d967989dc --- /dev/null +++ b/src/GitHub.InlineReviews/ViewModels/InlineCommentPeekViewModel.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Commands; +using GitHub.Extensions; +using GitHub.Extensions.Reactive; +using GitHub.Factories; +using GitHub.InlineReviews.Services; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using ReactiveUI; +using Serilog; + +namespace GitHub.InlineReviews.ViewModels +{ + /// + /// Represents the contents of an inline comment peek view displayed in an editor. + /// + public sealed class InlineCommentPeekViewModel : ReactiveObject, IDisposable + { + static readonly ILogger log = LogManager.ForContext(); + readonly IInlineCommentPeekService peekService; + readonly IPeekSession peekSession; + readonly IPullRequestSessionManager sessionManager; + readonly IViewViewModelFactory factory; + IPullRequestSession session; + IPullRequestSessionFile file; + IPullRequestReviewCommentThreadViewModel thread; + IReadOnlyList annotations; + IDisposable fileSubscription; + IDisposable sessionSubscription; + IDisposable threadSubscription; + ITrackingPoint triggerPoint; + string relativePath; + DiffSide side; + bool availableForComment; + + /// + /// Initializes a new instance of the class. + /// + public InlineCommentPeekViewModel(IInlineCommentPeekService peekService, + IPeekSession peekSession, + IPullRequestSessionManager sessionManager, + INextInlineCommentCommand nextCommentCommand, + IPreviousInlineCommentCommand previousCommentCommand, + IViewViewModelFactory factory) + { + Guard.ArgumentNotNull(peekService, nameof(peekService)); + Guard.ArgumentNotNull(peekSession, nameof(peekSession)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(nextCommentCommand, nameof(nextCommentCommand)); + Guard.ArgumentNotNull(previousCommentCommand, nameof(previousCommentCommand)); + Guard.ArgumentNotNull(factory, nameof(factory)); + + this.peekService = peekService; + this.peekSession = peekSession; + this.sessionManager = sessionManager; + this.factory = factory; + triggerPoint = peekSession.GetTriggerPoint(peekSession.TextView.TextBuffer); + + peekSession.Dismissed += (s, e) => Dispose(); + + Close = this.WhenAnyValue(x => x.Thread) + .Where(x => x != null) + .SelectMany(x => x.IsNewThread + ? x.Comments.Single().CancelEdit.SelectUnit() + : Observable.Never()); + + NextComment = ReactiveCommand.CreateFromTask( + () => nextCommentCommand.Execute(new InlineCommentNavigationParams + { + FromLine = peekService.GetLineNumber(peekSession, triggerPoint).Item1, + }), + Observable.Return(nextCommentCommand.Enabled)); + + PreviousComment = ReactiveCommand.CreateFromTask( + () => previousCommentCommand.Execute(new InlineCommentNavigationParams + { + FromLine = peekService.GetLineNumber(peekSession, triggerPoint).Item1, + }), + Observable.Return(previousCommentCommand.Enabled)); + } + + public bool AvailableForComment + { + get { return availableForComment; } + private set { this.RaiseAndSetIfChanged(ref availableForComment, value); } + } + + /// + /// Gets the annotations displayed. + /// + public IReadOnlyList Annotations + { + get { return annotations; } + private set { this.RaiseAndSetIfChanged(ref annotations, value); } + } + + /// + /// Gets the thread of comments to display. + /// + public IPullRequestReviewCommentThreadViewModel Thread + { + get { return thread; } + private set { this.RaiseAndSetIfChanged(ref thread, value); } + } + + /// + /// Gets a command which moves to the next inline comment in the file. + /// + public ReactiveCommand NextComment { get; } + + /// + /// Gets a command which moves to the previous inline comment in the file. + /// + public ReactiveCommand PreviousComment { get; } + + public IObservable Close { get; } + + public void Dispose() + { + threadSubscription?.Dispose(); + threadSubscription = null; + sessionSubscription?.Dispose(); + sessionSubscription = null; + fileSubscription?.Dispose(); + fileSubscription = null; + } + + public async Task Initialize() + { + var buffer = peekSession.TextView.TextBuffer; + var info = sessionManager.GetTextBufferInfo(buffer); + + if (info != null) + { + var commitSha = info.Side == DiffSide.Left ? "HEAD" : info.CommitSha; + relativePath = info.RelativePath; + side = info.Side ?? DiffSide.Right; + file = await info.Session.GetFile(relativePath, commitSha); + session = info.Session; + await UpdateThread(); + } + else + { + relativePath = sessionManager.GetRelativePath(buffer); + side = DiffSide.Right; + file = await sessionManager.GetLiveFile(relativePath, peekSession.TextView, buffer); + await SessionChanged(sessionManager.CurrentSession); + sessionSubscription = sessionManager.WhenAnyValue(x => x.CurrentSession) + .Skip(1) + .Subscribe(x => SessionChanged(x).Forget()); + } + + fileSubscription?.Dispose(); + fileSubscription = file.LinesChanged.ObserveOn(RxApp.MainThreadScheduler).Subscribe(x => LinesChanged(x).Forget()); + } + + async Task LinesChanged(IReadOnlyList> lines) + { + try + { + var lineNumber = peekService.GetLineNumber(peekSession, triggerPoint).Item1; + + if (lines.Contains(Tuple.Create(lineNumber, side))) + { + await UpdateThread(); + } + } + catch (Exception e) + { + log.Error(e, "Error updating InlineCommentViewModel"); + } + } + + async Task UpdateThread() + { + Thread = null; + threadSubscription?.Dispose(); + + Annotations = null; + + if (file == null) + return; + + var lineAndLeftBuffer = peekService.GetLineNumber(peekSession, triggerPoint); + var lineNumber = lineAndLeftBuffer.Item1; + var leftBuffer = lineAndLeftBuffer.Item2; + + AvailableForComment = + file.Diff.Any(chunk => chunk.Lines + .Any(line => leftBuffer ? + line.OldLineNumber - 1 == lineNumber : + line.NewLineNumber - 1 == lineNumber)); + + var thread = file.InlineCommentThreads?.FirstOrDefault(x => + x.LineNumber == lineNumber && + ((leftBuffer && x.DiffLineType == DiffChangeType.Delete) || (!leftBuffer && x.DiffLineType != DiffChangeType.Delete))); + + Annotations = file.InlineAnnotations?.Where(model => model.EndLine - 1 == lineNumber) + .Select(model => new InlineAnnotationViewModel(model)) + .ToArray(); + + var threadModel = factory.CreateViewModel(); + + if (thread?.Comments.Count > 0) + { + await threadModel.InitializeAsync(session, file, thread, true); + } + else + { + await threadModel.InitializeNewAsync(session, file, lineNumber, side, true); + } + + Thread = threadModel; + } + + async Task SessionChanged(IPullRequestSession pullRequestSession) + { + this.session = pullRequestSession; + + if (pullRequestSession == null) + { + Thread = null; + threadSubscription?.Dispose(); + threadSubscription = null; + return; + } + else + { + await UpdateThread(); + } + } + } +} diff --git a/src/GitHub.InlineReviews/ViewModels/PullRequestStatusViewModel.cs b/src/GitHub.InlineReviews/ViewModels/PullRequestStatusViewModel.cs new file mode 100644 index 0000000000..6e4db9f6db --- /dev/null +++ b/src/GitHub.InlineReviews/ViewModels/PullRequestStatusViewModel.cs @@ -0,0 +1,77 @@ +using System; +using System.Windows.Input; +using System.ComponentModel; + +namespace GitHub.InlineReviews.ViewModels +{ + public class PullRequestStatusViewModel : INotifyPropertyChanged + { + int? number; + string title; + string repositoryName; + string repositoryOwner; + + public PullRequestStatusViewModel(ICommand openPullRequestsCommand, ICommand showCurrentPullRequestCommand) + { + OpenPullRequestsCommand = openPullRequestsCommand; + ShowCurrentPullRequestCommand = showCurrentPullRequestCommand; + } + + public int? Number + { + get { return number; } + set + { + if (number != value) + { + number = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Number))); + } + } + } + + public string Title + { + get { return title; } + set + { + if (title != value) + { + title = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Title))); + } + } + } + + public string RepositoryOwner + { + get { return repositoryOwner; } + set + { + if (repositoryOwner != value) + { + repositoryOwner = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(RepositoryOwner))); + } + } + } + + public string RepositoryName + { + get { return repositoryName; } + set + { + if (repositoryName != value) + { + repositoryName = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(RepositoryName))); + } + } + } + + public ICommand OpenPullRequestsCommand { get; } + public ICommand ShowCurrentPullRequestCommand { get; } + + public event PropertyChangedEventHandler PropertyChanged; + } +} diff --git a/src/GitHub.InlineReviews/Views/GlyphMarginGrid.xaml b/src/GitHub.InlineReviews/Views/GlyphMarginGrid.xaml new file mode 100644 index 0000000000..5c8097f256 --- /dev/null +++ b/src/GitHub.InlineReviews/Views/GlyphMarginGrid.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Views/GlyphMarginGrid.xaml.cs b/src/GitHub.InlineReviews/Views/GlyphMarginGrid.xaml.cs new file mode 100644 index 0000000000..251d92ef27 --- /dev/null +++ b/src/GitHub.InlineReviews/Views/GlyphMarginGrid.xaml.cs @@ -0,0 +1,16 @@ +using System; +using System.Windows.Controls; + +namespace GitHub.InlineReviews.Views +{ + /// + /// Interaction logic for GlyphMarginGrid.xaml + /// + public partial class GlyphMarginGrid : Grid + { + public GlyphMarginGrid() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.InlineReviews/Views/InlineCommentPeekView.xaml b/src/GitHub.InlineReviews/Views/InlineCommentPeekView.xaml new file mode 100644 index 0000000000..06bfd3a199 --- /dev/null +++ b/src/GitHub.InlineReviews/Views/InlineCommentPeekView.xaml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.InlineReviews/Views/InlineCommentPeekView.xaml.cs b/src/GitHub.InlineReviews/Views/InlineCommentPeekView.xaml.cs new file mode 100644 index 0000000000..10cc2c5e06 --- /dev/null +++ b/src/GitHub.InlineReviews/Views/InlineCommentPeekView.xaml.cs @@ -0,0 +1,33 @@ +using System; +using System.Reactive.Subjects; +using System.Windows.Controls; +using GitHub.VisualStudio.UI.Helpers; + +namespace GitHub.InlineReviews.Views +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")] + public partial class InlineCommentPeekView : UserControl + { + readonly Subject desiredHeight; + + public InlineCommentPeekView() + { + InitializeComponent(); + + desiredHeight = new Subject(); + threadView.LayoutUpdated += ChildLayoutUpdated; + annotationsView.LayoutUpdated += ChildLayoutUpdated; + threadScroller.PreviewMouseWheel += ScrollViewerUtilities.FixMouseWheelScroll; + } + + public IObservable DesiredHeight => desiredHeight; + + void ChildLayoutUpdated(object sender, EventArgs e) + { + var otherControlsHeight = ActualHeight - threadScroller.ActualHeight; + var threadViewHeight = threadView.DesiredSize.Height + threadView.Margin.Top + threadView.Margin.Bottom; + var annotationsViewHeight = annotationsView.DesiredSize.Height + annotationsView.Margin.Top + annotationsView.Margin.Bottom; + desiredHeight.OnNext(threadViewHeight + annotationsViewHeight + otherControlsHeight); + } + } +} diff --git a/src/GitHub.InlineReviews/Views/PullRequestStatusView.xaml b/src/GitHub.InlineReviews/Views/PullRequestStatusView.xaml new file mode 100644 index 0000000000..884ba67fed --- /dev/null +++ b/src/GitHub.InlineReviews/Views/PullRequestStatusView.xaml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + # - + + + + + diff --git a/src/GitHub.InlineReviews/Views/PullRequestStatusView.xaml.cs b/src/GitHub.InlineReviews/Views/PullRequestStatusView.xaml.cs new file mode 100644 index 0000000000..535830924a --- /dev/null +++ b/src/GitHub.InlineReviews/Views/PullRequestStatusView.xaml.cs @@ -0,0 +1,13 @@ +using System; +using System.Windows.Controls; + +namespace GitHub.InlineReviews.Views +{ + public partial class PullRequestStatusView : UserControl + { + public PullRequestStatusView() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.InlineReviews/VisualStudioExtensions.cs b/src/GitHub.InlineReviews/VisualStudioExtensions.cs new file mode 100644 index 0000000000..e2d1b9f5c5 --- /dev/null +++ b/src/GitHub.InlineReviews/VisualStudioExtensions.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; + +namespace GitHub.InlineReviews +{ + static class VisualStudioExtensions + { + public static T GetOptionValue(this IEditorOptions options, string optionId, T defaultValue) + { + return options.IsOptionDefined(optionId, false) ? + options.GetOptionValue(optionId) : defaultValue; + } + + public static T GetProperty(this PropertyCollection properties, object key, T defaultValue) + { + T value; + return properties.TryGetProperty(key, out value) ? value : defaultValue; + } + } +} diff --git a/src/GitHub.Logging/GitHub.Logging.csproj b/src/GitHub.Logging/GitHub.Logging.csproj new file mode 100644 index 0000000000..951e3bb796 --- /dev/null +++ b/src/GitHub.Logging/GitHub.Logging.csproj @@ -0,0 +1,19 @@ + + + net46 + GitHub + full + true + + + + + + + + + + + + + diff --git a/src/GitHub.Logging/Info/ApplicationInfo.cs b/src/GitHub.Logging/Info/ApplicationInfo.cs new file mode 100644 index 0000000000..7134aad155 --- /dev/null +++ b/src/GitHub.Logging/Info/ApplicationInfo.cs @@ -0,0 +1,40 @@ +using System; +using System.Diagnostics; + +namespace GitHub.Info +{ + public static class ApplicationInfo + { +#if DEBUG + public const string ApplicationName = "GìtHūbVisualStudio"; + public const string ApplicationProvider = "GitHub"; +#else + public const string ApplicationName = "GitHubVisualStudio"; + public const string ApplicationProvider = "GitHub"; +#endif + public const string ApplicationSafeName = "GitHubVisualStudio"; + public const string ApplicationDescription = "GitHub Extension for Visual Studio"; + + /// + /// Gets the version information for the host process. + /// + /// The version of the host process. + public static FileVersionInfo GetHostVersionInfo() + { + return Process.GetCurrentProcess().MainModule.FileVersionInfo; + } + + /// + /// Gets the version of a Visual Studio package. + /// + /// + /// The VS Package object. This is untyped here as this assembly does not depend on + /// any VS assemblies. + /// + /// The version of the package. + public static Version GetPackageVersion(object package) + { + return package.GetType().Assembly.GetName().Version; + } + } +} diff --git a/src/GitHub.Logging/Logging/ILoggerExtensions.cs b/src/GitHub.Logging/Logging/ILoggerExtensions.cs new file mode 100644 index 0000000000..aea5ca98d7 --- /dev/null +++ b/src/GitHub.Logging/Logging/ILoggerExtensions.cs @@ -0,0 +1,64 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Serilog; + +namespace GitHub.Logging +{ + public static class ILoggerExtensions + { + public static void Assert(this ILogger logger, bool condition, string messageTemplate) + { + if (!condition) + { + messageTemplate = "Assertion Failed: " + messageTemplate; +#pragma warning disable Serilog004 // propertyValues might not be strings + logger.Warning(messageTemplate); +#pragma warning restore Serilog004 + } + } + + public static void Assert(this ILogger logger, bool condition, string messageTemplate, params object[] propertyValues) + { + if (!condition) + { + messageTemplate = "Assertion Failed: " + messageTemplate; +#pragma warning disable Serilog004 // propertyValues might not be strings + logger.Warning(messageTemplate, propertyValues); +#pragma warning restore Serilog004 + } + } + + public static void Time(this ILogger logger, string name, Action method) + { + var startTime = DateTime.Now; + method(); + logger.Verbose("{Name} took {Seconds} seconds", name, FormatSeconds(DateTime.Now - startTime)); + } + + public static T Time(this ILogger logger, string name, Func method) + { + var startTime = DateTime.Now; + var value = method(); + logger.Verbose("{Name} took {Seconds} seconds", name, FormatSeconds(DateTime.Now - startTime)); + return value; + } + + public static async Task TimeAsync(this ILogger logger, string name, Func methodAsync) + { + var startTime = DateTime.Now; + await methodAsync().ConfigureAwait(false); + logger.Verbose("{Name} took {Seconds} seconds", name, FormatSeconds(DateTime.Now - startTime)); + } + + public static async Task TimeAsync(this ILogger logger, string name, Func> methodAsync) + { + var startTime = DateTime.Now; + var value = await methodAsync().ConfigureAwait(false); + logger.Verbose("{Name} took {Seconds} seconds", name, FormatSeconds(DateTime.Now - startTime)); + return value; + } + + static string FormatSeconds(TimeSpan timeSpan) => timeSpan.TotalSeconds.ToString("0.##", CultureInfo.InvariantCulture); + } +} diff --git a/src/GitHub.Logging/Logging/Log.cs b/src/GitHub.Logging/Logging/Log.cs new file mode 100644 index 0000000000..721f528391 --- /dev/null +++ b/src/GitHub.Logging/Logging/Log.cs @@ -0,0 +1,13 @@ +using System; +using Serilog; + +namespace GitHub.Logging +{ + public static class Log + { + private static Lazy Logger { get; } = new Lazy(() => LogManager.ForContext(typeof(Log))); + + public static void Assert(bool condition, string messageTemplate) + => Logger.Value.Assert(condition, messageTemplate); + } +} \ No newline at end of file diff --git a/src/GitHub.Logging/Logging/LogManager.cs b/src/GitHub.Logging/Logging/LogManager.cs new file mode 100644 index 0000000000..f6212e5490 --- /dev/null +++ b/src/GitHub.Logging/Logging/LogManager.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Diagnostics.CodeAnalysis; +using GitHub.Info; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace GitHub.Logging +{ + public static class LogManager + { +#if DEBUG + private static LogEventLevel DefaultLoggingLevel = LogEventLevel.Debug; +#else + private static LogEventLevel DefaultLoggingLevel = LogEventLevel.Information; +#endif + + private static LoggingLevelSwitch LoggingLevelSwitch = new LoggingLevelSwitch(DefaultLoggingLevel); + + static Logger CreateLogger() + { + var logPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + ApplicationInfo.ApplicationName, + "extension.log"); + + const string outputTemplate = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{ProcessId:00000}] {Level:u4} [{ThreadId:00}] {ShortSourceContext,-25} {Message:lj}{NewLine}{Exception}"; + + return new LoggerConfiguration() + .Enrich.WithProcessId() + .Enrich.WithThreadId() + .MinimumLevel.ControlledBy(LoggingLevelSwitch) + .WriteTo.File(logPath, + fileSizeLimitBytes: null, + outputTemplate: outputTemplate, + shared: true) + .CreateLogger(); + } + + public static void EnableTraceLogging(bool enable) + { + var logEventLevel = enable ? LogEventLevel.Verbose : DefaultLoggingLevel; + if(LoggingLevelSwitch.MinimumLevel != logEventLevel) + { + ForContext(typeof(LogManager)).Information("Set Logging Level: {LogEventLevel}", logEventLevel); + LoggingLevelSwitch.MinimumLevel = logEventLevel; + } + } + + static Lazy Logger { get; } = new Lazy(CreateLogger); + + [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter")] + public static ILogger ForContext() => ForContext(typeof(T)); + + public static ILogger ForContext(Type type) => Logger.Value.ForContext(type).ForContext("ShortSourceContext", type.Name); + } +} \ No newline at end of file diff --git a/src/GitHub.Resources/GitHub.Resources.csproj b/src/GitHub.Resources/GitHub.Resources.csproj new file mode 100644 index 0000000000..c83638a827 --- /dev/null +++ b/src/GitHub.Resources/GitHub.Resources.csproj @@ -0,0 +1,27 @@ + + + net46 + GitHub + + + + + + + + + + + Resources.resx + True + True + + + + + + Resources.Designer.cs + PublicResXFileCodeGenerator + + + diff --git a/src/GitHub.Resources/Properties/AssemblyInfo.cs b/src/GitHub.Resources/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..19ba3352c8 --- /dev/null +++ b/src/GitHub.Resources/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Windows.Markup; + +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub")] diff --git a/src/GitHub.Resources/Resources.Designer.cs b/src/GitHub.Resources/Resources.Designer.cs new file mode 100644 index 0000000000..0db38b220b --- /dev/null +++ b/src/GitHub.Resources/Resources.Designer.cs @@ -0,0 +1,2372 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace GitHub { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GitHub.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Add/Change Accounts. + /// + public static string AddChangeAccounts { + get { + return ResourceManager.GetString("AddChangeAccounts", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to add. + /// + public static string AddedFileStatus { + get { + return ResourceManager.GetString("AddedFileStatus", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to added some commits. + /// + public static string AddedSomeCommits { + get { + return ResourceManager.GetString("AddedSomeCommits", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add review comment. + /// + public static string AddReviewComment { + get { + return ResourceManager.GetString("AddReviewComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add a single comment. + /// + public static string AddSingleComment { + get { + return ResourceManager.GetString("AddSingleComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add your review. + /// + public static string AddYourReview { + get { + return ResourceManager.GetString("AddYourReview", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to and others. + /// + public static string AndOthers { + get { + return ResourceManager.GetString("AndOthers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Approve. + /// + public static string Approve { + get { + return ResourceManager.GetString("Approve", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Approved. + /// + public static string Approved { + get { + return ResourceManager.GetString("Approved", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Assignee. + /// + public static string Assignee { + get { + return ResourceManager.GetString("Assignee", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid authentication code. + /// + public static string authenticationFailedLabelContent { + get { + return ResourceManager.GetString("authenticationFailedLabelContent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Try entering the code again or clicking the resend button to get a new authentication code.. + /// + public static string authenticationFailedLabelMessage { + get { + return ResourceManager.GetString("authenticationFailedLabelMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authentication code sent!. + /// + public static string authenticationSentLabelContent { + get { + return ResourceManager.GetString("authenticationSentLabelContent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to If you do not receive the authentication code, contact support@github.com.. + /// + public static string authenticationSentLabelMessage { + get { + return ResourceManager.GetString("authenticationSentLabelMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Author. + /// + public static string Author { + get { + return ResourceManager.GetString("Author", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Back. + /// + public static string Back { + get { + return ResourceManager.GetString("Back", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The GitHub extension is not available inside Blend. + /// + public static string BlendDialogText { + get { + return ResourceManager.GetString("BlendDialogText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Powerful collaboration, code review, and code management for open source and private projects.. + /// + public static string BlurbText { + get { + return ResourceManager.GetString("BlurbText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select a containing folder for your new repository.. + /// + public static string BrowseForDirectory { + get { + return ResourceManager.GetString("BrowseForDirectory", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browse. + /// + public static string browsePathButtonContent { + get { + return ResourceManager.GetString("browsePathButtonContent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cancel. + /// + public static string Cancel { + get { + return ResourceManager.GetString("Cancel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cancel. + /// + public static string CancelLink { + get { + return ResourceManager.GetString("CancelLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Are you sure you want to cancel this review? You will lose all your pending comments.. + /// + public static string CancelPendingReviewConfirmation { + get { + return ResourceManager.GetString("CancelPendingReviewConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cancel Review. + /// + public static string CancelPendingReviewConfirmationCaption { + get { + return ResourceManager.GetString("CancelPendingReviewConfirmationCaption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Can't find GitHub URL for repository. + /// + public static string CantFindGitHubUrlForRepository { + get { + return ResourceManager.GetString("CantFindGitHubUrlForRepository", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Changes ({0}). + /// + public static string ChangesCountFormat { + get { + return ResourceManager.GetString("ChangesCountFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The working file is different to the file at '{0}'. Please checkout the corresponding branch, pull request or commit.. + /// + public static string ChangesInWorkingDirectoryMessage { + get { + return ResourceManager.GetString("ChangesInWorkingDirectoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Changes Requested. + /// + public static string ChangesRequested { + get { + return ResourceManager.GetString("ChangesRequested", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clone. + /// + public static string CloneLink { + get { + return ResourceManager.GetString("CloneLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Close and comment. + /// + public static string CloseAndComment { + get { + return ResourceManager.GetString("CloseAndComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Close issue. + /// + public static string CloseIssue { + get { + return ResourceManager.GetString("CloseIssue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Close pull request. + /// + public static string ClosePullRequest { + get { + return ResourceManager.GetString("ClosePullRequest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Comment. + /// + public static string Comment { + get { + return ResourceManager.GetString("Comment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Commented. + /// + public static string Commented { + get { + return ResourceManager.GetString("Commented", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Comment only. + /// + public static string CommentOnly { + get { + return ResourceManager.GetString("CommentOnly", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Comments. + /// + public static string Comments { + get { + return ResourceManager.GetString("Comments", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} commits. + /// + public static string CommitCountFormat { + get { + return ResourceManager.GetString("CommitCountFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Compare File as Default Action. + /// + public static string CompareFileAsDefaultAction { + get { + return ResourceManager.GetString("CompareFileAsDefaultAction", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Continue your review. + /// + public static string ContinueYourReview { + get { + return ResourceManager.GetString("ContinueYourReview", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not connect to github.com. + /// + public static string CouldNotConnectToGitHub { + get { + return ResourceManager.GetString("CouldNotConnectToGitHub", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not connect to github.com. + /// + public static string couldNotConnectToGitHubText { + get { + return ResourceManager.GetString("couldNotConnectToGitHubText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not connect to the server.. + /// + public static string couldNotConnectToTheServerText { + get { + return ResourceManager.GetString("couldNotConnectToTheServerText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Couldn't find file corresponding to '{0}' in the repository. Please do a 'git fetch' or checkout the target pull request.. + /// + public static string CouldntFindCorrespondingFile { + get { + return ResourceManager.GetString("CouldntFindCorrespondingFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Couldn't find Git.exe on PATH. + /// + ///Please install Git for Windows from: + ///https://git-scm.com/download/win. + /// + public static string CouldntFindGitOnPath { + get { + return ResourceManager.GetString("CouldntFindGitOnPath", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create an account. + /// + public static string CreateAccountLink { + get { + return ResourceManager.GetString("CreateAccountLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to create a pull request. + /// + public static string CreateAPullRequest { + get { + return ResourceManager.GetString("CreateAPullRequest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create a GitHub Gist. + /// + public static string CreateGistTitle { + get { + return ResourceManager.GetString("CreateGistTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create. + /// + public static string CreateLink { + get { + return ResourceManager.GetString("CreateLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create New. + /// + public static string CreateNew { + get { + return ResourceManager.GetString("CreateNew", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create pull request. + /// + public static string CreatePullRequest { + get { + return ResourceManager.GetString("CreatePullRequest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create a {0} Repository. + /// + public static string CreateTitle { + get { + return ResourceManager.GetString("CreateTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} day ago. + /// + public static string day { + get { + return ResourceManager.GetString("day", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} days ago. + /// + public static string days { + get { + return ResourceManager.GetString("days", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to GistFromVisualStudio.cs. + /// + public static string DefaultGistFileName { + get { + return ResourceManager.GetString("DefaultGistFileName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Are you sure you want to delete this comment?. + /// + public static string DeleteCommentConfirmation { + get { + return ResourceManager.GetString("DeleteCommentConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete Comment. + /// + public static string DeleteCommentConfirmationCaption { + get { + return ResourceManager.GetString("DeleteCommentConfirmationCaption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Description. + /// + public static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Description (Optional). + /// + public static string DescriptionOptional { + get { + return ResourceManager.GetString("DescriptionOptional", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A file exists at the destination path.. + /// + public static string DestinationAlreadyExists { + get { + return ResourceManager.GetString("DestinationAlreadyExists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please open the repository '{0}' and try again. + /// + public static string DifferentRepositoryMessage { + get { + return ResourceManager.GetString("DifferentRepositoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The directory at the destination path is not empty.. + /// + public static string DirectoryAtDestinationNotEmpty { + get { + return ResourceManager.GetString("DirectoryAtDestinationNotEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Don’t have an account? . + /// + public static string dontHaveAnAccountText { + get { + return ResourceManager.GetString("dontHaveAnAccountText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Don’t have GitHub Enterprise? . + /// + public static string dontHaveGitHubEnterpriseText { + get { + return ResourceManager.GetString("dontHaveGitHubEnterpriseText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please check your internet connection and try again.. + /// + public static string dotComConnectionFailedMessageMessage { + get { + return ResourceManager.GetString("dotComConnectionFailedMessageMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The host isn't available or is not a GitHub Enterprise server. Check the address and try again.. + /// + public static string enterpriseConnectingFailedMessage { + get { + return ResourceManager.GetString("enterpriseConnectingFailedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to GitHub Enterprise server address. + /// + public static string enterpriseUrlPromptText { + get { + return ResourceManager.GetString("enterpriseUrlPromptText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter an Enterprise URL. + /// + public static string EnterpriseUrlValidatorEmpty { + get { + return ResourceManager.GetString("EnterpriseUrlValidatorEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter a valid Enterprise URL. + /// + public static string EnterpriseUrlValidatorInvalid { + get { + return ResourceManager.GetString("EnterpriseUrlValidatorInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not an Enterprise server. Please enter an Enterprise URL. + /// + public static string EnterpriseUrlValidatorNotAGitHubHost { + get { + return ResourceManager.GetString("EnterpriseUrlValidatorNotAGitHubHost", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not copy to the clipboard. Please try again.. + /// + public static string Error_FailedToCopyToClipboard { + get { + return ResourceManager.GetString("Error_FailedToCopyToClipboard", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to File Name. + /// + public static string fileNameText { + get { + return ResourceManager.GetString("fileNameText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter branches. + /// + public static string filterBranchesText { + get { + return ResourceManager.GetString("filterBranchesText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter by Author. + /// + public static string FilterByAuthor { + get { + return ResourceManager.GetString("FilterByAuthor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search repositories. + /// + public static string filterTextPromptText { + get { + return ResourceManager.GetString("filterTextPromptText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (forgot your password?). + /// + public static string ForgotPasswordLink { + get { + return ResourceManager.GetString("ForgotPasswordLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to fork. + /// + public static string Fork { + get { + return ResourceManager.GetString("Fork", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fork. + /// + public static string ForkNavigationItemText { + get { + return ResourceManager.GetString("ForkNavigationItemText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fork Repository. + /// + public static string ForkRepository { + get { + return ResourceManager.GetString("ForkRepository", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fork Repository. + /// + public static string ForkRepositoryTitle { + get { + return ResourceManager.GetString("ForkRepositoryTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fork the repository. + /// + public static string ForkTheRepository { + get { + return ResourceManager.GetString("ForkTheRepository", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get Started. + /// + public static string GetStartedText { + get { + return ResourceManager.GetString("GetStartedText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Gist created. + /// + public static string gistCreatedMessage { + get { + return ResourceManager.GetString("gistCreatedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to create gist. + /// + public static string gistCreationFailedMessage { + get { + return ResourceManager.GetString("gistCreationFailedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connect…. + /// + public static string GitHubInvitationSectionConnectLabel { + get { + return ResourceManager.GetString("GitHubInvitationSectionConnectLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Publish to GitHub. + /// + public static string GitHubPublishSectionTitle { + get { + return ResourceManager.GetString("GitHubPublishSectionTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Graphs. + /// + public static string GraphsNavigationItemText { + get { + return ResourceManager.GetString("GraphsNavigationItemText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} hour ago. + /// + public static string hour { + get { + return ResourceManager.GetString("hour", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} hours ago. + /// + public static string hours { + get { + return ResourceManager.GetString("hours", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Git ignore. + /// + public static string ignoreTemplateListText { + get { + return ResourceManager.GetString("ignoreTemplateListText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to InProgress. + /// + public static string InProgress { + get { + return ResourceManager.GetString("InProgress", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to [invalid]. + /// + public static string InvalidBranchName { + get { + return ResourceManager.GetString("InvalidBranchName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Issues. + /// + public static string IssuesNavigationItemText { + get { + return ResourceManager.GetString("IssuesNavigationItemText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to just now. + /// + public static string JustNow { + get { + return ResourceManager.GetString("JustNow", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Learn more. + /// + public static string learnMoreLink { + get { + return ResourceManager.GetString("learnMoreLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to License. + /// + public static string licenseListText { + get { + return ResourceManager.GetString("licenseListText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Link copied to clipboard. + /// + public static string LinkCopiedToClipboardMessage { + get { + return ResourceManager.GetString("LinkCopiedToClipboardMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Some or all repositories may not have loaded. Close the dialog and try again.. + /// + public static string loadingFailedMessageContent { + get { + return ResourceManager.GetString("loadingFailedMessageContent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An error occurred while loading repositories. + /// + public static string loadingFailedMessageMessage { + get { + return ResourceManager.GetString("loadingFailedMessageMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Local branch up to date. + /// + public static string LocalBranchUpToDate { + get { + return ResourceManager.GetString("LocalBranchUpToDate", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Local path:. + /// + public static string localPathText { + get { + return ResourceManager.GetString("localPathText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A repository already exists at this location, but it doesn't have a remote named "origin".. + /// + public static string LocalRepositoryDoesntHaveARemoteOrigin { + get { + return ResourceManager.GetString("LocalRepositoryDoesntHaveARemoteOrigin", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A repository already exists at this location, but it has a remote of {0}.. + /// + public static string LocalRepositoryHasARemoteOf { + get { + return ResourceManager.GetString("LocalRepositoryHasARemoteOf", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Make sure to use your password and not a Personal Access token to sign in.. + /// + public static string LoginFailedForbiddenMessage { + get { + return ResourceManager.GetString("LoginFailedForbiddenMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Check your username and password, then try again. + /// + public static string LoginFailedMessage { + get { + return ResourceManager.GetString("LoginFailedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign in failed.. + /// + public static string LoginFailedText { + get { + return ResourceManager.GetString("LoginFailedText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign in. + /// + public static string LoginLink { + get { + return ResourceManager.GetString("LoginLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connect To GitHub. + /// + public static string LoginTitle { + get { + return ResourceManager.GetString("LoginTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Logout Required. + /// + public static string LogoutRequired { + get { + return ResourceManager.GetString("LogoutRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Private Repository. + /// + public static string makePrivateContent { + get { + return ResourceManager.GetString("makePrivateContent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Private Gist. + /// + public static string makePrivateGist { + get { + return ResourceManager.GetString("makePrivateGist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} minute ago. + /// + public static string minute { + get { + return ResourceManager.GetString("minute", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} minutes ago. + /// + public static string minutes { + get { + return ResourceManager.GetString("minutes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} month ago. + /// + public static string month { + get { + return ResourceManager.GetString("month", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} months ago. + /// + public static string months { + get { + return ResourceManager.GetString("months", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} most recently pushed. + /// + public static string MostRecentlyPushed { + get { + return ResourceManager.GetString("MostRecentlyPushed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You must pull before you can push. + /// + public static string MustPullBeforePush { + get { + return ResourceManager.GetString("MustPullBeforePush", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name. + /// + public static string nameText { + get { + return ResourceManager.GetString("nameText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Checkout PR branch before navigating to Editor. + /// + public static string NavigateToEditorNotCheckedOutInfoMessage { + get { + return ResourceManager.GetString("NavigateToEditorNotCheckedOutInfoMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Press Enter to navigate to Editor (PR branch must be checked out). + /// + public static string NavigateToEditorNotCheckedOutStatusMessage { + get { + return ResourceManager.GetString("NavigateToEditorNotCheckedOutStatusMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Press Enter to navigate to Editor. + /// + public static string NavigateToEditorStatusMessage { + get { + return ResourceManager.GetString("NavigateToEditorStatusMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Next Comment. + /// + public static string NextComment { + get { + return ResourceManager.GetString("NextComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There is no active repository to navigate. + /// + public static string NoActiveRepositoryMessage { + get { + return ResourceManager.GetString("NoActiveRepositoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No commits to pull. + /// + public static string NoCommitsToPull { + get { + return ResourceManager.GetString("NoCommitsToPull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No commits to push. + /// + public static string NoCommitsToPush { + get { + return ResourceManager.GetString("NoCommitsToPush", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *No description provided.*. + /// + public static string NoDescriptionProvidedMarkdown { + get { + return ResourceManager.GetString("NoDescriptionProvidedMarkdown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Couldn't a find a GitHub URL in clipboard. + /// + public static string NoGitHubUrlMessage { + get { + return ResourceManager.GetString("NoGitHubUrlMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No repositories. + /// + public static string noRepositoriesMessageText { + get { + return ResourceManager.GetString("noRepositoriesMessageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The target URL has a different owner to the current repository.. + /// + public static string NoResolveDifferentOwnerMessage { + get { + return ResourceManager.GetString("NoResolveDifferentOwnerMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Couldn't find target URL in current repository. Try again after doing a fetch.. + /// + public static string NoResolveSameOwnerMessage { + get { + return ResourceManager.GetString("NoResolveSameOwnerMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No results matched your search.. + /// + public static string NoResultsMatchedYourSearch { + get { + return ResourceManager.GetString("NoResultsMatchedYourSearch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This repository is not on GitHub. + /// + public static string NotAGitHubRepository { + get { + return ResourceManager.GetString("NotAGitHubRepository", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Publish this repository to GitHub and get powerful collaboration, code review, and code management for open source and private projects.. + /// + public static string NotAGitHubRepositoryMessage { + get { + return ResourceManager.GetString("NotAGitHubRepositoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No repository. + /// + public static string NotAGitRepository { + get { + return ResourceManager.GetString("NotAGitRepository", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to We couldn't find a git repository here. Open a git project or click "File -> Add to Source Control" in a project to get started.. + /// + public static string NotAGitRepositoryMessage { + get { + return ResourceManager.GetString("NotAGitRepositoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not signed in to {0}, so certain git operations may fail. [Sign in now]({1}). + /// + public static string NotLoggedInMessage { + get { + return ResourceManager.GetString("NotLoggedInMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open. + /// + public static string Open { + get { + return ResourceManager.GetString("Open", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open File as Default Action. + /// + public static string OpenFileAsDefaultAction { + get { + return ResourceManager.GetString("OpenFileAsDefaultAction", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open File in Solution. + /// + public static string OpenFileInSolution { + get { + return ResourceManager.GetString("OpenFileInSolution", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Browse.... + /// + public static string OpenFromGitHubBrowse { + get { + return ResourceManager.GetString("OpenFromGitHubBrowse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clone. + /// + public static string OpenFromGitHubClone { + get { + return ResourceManager.GetString("OpenFromGitHubClone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search or enter a URL. + /// + public static string OpenFromGitHubSearchOrEnterAUrl { + get { + return ResourceManager.GetString("OpenFromGitHubSearchOrEnterAUrl", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open from GitHub. + /// + public static string OpenFromGitHubTitle { + get { + return ResourceManager.GetString("OpenFromGitHubTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open in Browser. + /// + public static string openInBrowser { + get { + return ResourceManager.GetString("openInBrowser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View Pull Request on GitHub. + /// + public static string OpenPROnGitHubToolTip { + get { + return ResourceManager.GetString("OpenPROnGitHubToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open repository at '{0}'?. + /// + public static string OpenRepositoryAtDir { + get { + return ResourceManager.GetString("OpenRepositoryAtDir", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open the two-factor authentication app on your device to view your authentication code.. + /// + public static string openTwoFactorAuthAppText { + get { + return ResourceManager.GetString("openTwoFactorAuthAppText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Debugging. + /// + public static string Options_DebuggingTitle { + get { + return ResourceManager.GetString("Options_DebuggingTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show PR comments on editor margin. + /// + public static string Options_EditorCommentsLabel { + get { + return ResourceManager.GetString("Options_EditorCommentsLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enable Trace Logging. + /// + public static string Options_EnableTraceLoggingText { + get { + return ResourceManager.GetString("Options_EnableTraceLoggingText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to These features might change in a future version. + /// + public static string Options_ExperimentalNote { + get { + return ResourceManager.GetString("Options_ExperimentalNote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Experimental features. + /// + public static string Options_ExperimentalTitle { + get { + return ResourceManager.GetString("Options_ExperimentalTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Help us improve by sending anonymous usage data. + /// + public static string Options_MetricsLabel { + get { + return ResourceManager.GetString("Options_MetricsLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Privacy. + /// + public static string Options_PrivacyTitle { + get { + return ResourceManager.GetString("Options_PrivacyTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to or. + /// + public static string orText { + get { + return ResourceManager.GetString("orText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Outdated comments. + /// + public static string OutdatedComments { + get { + return ResourceManager.GetString("OutdatedComments", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Password. + /// + public static string PasswordPrompt { + get { + return ResourceManager.GetString("PasswordPrompt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter your password. + /// + public static string PasswordValidatorEmpty { + get { + return ResourceManager.GetString("PasswordValidatorEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Path. + /// + public static string pathText { + get { + return ResourceManager.GetString("pathText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pending. + /// + public static string Pending { + get { + return ResourceManager.GetString("Pending", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pull Request for branch **{0}** created successfully at [{1}]({2}). + /// + public static string PRCreatedUpstream { + get { + return ResourceManager.GetString("PRCreatedUpstream", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Previous Comment. + /// + public static string PreviousComment { + get { + return ResourceManager.GetString("PreviousComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to by. + /// + public static string prUpdatedByText { + get { + return ResourceManager.GetString("prUpdatedByText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Updated. + /// + public static string prUpdatedText { + get { + return ResourceManager.GetString("prUpdatedText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Publish. + /// + public static string publishText { + get { + return ResourceManager.GetString("publishText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Publish to GitHub. + /// + public static string PublishToGitHubButton { + get { + return ResourceManager.GetString("PublishToGitHubButton", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pull. + /// + public static string Pull { + get { + return ResourceManager.GetString("Pull", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter a title for the Pull Request. + /// + public static string PullRequestCreationTitleValidatorEmpty { + get { + return ResourceManager.GetString("PullRequestCreationTitleValidatorEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Checkout {0}. + /// + public static string PullRequestDetailsCheckout { + get { + return ResourceManager.GetString("PullRequestDetailsCheckout", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Checkout to {0}. + /// + public static string PullRequestDetailsCheckoutTo { + get { + return ResourceManager.GetString("PullRequestDetailsCheckoutTo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pull from {0} branch {1}. + /// + public static string PullRequestDetailsPullToolTip { + get { + return ResourceManager.GetString("PullRequestDetailsPullToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Push to {0} branch {1}. + /// + public static string PullRequestDetailsPushToolTip { + get { + return ResourceManager.GetString("PullRequestDetailsPushToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pull Request. + /// + public static string PullRequestNavigationItemText { + get { + return ResourceManager.GetString("PullRequestNavigationItemText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pull requests let you tell others about changes you've pushed to a repository on GitHub. + /// + public static string PullRequestsLetYouTellOthersAboutChangesYouVePushedToARepositoryOnGitHub { + get { + return ResourceManager.GetString("PullRequestsLetYouTellOthersAboutChangesYouVePushedToARepositoryOnGitHub", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pull Requests. + /// + public static string PullRequestsNavigationItemText { + get { + return ResourceManager.GetString("PullRequestsNavigationItemText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Source and target branch cannot be the same. + /// + public static string PullRequestSourceAndTargetBranchTheSame { + get { + return ResourceManager.GetString("PullRequestSourceAndTargetBranchTheSame", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Source branch doesn't exist remotely, have you pushed it?. + /// + public static string PullRequestSourceBranchDoesNotExist { + get { + return ResourceManager.GetString("PullRequestSourceBranchDoesNotExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pulse. + /// + public static string PulseNavigationItemText { + get { + return ResourceManager.GetString("PulseNavigationItemText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Push. + /// + public static string Push { + get { + return ResourceManager.GetString("Push", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to remote. + /// + public static string Remote { + get { + return ResourceManager.GetString("Remote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to rename. + /// + public static string RenamedFileStatus { + get { + return ResourceManager.GetString("RenamedFileStatus", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reopen and comment. + /// + public static string ReopenAndComment { + get { + return ResourceManager.GetString("ReopenAndComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reopen issue. + /// + public static string ReopenIssue { + get { + return ResourceManager.GetString("ReopenIssue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reopen pull request. + /// + public static string ReopenPullRequest { + get { + return ResourceManager.GetString("ReopenPullRequest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This repository does not have a remote. Fill out the form to publish it to GitHub.. + /// + public static string RepoDoesNotHaveRemoteText { + get { + return ResourceManager.GetString("RepoDoesNotHaveRemoteText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository Name. + /// + public static string RepoNameText { + get { + return ResourceManager.GetString("RepoNameText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repositories must have a remote called "origin" defined in order to locate their GitHub URL.. + /// + public static string RepositoriesMustHaveRemoteOrigin { + get { + return ResourceManager.GetString("RepositoriesMustHaveRemoteOrigin", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please rename one of your existing remotes to 'origin' or add a new remote named 'origin' and fetch. This can be done from the command line or by clicking the button below.. + /// + public static string RepositoriesMustHaveRemoteOriginHowToFix { + get { + return ResourceManager.GetString("RepositoriesMustHaveRemoteOriginHowToFix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter a repository path. + /// + public static string RepositoryCreationClonePathEmpty { + get { + return ResourceManager.GetString("RepositoryCreationClonePathEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter a valid path. + /// + public static string RepositoryCreationClonePathInvalid { + get { + return ResourceManager.GetString("RepositoryCreationClonePathInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Path contains invalid characters. + /// + public static string RepositoryCreationClonePathInvalidCharacters { + get { + return ResourceManager.GetString("RepositoryCreationClonePathInvalidCharacters", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Path too long. + /// + public static string RepositoryCreationClonePathTooLong { + get { + return ResourceManager.GetString("RepositoryCreationClonePathTooLong", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository '{0}/{1}' already exists.. + /// + public static string RepositoryCreationFailedAlreadyExists { + get { + return ResourceManager.GetString("RepositoryCreationFailedAlreadyExists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Change the repository name or select a different account and try again.. + /// + public static string RepositoryCreationFailedAlreadyExistsMessage { + get { + return ResourceManager.GetString("RepositoryCreationFailedAlreadyExistsMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Exceeded private repositories quota.. + /// + public static string RepositoryCreationFailedQuota { + get { + return ResourceManager.GetString("RepositoryCreationFailedQuota", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository with same name already exists at this location. + /// + public static string RepositoryNameValidatorAlreadyExists { + get { + return ResourceManager.GetString("RepositoryNameValidatorAlreadyExists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter a repository name. + /// + public static string RepositoryNameValidatorEmpty { + get { + return ResourceManager.GetString("RepositoryNameValidatorEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository name must be fewer than 100 characters. + /// + public static string RepositoryNameValidatorTooLong { + get { + return ResourceManager.GetString("RepositoryNameValidatorTooLong", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Repository created successfully.. + /// + public static string RepositoryPublishedMessage { + get { + return ResourceManager.GetString("RepositoryPublishedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Collaborator repositories. + /// + public static string RepositorySelectCollaboratorRepositories { + get { + return ResourceManager.GetString("RepositorySelectCollaboratorRepositories", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Contributed to repositories. + /// + public static string RepositorySelectContributedRepositories { + get { + return ResourceManager.GetString("RepositorySelectContributedRepositories", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your repositories. + /// + public static string RepositorySelectYourRepositories { + get { + return ResourceManager.GetString("RepositorySelectYourRepositories", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Request changes. + /// + public static string RequestChanges { + get { + return ResourceManager.GetString("RequestChanges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resend. + /// + public static string resendCodeButtonContent { + get { + return ResourceManager.GetString("resendCodeButtonContent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Send the code to your registered SMS Device again. + /// + public static string resendCodeButtonToolTip { + get { + return ResourceManager.GetString("resendCodeButtonToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Retry. + /// + public static string Retry { + get { + return ResourceManager.GetString("Retry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reviewers. + /// + public static string Reviewers { + get { + return ResourceManager.GetString("Reviewers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Will be created as {0}. + /// + public static string SafeRepositoryNameWarning { + get { + return ResourceManager.GetString("SafeRepositoryNameWarning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} second ago. + /// + public static string second { + get { + return ResourceManager.GetString("second", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} seconds ago. + /// + public static string seconds { + get { + return ResourceManager.GetString("seconds", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select Fork. + /// + public static string SelectFork { + get { + return ResourceManager.GetString("SelectFork", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign in.... + /// + public static string SignInCallToAction { + get { + return ResourceManager.GetString("SignInCallToAction", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign in. + /// + public static string SignInLink { + get { + return ResourceManager.GetString("SignInLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign in to GitHub. + /// + public static string SignInToGitHub { + get { + return ResourceManager.GetString("SignInToGitHub", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign in with your browser. + /// + public static string SignInWithYourBrowser { + get { + return ResourceManager.GetString("SignInWithYourBrowser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign out. + /// + public static string SignOutLink { + get { + return ResourceManager.GetString("SignOutLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign up. + /// + public static string SignUpLink { + get { + return ResourceManager.GetString("SignUpLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The source repository is no longer available.. + /// + public static string SourceRepositoryNoLongerAvailable { + get { + return ResourceManager.GetString("SourceRepositoryNoLongerAvailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Start a review. + /// + public static string StartAReview { + get { + return ResourceManager.GetString("StartAReview", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Submit your review for. + /// + public static string SubmitYourReviewFor { + get { + return ResourceManager.GetString("SubmitYourReviewFor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Switch Origin. + /// + public static string SwitchOriginTitle { + get { + return ResourceManager.GetString("SwitchOriginTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Switch to List View. + /// + public static string SwitchToListView { + get { + return ResourceManager.GetString("SwitchToListView", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Switch to Tree View. + /// + public static string SwitchToTreeView { + get { + return ResourceManager.GetString("SwitchToTreeView", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sync. + /// + public static string Sync { + get { + return ResourceManager.GetString("Sync", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sync {0} submodules. + /// + public static string SyncSubmodules { + get { + return ResourceManager.GetString("SyncSubmodules", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Welcome to GitHub for Visual Studio! Why not take a look at our [training](show-training) or [documentation](show-docs)? + /// + ///[Don't show this again](dont-show-again). + /// + public static string TeamExplorerWelcomeMessage { + get { + return ResourceManager.GetString("TeamExplorerWelcomeMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to There aren't any open pull requests. + /// + public static string ThereArenTAnyOpenPullRequests { + get { + return ResourceManager.GetString("ThereArenTAnyOpenPullRequests", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This conversation was marked as resolved. + /// + public static string ThisConversationWasMarkedAsResolved { + get { + return ResourceManager.GetString("ThisConversationWasMarkedAsResolved", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Title (required). + /// + public static string TitleRequired { + get { + return ResourceManager.GetString("TitleRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to To get started you can. + /// + public static string ToGetStartedYouCan { + get { + return ResourceManager.GetString("ToGetStartedYouCan", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Token. + /// + public static string TokenPrompt { + get { + return ResourceManager.GetString("TokenPrompt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to to point to. + /// + public static string ToPointTo { + get { + return ResourceManager.GetString("ToPointTo", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open the two-factor authentication app on your device to view your authentication code.. + /// + public static string TwoFactorApp { + get { + return ResourceManager.GetString("TwoFactorApp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-factor authentication. + /// + public static string twoFactorAuthText { + get { + return ResourceManager.GetString("twoFactorAuthText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to We sent you a message via SMS with your authentication code.. + /// + public static string TwoFactorSms { + get { + return ResourceManager.GetString("TwoFactorSms", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-Factor authentication required. + /// + public static string TwoFactorTitle { + get { + return ResourceManager.GetString("TwoFactorTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter a sign in authentication code here. + /// + public static string TwoFactorUnknown { + get { + return ResourceManager.GetString("TwoFactorUnknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Couldn't open from '{0}'. Only URLs that link to repository files are currently supported.. + /// + public static string UnknownLinkTypeMessage { + get { + return ResourceManager.GetString("UnknownLinkTypeMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Update comment. + /// + public static string UpdateComment { + get { + return ResourceManager.GetString("UpdateComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to updated {0}. + /// + public static string UpdatedFormat { + get { + return ResourceManager.GetString("UpdatedFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Update your local repository's. + /// + public static string UpdateYourLocalRepositoryS { + get { + return ResourceManager.GetString("UpdateYourLocalRepositoryS", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Username or email. + /// + public static string UserNameOrEmailPromptText { + get { + return ResourceManager.GetString("UserNameOrEmailPromptText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Please enter your username or email address. + /// + public static string UsernameOrEmailValidatorEmpty { + get { + return ResourceManager.GetString("UsernameOrEmailValidatorEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Username or email address must not have spaces. + /// + public static string UsernameOrEmailValidatorSpaces { + get { + return ResourceManager.GetString("UsernameOrEmailValidatorSpaces", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify. + /// + public static string verifyText { + get { + return ResourceManager.GetString("verifyText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View Changes. + /// + public static string ViewChanges { + get { + return ResourceManager.GetString("ViewChanges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View Changes in Solution. + /// + public static string ViewChangesInSolution { + get { + return ResourceManager.GetString("ViewChangesInSolution", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View, Checkout or Create a Pull request. + /// + public static string ViewCheckoutOrCreateAPullRequest { + get { + return ResourceManager.GetString("ViewCheckoutOrCreateAPullRequest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View File. + /// + public static string ViewFile { + get { + return ResourceManager.GetString("ViewFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wiki. + /// + public static string WikiNavigationItemText { + get { + return ResourceManager.GetString("WikiNavigationItemText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot checkout as your working directory has uncommitted changes.. + /// + public static string WorkingDirectoryHasUncommittedCHanges { + get { + return ResourceManager.GetString("WorkingDirectoryHasUncommittedCHanges", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to wrote. + /// + public static string Wrote { + get { + return ResourceManager.GetString("Wrote", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} year ago. + /// + public static string year { + get { + return ResourceManager.GetString("year", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0:N0} years ago. + /// + public static string years { + get { + return ResourceManager.GetString("years", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You have already cloned to this location. Click 'Open' to open the local repository.. + /// + public static string YouHaveAlreadyClonedToThisLocation { + get { + return ResourceManager.GetString("YouHaveAlreadyClonedToThisLocation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You must commit and push your changes to add a comment here.. + /// + public static string YouMustCommitAndPushYourChangesToAddACommentHere { + get { + return ResourceManager.GetString("YouMustCommitAndPushYourChangesToAddACommentHere", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your review summary. + /// + public static string YourReviewSummary { + get { + return ResourceManager.GetString("YourReviewSummary", resourceCulture); + } + } + } +} diff --git a/src/GitHub.Resources/Resources.cs-CZ.resx b/src/GitHub.Resources/Resources.cs-CZ.resx new file mode 100644 index 0000000000..70d0994fc7 --- /dev/null +++ b/src/GitHub.Resources/Resources.cs-CZ.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Vyberte nadřazenou složku pro nové úložiště. + + + Otevřít z GitHubu + + + Nepodařilo se připojit ke github.com. + + + Vytvořit gist GitHubu + + + Vytvořit úložiště {0} + + + GistFromVisualStudio.cs + + + Zadejte prosím adresu URL severu Enterprise. + + + Zadejte prosím platnou adresu URL severu Enterprise. + + + Nejedná se o server Enterprise. Zadejte prosím adresu URL serveru Enterprise. + + + (zapomněli jste heslo?) + + + K přihlášení použijte heslo, ne token PAT. + + + Zkontrolujte svoje uživatelské jméno a heslo a zkuste to znovu. + + + Přihlášení se nepovedlo. + + + Připojit ke GitHubu + + + Zadejte prosím heslo. + + + Žádost o přijetí změn pro větev **{0}** se úspěšně vytvořila v [{1}]({2}) + + + Zadejte prosím název žádosti o přijetí změn. + + + Zdrojová a cílová větev nemůžou být stejné. + + + Zdrojová větev neexistuje vzdáleně, nasdíleli jste ji? + + + Zadejte prosím cestu k úložišti. + + + Zadejte prosím platnou cestu. + + + Cesta obsahuje neplatné znaky. + + + Cesta je moc dlouhá. + + + Úložiště {0}/{1} už existuje. + + + Změňte název úložiště nebo vyberte jiný účet a zkuste to znovu. + + + Překročila se kvóta pro privátní úložiště. + + + Úložiště se stejným názvem už v tomto umístění existuje. + + + Zadejte prosím název úložiště. + + + Název úložiště musí mít méně než 100 znaků. + + + Vytvoří se jako {0}. + + + Otevřete aplikaci pro dvoufaktorové ověřování na vašem zařízení a zobrazte svůj ověřovací kód. + + + Poslali jsme vám SMS s vaším ověřovacím kódem. + + + Vyžaduje se dvoufaktorové ověřování. + + + Sem zadejte ověřovací kód pro přihlášení. + + + Zadejte prosím svoje uživatelské jméno nebo e-mailovou adresu. + + + Uživatelské jméno nebo e-mailová adresa nesmí obsahovat mezery. + + + Žádosti o přijetí změn + + + Žádost o přijetí změn + + + přidat + + + fork + + + [neplatný] + + + Před nasdílením změn musíte napřed přijmout změny. + + + Žádné zápisy k přijetí + + + Žádné zápisy k nasdílení + + + *Není zadaný žádný popis.* + + + Rezervovat {0} + + + Rezervovat pro {0} + + + Přijmout změny z větve {0} {1} + + + Nasdílet změny do větve {0} {1} + + + vzdálené úložiště + + + přejmenovat + + + Úložiště zdrojového kódu už není k dispozici. + + + Nelze rezervovat, protože pracovní adresář obsahuje nezapsané změny. + + + Synchronizovat dílčí moduly {0} + + + Git.exe se na zadané cestě nepodařilo najít. + +Nainstalujte si prosím Git pro Windows z tohoto umístění: +https://git-scm.com/download/win + + + Schválené + + + Požadované změny + + + Okomentované + + + Probíhá zpracování. + + + Pokud chcete přejít do editoru, stiskněte Enter. + + + Před přechodem do editoru napřed rezervujte větev PR. + + + Pokud chcete přejít do editoru, stiskněte Enter (větev PR musí být rezervovaná). + + + Fork úložiště + + + Přepnout úložiště Origin + + + Opravdu chcete tuto revizi zrušit? Všechny vaše čekající komentáře se ztratí. + + + Zrušit revizi + + + Soubor na cílové cestě existuje. + + + Vyžaduje se odhlášení. + + + před {0:N0} dnem + + + před {0:N0} dny + + + před {0:N0} hodinou + + + před {0:N0} hodinami + + + právě teď + + + před {0:N0} minutou + + + před {0:N0} minutami + + + před {0:N0} měsícem + + + před {0:N0} měsíci + + + před {0:N0} sekundou + + + před {0:N0} sekundami + + + před {0:N0} rokem + + + před {0:N0} roky + + + Neplatný ověřovací kód + + + Zkuste zadat kód znovu nebo kliknutím na tlačítko pro opětovné odeslání získejte nový ověřovací kód. + + + Ověřovací kód se odeslal. + + + Pokud ověřovací kód nedostanete, obraťte se na support@github.com. + + + Procházet + + + Nepodařilo se připojit ke github.com. + + + Nepodařilo se připojit k serveru. + + + Vytvořit + + + Popis (nepovinné) + + + Otevřít v prohlížeči + + + Zrušit + + + Gist se vytvořil. + + + Gist se nepodařilo vytvořit. + + + podle + + + Ochrana osobních údajů + + + Pomozte nám s vylepšováním posíláním anonymních dat o používání + + + Zkopírování do schránky se nepodařilo. Zkuste to prosím znovu. + + + Odkaz se zkopíroval do schránky. + + + Úložiště se úspěšně vytvořilo. + + + Privátní gist + + + Název souboru + + + Nejste přihlášeni k {0}, takže některé operace git se asi nezdaří. [Přihlásit]({1}) + + + Wiki + + + Impulz + + + Cesta + + + Problémy + + + Grafy + + + Publikovat na GitHub + + + Výkonná spolupráce, revize kódu a správa kódu pro open source a privátní projekty. + + + Připojit… + + + Klonovat + + + Ověřit + + + Dvoufaktorové ověřování + + + Zaregistrovat se + + + Odhlásit se + + + Znovu odeslat kód na vaše registrované zařízení SMS + + + Poslat znovu + + + Název úložiště + + + Toto úložiště nemá vzdálené úložiště. Vyplňte formulář pro jeho publikování na GitHubu. + + + Publikovat + + + nebo + + + Otevřete aplikaci pro dvoufaktorové ověřování na vašem zařízení a zobrazte svůj ověřovací kód. + + + Žádná úložiště + + + Název + + + Privátní úložiště + + + Místní cesta: + + + Licence + + + Další informace + + + Ignorování Gitu + + + Hledat úložiště + + + Některá nebo všechna úložiště se možná nenačetla. Zavřete dialogové okno a zkuste to znovu. + + + Při načítání úložišť došlo k chybě. + + + Adresa serveru GitHub Enterprise + + + Hostitel není dostupný nebo se nejedná o server GitHub Enterprise. Zkontrolujte adresu a zkuste to znovu. + + + Uživatelské jméno nebo e-mail + + + Heslo + + + Přihlásit se + + + Zkontrolujte prosím připojení k internetu a zkuste to znovu. + + + Nemáte GitHub Enterprise? + + + Nemáte účet? + + + Název (povinné) + + + Popis + + + Publikujte toto úložiště na GitHub a získáte výkonnou spolupráci, revizi kódu a správu kódu pro open source a privátní projekty. + + + Toto úložiště není na GitHubu. + + + Žádné úložiště + + + Žádné úložiště git jsme tady nenašli. Pokud chcete začít, otevřete projekt git nebo klikněte v projektu na Soubor -> Přidat do správy zdrojového kódu. + + + Vytvořit účet + + + Filtrovat větve + + + Publikovat na GitHub + + + Začínáme + + + Přihlásit se + + + Přihlásit se... + + + Místní větev je aktuální. + + + Změny ({0}) + + + Zobrazit změny + + + Porovnat soubor jako výchozí akce + + + Zobrazit soubor + + + Otevřít soubor jako výchozí akce + + + Přepnout na zobrazení seznamu + + + Přepnout na stromové zobrazení + + + aktualizoval(a) {0} + + + Zobrazit žádost o přijetí změn na GitHubu + + + Vítá vás GitHub pro Visual Studio. Podívejte se na naše [školení](show-training) a [dokumentaci](show-docs). + +[Příště už nezobrazovat](dont-show-again) + + + Aktualizováno + + + Zobrazit komentáře PR na okraji editoru + + + Experimentální funkce + + + Tyto funkce se můžou v budoucí verzi změnit. + + + Zobrazit změny v řešení + + + Otevřít soubor v řešení + + + Token + + + Pokračovat v revizi + + + Přidat revizi + + + Revidující + + + Přidat komentář revize + + + Přidat jeden komentář + + + Fork + + + Ladění + + + Povolit protokolování trasování + + + Rozšíření GitHub není k dispozici v Blendu. + + + Aktualizovat komentář + + + Zrušit + + + Čekající + + + Zahájit revizi + + + Pokud sem chcete přidat komentář, musíte zapsat a nasdílet změny. + + + Předchozí komentář + + + Další komentář + + + Zobrazit, rezervovat nebo vytvořit žádost o přijetí změn + + + Zpět + + + Fork úložiště + + + Fork úložiště + + + Aktualizovat pro místní úložiště + + + přejít na + + + Vytvořit žádost o přijetí změn + + + Přihlásit se ke GitHubu + + + Přijmout změny + + + Vložit + + + Synchronizovat + + + zapsal(a) + + + Odeslat revizi pro + + + Souhrn vaší revize + + + Jenom komentář + + + Schválit + + + Požádat o změny + + + Komentáře + + + Zastaralé komentáře + + + Vytvořit nový + + + Pověřená osoba + + + Autor + + + Přihlásit se pomocí prohlížeče + + + Otevřít + + + Filtrovat podle autora + + + Vybrat fork + + + Opravdu chcete odstranit tento komentář? + + + Odstranit komentář + + + Opakovat + + + Nejsou k dispozici žádné otevřené žádosti o přijetí změn. + + + Žádosti o přijetí změn vám umožňují informovat ostatní o změnách, které jste nasdíleli do úložiště na GitHubu. + + + Vašemu hledání neodpovídají žádné výsledky. + + + Pokud chcete začít, můžete + + + vytvořit žádost o přijetí změn + + + Otevřít úložiště v {0}? + + + Cílová adresa URL má jiného vlastníka než aktuální úložiště. + + + Není k dispozici žádné aktivní úložiště, do kterého by bylo možné přejít. + + + Pracovní soubor je jiný než soubor v {0}. Rezervujte si prosím odpovídající větev, žádost o přijetí změn nebo zápis. + + + Otevřete prosím úložiště {0} a zkuste to znovu. + + + Nelze otevřít z {0}. Momentálně jsou podporované jenom adresy URL, které odkazují na soubory v úložišti. + + + Ve schránce se nepodařilo najít adresu URL na GitHubu. + + + V aktuálním úložišti se nepodařilo najít cílovou adresu URL. Zkuste to znovu po načtení změn. + + + Adresář v cílové cestě není prázdný. + + + Úložiště už v tomto umístění existuje, ale nemá vzdálené úložiště s názvem origin. + + + Úložiště už v tomto umístění existuje, ale má vzdálené úložiště {0}. + + + Do tohoto umístění jste už klonovali. Kliknutím na Otevřít otevřete místní úložiště. + + + Pro úložiště nelze najít adresu URL na GitHubu. + + + Úložiště musí mít definované vzdálené úložiště s názvem origin, aby bylo možné vyhledat jejich adresu URL na GitHubu. + + + Přejmenujte prosím jedno z vašich existujících vzdálených úložišť na origin nebo přidejte nové vzdálené úložiště s názvem origin a načtěte je. Můžete to provést z příkazového řádku nebo kliknutím na tlačítko níže. + + + V úložišti se nepodařilo najít soubor odpovídající {0}. Proveďte prosím příkaz git fetch nebo rezervujte žádost o přijetí změn v cíli. + + + {0} – naposledy nasdíleno + + + Komentář + + + Zavřít žádost o přijetí změn + + + Zavřít problém + + + Zavřít a okomentovat + + + Znovu otevřít a okomentovat + + + Znovu otevřít problém + + + Znovu otevřít žádost o přijetí změn + + + Počet zápisů: {0} + + + přidal(a) nějaké zápisy + + + a další + + + Tato konverzace byla označena jako vyřešená. + + + Vaše úložiště + + + Úložiště spolupracovníka + + + Přidáno do úložišť + + + Vyhledejte nebo zadejte adresu URL. + + + + Procházet... + + + Klon + + + Přidat nebo změnit účty + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.de-DE.resx b/src/GitHub.Resources/Resources.de-DE.resx new file mode 100644 index 0000000000..42da2e0b7e --- /dev/null +++ b/src/GitHub.Resources/Resources.de-DE.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Wählen Sie einen enthaltenden Ordner für Ihr neues Repository aus. + + + Aus GitHub öffnen + + + Mit Github.com konnte keine Verbindung hergestellt werden. + + + GitHub-Gist erstellen + + + {0}-Repository erstellen + + + GistFromVisualStudio.cs + + + Geben Sie eine Enterprise-URL ein. + + + Geben Sie eine gültige Enterprise-URL ein. + + + Kein Enterprise-Server. Geben Sie eine Enterprise-URL ein. + + + (Kennwort vergessen?) + + + Stellen Sie sicher, dass Sie zur Anmeldung Ihr Kennwort und kein persönliches Zugriffstoken verwenden. + + + Überprüfen Sie Ihren Benutzernamen und das Kennwort, und versuchen Sie es dann noch mal. + + + Fehler bei der Anmeldung. + + + Verbindung mit GitHub herstellen + + + Geben Sie Ihr Kennwort ein. + + + Pull Request für Branch **{0}** wurde unter [{1}]({2}) erfolgreich erstellt. + + + Geben Sie einen Titel für den Pull Request ein. + + + Quell- und Zielbranch dürfen nicht identisch sein. + + + Der Quellpfad ist im Remoterepository nicht vorhanden, haben Sie ihn mithilfe von Push übertragen? + + + Geben Sie einen Repositorypfad ein. + + + Geben Sie einen gültigen Pfad ein. + + + Der Pfad enthält ungültige Zeichen. + + + Der Pfad ist zu lang. + + + Das Repository "{0}/{1}" ist bereits vorhanden. + + + Ändern Sie den Repositorynamen, oder wählen Sie ein anderes Konto aus, und versuchen Sie es noch mal. + + + Kontingent für private Repositorys überschritten. + + + Ein Repository mit dem gleichen Namen ist an diesem Speicherort bereits vorhanden. + + + Geben Sie einen Repositorynamen ein. + + + Der Repositoryname muss weniger als 100 Zeichen enthalten. + + + Wird als "{0}" erstellt. + + + Öffnen Sie die App zur zweistufigen Authentifizierung auf Ihrem Gerät, um Ihren Authentifizierungscode anzuzeigen. + + + Wir haben Ihnen eine SMS mit dem Authentifizierungscode gesendet. + + + Zweistufige Authentifizierung erforderlich + + + Geben Sie hier einen Authentifizierungscode für die Anmeldung ein. + + + Geben Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse ein. + + + Der Benutzername oder die E-Mail-Adresse darf keine Leerzeichen enthalten. + + + Pull Requests + + + Pull Request + + + hinzufügen + + + Forken + + + [ungültig] + + + Vor dem Pushvorgang muss ein Pull erfolgen. + + + Es liegen keine Commits für einen Pullvorgang vor. + + + Es liegen keine Commits für einen Pushvorgang vor. + + + *Keine Beschreibung angegeben.* + + + "{0}" auschecken + + + Bei "{0}" auschecken + + + Per Pull aus {0}-Branch "{1}" übertragen + + + Per Push zu {0}-Branch "{1}" übertragen + + + Remoterepository + + + umbenennen + + + Das Quellrepository ist nicht mehr verfügbar. + + + Auschecken nicht möglich, weil für Ihr Arbeitsverzeichnis ausgecheckte Änderungen vorliegen. + + + {0} Untermodule synchronisieren + + + "Git.exe" wurde in PATH nicht gefunden. + +Installieren Sie Git für Windows: +https://git-scm.com/download/win + + + Genehmigt + + + Angeforderte Änderungen + + + Kommentiert + + + InBearbeitung + + + Drücken Sie die EINGABETASTE, um zum Editor zu navigieren. + + + Checken Sie den PR-Branch aus, bevor Sie zum Editor navigieren. + + + Drücken Sie die EINGABETASTE, um zum Editor zu navigieren. (PR-Branch muss ausgecheckt sein.) + + + Repository forken + + + Ursprung wechseln + + + Möchten Sie diesen Review abbrechen? Alle ausstehenden Kommentare gehen verloren. + + + Review abbrechen + + + Im Zielpfad ist eine Datei vorhanden. + + + Abmeldung erforderlich. + + + Vor {0:N0} Tag + + + Vor {0:N0} Tagen + + + Vor {0:N0} Stunde + + + Vor {0:N0} Stunden + + + jetzt + + + Vor {0:N0} Minute + + + Vor {0:N0} Minuten + + + Vor {0:N0} Monat + + + Vor {0:N0} Monaten + + + Vor {0:N0} Sekunde + + + Vor {0:N0} Sekunden + + + Vor {0:N0} Jahr + + + Vor {0:N0} Jahren + + + Ungültiger Authentifizierungscode. + + + Geben Sie den Code erneut ein, oder klicken Sie auf die Schaltfläche "Erneut senden", um einen neuen Authentifizierungscode zu erhalten. + + + Authentifizierungscode gesendet! + + + Wenn Sie keinen Authentifizierungscode erhalten, wenden Sie sich an support@github.com. + + + Durchsuchen + + + Mit Github.com konnte keine Verbindung hergestellt werden. + + + Verbindung mit dem Server nicht möglich. + + + Erstellen + + + Beschreibung (optional) + + + In Browser öffnen + + + Abbrechen + + + Gist wurde erstellt. + + + Fehler beim Erstellen von Gist. + + + durch + + + Datenschutz + + + Helfen Sie uns bei der Verbesserung unserer Produkte, indem Sie anonyme Nutzungsdaten senden. + + + Das Kopieren in die Zwischenablage war nicht möglich. Versuchen Sie es noch mal. + + + Der Link wurde in die Zwischenablage kopiert. + + + Das Repository wurde erfolgreich erstellt. + + + Privates Gist + + + Dateiname + + + Sie sind nicht bei {0} angemeldet, daher tritt bei bestimmten Git-Vorgängen möglicherweise ein Fehler auf. [Jetzt anmelden]({1}) + + + Wiki + + + Puls + + + Pfad + + + Probleme + + + Diagramme + + + In GitHub veröffentlichen + + + Leistungsstarke Funktionen für Zusammenarbeit, Code Review und Codeverwaltung für Open Source- und private Projekte. + + + Verbindung herstellen... + + + Klonen + + + Überprüfen + + + Zweistufige Authentifizierung + + + Registrieren + + + Abmelden + + + Code erneut an Ihr registriertes SMS-Gerät senden + + + Erneut senden + + + Repositoryname + + + Dieses Repository weist kein Remoterepository auf. Füllen Sie das Formular aus, um es in GitHub zu veröffentlichen. + + + Veröffentlichen + + + oder + + + Öffnen Sie die App zur zweistufigen Authentifizierung auf Ihrem Gerät, um Ihren Authentifizierungscode anzuzeigen. + + + Keine Repositorys. + + + Name + + + Privates Repository + + + Lokaler Pfad: + + + Lizenz + + + Weitere Informationen + + + Git ignorieren + + + Repositorys durchsuchen + + + Einige oder alle Repositorys wurden möglicherweise nicht geladen. Schließen Sie das Dialogfeld, und versuchen Sie es noch mal. + + + Fehler beim Laden von Repositorys. + + + GitHub Enterprise-Serveradresse + + + Der Host ist nicht verfügbar, oder es handelt sich nicht um einen GitHub Enterprise-Server. Überprüfen Sie die Adresse, und versuchen Sie es noch mal. + + + Benutzername oder E-Mail-Adresse + + + Kennwort + + + Anmelden + + + Prüfen Sie Ihre Internetverbindung, und versuchen Sie es noch mal. + + + Sie verfügen nicht über GitHub Enterprise? + + + Sie haben noch kein Konto? + + + Titel (erforderlich) + + + Beschreibung + + + Veröffentlichen Sie dieses Repository in GitHub, und erhalten Sie leistungsstarke Funktionen für Zusammenarbeit, Code Review und Codeverwaltung für Open Source- und private Projekte. + + + Dieses Repository befindet sich nicht auf GitHub. + + + Kein Repository + + + Hier wurde kein Git-Repository gefunden. Öffnen Sie ein Git-Projekt, oder klicken Sie in einem Projekt auf "Datei" > "Zur Quellcodeverwaltung hinzufügen", um den Vorgang zu starten. + + + Konto erstellen + + + Branches filtern + + + In GitHub veröffentlichen + + + Erste Schritte + + + Anmelden + + + Anmelden... + + + Lokaler Branch auf dem neuesten Stand + + + Änderungen ({0}) + + + Änderungen anzeigen + + + Datei vergleichen als Standardaktion + + + Datei anzeigen + + + Datei öffnen als Standardaktion + + + Zur Listenansicht wechseln + + + Zur Strukturansicht wechseln + + + "{0}" aktualisiert. + + + Pull Request in GitHub anzeigen + + + Willkommen bei GitHub für Visual Studio! Sehen Sie sich doch unser [Training](show-training) oder die [Dokumentation](show-docs) an. + +[Nicht erneut anzeigen](dont-show-again) + + + Aktualisiert + + + PR-Kommentare in Editor-Rand anzeigen + + + Experimentelle Features + + + Diese Features werden in einer zukünftigen Version möglicherweise geändert. + + + Änderungen in Projektmappe anzeigen + + + Datei in Projektmappe öffnen + + + Token + + + Review fortsetzen + + + Review hinzufügen + + + Reviewer + + + Reviewkommentar hinzufügen + + + Einzelnen Kommentar hinzufügen + + + Fork + + + Debugging + + + Ablaufprotokollierung aktivieren + + + Die GitHub-Erweiterung ist innerhalb von Blend nicht verfügbar. + + + Kommentar aktualisieren + + + Abbrechen + + + Ausstehend + + + Review starten + + + Sie müssen für Ihre Änderungen einen Commit und einen Pushvorgang ausführen, um hier einen Kommentar hinzuzufügen. + + + Vorheriger Kommentar + + + Nächster Kommentar + + + Pull Request anzeigen, auschecken oder erstellen + + + Zurück + + + Repository forken + + + Repository forken + + + Lokale Repositorys aktualisieren + + + zum Verweis auf + + + Pull Request erstellen + + + Bei GitHub anmelden + + + Pull + + + Push + + + Sync + + + schrieb + + + Review übermitteln für + + + Ihre Reviewzusammenfassung + + + Nur Kommentar + + + Genehmigen + + + Änderungen anfordern + + + Kommentare + + + Veraltete Kommentare + + + Neues Element erstellen + + + Zugewiesene Person + + + Autor + + + Mit Ihrem Browser anmelden + + + Öffnen + + + Nach Autor filtern + + + Fork auswählen + + + Möchten Sie diesen Kommentar wirklich löschen? + + + Kommentar löschen + + + Wiederholen + + + Es liegen keine offenen Pull Requests vor. + + + Mithilfe von Pull Requests können Sie andere Benutzer über Änderungen informieren, die Sie per Push an ein Repository in GitHub übertragen haben. + + + Mit Ihren Suchkriterien stimmen keine Ergebnisse überein. + + + Zum Einstieg können Sie folgende Aktionen durchführen: + + + Pull Request erstellen + + + Repository unter "{0}" öffnen? + + + Die Ziel-URL weist einen anderen Besitzer für das aktuelle Repository auf. + + + Es liegt kein aktives Repository für die Navigation vor. + + + Die Arbeitsdatei unterscheidet sich von der Datei unter "{0}". Checken Sie den entsprechenden Branch, Pull Request oder Commit aus. + + + Öffnen Sie das Repository "{0}", und versuchen Sie es noch mal. + + + Öffnen von "{0}" aus nicht möglich. Derzeit werden nur URLs unterstützt, die mit Repositorydateien verknüpft sind. + + + In der Zwischenablage wurde keine GitHub-URL gefunden. + + + Die Ziel-URL wurde im aktuellen Repository nicht gefunden. Versuchen Sie es nach einem Abrufvorgang (fetch) noch einmal. + + + Das Verzeichnis im Zielpfad ist nicht leer. + + + An diesem Speicherort ist bereits ein Repository vorhanden, das aber kein Remoterepository namens "origin" aufweist. + + + An diesem Speicherort ist bereits ein Repository vorhanden, das aber ein Remoterepository "{0}" aufweist. + + + Sie haben bereits einen Klonvorgang in diesen Speicherort durchgeführt. Klicken Sie auf "Öffnen", um das lokale Repository zu öffnen. + + + Die GitHub-URL für das Repository wurde nicht gefunden. + + + Für Repositorys muss ein Remoterepository namens "origin" definiert sein, damit die entsprechende GitHub-URL gefunden wird. + + + Benennen Sie eines der vorhandenen Remoterepositorys in "origin" um, oder fügen Sie ein neues Remoterepository namens "origin" hinzu, und führen Sie einen Abrufvorgang (fetch) durch. Dies kann über die Befehlszeile oder durch Klicken auf die untenstehende Schaltfläche erfolgen. + + + Die entsprechende Datei für "{0}" wurde im Repository nicht gefunden. Führen Sie "git fetch" aus, oder checken Sie den Ziel-Pull Request aus. + + + Letzte {0} mithilfe von Push übertragene Elemente + + + Kommentar + + + Pull Request schließen + + + Issue schließen + + + Schließen und kommentieren + + + Erneut öffnen und kommentieren + + + Issue öffnen + + + Pull Request erneut öffnen + + + {0} Commits + + + Einige Commits hinzugefügt + + + und weitere + + + Diese Konversation wurde als aufgelöst markiert. + + + Ihre Repositorys + + + Repositorys von Projektmitarbeitern + + + Beitrag zu Repositorys + + + URL suchen oder eingeben + + + + Durchsuchen... + + + Klonen + + + Konten hinzufügen/ändern + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.es-ES.resx b/src/GitHub.Resources/Resources.es-ES.resx new file mode 100644 index 0000000000..05cbd25f73 --- /dev/null +++ b/src/GitHub.Resources/Resources.es-ES.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Seleccione una carpeta contenedora para el nuevo repositorio. + + + Abrir desde GitHub + + + No se pudo conectar a github.com + + + Crear gist de GitHub + + + Crear un repositorio de {0} + + + GistFromVisualStudio.cs + + + Escriba una dirección URL de Enterprise + + + Escriba una dirección URL de Enterprise válida + + + No es un servidor Enterprise. Especifique una dirección URL de Enterprise. + + + (¿olvidó su contraseña?) + + + Asegúrese de usar la contraseña y no un token de acceso personal para iniciar sesión. + + + Compruebe el nombre de usuario y la contraseña y vuelva a intentarlo. + + + Error al iniciar sesión. + + + Conectarse a GitHub + + + Escriba su contraseña + + + La solicitud de incorporación de cambios para la rama **{0}** se creó correctamente a las [{1}]({2}) + + + Escriba un título para la solicitud de incorporación de cambios + + + La rama de origen y de destino no puede ser la misma. + + + La rama de origen no existe en la ubicación remota. ¿La ha insertado? + + + Escriba una ruta de acceso de repositorio. + + + Escriba una ruta de acceso válida. + + + La ruta de acceso contiene caracteres no válidos. + + + La ruta de acceso es demasiado larga. + + + El repositorio "{0}/{1}" ya existe. + + + Cambie el nombre del repositorio o seleccione otra cuenta y vuelva a intentarlo. + + + Se ha superado la cuota de repositorios privados. + + + Ya existe un repositorio con el mismo nombre en esta ubicación + + + Escriba un nombre de repositorio + + + El nombre del repositorio debe tener menos de 100 caracteres. + + + Se creará como {0} + + + Abra la aplicación de autenticación en dos fases en el dispositivo para ver el código de autenticación. + + + Le hemos enviado un mensaje mediante SMS con el código de autenticación. + + + Se requiere la autenticación en dos fases + + + Especifique aquí un código de autenticación de inicio de sesión + + + Escriba su nombre de usuario o dirección de correo electrónico. + + + El nombre de usuario o la dirección de correo electrónico no deben tener espacios. + + + Solicitudes de incorporación de cambios + + + Solicitud de incorporación de cambios + + + agregue + + + bifurcar + + + [no válido] + + + Debe incorporar los cambios antes de enviarlos + + + No hay commits para incorporar cambios + + + No hay commits para enviar cambios + + + *No se ha proporcionado ninguna descripción.* + + + Extraer {0} del repositorio + + + Extraer del repositorio en {0} + + + Incorporar cambios de {0}, rama {1} + + + Enviar cambios a {0}, rama {1} + + + repositorio remoto + + + cambiar nombre + + + El repositorio de origen ya no está disponible. + + + No se puede extraer del repositorio porque el directorio de trabajo tiene cambios pendientes de hacer commit. + + + Sincronizar {0} submódulos + + + No se encontró Git.exe en PATH. + +Instale GIT para Windows desde: +https://git-scm.com/download/win + + + Aprobado + + + Cambios solicitados + + + Comentado + + + En curso + + + Presione ENTRAR para ir al editor. + + + Extraiga del repositorio la rama de la solicitud de incorporación de cambios antes de navegar al Editor. + + + Presione ENTRAR para ir al editor (la rama de la solicitud de incorporación de cambios debe extraerse del repositorio). + + + Bifurcar repositorio + + + Cambiar origen + + + ¿Seguro que quiere cancelar esta revisión? Se perderán todos los comentarios pendientes. + + + Cancelar revisión + + + Existe un archivo en la ruta de acceso de destino. + + + Cierre de sesión necesario + + + hace {0:N0} día + + + hace {0:N0} días + + + hace {0:N0} hora + + + hace {0:N0} horas + + + ahora mismo + + + hace {0:N0} minuto + + + hace {0:N0} minutos + + + hace {0:N0} mes + + + hace {0:N0} meses + + + hace {0:N0} segundo + + + hace {0:N0} segundos + + + hace {0:N0} año + + + hace {0:N0} años + + + Código de autenticación no válido + + + Vuelva a escribir el código o haga clic en el botón de reenvío para obtener un nuevo código de autenticación. + + + El código de autenticación se ha enviado. + + + Si no recibe el código de autenticación, póngase en contacto con support@github.com. + + + Examinar + + + No se pudo conectar a github.com + + + No se pudo conectar al servidor. + + + Crear + + + Descripción (opcional) + + + Abrir en el explorador + + + Cancelar + + + Gist creada + + + No se pudo crear la característica gist + + + de + + + Privacidad + + + Envíenos datos de uso anónimos para ayudarnos a mejorar + + + No se pudo copiar en el Portapapeles. Vuelva a intentarlo. + + + Vínculo copiado al Portapapeles + + + El repositorio se ha creado correctamente. + + + Gist privada + + + Nombre de archivo + + + No ha iniciado sesión en {0}, por lo que algunas operaciones de GIT pueden generar errores. [Inicie sesión ahora]({1}) + + + Wiki + + + Pulso + + + Ruta de acceso + + + Problemas + + + Gráficos + + + Publicar en GitHub + + + Colaboración, revisión de código y administración de código eficaces para proyectos privados y de código abierto. + + + Conectar… + + + Clonar + + + Verificar + + + Autenticación en dos fases + + + Suscribirse + + + Cerrar sesión + + + Volver a enviar el código al dispositivo SMS registrado + + + Volver a enviar + + + Nombre del repositorio + + + Este repositorio no tiene un repositorio remoto. Rellene el formulario para publicarlo en GitHub. + + + Publicar + + + o + + + Abra la aplicación de autenticación en dos fases en el dispositivo para ver el código de autenticación. + + + No hay repositorios + + + Nombre + + + Repositorio privado + + + Ruta de acceso local: + + + Licencia + + + Más información + + + Omitir GIT + + + Buscar repositorios + + + Puede que no se hayan cargado algunos repositorios o ninguno de ellos. Cierre el cuadro de diálogo y vuelva a intentarlo. + + + Error al cargar los repositorios + + + Dirección del servidor de GitHub Enterprise + + + El host no está disponible o no es un servidor de GitHub Enterprise. Compruebe la dirección y vuelva a intentarlo. + + + Nombre de usuario o correo electrónico + + + Contraseña + + + Iniciar sesión + + + Compruebe la conexión a Internet y vuelva a intentarlo. + + + ¿No tiene GitHub Enterprise? + + + ¿No tiene ninguna cuenta? + + + Título (obligatorio) + + + Descripción + + + Publique este repositorio en GitHub para conseguir una colaboración, revisión de código y administración de código eficaces en los proyectos privados y de código abierto. + + + Este repositorio no está en GitHub + + + No se encontró ningún repositorio. + + + No se encontró ningún repositorio GIT aquí. Abra un proyecto GIT o haga clic en "Archivo -> Agregar al control de código fuente" en un proyecto para empezar. + + + Crear una cuenta + + + Filtrar ramas + + + Publicar en GitHub + + + Introducción + + + Iniciar sesión + + + Iniciar sesión... + + + Rama local actualizada + + + Cambios ({0}) + + + Ver cambios + + + Comparar archivo como acción predeterminada + + + Ver archivo + + + Abrir archivo como acción predeterminada + + + Cambiar a vista de lista + + + Cambiar a vista de árbol + + + actualizó {0} + + + Ver solicitud de incorporación de cambios en GitHub + + + Esto es GitHub para Visual Studio. ¿Por qué no echa un vistazo a nuestro [material de aprendizaje](show-training) o [documentación](show-docs)? + +[No volver a mostrar](dont-show-again) + + + Actualizado + + + Mostrar comentarios de solicitud de incorporación de cambios en margen de editor + + + Características experimentales + + + Estas características pueden cambiar en una versión futura. + + + Ver cambios en la solución + + + Abrir archivo en la solución + + + Token + + + Continuar la revisión + + + Agregar su revisión + + + Revisores + + + Agregar un comentario de revisión + + + Agregar un solo comentario + + + Bifurcación + + + Depuración + + + Habilitar el registro de seguimiento + + + La extensión de GitHub no está disponible en Blend + + + Actualizar el comentario + + + Cancelar + + + Pendiente + + + Iniciar una revisión + + + Debe hacer commit y enviar los cambios para agregar un comentario aquí. + + + Comentario anterior + + + Comentario siguiente + + + Ver, extraer del repositorio o crear una solicitud de incorporación de cambios + + + Atrás + + + Bifurcar repositorio + + + Bifurcar el repositorio + + + Actualizar el repositorio local + + + para que apunte a + + + Crear solicitud de incorporación de cambios + + + Iniciar sesión en GitHub + + + Extraer + + + Insertar + + + Sincronizar + + + escribió + + + Enviar la revisión para + + + Resumen de la revisión + + + Solo comentario + + + Aprobar + + + Solicitar cambios + + + Comentarios + + + Comentarios obsoletos + + + Crear nuevo + + + Persona asignada + + + Autor + + + Iniciar sesión con el explorador + + + Abrir + + + Filtrar por autor + + + Seleccionar una bifurcación + + + ¿Está seguro de que desea eliminar este comentario? + + + Eliminar comentario + + + Reintentar + + + No hay ninguna solicitud de incorporación de cambios abierta. + + + Las solicitudes de incorporación de cambios le permiten indicar a otros usuarios los cambios que ha enviado a un repositorio en GitHub. + + + No hay resultados que coincidan con su búsqueda. + + + Para empezar, puede + + + crear una solicitud de incorporación de cambios + + + ¿Abrir el repositorio en "{0}"? + + + La dirección URL de destino tiene un propietario distinto al del repositorio actual. + + + No hay ningún repositorio activo para navegar. + + + El archivo de trabajo es distinto al archivo que hay en "{0}". Extraiga del repositorio la rama, la solicitud de incorporación de cambios o el commit correspondiente. + + + Abra el repositorio "{0}" y vuelva a intentarlo + + + No se pudo abrir desde "{0}". Actualmente solo se admiten las direcciones URL que vinculan a archivos del repositorio. + + + No se encontró ninguna dirección URL de GitHub en el Portapapeles. + + + No se encontró la dirección URL de destino en el repositorio actual. Vuelva a intentarlo tras una recuperación de cambios. + + + El directorio en la ruta de acceso de destino no está vacío. + + + Ya existe un repositorio en esta ubicación, pero no tiene un repositorio remoto con el nombre "origen". + + + Ya existe un repositorio en esta ubicación, pero tiene un repositorio remoto de {0}. + + + Ya se ha realizado una clonación en esta ubicación. Haga clic en "Abrir" para abrir el repositorio local. + + + No se encuentra la dirección URL de GitHub para el repositorio + + + Los repositorios deben tener definido un repositorio remoto llamado "origen" para encontrar su dirección URL de GitHub. + + + Cambie el nombre de uno de los repositorios remotos actuales a "origen", o bien agregue uno nuevo llamado "origen" y recupere los cambios. Esta operación puede realizarse desde la línea de comandos o al hacer clic en el botón siguiente. + + + No se encontró el archivo correspondiente a "{0}" en el repositorio. Realice la operación "Búsqueda de GIT" o extraiga del repositorio la solicitud de incorporación de cambios de destino. + + + {0} con cambios enviados más recientemente + + + Comentario + + + Cerrar la solicitud de incorporación de cambios + + + Cerrar el problema + + + Cerrar y comentar + + + Volver a abrir y comentar + + + Volver a abrir el problema + + + Volver a abrir la solicitud de incorporación de cambios + + + {0} confirmaciones + + + se han agregado algunas confirmaciones + + + y otros + + + Esta conversación se ha marcado como resuelta + + + Los repositorios + + + Repositorios de colaborador + + + Contribución a los repositorios + + + Buscar o escribir una dirección URL + + + + Examinar... + + + Clonar + + + Agregar o cambiar cuentas + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.fr-FR.resx b/src/GitHub.Resources/Resources.fr-FR.resx new file mode 100644 index 0000000000..341162f61e --- /dev/null +++ b/src/GitHub.Resources/Resources.fr-FR.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Sélectionnez un dossier conteneur pour votre nouveau dépôt. + + + Ouvrir à partir de GitHub + + + Impossible de se connecter à github.com + + + Créer un Gist GitHub + + + Créer un dépôt {0} + + + GistFromVisualStudio.cs + + + Entrez une URL Enterprise + + + Entrez une URL Enterprise valide + + + Il ne s'agit pas d'un serveur Enterprise. Entrez une URL Enterprise + + + (vous avez oublié votre mot de passe ?) + + + Veillez à utiliser votre mot de passe et non un jeton d'accès personnel pour vous connecter. + + + Vérifiez votre nom d'utilisateur et votre mot de passe, puis recommencez + + + La connexion a échoué. + + + Se connecter à GitHub + + + Entrez votre mot de passe + + + Demande de tirage (pull request) pour la branche **{0}** créée à l'emplacement [{1}]({2}) + + + Entrez un titre pour la demande de tirage (pull request) + + + Les branches source et cible ne peuvent pas être les mêmes + + + La branche source n'existe pas à distance. L'avez-vous envoyée (push) ? + + + Entrez un chemin de dépôt + + + Entrez un chemin valide + + + Le chemin contient des caractères non valides + + + Chemin trop long + + + Le dépôt « {0}/{1} » existe déjà. + + + Changez le nom du dépôt ou sélectionnez un compte différent, puis réessayez. + + + Le quota de dépôts privés a été dépassé. + + + Un dépôt portant le même nom existe déjà à cet emplacement + + + Entrez un nom de dépôt + + + Le nom de dépôt ne doit pas dépasser 100 caractères + + + Sera créé en tant que {0} + + + Ouvrez l'application d'authentification à 2 facteurs sur votre appareil pour afficher votre code d'authentification. + + + Nous vous avons envoyé un SMS avec votre code d'authentification. + + + Authentification à 2 facteurs obligatoire + + + Entrer un code d'authentification de connexion ici + + + Entrez votre nom d'utilisateur ou votre adresse e-mail + + + Le nom d'utilisateur ou l'adresse e-mail ne doit pas comporter d'espaces + + + Requêtes de tirage + + + Demande de tirage + + + ajouter + + + dupliquer (fork) + + + [non valide] + + + Vous devez effectuer un tirage (pull) avant un envoi (push) + + + Aucun commit à tirer (pull) + + + Aucun commit à envoyer (push) + + + *Aucune description fournie.* + + + Extraire {0} + + + Extraire dans {0} + + + Tirer (pull) de {0} la branche {1} + + + Envoyer (push) à {0} la branche {1} + + + dépôt distant + + + renommer + + + Le dépôt source n'est plus disponible. + + + Extraction impossible car votre répertoire de travail contient des changements non commités. + + + Synchroniser {0} sous-modules + + + Git.exe est introuvable sur PATH. + +Installez Git pour Windows à partir de l'adresse suivante : +https://git-scm.com/download/win + + + Approuvé + + + Changements demandés + + + Commenté + + + En cours + + + Appuyez sur Entrée pour accéder à l'éditeur + + + Extraire la branche PR avant la navigation vers l'éditeur + + + Appuyez sur Entrée pour accéder à l'éditeur (la branche PR doit être extraite) + + + Dupliquer (fork) le dépôt + + + Changer de dépôt origin + + + Voulez-vous vraiment annuler cette revue ? Vous allez perdre tous vos commentaires en attente. + + + Annuler la revue + + + Un fichier existe dans le chemin de destination. + + + Déconnexion obligatoire + + + il y a {0:N0} jour + + + il y a {0:N0} jours + + + il y a {0:N0} heure + + + il y a {0:N0} heures + + + maintenant + + + il y a {0:N0} minute + + + il y a {0:N0} minutes + + + il y a {0:N0} mois + + + il y a {0:N0} mois + + + il y a {0:N0} seconde + + + il y a {0:N0} secondes + + + il y a {0:N0} an + + + il y a {0:N0} ans + + + Code d'authentification non valide + + + Essayez d'entrer à nouveau le code ou de cliquer sur le bouton de renvoi pour obtenir un nouveau code d'authentification. + + + Code d'authentification envoyé ! + + + Si vous ne recevez pas le code d'authentification, contactez support@github.com. + + + Parcourir + + + Impossible de se connecter à github.com + + + Impossible de se connecter au serveur. + + + Créer + + + Description (facultative) + + + Ouvrir dans un navigateur + + + Annuler + + + Gist créé + + + Échec de création de Gist + + + par + + + Confidentialité + + + Aidez-nous à nous améliorer en envoyant des données d'utilisation anonymes + + + Impossible de copier dans le Presse-papiers. Réessayez. + + + Lien copié dans le Presse-papiers + + + Le dépôt a été créé. + + + Gist privé + + + Nom de fichier + + + Vous n'êtes pas connecté à {0}, ce qui peut entraîner l'échec de certaines opérations git. [Connectez-vous maintenant]({1}) + + + Wiki + + + Pulsation + + + Chemin d'accès + + + Problèmes + + + Graphes + + + Publier sur GitHub + + + Fonctionnalités puissantes de collaboration, de revue et de gestion du code pour les projets open source et privés. + + + Connecter... + + + Cloner + + + Vérifier + + + Authentification à 2 facteurs + + + S'inscrire + + + Se déconnecter + + + Renvoyer le code à votre appareil SMS inscrit + + + Renvoyer + + + Nom du dépôt + + + Ce dépôt n'a pas de dépôt distant. Remplissez le formulaire pour le publier sur GitHub. + + + Publier + + + ou + + + Ouvrez l'application d'authentification à 2 facteurs sur votre appareil pour afficher votre code d'authentification. + + + Aucun dépôt + + + Nom + + + Référentiel privé + + + Chemin local : + + + Licence + + + En savoir plus + + + Git ignore + + + Rechercher dans les dépôts + + + Certains ou tous les dépôts n'ont peut-être pas été chargés. Fermez la boîte de dialogue et réessayez. + + + Une erreur s'est produite durant le chargement des dépôts + + + Adresse du serveur GitHub Enterprise + + + L'hôte n'est pas disponible ou n'est pas un serveur GitHub Enterprise. Vérifiez l'adresse, puis réessayez. + + + Nom d'utilisateur ou e-mail + + + Mot de passe + + + Se connecter + + + Vérifiez votre connexion Internet et réessayez. + + + Vous n'avez pas GitHub Enterprise ? + + + Vous n'avez pas de compte ? + + + Titre (obligatoire) + + + Description + + + Publiez ce dépôt sur GitHub et bénéficiez de fonctionnalités puissantes de collaboration, de revue et de gestion du code pour les projets open source et privés. + + + Ce dépôt n'est pas sur GitHub + + + Aucun dépôt + + + Nous n'avons trouvé aucun dépôt git ici. Ouvrez un projet git ou cliquez sur « Fichier -> Ajouter au contrôle de code source » dans un projet pour démarrer. + + + Créer un compte + + + Filtrer les branches + + + Publier sur GitHub + + + Prise en main + + + Se connecter + + + Se connecter... + + + Branche locale à jour + + + Modifications ({0}) + + + Afficher les modifications + + + Comparer le fichier en tant qu'action par défaut + + + Afficher le fichier + + + Ouvrir le fichier en tant qu'action par défaut + + + Basculer en vue Liste + + + Basculer en arborescence + + + {0} mis à jour + + + Afficher la demande de tirage (pull request) sur GitHub + + + Bienvenue dans GitHub pour Visual Studio ! Pensez à consulter nos [formations](show-formation) et notre [documentation](show-docs). + +[Ne plus afficher ce message](dont-show-again) + + + Mis à jour + + + Afficher les commentaires PR sur l'éditeur de marge + + + Fonctionnalités expérimentales + + + Ces fonctionnalités peuvent être amenées à changer dans une version ultérieure + + + Afficher les changements dans la solution + + + Ouvrir le fichier dans la solution + + + Jeton + + + Continuer votre revue + + + Ajouter votre revue + + + Réviseurs + + + Ajouter un commentaire de revue + + + Ajouter un commentaire unique + + + Dupliquer (fork) + + + Débogage + + + Activer la journalisation du suivi + + + L'extension GitHub n'est pas disponible dans Blend + + + Mettre à jour le commentaire + + + Annuler + + + En attente + + + Commencer une revue + + + Vous devez commiter et envoyer (push) vos changements pour ajouter un commentaire ici. + + + Commentaire précédent + + + Commentaire suivant + + + Afficher, extraire ou créer une demande de tirage (pull request) + + + Précédent + + + Dupliquer (fork) le dépôt + + + Dupliquer (fork) le dépôt + + + Mettre à jour votre dépôt local + + + pour pointer sur + + + Créer la demande de tirage (pull request) + + + Se connecter à GitHub + + + Tirer + + + Pousser + + + Synchroniser + + + a écrit + + + Envoyer votre revue pour + + + Résumé de votre revue + + + Commentaire uniquement + + + Approuver + + + Demander des changements + + + Commentaires + + + Commentaires obsolètes + + + Créer + + + Personne responsable + + + Auteur + + + Se connecter avec votre navigateur + + + Ouvrir + + + Filtrer par auteur + + + Sélectionner une duplication (fork) + + + Voulez-vous vraiment supprimer ce commentaire ? + + + Supprimer le commentaire + + + Réessayer + + + Il n'y a aucune demande de tirage (pull request) ouverte + + + Les demandes de tirage (pull request) vous permettent de signaler à d'autres personnes les changements que vous avez envoyés (push) à un dépôt sur GitHub + + + Aucun résultat ne correspond à votre recherche. + + + Pour commencer, vous pouvez + + + créer une demande de tirage (pull request) + + + Ouvrir le dépôt à l'emplacement « {0} » ? + + + L'URL cible a un propriétaire différent du dépôt actuel. + + + Aucun dépôt actif n'est accessible à la navigation + + + Le fichier de travail est différent du fichier à l'emplacement « {0} ». Extrayez la branche, la demande de tirage (pull request) ou le commit correspondant. + + + Ouvrez le dépôt « {0} », puis réessayez + + + Ouverture impossible à partir de « {0} ». Seules les URL pointant vers des fichiers de dépôt sont actuellement prises en charge. + + + URL GitHub introuvable dans le Presse-papiers + + + L'URL cible est introuvable dans le dépôt actuel. Réessayez après une récupération (fetch). + + + Le répertoire du chemin de destination n'est pas vide. + + + Un dépôt existe déjà à cet emplacement, mais il n'a pas de dépôt distant nommé « origin ». + + + Un dépôt existe déjà à cet emplacement, mais il a un dépôt distant {0}. + + + Vous avez déjà cloné à cet emplacement. Cliquez sur Ouvrir pour ouvrir le dépôt local. + + + URL GitHub du dépôt introuvable + + + Les dépôts doivent avoir un dépôt distant appelé « origin » pour localiser leur URL GitHub. + + + Renommez l'un de vos dépôts distants existants « origin » ou ajoutez-en un nouveau nommé « origin » et effectuez une récupération (fetch). Pour cela, utilisez la ligne de commande ou cliquez sur le bouton ci-dessous. + + + Le fichier correspondant à « {0} » est introuvable dans le dépôt. Effectuez un « git fetch » ou extrayez la demande de tirage (pull request) cible. + + + {0} envoyé (push) le plus récemment + + + Commentaire + + + Fermer la demande de tirage + + + Fermer le problème + + + Fermer et commenter + + + Rouvrir et commenter + + + Rouvrir le problème + + + Rouvrir la demande de tirage + + + {0} validations + + + commits ajoutés + + + et autres + + + Cette conversation a été marquée comme résolue + + + Vos dépôts + + + Dépôts de collaborateur + + + A contribué aux dépôts + + + Rechercher ou entrer une URL + + + + Parcourir... + + + Cloner + + + Ajouter/Changer des comptes + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.it-IT.resx b/src/GitHub.Resources/Resources.it-IT.resx new file mode 100644 index 0000000000..2d109fe4d3 --- /dev/null +++ b/src/GitHub.Resources/Resources.it-IT.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Selezionare una cartella contenitore per il nuovo repository. + + + Apri da GitHub + + + Non è stato possibile connettersi a github.com + + + Crea un gist GitHub + + + Crea un repository {0} + + + GistFromVisualStudio.cs + + + Immettere un URL di Enterprise + + + Immettere un URL valido di Enterprise + + + Non è un server Enterprise. Immettere un URL di Enterprise + + + (password dimenticata?) + + + Assicurarsi di usare la password e non un token di accesso personale per eseguire l'accesso. + + + Verificare nome utente e password, quindi riprovare + + + L'accesso non è riuscito. + + + Connetti a GitHub + + + Immettere la password + + + La richiesta pull per il ramo **{0}** è stata creata in [{1}]({2}) + + + Immettere un titolo per la richiesta pull + + + I rami di origine e di destinazione non possono essere uguali + + + Il ramo di origine non esiste in remoto. Ne è stato eseguito il push? + + + Immettere un percorso per il repository + + + Immettere un percorso valido + + + Il percorso contiene caratteri non validi + + + Il percorso è troppo lungo + + + Il repository '{0}/{1}' esiste già. + + + Modificare il nome del repository oppure selezionare un account diverso e riprovare. + + + È stata superata la quota di repository privati. + + + In questo percorso esiste già un repository con lo stesso nome + + + Immettere un nome per il repository + + + Il nome del repository non deve superare i 100 caratteri + + + Verrà creato con il nome {0} + + + Aprire l'app di autenticazione a due fattori nel dispositivo per visualizzare il codice di autenticazione. + + + È stato inviato un SMS con il codice di autenticazione. + + + Autenticazione a due fattori obbligatoria + + + Immettere qui il codice di autenticazione per l'accesso + + + Immettere il nome utente o l'indirizzo di posta elettronica + + + Il nome utente o l'indirizzo di posta elettronica non deve contenere spazi + + + Richieste pull + + + Richiesta pull + + + aggiungere + + + fork + + + [non valido] + + + È necessario eseguire il pull prima di eseguire il push + + + Non sono presenti commit di cui eseguire il pull + + + Non sono presenti commit di cui eseguire il push + + + *Non è stata specificata alcuna descrizione.* + + + Esegui checkout di {0} + + + Esegui checkout in {0} + + + Eseguire pull dal ramo {1} di {0} + + + Eseguire push dal ramo {1} di {0} + + + repository remoto + + + rinomina + + + Il repository di origine non è più disponibile. + + + Non è possibile eseguire il checkout perché la directory di lavoro contiene modifiche non sottoposte a commit. + + + Sincronizza i moduli secondari di {0} + + + Non è stato possibile trovare Git.exe in PATH. + +Installare GIT per Windows da: +https://git-scm.com/download/win + + + Approvate + + + Modifiche richieste + + + Commento aggiunto + + + In corso + + + Premere INVIO per spostarsi nell'editor + + + Eseguire il checkout del ramo della richiesta pull prima di passare all'editor + + + Premere INVIO per spostarsi nell'editor (è necessario aver eseguito il checkout del ramo della richiesta pull) + + + Crea una copia del repository tramite fork + + + Cambia origine + + + Annullare questa revisione? Tutti i commenti in sospeso andranno persi. + + + Annulla revisione + + + Nel percorso di destinazione è presente un file. + + + È necessario disconnettersi + + + {0:N0} giorno fa + + + {0:N0} giorni fa + + + {0:N0} ora fa + + + {0:N0} ore fa + + + adesso + + + {0:N0} minuto fa + + + {0:N0} minuti fa + + + {0:N0} mese fa + + + {0:N0} mesi fa + + + {0:N0} secondo fa + + + {0:N0} secondi fa + + + {0:N0} anno fa + + + {0:N0} anni fa + + + Codice di autenticazione non valido + + + Provare a immettere di nuovo il codice o a fare clic sul pulsante Invia di nuovo per ottenere un nuovo codice di autenticazione. + + + Il codice di autenticazione è stato inviato. + + + Se non si riceve il codice di autenticazione, contattare support@github.com. + + + Sfoglia + + + Non è stato possibile connettersi a github.com + + + Non è stato possibile connettersi al server. + + + Crea + + + Descrizione (facoltativa) + + + Apri nel browser + + + Annulla + + + Il gist è stato creato + + + Non è stato possibile creare il gist + + + da + + + Privacy + + + È possibile inviare dati di utilizzo anonimi per contribuire al miglioramento del prodotto + + + Non è stato possibile copiare negli Appunti. Riprovare. + + + Collegamento copiato negli Appunti + + + Il repository è stato creato. + + + Gist privato + + + Nome file + + + Non è stato eseguito l'accesso a {0}, di conseguenza alcune operazioni GIT potrebbero non riuscire. [Accedi adesso]({1}) + + + Wiki + + + A impulsi + + + Percorso + + + Problemi + + + Grafici + + + Pubblica in GitHub + + + Potenti funzionalità di collaborazione, revisione del codice e gestione del codice per progetti privati e open source. + + + Connetti… + + + Clona + + + Verifica + + + Autenticazione a due fattori + + + Iscrizione + + + Disconnetti + + + Invia di nuovo il codice al dispositivo SMS registrato + + + Rinvia + + + Nome repository + + + In questo repository non è presente un repository remoto. Compilare il modulo per pubblicarlo in GitHub. + + + Pubblica + + + oppure + + + Aprire l'app di autenticazione a due fattori nel dispositivo per visualizzare il codice di autenticazione. + + + Non esiste alcun repository + + + Nome + + + Repository privato + + + Percorso locale: + + + Licenza + + + Altre informazioni + + + Elenco gitignore + + + Cerca nei repository + + + È possibile che alcuni o tutti i repository non siano stati caricati. Chiudere la finestra di dialogo e riprovare. + + + Si è verificato un errore durante il caricamento dei repository + + + Indirizzo del server GitHub Enterprise + + + L'host non è disponibile oppure non è un server GitHub Enterprise. Controllare l'indirizzo e riprovare. + + + Nome utente o indirizzo di posta elettronica + + + Password + + + Accedi + + + Controllare la connessione Internet e riprovare. + + + GitHub Enterprise non è disponibile? + + + Non si dispone di un account? + + + Titolo (obbligatorio) + + + Descrizione + + + Pubblicando questo repository in GitHub è possibile accedere a potenti funzionalità di collaborazione, revisione del codice e gestione del codice per progetti privati e open source. + + + Questo repository non è presente in GitHub + + + Non è stato trovato alcun repository + + + Non è stato possibile trovare alcun repository GIT qui. Per iniziare, aprire un progetto git o fare clic su "File -> Aggiungi al controllo del codice sorgente" in un progetto. + + + Crea un account + + + Filtra rami + + + Pubblica in GitHub + + + Guida introduttiva + + + Accedi + + + Accedi... + + + Il ramo locale è aggiornato + + + Modifiche ({0}) + + + Visualizza modifiche + + + Confronta file come azione predefinita + + + Visualizza file + + + Apri file come azione predefinita + + + Passa a visualizzazione elenco + + + Passa a visualizzazione albero + + + {0} aggiornato + + + Visualizza richiesta pull in GitHub + + + Benvenuti a GitHub per Visual Studio. Per iniziare, provare a seguire il [training](show-training) o a consultare la [documentazione](show-docs). + +[Non visualizzare più](dont-show-again) + + + Aggiornato + + + Mostra i commenti alla richiesta pull a margine dell'editor + + + Funzionalità sperimentali + + + Queste funzionalità potrebbero cambiare in una versione futura + + + Visualizza modifiche nella soluzione + + + Apri file nella soluzione + + + Token + + + Continua la revisione + + + Aggiungi la revisione + + + Revisori + + + Aggiungi commento sulla revisione + + + Aggiungi un singolo commento + + + Crea una copia tramite fork + + + Debug + + + Abilita registrazione traccia + + + L'estensione GitHub non è disponibile in Blend + + + Aggiorna il commento + + + Annulla + + + In sospeso + + + Avvia una revisione + + + È necessario eseguire il commit e il push delle modifiche per aggiungere un commento qui. + + + Commento precedente + + + Commento successivo + + + Visualizza, esegui checkout o crea una richiesta pull + + + Indietro + + + Crea una copia del repository tramite fork + + + Crea una copia del repository tramite fork + + + Aggiorna il repository locale + + + in modo che punti a + + + Crea richiesta pull + + + Accedi a GitHub + + + Pull + + + Effettua il push + + + Sincronizzazione + + + scritto + + + Invia la revisione per + + + Riepilogo delle revisioni + + + Commenta solo + + + Approva + + + Modifiche della richiesta + + + Commenti + + + Commenti obsoleti + + + Crea nuova + + + Assegnatario + + + Autore + + + Accedi con il browser + + + Apri + + + Filtra per autore + + + Seleziona fork + + + Eliminare questo commento? + + + Elimina commento + + + Riprova + + + Non sono presenti richieste pull aperte + + + Le richieste pull consentono di informare altri utenti sulle modifiche di cui è stato eseguito il push in un repository in GitHub + + + Non sono stati trovati risultati corrispondenti alla ricerca. + + + Per iniziare, è possibile + + + creare una richiesta pull + + + Aprire il repository in '{0}'? + + + Il proprietario dell'URL di destinazione è diverso da quello del repository corrente. + + + Non è presente alcun repository attivo da esplorare + + + Il file di lavoro è diverso dal file in '{0}'. Eseguire il checkout del ramo, della richiesta pull o del commit corrispondente. + + + Aprire il repository '{0}' e riprovare + + + Non è stato possibile aprire da '{0}'. Al momento sono supportati solo gli URL collegati a file di repository. + + + Non è stato trovato alcun URL GitHub negli Appunti + + + Non è stato trovato alcun URL di destinazione nel repository corrente. Riprovare dopo aver eseguito il comando fetch. + + + La directory nel percorso di destinazione non è vuota. + + + In questo percorso esiste già un repository che però non include un repository remoto denominato "origin". + + + In questo percorso esiste già un repository che però contiene un repository remoto di {0}. + + + È già stata eseguita la clonazione in questo percorso. Fare clic su 'Apri' per aprire il repository locale. + + + Non è possibile trovare l'URL GitHub per il repository + + + Nei repository è necessario definire un repository remoto denominato "origin" per consentire l'individuazione dell'URL GitHub. + + + Rinominare uno dei repository remoti esistenti in 'origin' oppure aggiungere un nuovo repository remoto denominato 'origin' ed eseguire il comando fetch. È possibile eseguire questa operazione dalla riga di comando oppure facendo clic sul pulsante in basso. + + + Non è stato possibile trovare il file corrispondente a '{0}' nel repository. Eseguire 'git fetch' oppure il checkout della richiesta pull di destinazione. + + + {0} di cui è stato eseguito il push più di recente + + + Commento + + + Chiudi la richiesta pull + + + Chiudi il problema + + + Chiudi e aggiungi commento + + + Riapri e aggiungi commento + + + Riapri il problema + + + Riapri la richiesta pull + + + {0} commit + + + ha aggiunto alcuni commit + + + e altri + + + Questa conversazione è stata contrassegnata come risolta + + + Repository personali + + + Repository dei collaboratori + + + Aggiunto come contributo ai repository + + + Cercare o immettere un URL + + + + Sfoglia... + + + Clona + + + Aggiungi/Modifica account + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.ja-JP.resx b/src/GitHub.Resources/Resources.ja-JP.resx new file mode 100644 index 0000000000..7ed86f39cf --- /dev/null +++ b/src/GitHub.Resources/Resources.ja-JP.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 新しいリポジトリを含んでいるフォルダーを選択します。 + + + GitHub から開く + + + Github.com に接続できませんでした + + + GitHub Gist の作成 + + + {0} リポジトリの作成 + + + GistFromVisualStudio.cs + + + Enterprise URL を入力してください + + + 有効な Enterprise URL を入力してください + + + Enterprise サーバーではありません。Enterprise URL を入力してください + + + (パスワードを忘れた場合) + + + サインインするには、個人用アクセス トークンではなく、パスワードを使用してください。 + + + ユーザー名とパスワードを確認してから、もう一度お試しください + + + サインインできませんでした。 + + + GitHub に接続 + + + パスワードを入力してください + + + ブランチの Pull Request **{0}** が [{1}]({2}) に正常に作成されました + + + Pull Request のタイトルを入力してください + + + ソース ブランチとターゲット ブランチを同じにすることはできません + + + ソース ブランチがリモートに存在しません。ブランチをプッシュしましたか? + + + リポジトリのパスを入力してください + + + 有効なパスを入力してください + + + パスに無効な文字が含まれています + + + パスが長すぎます + + + リポジトリ '{0}/{1}' は既に存在します。 + + + リポジトリ名を変更するか、別のアカウントを選択してから、もう一度お試しください。 + + + プライベート リポジトリのクォータを超えました。 + + + 同じ名前のリポジトリがこの場所に既に存在します + + + リポジトリ名を入力してください + + + リポジトリ名は 100 文字未満でなければなりません + + + {0} として作成されます + + + デバイスで 2 要素認証アプリを開き、認証コードを確認してださい。 + + + SMS メッセージで認証コードを送信しました。 + + + 2 要素認証が必要です + + + サインインの認証コードをここに入力 + + + ユーザー名または電子メール アドレスを入力してください + + + ユーザー名または電子メール アドレスにスペースを含めることはできません + + + Pull Request + + + Pull Request + + + 追加 + + + フォーク + + + [無効] + + + プッシュする前にプルする必要があります + + + プルするコミットがありません + + + プッシュするコミットがありません + + + *説明が指定されていません。* + + + {0} のチェックアウト + + + {0} へのチェックアウト + + + {0} ブランチ {1} からプルします + + + {0} ブランチ {1} にプッシュします + + + リモート + + + 名前の変更 + + + ソース リポジトリは使用できなくなりました。 + + + 作業ディレクトリにコミットされていない変更があるため、チェックアウトできません。 + + + {0} サブモジュールの同期 + + + PATH 上に Git.exe が見つかりませんでした。 + +Git for Windows を次の場所からインストールしてください。 +https://git-scm.com/download/win + + + 承認済み + + + 変更が要求されました + + + コメントされました + + + 処理中 + + + エディターに移動するには Enter キーを押します + + + エディターに移動する前に PR ブランチをチェックアウトしてください + + + エディターに移動するには Enter キーを押します (PR ブランチをチェックアウトする必要があります) + + + リポジトリのフォーク + + + Origin の切り替え + + + このレビューをキャンセルしますか? 保留中のすべてのコメントが失われます。 + + + レビューのキャンセル + + + 宛先パスにファイルが存在します。 + + + ログアウトが必要です + + + {0:N0} 日前 + + + {0:N0} 日前 + + + {0:N0} 時間前 + + + {0:N0} 時間前 + + + 今すぐ + + + {0:N0} 分前 + + + {0:N0} 分前 + + + {0:N0} か月前 + + + {0:N0} か月前 + + + {0:N0} 秒前 + + + {0:N0} 秒前 + + + {0:N0} 年前 + + + {0:N0} 年前 + + + 認証コードが無効です + + + コードをもう一度入力してみてください。または、再送信ボタンをクリックして新しい認証コードを取得します。 + + + 認証コードを送信しました。 + + + 認証コードを受け取れない場合は、support@github.com にお問い合わせください。 + + + 参照 + + + Github.com に接続できませんでした + + + サーバーに接続できませんでした。 + + + 作成 + + + 説明 (オプション) + + + ブラウザーで開く + + + キャンセル + + + gist が作成されました + + + gist を作成できませんでした + + + 作成者 + + + プライバシー + + + 機能向上のために匿名の利用状況データを送信する + + + クリップボードにコピーできませんでした。もう一度お試しください。 + + + リンクがクリップボードにコピーされました + + + リポジトリが正常に作成されました。 + + + プライベート gist + + + ファイル名 + + + {0} にサインインしていないため、特定の Git 操作を実行できない可能性があります。[今すぐサインインする]({1}) + + + Wiki + + + パルス + + + パス + + + 懸案事項 + + + グラフ + + + GitHub に発行 + + + オープン ソースとプライベート プロジェクトの強力なコラボレーション、コード レビュー、コード管理が提供されます。 + + + 接続… + + + 複製 + + + 確認 + + + 2 要素認証 + + + サインアップ + + + サインアウト + + + 登録された SMS デバイスにコードを再送信します + + + 再送信 + + + リポジトリ名 + + + このリポジトリにはリモートがありません。このリポジトリを GitHub に発行するには、フォームに入力します。 + + + 公開 + + + または + + + デバイスで 2 要素認証アプリを開き、認証コードを確認してださい。 + + + リポジトリがありません + + + 名前 + + + プライベート リポジトリ + + + ローカル パス: + + + ライセンス + + + 詳細情報 + + + Git 無視 + + + リポジトリの検索 + + + 一部またはすべてのリポジトリが読み込まれていない可能性があります。ダイアログを閉じて、もう一度お試しください。 + + + リポジトリの読み込み中にエラーが発生しました + + + GitHub Enterprise サーバーのアドレス + + + ホストが使用できないか、GitHub Enterprise サーバーではありません。アドレスを確認して、もう一度お試しください。 + + + ユーザー名または電子メール + + + パスワード + + + サインイン + + + インターネット接続を確認して、もう一度お試しください。 + + + GitHub Enterprise をお持ちでない場合 + + + アカウントをお持ちでない場合 + + + タイトル (必須) + + + 説明 + + + このリポジトリを GitHub に発行すると、オープン ソースとプライベート プロジェクトの強力なコラボレーション、コード レビュー、コード管理が提供されます。 + + + このリポジトリは GitHub 上にありません + + + リポジトリが見つかりません + + + この場所に Git リポジトリが見つかりませんでした。Git プロジェクトを開くか、プロジェクトで [ファイル] -> [ソース管理に追加] をクリックして作業を開始してください。 + + + アカウントを作成する + + + ブランチのフィルター処理 + + + GitHub に発行 + + + はじめに + + + サインイン + + + サインイン... + + + 最新のローカル ブランチ + + + 変更 ({0}) + + + 変更の表示 + + + 既定のアクションとしてファイルを比較する + + + ファイルの表示 + + + 既定のアクションとしてファイルを開く + + + リスト ビューに切り替え + + + ツリー ビューに切り替え + + + {0} が更新されました + + + GitHub 上で Pull Request を表示 + + + GitHub for Visual Studio へようこそ! [トレーニング](show-training) または [ドキュメント](show-docs) をご覧ください。 + +[今後このメッセージを表示しない](dont-show-again) + + + 更新 + + + エディターの余白に PR コメントを表示する + + + 試験的な機能 + + + これらの機能は今後のバージョンで変更される可能性があります + + + ソリューション内の変更の表示 + + + ソリューション内のファイルを開く + + + トークン + + + レビューを続行 + + + レビューの追加 + + + レビュー担当者 + + + レビュー コメントの追加 + + + 単一コメントの追加 + + + フォーク + + + デバッグ + + + トレース ログを有効にする + + + Blend 内では GitHub 拡張機能を使用できません + + + コメントの更新 + + + キャンセル + + + 保留中 + + + レビューの開始 + + + ここにコメントを追加するには、変更をコミットしてプッシュする必要があります。 + + + 前のコメント + + + 次のコメント + + + Pull Request の表示、チェックアウト、または作成 + + + 戻る + + + リポジトリのフォーク + + + リポジトリをフォークします + + + ローカル リポジトリの次のものを更新します + + + ポイントする + + + pull request の作成 + + + GitHub にサインイン + + + プル + + + プッシュ + + + 同期 + + + 書き込みました + + + 次に対するレビュー送信をします: + + + レビューの概要 + + + コメントのみ + + + 承認 + + + 変更の要求 + + + コメント + + + 期限切れのコメント + + + 新規作成 + + + 担当者 + + + 作成者 + + + ブラウザーでサインインします + + + 開く + + + 作成者でフィルター処理 + + + フォークの選択 + + + このコメントを削除してもよろしいですか? + + + コメントの削除 + + + 再試行 + + + 開いている pull request はありません + + + pull request を使用すると、GitHub 上のリポジトリにプッシュした変更が他のユーザーに知らされます + + + 検索に一致する結果がありませんでした。 + + + 作業を開始するには、次のようにします。 + + + pull request を作成します + + + '{0}' にあるリポジトリを開きますか? + + + ターゲット URL には現在のリポジトリに対する別の所有者がいます。 + + + 移動するアクティブなリポジトリがありません + + + 作業ファイルは、'{0}' にあるファイルとは別のファイルです。対応するブランチ、pull request、またはコミットをチェックアウトしてください。 + + + リポジトリ '{0}' を開き、もう一度お試しください + + + '{0}' から開くことができませんでした。現在、リポジトリ ファイルにリンクする URL のみがサポートされています。 + + + クリップボード内に GitHub URL が見つかりませんでした + + + 現在のリポジトリにターゲット URL が見つかりませんでした。フェッチを実行した後、もう一度お試しください。 + + + ターゲット パスのディレクトリが空ではありません。 + + + この場所にリポジトリが既に存在しますが、そのリポジトリには "origin" という名前のリモートがありません。 + + + この場所にリポジトリが既に存在しますが、そのリポジトリには {0} のリモートがあります。 + + + この場所に対する複製は既に実行済みです。[開く] をクリックして、ローカル リポジトリを開いてください。 + + + リポジトリの GitHub URL が見つかりません + + + リポジトリの GitHub URL を検索するには、"origin" という名前のリモートがそのリポジトリで定義されている必要があります。 + + + 既存のリモートのいずれかの名前を 'origin' に変更するか、'origin' という名前の新しいリモートを追加してください。これを行うには、コマンド ラインを使用するか、下のボタンをクリックします。 + + + '{0}' に対応するファイルがリポジトリ内に見つかりませんでした。'git fetch' を実行するか、ターゲット pull request をチェックアウトしてください。 + + + {0} が最近プッシュされました + + + コメント + + + pull request を閉じる + + + 問題を閉じる + + + 閉じてコメントする + + + 再度開いてコメントする + + + 問題を再度開く + + + pull request を再度開く + + + {0} 個のコミット + + + コミットがいくつか追加されました + + + その他 + + + この会話は解決済みとしてマークされました + + + ご使用のリポジトリ + + + コラボレーターのリポジトリ + + + 参加先のリポジトリ + + + URL を検索または入力します + + + + 参照... + + + 複製 + + + アカウントの追加または変更 + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.ko-KR.resx b/src/GitHub.Resources/Resources.ko-KR.resx new file mode 100644 index 0000000000..c72155ba8f --- /dev/null +++ b/src/GitHub.Resources/Resources.ko-KR.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 새 리포지토리의 포함 폴더를 선택합니다. + + + GitHub에서 열기 + + + Github.com에 연결할 수 없습니다. + + + GitHub Gist 만들기 + + + {0} 리포지토리 만들기 + + + GistFromVisualStudio.cs + + + Enterprise URL을 입력하세요. + + + 유효한 Enterprise URL을 입력하세요. + + + Enterprise 서버가 아닙니다. Enterprise URL을 입력하세요. + + + (암호를 잊으셨습니까?) + + + 로그인하는 데 개인용 액세스 토큰이 아닌 암호를 사용해야 합니다. + + + 사용자 이름과 암호를 확인하고 다시 시도하세요. + + + 로그인에 실패했습니다. + + + GitHub에 연결 + + + 암호를 입력하세요. + + + 분기 **{0}**에 대한 끌어오기 요청이 [{1}]({2})에 생성됨 + + + 끌어오기 요청의 제목을 입력하세요. + + + 소스 및 대상 분기는 같을 수 없습니다. + + + 소스 분기가 원격으로 존재하지 않습니다. 소스 분기를 푸시하셨습니까? + + + 리포지토리 경로를 입력하세요. + + + 유효한 경로를 입력하세요. + + + 경로에 잘못된 문자가 들어 있습니다. + + + 경로가 너무 김 + + + 리포지토리 '{0}/{1}'이(가) 이미 있습니다. + + + 리포지토리 이름을 변경하거나 다른 계정을 선택하고 다시 시도하세요. + + + 비공개 리포지토리 할당량을 초과했습니다. + + + 같은 이름을 가진 리포지토리가 이 위치에 이미 있습니다. + + + 리포지토리 이름을 입력하세요. + + + 리포지토리 이름은 100자 미만이어야 합니다. + + + {0}(으)로 만들어집니다. + + + 디바이스에서 2단계 인증 앱을 열어 인증 코드를 봅니다. + + + SMS 통해 인증 코드가 포함된 메시지를 보냈습니다. + + + 2단계 인증 필요 + + + 여기에 로그인 인증 코드 입력 + + + 사용자 이름 또는 전자 메일 주소를 입력하세요. + + + 사용자 이름 또는 전자 메일 주소에는 공백이 없어야 합니다. + + + 끌어오기 요청 + + + 끌어오기 요청 + + + 추가 + + + 포크 + + + [잘못됨] + + + 푸시하려면 먼저 풀해야 합니다. + + + 풀할 커밋 없음 + + + 푸시할 커밋 없음 + + + *제공된 설명이 없습니다.* + + + {0} 체크 아웃 + + + {0}(으)로 체크 아웃 + + + {0} 분기 {1}에서 풀 + + + {0} 분기 {1}에 푸시 + + + 원격 + + + 이름 바꾸기 + + + 소스 리포지토리를 더 이상 사용할 수 없습니다. + + + 작업 디렉터리에 커밋되지 않은 변경 내용이 있으므로 체크 아웃할 수 없습니다. + + + {0} 하위 모듈 동기화 + + + PATH에서 Git.exe를 찾을 수 없습니다. + +다음 위치에서 Git for Windows를 설치하세요. +https://git-scm.com/download/win + + + 승인됨 + + + 요청된 변경 내용 + + + 주석 처리됨 + + + 진행 중 + + + <Enter> 키를 눌러 편집기로 이동 + + + 편집기로 이동하기 전에 PR 분기 체크 아웃 + + + <Enter> 키를 눌러 편집기로 이동(PR 분기를 체크 아웃해야 함) + + + 리포지토리 포크 + + + 원점 전환 + + + 이 검토를 취소하시겠습니까? 보류 중인 모든 주석이 손실됩니다. + + + 검토 취소 + + + 대상 경로에 파일이 있습니다. + + + 로그아웃 필요 + + + {0:N0}일 전 + + + {0:N0}일 전 + + + {0:N0}시간 전 + + + {0:N0}시간 전 + + + 방금 + + + {0:N0}분 전 + + + {0:N0}분 전 + + + {0:N0}개월 전 + + + {0:N0}개월 전 + + + {0:N0}초 전 + + + {0:N0}초 전 + + + {0:N0}년 전 + + + {0:N0}년 전 + + + 잘못된 인증 코드 + + + 코드를 다시 입력하거나, [다시 보내기] 단추를 클릭하여 새 인증 코드를 가져오세요. + + + 인증 코드가 전송되었습니다. + + + 인증 코드를 받지 못한 경우 support@github.com에 문의하세요. + + + 찾아보기 + + + Github.com에 연결할 수 없습니다. + + + 서버에 연결할 수 없습니다. + + + 만들기 + + + 설명(선택 사항) + + + 브라우저에서 열기 + + + 취소 + + + Gist 생성됨 + + + Gist를 만들지 못했습니다. + + + 기준 + + + 개인 정보 + + + 익명 사용 데이터를 보내 개선에 참여 + + + 클립보드에 복사할 수 없습니다. 다시 시도하세요. + + + 링크가 클립보드에 복사됨 + + + 리포지토리를 만들었습니다. + + + 개인 Gist + + + 파일 이름 + + + {0}에 로그인되지 않았으므로, 특정 Git 작업이 실패할 수 있습니다. [지금 로그인]({1}) + + + Wiki + + + Pulse + + + 경로 + + + 문제 + + + 그래프 + + + GitHub에 게시 + + + 오픈 소스 및 프라이빗 프로젝트에 대한 강력한 협업, 코드 검토 및 코드 관리. + + + 연결... + + + 복제 + + + 확인 + + + 2단계 인증 + + + 등록 + + + 로그아웃 + + + 등록된 SMS 디바이스에 코드를 다시 보냅니다. + + + 다시 보내기 + + + 리포지토리 이름 + + + 이 리포지토리에는 원격 항목이 없습니다. 양식을 작성하여 GitHub에 게시합니다. + + + 게시 + + + 또는 + + + 디바이스에서 2단계 인증 앱을 열어 인증 코드를 봅니다. + + + 리포지토리 없음 + + + 이름 + + + 비공개 리포지토리 + + + 로컬 경로: + + + 라이선스 + + + 자세한 정보 + + + Git ignore + + + 리포지토리 검색 + + + 일부 또는 전체 리포지토리가 로드되지 않았을 수 있습니다. 대화 상자를 닫고 다시 시도하세요. + + + 리포지토리를 로드하는 동안 오류가 발생했습니다. + + + GitHub Enterprise 서버 주소 + + + 호스트를 사용할 수 없거나 호스트가 GitHub Enterprise 서버가 아닙니다. 주소를 확인하고 다시 시도하세요. + + + 사용자 이름 또는 전자 메일 + + + 암호 + + + 로그인 + + + 인터넷 연결을 확인하고 다시 시도하세요. + + + GitHub Enterprise가 없으십니까? + + + 계정이 없으십니까? + + + 제목(필수) + + + 설명 + + + 이 리포지토리를 GitHub에 게시하고 오픈 소스 및 프라이빗 프로젝트에 대한 강력한 협업, 코드 검토 및 코드 관리를 가져옵니다. + + + 이 리포지토리가 GitHub에 없습니다. + + + 리포지토리 없음 + + + 여기에서 Git 리포지토리를 찾을 수 없습니다. Git 프로젝트를 열거나, 프로젝트에서 "파일 -> 소스 제어에 추가"를 클릭하여 시작합니다. + + + 계정 만들기 + + + 분기 필터링 + + + GitHub에 게시 + + + 시작 + + + 로그인 + + + 로그인... + + + 로컬 분기 최신 상태 + + + 변경 내용({0}개) + + + 변경 내용 보기 + + + 기본 작업으로 파일 비교 + + + 파일 보기 + + + 기본 작업으로 파일 열기 + + + 목록 보기로 전환 + + + 트리 뷰로 전환 + + + 업데이트된 {0} + + + GitHub에서 끌어오기 요청 보기 + + + Visual Studio용 GitHub를 시작합니다. [학습](show-training) 또는 [설명서](show-docs)를 살펴보세요. + +[다시 표시 안 함](dont-show-again) + + + 업데이트됨 + + + 편집기 여백에 PR 주석 표시 + + + 실험적 기능 + + + 이 기능은 향후 버전에서 변경될 수 있습니다. + + + 솔루션에서 변경 내용 보기 + + + 솔루션에서 파일 열기 + + + 토큰 + + + 검토 계속 + + + 검토 추가 + + + 검토자 + + + 검토 주석 추가 + + + 단일 주석 추가 + + + 포크 + + + 디버깅 + + + 추적 로깅 사용 + + + GitHub 확장은 Blend 내에서 사용할 수 없습니다. + + + 주석 업데이트 + + + 취소 + + + 보류 중 + + + 검토 시작 + + + 여기에 주석을 추가하려면 변경 내용을 커밋하고 푸시해야 합니다. + + + 이전 주석 + + + 다음 주석 + + + 끌어오기 요청 보기, 체크 아웃 또는 만들기 + + + 뒤로 + + + 리포지토리 포크 + + + 리포지토리 포크 + + + 로컬 리포지토리를 업데이트합니다. + + + 가리키려면 + + + 끌어오기 요청 만들기 + + + GitHub에 로그인 + + + 끌어오기 + + + 푸시 + + + 동기화 + + + 기록됨 + + + 다음에 대한 검토 제출 + + + 검토 요약 + + + 주석만 + + + 승인 + + + 변경 요청 + + + 주석 + + + 오래된 주석 + + + 새로 만들기 + + + 담당자 + + + 만든 사람 + + + 브라우저로 로그인 + + + 열기 + + + 작성자별 필터링 + + + 포크 선택 + + + 이 주석을 삭제하시겠습니까? + + + 주석 삭제 + + + 다시 시도 + + + 열린 끌어오기 요청이 없습니다. + + + 끌어오기 요청을 사용하면 GitHub의 리포지토리에 푸시한 변경 내용을 다른 사용자에게 알릴 수 있습니다. + + + 검색과 일치하는 결과가 없습니다. + + + 시작하기 위해 다음을 할 수 있습니다. + + + 끌어오기 요청 만들기 + + + '{0}'에서 리포지토리를 여시겠습니까? + + + 대상 URL의 소유자가 현재 리포지토리의 소유자와 다릅니다. + + + 탐색할 활성 리포지토리가 없습니다. + + + 작업 파일은 '{0}'에 있는 파일과 다릅니다. 해당 분기, 끌어오기 요청 또는 커밋을 체크 아웃하세요. + + + '{0}' 리포지토리를 열고 다시 시도하세요. + + + '{0}'에서 열 수 없습니다. 현재는 리포지토리 파일에 연결할 URL만 지원됩니다. + + + 클립보드에서 GitHub URL을 찾을 수 없습니다. + + + 현재 리포지토리에서 대상 URL을 찾을 수 없습니다. 페치를 수행한 후 다시 시도하세요. + + + 대상 경로의 디렉터리가 비어 있지 않습니다. + + + 리포지토리가 이 위치에 이미 있지만, "origin"이라는 원격 항목을 포함하지 않습니다. + + + 리포지토리가 이 위치에 이미 있지만, {0}의 원격 항목을 포함하지 않습니다. + + + 이 위치에 이미 복제했습니다. '열기'를 클릭하여 로컬 리포지토리를 엽니다. + + + 리포지토리의 GitHub URL을 찾을 수 없습니다. + + + 리포지토리에는 해당 GitHub URL을 찾기 위해 정의된 "origin"이라는 원격 항목이 있어야 합니다. + + + 'origin'에 대한 기존 원격 항목 중 하나의 이름을 변경하거나, 'origin'이라는 새 원격 항목을 추가하고 페치하세요. 이 작업은 명령줄에서 또는 아래 단추를 클릭하여 수행할 수 있습니다. + + + 리포지토리에서 '{0}'에 해당하는 파일을 찾을 수 없습니다. 'git fetch'를 수행하거나 대상 끌어오기 요청을 체크 아웃하세요. + + + {0}이(가) 가장 최근 푸시됨 + + + 주석 + + + 끌어오기 요청 닫기 + + + 문제 닫기 + + + 닫기 및 주석 처리 + + + 다시 열기 및 주석 처리 + + + 문제 다시 열기 + + + 끌어오기 요청 다시 열기 + + + {0}개 커밋 + + + 일부 커밋을 추가함 + + + 및 기타 + + + 이 대화는 확인된 것으로 표시되었습니다. + + + 사용자 리포지토리 + + + 협력자 리포지토리 + + + 리포지토리에 협력함 + + + URL 검색 또는 입력 + + + + 찾아보기... + + + 복제 + + + 계정 추가/변경 + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.pl-PL.resx b/src/GitHub.Resources/Resources.pl-PL.resx new file mode 100644 index 0000000000..b34e070d57 --- /dev/null +++ b/src/GitHub.Resources/Resources.pl-PL.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Wybierz folder zawierający dla nowego repozytorium. + + + Otwórz z witryny GitHub + + + Nie można nawiązać połączenia z witryną github.com + + + Utwórz gist usługi GitHub + + + Tworzenie repozytorium {0} + + + GistFromVisualStudio.cs + + + Wprowadź adres URL usługi Enterprise + + + Wprowadź prawidłowy adres URL usługi Enterprise + + + To nie jest serwer usługi Enterprise. Wprowadź adres URL usługi Enterprise + + + (Nie pamiętasz hasła?) + + + Upewnij się, że podczas logowania używasz hasła, a nie osobistego tokenu dostępu. + + + Sprawdź nazwę użytkownika i hasło i spróbuj ponownie + + + Nie można się zalogować. + + + Połącz z usługą GitHub + + + Wprowadź hasło + + + Pomyślnie utworzono żądanie ściągnięcia dla gałęzi **{0}**: [{1}]({2}) + + + Wprowadź tytuł żądania ściągnięcia + + + Gałąź źródłowa i docelowa nie mogą być takie same + + + Gałąź źródłowa nie istnieje zdalnie. Czy została wypchnięta? + + + Wprowadź ścieżkę repozytorium + + + Wprowadź prawidłową ścieżkę + + + Ścieżka zawiera nieprawidłowe znaki + + + Ścieżka jest za długa + + + Repozytorium „{0}/{1}” już istnieje. + + + Zmień nazwę repozytorium lub wybierz inne konto i spróbuj ponownie. + + + Przekroczono limit przydziału repozytoriów prywatnych. + + + Repozytorium o tej samej nazwie już istnieje w tej lokalizacji + + + Wprowadź nazwę repozytorium + + + Nazwa repozytorium musi zawierać mniej niż 100 znaków + + + Zostanie utworzone jako {0} + + + Otwórz aplikację uwierzytelniania dwuskładnikowego na urządzeniu, aby wyświetlić kod uwierzytelniania. + + + Wysłaliśmy Ci wiadomość SMS z kodem uwierzytelniania. + + + Wymagane jest uwierzytelnianie dwuskładnikowe + + + Wprowadź tutaj kod uwierzytelniania logowania + + + Wprowadź nazwę użytkownika lub adres e-mail + + + Nazwa użytkownika lub adres e-mail nie może zawierać spacji + + + Żądania ściągnięcia + + + Żądanie ściągnięcia + + + dodaj + + + rozwidlenie + + + [nieprawidłowa] + + + Aby móc wypchnąć, musisz najpierw ściągnąć + + + Brak zatwierdzeń do ściągnięcia + + + Brak zatwierdzeń do wypchnięcia + + + *Nie podano opisu.* + + + Wyewidencjonuj {0} + + + Wyewidencjonuj do: {0} + + + Ściągnij z {0} gałęzi {1} + + + Wypchnij do {0} gałęzi {1} + + + repozytorium zdalne + + + zmień nazwę + + + Repozytorium źródłowe nie jest już dostępne. + + + Nie można wyewidencjonować, ponieważ katalog roboczy zawiera niezatwierdzone zmiany. + + + Synchronizuj moduły podrzędne ({0}) + + + Nie można odnaleźć pliku Git.exe w ŚCIEŻCE. + +Zainstaluj narzędzie Git dla systemu Windows: +https://git-scm.com/download/win + + + Zatwierdzono + + + Żądano zmian + + + Dodano komentarz + + + W toku + + + Naciśnij klawisz Enter, aby przejść do edytora + + + Wyewidencjonuj gałąź żądania ściągnięcia przed przejściem do edytora + + + Naciśnij klawisz Enter, aby przejść do edytora (gałąź żądania ściągnięcia musi zostać wyewidencjonowana) + + + Utwórz rozwidlenie repozytorium + + + Przełącz źródło + + + Czy na pewno chcesz anulować ten przegląd? Utracisz wszystkie oczekujące komentarze. + + + Anuluj przegląd + + + Plik istnieje w ścieżce docelowej. + + + Wymagane wylogowanie + + + {0:N0} dzień temu + + + {0:N0} dni temu + + + {0:N0} godzinę temu + + + {0:N0} godz. temu + + + przed chwilą + + + {0:N0} minutę temu + + + {0:N0} min temu + + + {0:N0} miesiąc temu + + + {0:N0} mies. temu + + + {0:N0} sekundę temu + + + {0:N0} s temu + + + {0:N0} rok temu + + + {0:N0} l. temu + + + Nieprawidłowy kod uwierzytelniania + + + Spróbuj ponownie wprowadzić kod lub kliknąć przycisk Wyślij ponownie, aby uzyskać nowy kod uwierzytelniania. + + + Wysłano kod uwierzytelniania! + + + Jeśli nie otrzymasz kodu uwierzytelniania, wyślij wiadomość na adres support@github.com. + + + Przeglądaj + + + Nie można nawiązać połączenia z witryną github.com + + + Nie można nawiązać połączenia z serwerem. + + + Utwórz + + + Opis (opcjonalnie) + + + Otwórz w przeglądarce + + + Anuluj + + + Utworzono gist + + + Nie można utworzyć gistu + + + według + + + Prywatność + + + Pomóż nam w ulepszaniu produktu przez wysyłanie anonimowych danych użycia + + + Nie można skopiować do schowka. Spróbuj ponownie. + + + Skopiowano link do schowka + + + Pomyślnie utworzono repozytorium. + + + Gist prywatny + + + Nazwa pliku + + + Nie zalogowano Cię w produkcie {0}, więc niektóre operacje Git mogą kończyć się niepowodzeniem. [Zaloguj się teraz]({1}) + + + Witryna typu wiki + + + Puls + + + Ścieżka + + + Problemy + + + Wykresy + + + Publikowanie w usłudze GitHub + + + Zaawansowane funkcje współpracy, przeglądu kodu i zarządzania kodem dla prywatnych projektów i projektów typu open source. + + + Połącz… + + + Sklonuj + + + Weryfikuj + + + Uwierzytelnianie dwuskładnikowe + + + Utwórz konto + + + Wyloguj się + + + Ponownie wyślij kod do zarejestrowanego urządzenia obsługującego wiadomości SMS + + + Wyślij ponownie + + + Nazwa repozytorium + + + To repozytorium nie ma repozytorium zdalnego. Wypełnij formularz, aby opublikować je w witrynie GitHub. + + + Publikuj + + + lub + + + Otwórz aplikację uwierzytelniania dwuskładnikowego na urządzeniu, aby wyświetlić kod uwierzytelniania. + + + Brak repozytoriów + + + Nazwa + + + Repozytorium prywatne + + + Ścieżka lokalna: + + + Licencja + + + Dowiedz się więcej + + + Git — ignoruj + + + Wyszukaj repozytoria + + + Być może nie załadowano niektórych lub wszystkich repozytoriów. Zamknij okno dialogowe i spróbuj ponownie. + + + Wystąpił błąd podczas ładowania repozytoriów + + + Adres serwera usługi GitHub Enterprise + + + Host jest niedostępny lub nie jest serwerem usługi GitHub Enterprise. Sprawdź adres i spróbuj ponownie. + + + Nazwa użytkownika lub adres e-mail + + + Hasło + + + Zaloguj + + + Sprawdź połączenie internetowe i spróbuj ponownie. + + + Nie masz usługi GitHub Enterprise? + + + Nie masz konta? + + + Tytuł (wymagany) + + + Opis + + + Opublikuj to repozytorium w usłudze GitHub i korzystaj z zaawansowanych funkcji współpracy, przeglądu kodu i zarządzania kodem dla prywatnych projektów i projektów typu open source. + + + Tego repozytorium nie ma w witrynie GitHub + + + Brak repozytorium + + + Nie można odnaleźć tutaj repozytorium Git. Otwórz projekt Git lub kliknij w projekcie pozycję „Plik -> Dodaj do kontroli źródła”, aby rozpocząć. + + + Utwórz konto + + + Filtruj gałęzie + + + Opublikuj w usłudze GitHub + + + Wprowadzenie + + + Zaloguj + + + Zaloguj... + + + Gałąź lokalna jest aktualna + + + Zmiany ({0}) + + + Wyświetl zmiany + + + Porównanie pliku jako akcja domyślna + + + Wyświetl plik + + + Otwarcie pliku jako akcja domyślna + + + Przełącz do widoku listy + + + Przełącz do widoku drzewa + + + zaktualizowano {0} + + + Wyświetl żądanie ściągnięcia w witrynie GitHub + + + Witamy w usłudze GitHub for Visual Studio! Zapoznaj się naszym [szkoleniem](show-training) lub [dokumentacją](show-docs). + +[Nie pokazuj ponownie](dont-show-again) + + + Zaktualizowane + + + Pokaż komentarze dotyczące żądania ściągnięcia na marginesie edytora + + + Funkcje eksperymentalne + + + Te funkcje mogą ulec zmianie w przyszłej wersji + + + Wyświetl zmiany w rozwiązaniu + + + Otwórz plik w rozwiązaniu + + + Token + + + Kontynuuj przegląd + + + Dodaj przegląd + + + Recenzenci + + + Dodaj komentarz do przeglądu + + + Dodaj jeden komentarz + + + Rozwidlenie + + + Debugowanie + + + Włącz rejestrowanie śledzenia + + + Rozszerzenie GitHub jest niedostępne w programie Blend + + + Zaktualizuj komentarz + + + Anuluj + + + Oczekujący + + + Rozpocznij przegląd + + + Musisz zatwierdzić i wypchnąć zmiany, aby dodać tutaj komentarz. + + + Poprzedni komentarz + + + Następny komentarz + + + Wyświetl, wyewidencjonuj lub utwórz żądanie ściągnięcia + + + Wstecz + + + Utwórz rozwidlenie repozytorium + + + Utwórz rozwidlenie repozytorium + + + Aktualizuj repozytorium lokalne + + + w celu wskazywania + + + Utwórz żądanie ściągnięcia + + + Zaloguj się w witrynie GitHub + + + Ściągnij + + + Wypchnij + + + Synchronizacja + + + zapisano + + + Prześlij przegląd do + + + Podsumowanie przeglądu + + + Tylko komentarz + + + Zatwierdź + + + Żądaj zmian + + + Komentarze + + + Nieaktualne komentarze + + + Utwórz nowy + + + Osoba przydzielona + + + Autor + + + Zaloguj się przy użyciu przeglądarki + + + Otwórz + + + Filtruj według autora + + + Wybierz rozwidlenie + + + Czy na pewno chcesz usunąć ten komentarz? + + + Usuń komentarz + + + Ponów próbę + + + Nie ma żadnych otwartych żądań ściągnięcia + + + Żądania ściągnięcia umożliwiają informowanie o zmianach wypchniętych do repozytorium w usłudze GitHub + + + Brak wyników spełniających kryteria wyszukiwania. + + + Aby rozpocząć, możesz + + + utwórz żądanie ściągnięcia + + + Otworzyć repozytorium w lokalizacji „{0}”? + + + Docelowy adres URL ma innego właściciela bieżącego repozytorium. + + + Brak aktywnego repozytorium do nawigacji + + + Plik roboczy różni się od pliku w lokalizacji „{0}”. Wyewidencjonuj odpowiednią gałąź, żądanie ściągnięcia lub zatwierdzenie. + + + Otwórz repozytorium „{0}” i spróbuj ponownie + + + Nie można otworzyć z: „{0}”. Obecnie obsługiwane są tylko adresy URL prowadzące do plików repozytorium. + + + Nie można odnaleźć adresu URL usługi GitHub w schowku + + + Nie można odnaleźć docelowego adresu URL w bieżącym repozytorium. Spróbuj ponownie po zakończeniu pobierania. + + + Katalog w ścieżce docelowej nie jest pusty. + + + Repozytorium już istnieje w tej lokalizacji, ale nie ma zdalnego repozytorium o nazwie „origin”. + + + Repozytorium już istnieje w tej lokalizacji, ale ma repozytorium zdalne {0}. + + + Sklonowano już zawartość do tej lokalizacji. Kliknij pozycję „Otwórz”, aby otworzyć repozytorium lokalne. + + + Nie można odnaleźć adresu URL repozytorium w usłudze GitHub + + + Repozytoria muszą mieć zdefiniowane repozytorium zdalne o nazwie „origin” w celu zlokalizowania ich adresu URL usługi GitHub. + + + Zmień nazwę jednego z istniejących repozytoriów zdalnych na „origin” lub dodaj nowe repozytorium zdalne o nazwie „origin” i pobierz. Można to zrobić z poziomu wiersza polecenia lub przez kliknięcie przycisku poniżej. + + + Nie można odnaleźć pliku odpowiadającego elementowi „{0}” w repozytorium. Wykonaj polecenie pobierania danych Git lub wyewidencjonuj docelowe żądanie ściągnięcia. + + + Ostatnio wypchnięte ({0}) + + + Komentarz + + + Zamknij żądanie ściągnięcia + + + Zamknij problem + + + Zamknij i skomentuj + + + Otwórz ponownie i skomentuj + + + Otwórz ponownie problem + + + Otwórz ponownie żądanie ściągnięcia + + + Zatwierdzenia w {0} + + + — użytkownik dodał zatwierdzenia + + + i inni + + + Ta konwersacja została oznaczona jako rozwiązana + + + Twoje repozytoria + + + Repozytoria współpracowników + + + Współtworzone repozytoria + + + Wyszukaj lub wprowadź adres URL + + + + Przeglądaj... + + + Klonuj + + + Dodaj/Zmień konta + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.pt-BR.resx b/src/GitHub.Resources/Resources.pt-BR.resx new file mode 100644 index 0000000000..b74ea550ff --- /dev/null +++ b/src/GitHub.Resources/Resources.pt-BR.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Selecione uma pasta de contenção para o seu novo repositório. + + + Abrir do GitHub + + + Não foi possível conectar ao github.com + + + Criar um Gist do GitHub + + + Criar um Repositório {0} + + + GistFromVisualStudio.cs + + + Insira uma URL do Enterprise + + + Insira uma URL do Enterprise válida + + + Não é um servidor do Enterprise. Insira uma URL do Enterprise + + + (esqueceu a senha?) + + + Use sua senha e não um token de Acesso Pessoal para entrar. + + + Verifique suas informações de nome de usuário e senha e tente novamente + + + Falha ao entrar. + + + Conectar-se Ao GitHub + + + Insira sua senha + + + Solicitação de Pull para branch **{0}** criada com êxito em [{1}]({2}) + + + Insira um título para a Solicitação de Pull + + + O branch de origem e de destino não podem ser o mesmo + + + O branch de origem não existe remotamente. Você o enviou por push? + + + Insira um caminho de repositório + + + Insira um caminho válido + + + O caminho contém caracteres inválidos + + + O caminho é muito longo + + + O repositório '{0}/{1}' já existe. + + + Altere o nome do repositório ou selecione uma conta diferente e tente novamente. + + + Ultrapassou a cota de repositórios privada. + + + Já existe um repositório com o mesmo nome neste local + + + Insira um nome de repositório + + + O nome do repositório deve ter menos de 100 caracteres + + + Será criado como {0} + + + Abra o aplicativo de autenticação de dois fatores em seu dispositivo para exibir o código de autenticação. + + + Enviamos uma mensagem para você por SMS com o código de autenticação. + + + É necessária a autenticação de Dois Fatores + + + Insira um sinal no código de autenticação aqui + + + Insira seu nome de usuário ou endereço de email + + + O nome de usuário ou endereço de email não deve ter espaços + + + Solicitações Pull + + + Solicitação pull + + + adicionar + + + bifurcação + + + [inválido] + + + Você deve efetuar pull antes de poder enviar por push + + + Não há commits dos quais efetuar pull + + + Não há commits a serem enviados por push + + + *Nenhuma descrição fornecida.* + + + Fazer check-out de {0} + + + Fazer check-out para {0} + + + Efetuar pull de {0} branch {1} + + + Enviar por push para {0} branch {1} + + + repositório remoto + + + renomear + + + O repositório de origem não está mais disponível. + + + Não é possível fazer check-out, pois o seu diretório de trabalho tem alterações cujo commit não foi feito. + + + Sincronizar {0} submódulos + + + Não foi possível encontrar Git.exe no CAMINHO. + +Instale o Git para Windows: +https://git-scm.com/download/win + + + Aprovado + + + Alterações Solicitadas + + + Comentado + + + EmAndamento + + + Pressione Enter para navegar para o Editor + + + Fazer check-out do branch de PR antes de navegar para o Editor + + + Pressione Enter para navegar para o Editor (é necessário fazer o check-out do branch de PR) + + + Repositório de Bifurcação + + + Alternar Origem + + + Tem certeza de que deseja cancelar esta revisão? Você perderá todos os seus comentários pendentes. + + + Cancelar Revisão + + + Existe um arquivo no caminho de destino. + + + Logoff Necessário + + + {0:N0} dia atrás + + + {0:N0} dias atrás + + + {0:N0} hora atrás + + + {0:N0} horas atrás + + + agora + + + {0:N0} minuto atrás + + + {0:N0} minutos atrás + + + {0:N0} mês atrás + + + {0:N0} meses atrás + + + {0:N0} segundo atrás + + + {0:N0} segundos atrás + + + {0:N0} ano atrás + + + {0:N0} anos atrás + + + Código de autenticação inválido + + + Tente inserir o código novamente ou clicar no botão de reenvio para obter um novo código de autenticação. + + + Código de autenticação enviado. + + + Se você não receber o código de autenticação, entre em contato com support@github.com. + + + Procurar + + + Não foi possível conectar ao github.com + + + Não foi possível conectar ao servidor. + + + Criar + + + Descrição (Opcional) + + + Abrir no Navegador + + + Cancelar + + + Gist criado + + + Falha ao criar gist + + + por + + + Privacidade + + + Ajude-nos a melhorar enviando dados de uso anônimos + + + Não foi possível copiar para a área de transferência. Tente novamente. + + + Link copiado para a área de transferência + + + Repositório criado com êxito. + + + Gist Privado + + + Nome do Arquivo + + + Você não entrou em {0}; portanto, determinadas operações git poderão falhar. [Entre agora]({1}) + + + Wiki + + + Pulso + + + Caminho + + + Questões + + + Grafos + + + Publicar no GitHub + + + Colaboração avançada, revisão de código e gerenciamento de código para projetos privados e de software livre. + + + Conectar… + + + Clonar + + + Verificar + + + Autenticação em dois fatores + + + Inscrever-se + + + Sair + + + Enviar o código novamente para o seu Dispositivo de SMS registrado + + + Reenviar + + + Nome do Repositório + + + Este repositório não tem um repositório remoto. Preencha o formulário para publicá-lo no GitHub. + + + Publicar + + + ou + + + Abra o aplicativo de autenticação de dois fatores em seu dispositivo para exibir o código de autenticação. + + + Nenhum repositório + + + Nome + + + Repositório Privado + + + Caminho local: + + + Licença + + + Saiba mais + + + Ignorar git + + + Pesquisar repositórios + + + Alguns ou todos os repositórios podem não ter carregado. Feche a caixa de diálogo e tente novamente. + + + Ocorreu um erro ao carregar os repositórios + + + Endereço do servidor do GitHub Enterprise + + + O host não está disponível ou não é um servidor do GitHub Enterprise. Verifique o endereço e tente novamente. + + + Nome de usuário ou email + + + Senha + + + Entrar + + + Verifique sua conexão com a Internet e tente novamente. + + + Não tem o GitHub Enterprise? + + + Não tem uma conta? + + + Título (obrigatório) + + + Descrição + + + Publique este repositório no GitHub e obtenha colaboração avançada, revisão de código e gerenciamento de código para projetos privados e de software livre. + + + Este repositório não está no GitHub + + + Nenhum repositório + + + Não foi possível localizar um repositório git aqui. Abra um projeto git ou clique em "Arquivo -> Adicionar ao Controle do Código-fonte" em um projeto para começar. + + + Criar uma conta + + + Filtrar branches + + + Publicar no GitHub + + + Introdução + + + Entrar + + + Entrar... + + + Branch local atualizado + + + Alterações ({0}) + + + Exibir alterações + + + Comparar o Arquivo como Ação Padrão + + + Exibir Arquivo + + + Abrir o Arquivo como Ação Padrão + + + Alternar para o Modo de Exibição de Lista + + + Alternar para o Modo de Exibição de Árvore + + + atualizou {0} + + + Exibir Solicitação de Pull no GitHub + + + Bem-vindo ao GitHub para Visual Studio! Por que não dá uma olhada em nosso [treinamento](show-training) ou [documentação](show-docs)? + +[Não mostrar esta mensagem novamente](dont-show-again) + + + Atualizado + + + Mostrar comentários de PR na margem do editor + + + Recursos experimentais + + + Estes recursos poderão ser alterados em uma versão futura + + + Exibir Alterações na Solução + + + Abrir o Arquivo na Solução + + + Token + + + Continuar sua revisão + + + Adicionar sua revisão + + + Revisores + + + Adicionar comentário de revisão + + + Adicionar um único comentário + + + Bifurcação + + + Depurando + + + Habilitar o Registro de Rastreamento + + + A extensão do GitHub não está disponível dentro do Blend + + + Atualizar o comentário + + + Cancelar + + + Pendente + + + Iniciar uma revisão + + + Você precisa fazer commit das alterações e enviá-las por push para adicionar um comentário aqui. + + + Comentário Anterior + + + Próximo Comentário + + + Exibir, Fazer Check-out ou Criar uma solicitação de Pull + + + Voltar + + + Repositório de Bifurcação + + + Bifurcar o repositório + + + Atualizar seu repositório local + + + para apontar para + + + Criar solicitação de pull + + + Entrar no GitHub + + + Efetuar pull + + + Enviar por push + + + Sincronizar + + + gravou + + + Enviar a sua revisão para + + + O resumo da sua revisão + + + Comentário somente + + + Aprovar + + + Solicitar alterações + + + Comentários + + + Comentários desatualizados + + + Criar Novo + + + Destinatário + + + Autor + + + Entrar com seu navegador + + + Abrir + + + Filtrar por Autor + + + Selecionar Bifurcação + + + Tem certeza de que deseja excluir este comentário? + + + Excluir Comentário + + + Tentar Novamente + + + Não há nenhuma solicitação de pull aberta + + + As solicitações de pull permitem que você informe outras pessoas sobre as alterações que você enviou por push a um repositório no GitHub + + + Nenhum resultado correspondeu à sua pesquisa. + + + Para começar, você pode + + + criar uma solicitação de pull + + + Abrir repositório em '{0}'? + + + A URL de destino tem um proprietário diferente para o repositório atual. + + + Não há nenhum repositório ativo para navegar + + + O arquivo de trabalho é diferente do arquivo em '{0}'. Faça o check-out do branch, da solicitação pull ou do commit correspondente. + + + Abra o repositório '{0}' e tente novamente + + + Não foi possível abrir pelo '{0}'. No momento, há suporte para apenas as URLs vinculadas aos arquivos do repositório. + + + Não foi possível localizar uma URL do GitHub na área de transferência + + + Não foi possível encontrar a URL de destino no repositório atual. Tente novamente depois de efetuar um fetch. + + + O diretório no caminho de destino não está vazio. + + + Já existe um repositório neste local, mas ele não tem um repositório remoto com o nome "origem". + + + Já existe um repositório neste local, mas ele tem um repositório remoto de {0}. + + + Você já clonou nesse local. Clique em 'Abrir' para abrir o repositório local. + + + Não é possível encontrar a URL do GitHub para repositório + + + Os repositórios precisam ter um repositório remoto chamado "origem" definido para localizar suas URLs do GitHub. + + + Renomeie um dos seus repositórios remotos existentes para 'origem' ou adicione um novo repositório remoto chamado 'origem' e efetue um fetch. Isso pode ser feito da linha de comando ou clicando no botão abaixo. + + + Não foi possível encontrar o arquivo correspondente a '{0}' no repositório. Faça um 'git fetch' ou o check-out da solicitação de pull de destino. + + + {0} mais recentemente pressionado + + + Comentário + + + Fechar solicitação de pull + + + Fechar problema + + + Fechar e comentar + + + Reabrir e comentar + + + Reabrir problema + + + Reabrir solicitação de pull + + + {0} confirma + + + adicionou algumas confirmações + + + e outros + + + Esta conversa foi marcada como resolvida + + + Seus repositórios + + + Repositórios de colaborador + + + Contribui para os repositórios + + + Pesquise ou insira uma URL + + + + Procurar... + + + Clonar + + + Adicionar/Alterar Contas + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.resx b/src/GitHub.Resources/Resources.resx new file mode 100644 index 0000000000..eef3e7efed --- /dev/null +++ b/src/GitHub.Resources/Resources.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Select a containing folder for your new repository. + + + Open from GitHub + + + Could not connect to github.com + + + Create a GitHub Gist + + + Create a {0} Repository + + + GistFromVisualStudio.cs + + + Please enter an Enterprise URL + + + Please enter a valid Enterprise URL + + + Not an Enterprise server. Please enter an Enterprise URL + + + (forgot your password?) + + + Make sure to use your password and not a Personal Access token to sign in. + + + Check your username and password, then try again + + + Sign in failed. + + + Connect To GitHub + + + Please enter your password + + + Pull Request for branch **{0}** created successfully at [{1}]({2}) + + + Please enter a title for the Pull Request + + + Source and target branch cannot be the same + + + Source branch doesn't exist remotely, have you pushed it? + + + Please enter a repository path + + + Please enter a valid path + + + Path contains invalid characters + + + Path too long + + + Repository '{0}/{1}' already exists. + + + Change the repository name or select a different account and try again. + + + Exceeded private repositories quota. + + + Repository with same name already exists at this location + + + Please enter a repository name + + + Repository name must be fewer than 100 characters + + + Will be created as {0} + + + Open the two-factor authentication app on your device to view your authentication code. + + + We sent you a message via SMS with your authentication code. + + + Two-Factor authentication required + + + Enter a sign in authentication code here + + + Please enter your username or email address + + + Username or email address must not have spaces + + + Pull Requests + + + Pull Request + + + add + + + fork + + + [invalid] + + + You must pull before you can push + + + No commits to pull + + + No commits to push + + + *No description provided.* + + + Checkout {0} + + + Checkout to {0} + + + Pull from {0} branch {1} + + + Push to {0} branch {1} + + + remote + + + rename + + + The source repository is no longer available. + + + Cannot checkout as your working directory has uncommitted changes. + + + Sync {0} submodules + + + Couldn't find Git.exe on PATH. + +Please install Git for Windows from: +https://git-scm.com/download/win + + + Approved + + + Changes Requested + + + Commented + + + InProgress + + + Press Enter to navigate to Editor + + + Checkout PR branch before navigating to Editor + + + Press Enter to navigate to Editor (PR branch must be checked out) + + + Fork Repository + + + Switch Origin + + + Are you sure you want to cancel this review? You will lose all your pending comments. + + + Cancel Review + + + A file exists at the destination path. + + + Logout Required + + + {0:N0} day ago + + + {0:N0} days ago + + + {0:N0} hour ago + + + {0:N0} hours ago + + + just now + + + {0:N0} minute ago + + + {0:N0} minutes ago + + + {0:N0} month ago + + + {0:N0} months ago + + + {0:N0} second ago + + + {0:N0} seconds ago + + + {0:N0} year ago + + + {0:N0} years ago + + + Invalid authentication code + + + Try entering the code again or clicking the resend button to get a new authentication code. + + + Authentication code sent! + + + If you do not receive the authentication code, contact support@github.com. + + + Browse + + + Could not connect to github.com + + + Could not connect to the server. + + + Create + + + Description (Optional) + + + Open in Browser + + + Cancel + + + Gist created + + + Failed to create gist + + + by + + + Privacy + + + Help us improve by sending anonymous usage data + + + Could not copy to the clipboard. Please try again. + + + Link copied to clipboard + + + Repository created successfully. + + + Private Gist + + + File Name + + + You are not signed in to {0}, so certain git operations may fail. [Sign in now]({1}) + + + Wiki + + + Pulse + + + Path + + + Issues + + + Graphs + + + Publish to GitHub + + + Powerful collaboration, code review, and code management for open source and private projects. + + + Connect… + + + Clone + + + Verify + + + Two-factor authentication + + + Sign up + + + Sign out + + + Send the code to your registered SMS Device again + + + Resend + + + Repository Name + + + This repository does not have a remote. Fill out the form to publish it to GitHub. + + + Publish + + + or + + + Open the two-factor authentication app on your device to view your authentication code. + + + No repositories + + + Name + + + Private Repository + + + Local path: + + + License + + + Learn more + + + Git ignore + + + Search repositories + + + Some or all repositories may not have loaded. Close the dialog and try again. + + + An error occurred while loading repositories + + + GitHub Enterprise server address + + + The host isn't available or is not a GitHub Enterprise server. Check the address and try again. + + + Username or email + + + Password + + + Sign in + + + Please check your internet connection and try again. + + + Don’t have GitHub Enterprise? + + + Don’t have an account? + + + Title (required) + + + Description + + + Publish this repository to GitHub and get powerful collaboration, code review, and code management for open source and private projects. + + + This repository is not on GitHub + + + No repository + + + We couldn't find a git repository here. Open a git project or click "File -> Add to Source Control" in a project to get started. + + + Create an account + + + Filter branches + + + Publish to GitHub + + + Get Started + + + Sign in + + + Sign in... + + + Local branch up to date + + + Changes ({0}) + + + View Changes + + + Compare File as Default Action + + + View File + + + Open File as Default Action + + + Switch to List View + + + Switch to Tree View + + + updated {0} + + + View Pull Request on GitHub + + + Welcome to GitHub for Visual Studio! Why not take a look at our [training](show-training) or [documentation](show-docs)? + +[Don't show this again](dont-show-again) + + + Updated + + + Show PR comments on editor margin + + + Experimental features + + + These features might change in a future version + + + View Changes in Solution + + + Open File in Solution + + + Token + + + Continue your review + + + Add your review + + + Reviewers + + + Add review comment + + + Add a single comment + + + Fork + + + Debugging + + + Enable Trace Logging + + + The GitHub extension is not available inside Blend + + + Update comment + + + Cancel + + + Pending + + + Start a review + + + You must commit and push your changes to add a comment here. + + + Previous Comment + + + Next Comment + + + View, Checkout or Create a Pull request + + + Back + + + Fork Repository + + + Fork the repository + + + Update your local repository's + + + to point to + + + Create pull request + + + Sign in to GitHub + + + Pull + + + Push + + + Sync + + + wrote + + + Submit your review for + + + Your review summary + + + Comment only + + + Approve + + + Request changes + + + Comments + + + Outdated comments + + + Create New + + + Assignee + + + Author + + + Sign in with your browser + + + Open + + + Filter by Author + + + Select Fork + + + Are you sure you want to delete this comment? + + + Delete Comment + + + Retry + + + There aren't any open pull requests + + + Pull requests let you tell others about changes you've pushed to a repository on GitHub + + + No results matched your search. + + + To get started you can + + + create a pull request + + + Open repository at '{0}'? + + + The target URL has a different owner to the current repository. + + + There is no active repository to navigate + + + The working file is different to the file at '{0}'. Please checkout the corresponding branch, pull request or commit. + + + Please open the repository '{0}' and try again + + + Couldn't open from '{0}'. Only URLs that link to repository files are currently supported. + + + Couldn't a find a GitHub URL in clipboard + + + Couldn't find target URL in current repository. Try again after doing a fetch. + + + The directory at the destination path is not empty. + + + A repository already exists at this location, but it doesn't have a remote named "origin". + + + A repository already exists at this location, but it has a remote of {0}. + + + You have already cloned to this location. Click 'Open' to open the local repository. + + + Can't find GitHub URL for repository + + + Repositories must have a remote called "origin" defined in order to locate their GitHub URL. + + + Please rename one of your existing remotes to 'origin' or add a new remote named 'origin' and fetch. This can be done from the command line or by clicking the button below. + + + Couldn't find file corresponding to '{0}' in the repository. Please do a 'git fetch' or checkout the target pull request. + + + {0} most recently pushed + + + Comment + + + Close pull request + + + Close issue + + + Close and comment + + + Reopen and comment + + + Reopen issue + + + Reopen pull request + + + {0} commits + + + added some commits + + + and others + + + This conversation was marked as resolved + + + Your repositories + + + Collaborator repositories + + + Contributed to repositories + + + Search or enter a URL + + + + Browse... + + + Clone + + + Add/Change Accounts + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.ru-RU.resx b/src/GitHub.Resources/Resources.ru-RU.resx new file mode 100644 index 0000000000..3c3511344c --- /dev/null +++ b/src/GitHub.Resources/Resources.ru-RU.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Выберите папку для нового репозитория. + + + Открыть из GitHub + + + Не удалось подключиться к github.com + + + Создать GitHub gist + + + Создать {0} репозиторий + + + GistFromVisualStudio.cs + + + Введите URL-адрес предприятия + + + Введите допустимый URL-адрес Enterprise + + + Это не сервер предприятия. Введите URL-адрес предприятия + + + (Забыли пароль?) + + + Используйте для входа только свой пароль, а не личный маркер доступа. + + + Проверьте имя пользователя и пароль и повторите попытку. + + + Сбой входа. + + + Подключиться к GitHub + + + Введите пароль + + + Запрос на вытягивание для ветви **{0}** успешно создан в [{1}]({2}) + + + Введите заголовок для запроса на вытягивание + + + Исходная и целевая ветви не могут совпадать + + + Исходная ветвь не существует удаленно, вы отправили ее? + + + Введите путь к репозиторию + + + Введите допустимый путь + + + Путь содержит недопустимые символы + + + Слишком длинный путь + + + Репозиторий "{0}/{1}" уже существует. + + + Измените имя репозитория или выберите другую учетную запись и повторите попытку. + + + Превышена квота для частных репозиториев. + + + Репозиторий с таким именем уже существует в этом расположении + + + Введите имя репозитория. + + + Имя репозитория должно содержать меньше 100 символов + + + Будет создан как {0} + + + Откройте приложение двухфакторной проверки подлинности на устройстве, чтобы просмотреть код проверки подлинности. + + + Мы отправили вам SMS с кодом проверки подлинности. + + + Требуется двухфакторная проверка подлинности + + + Введите знак в коде проверки подлинности + + + Введите имя пользователя или адрес электронной почты + + + Имя пользователя или адрес электронной почты не должны содержать пробелы + + + Запросы на вытягивание + + + Запрос на вытягивание + + + добавлено + + + вилка + + + [недопустимый] + + + Нужно вытянуть, прежде чем отправлять + + + Нет фиксаций для вытягивания + + + Нет фиксаций для отправки + + + *Описание не указано.* + + + Извлечение {0} + + + Извлечь в {0} + + + Вытянуть из ветви {0} {1} + + + Отправить в ветвь {0} {1} + + + удаленный + + + переименование + + + Репозиторий исходного кода больше не доступен. + + + Не удается извлечь, так как рабочий каталог имеет незафиксированные изменения. + + + Синхронизация подмодулей ({0}) + + + Не удалось найти Git.exe по пути в переменной PATH. + +Установите Git для Windows со страницы: +https://git-scm.com/download/win + + + Утверждено + + + Запрошены изменения + + + Комментарий добавлен + + + Выполняется + + + Нажмите клавишу ВВОД для перехода в редактор + + + Извлечь ветвь PR до перехода в редактор + + + Нажмите клавишу ВВОД для перехода в редактор (ветвь PR должна быть извлечена) + + + Создать вилку репозитория + + + Переключить origin + + + Вы действительно хотите отменить эту проверку? Все ожидающие комментарии будут потеряны. + + + Отменить проверку + + + В пути назначения существует файл. + + + Требуется выход из системы + + + {0:N0} дн. назад + + + {0:N0} дн. назад + + + {0:N0} ч назад + + + {0:N0} ч назад + + + сейчас + + + {0:N0} мин назад + + + {0:N0} мин назад + + + {0:N0} мес. назад + + + {0:N0} мес. назад + + + {0:N0} с назад + + + {0:N0} с назад + + + {0:N0} г. назад + + + {0:N0} года/лет назад + + + Недопустимый код проверки подлинности + + + Попробуйте ввести код еще раз или нажать кнопку повторной отправки, чтобы получить новый код проверки подлинности. + + + Код проверки подлинности отправлен. + + + Если вы не получите код проверки подлинности, обратитесь по адресу support@github.com. + + + Обзор + + + Не удалось подключиться к github.com + + + Не удалось подключиться к серверу. + + + Создать + + + Описание (необязательно) + + + Открыть в браузере + + + Отмена + + + Gist создан + + + Не удалось создать gist + + + по + + + Конфиденциальность + + + Помогите нам улучшить продукт, отправляя анонимные данные об использовании + + + Не удалось скопировать в буфер обмена. Повторите попытку. + + + Ссылка скопирована в буфер обмена + + + Репозиторий успешно создан. + + + Частный gist + + + Имя файла + + + Вы не вошли в {0}, поэтому некоторые операции Git могут завершиться сбоем. [Войти в систему]({1}) + + + Вики-сайт + + + Импульс + + + Путь + + + Вопросы + + + Диаграммы + + + Опубликовать в GitHub + + + Эффективные средства совместной работы, проверки кода и управления кодом для закрытых проектов и проектов открытого кода. + + + Подключиться… + + + Клонировать + + + Проверить + + + Двухфакторная проверка подлинности + + + Зарегистрироваться + + + Выход + + + Снова отправить код на зарегистрированное устройство для SMS + + + Повторно отправить + + + Имя репозитория + + + У этого репозитория нет удаленного репозитория. Заполните форму на публикацию в GitHub. + + + Опубликовать + + + или + + + Откройте приложение двухфакторной проверки подлинности на устройстве, чтобы просмотреть код проверки подлинности. + + + Нет репозиториев + + + Имя + + + Частный репозиторий + + + Локальный путь: + + + Лицензия + + + Дополнительные сведения + + + Игнорировать Git + + + Поиск по репозиториям + + + Не были загружены некоторые или все репозитории. Закройте это диалоговое окно и повторите попытку. + + + Произошла ошибка при загрузке панелей репозиториев + + + Адрес сервера GitHub Enterprise + + + Узел недоступен или не является сервером GitHub Enterprise. Проверьте адрес и повторите попытку. + + + Имя пользователя или адрес электронной почты + + + Пароль + + + Войти + + + Проверьте подключение к Интернету и повторите попытку. + + + У вас нет GitHub Enterprise? + + + У вас нет учетной записи? + + + Заголовок (обязательно) + + + Описание + + + Опубликуйте этот репозиторий в GitHub и получите эффективные средства совместной работы, проверки кода и управления кодом для закрытых проектов и проектов открытого кода. + + + Этот репозиторий находится не в GitHub + + + Репозиторий отсутствует. + + + Не удалось найти здесь репозиторий Git. Откройте проект Git или выберите "Файл" -> "Добавить в систему управления версиями" в проекте, чтобы приступить к работе. + + + Создать учетную запись + + + Фильтровать ветви + + + Опубликовать в GitHub + + + Начало работы + + + Войти + + + Войти... + + + Локальная ветвь актуальна + + + Изменения ({0}) + + + Просмотр изменений + + + Сравнить файл в качестве действия по умолчанию + + + Просмотр файла + + + Открыть файл в качестве действия по умолчанию + + + Перейти к представлению списка + + + Перейти к представлению в виде дерева + + + обновлен {0} + + + Просмотреть запрос на вытягивание в GitHub + + + Добро пожаловать в GitHub для Visual Studio. Рекомендуем вам ознакомиться с нашим [обучением](show-training) или нашей [документацией](show-docs)? + +[Больше не показывать](dont-show-again) + + + Обновлено + + + Показать комментарии PR на поле окна редактора + + + Экспериментальные функции + + + Эти компоненты могут быть изменены в будущей версии + + + Просмотреть изменения в решении + + + Открыть файл в решении + + + Токен + + + Продолжайте проверку + + + Добавьте проверку + + + Рецензенты + + + Добавить комментарий к проверке + + + Добавить отдельный комментарий + + + Вилка + + + Отладка + + + Включить ведение журнала трассировки + + + Расширение GitHub недоступно внутри Blend + + + Обновить комментарий + + + Отмена + + + Ожидание + + + Начать проверку + + + Вам нужно зафиксировать и отправить изменения, чтобы добавить сюда комментарий. + + + Предыдущий комментарий + + + Следующий комментарий + + + Просмотр, извлечение или создание запроса на вытягивание + + + Назад + + + Создать вилку репозитория + + + Создать вилку репозитория + + + В своем локальном репозитории обновите + + + для указания на + + + Создать запрос на вытягивание + + + Войти в GitHub + + + Вытянуть + + + Отправить + + + Синхронизировать + + + записал + + + Отправьте результаты проверки для + + + Сводка по вашей проверке + + + Только комментарий + + + Утвердить + + + Запросить изменения + + + Комментарии + + + Устаревшие комментарии + + + Создать + + + Уполномоченное лицо + + + Автор + + + Войдите с помощью браузера + + + Открыть + + + Фильтровать по автору + + + Выберите вилку + + + Действительно удалить этот комментарий? + + + Удаление комментария + + + Повторить + + + Нет открытых запросов на вытягивание + + + Запросы на вытягивание позволяют сообщить другим об изменениях, отправленных вами в репозиторий в GitHub + + + Нет результатов, отвечающих условиям поиска. + + + Чтобы приступить к работе, вы можете + + + создать запрос на вытягивание + + + Открыть репозиторий в "{0}"? + + + Целевой URL-адрес имеет другого владельца для текущего репозитория. + + + Отсутствует активный репозиторий для перехода + + + Рабочий файл отличается от файла в "{0}". Извлеките соответствующую ветвь, фиксацию или запрос на вытягивание. + + + Откройте репозиторий "{0}" и повторите попытку + + + Не удалось открыть из "{0}". Сейчас поддерживаются только URL-адреса, ссылающиеся на файлы репозитория. + + + Не удалось найти URL-адрес GitHub в буфере обмена + + + Не удалось найти целевой URL-адрес в текущем репозитории. Выполните принесение и повторите попытку. + + + Каталог в пути назначения не пуст. + + + В этом расположении уже существует репозиторий, но у него отсутствует удаленный репозиторий с именем "origin". + + + В этом расположении уже существует репозиторий, но у него имеется удаленный репозиторий {0}. + + + Вы уже клонировали элементы в это расположение. Нажмите кнопку "Открыть", чтобы открыть локальный репозиторий. + + + Не удается найти URL-адрес GitHub для репозитория + + + У репозиториев должен быть определен удаленный репозиторий "origin" для обнаружения их URL-адреса GitHub. + + + Переименуйте один из существующих удаленных репозиториев в "origin" или добавьте новый удаленный репозиторий с такими именем и выполните принесение. Это можно сделать с помощью командной строки или приведенной ниже кнопки. + + + Не удалось найти соответствующий файл "{0}" в репозитории. Выполните команду "git fetch" или извлечение целевого запроса на вытягивание. + + + Отправлено последний раз: {0} + + + Комментарий + + + Закрыть запрос на вытягивание + + + Закрыть обращение + + + Закрыть и прокомментировать + + + Повторно открыть и прокомментировать + + + Повторно открыть обращение + + + Повторно открыть запрос на включение внесенных изменений + + + Фиксаций: {0} + + + добавляет некоторые фиксации + + + и др. + + + Эта беседа была помечена как разрешенная. + + + Ваши хранилища + + + Репозитории участника совместной работы + + + Добавлял данные в репозитории + + + Поиск или ввод URL-адреса + + + + Обзор... + + + Клонировать + + + Добавить или изменить учетные записи + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.tr-TR.resx b/src/GitHub.Resources/Resources.tr-TR.resx new file mode 100644 index 0000000000..6b671d0c55 --- /dev/null +++ b/src/GitHub.Resources/Resources.tr-TR.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Yeni deponuzu içerecek klasörü seçin. + + + GitHub'dan Aç + + + github.com ile bağlantı kurulamadı + + + GitHub Gist oluştur + + + {0} Deposu Oluştur + + + GistFromVisualStudio.cs + + + Lütfen bir Enterprise URL'si girin + + + Lütfen geçerli bir Enterprise URL'si girin + + + Bu bir Enterprise sunucusu değil. Lütfen bir Enterprise URL'si girin + + + (parolanızı mı unuttunuz?) + + + Oturum açmak için Kişisel Erişim belirteci yerine parolanızı kullandığınızdan emin olun. + + + Kullanıcı adınızı ve parolanızı kontrol edin, sonra tekrar deneyin + + + Oturum açılamadı. + + + GitHub'a Bağlan + + + Lütfen parolanızı girin + + + **{0}** dalı için Çekme İsteği [{1}] ({2}) konumunda başarıyla oluşturuldu + + + Lütfen Çekme İsteği için bir başlık girin + + + Kaynak ve hedef dal aynı olamaz + + + Kaynak dal uzak depoda yok, kaynak dalı gönderdiniz mi? + + + Lütfen bir depo yolu girin + + + Lütfen geçerli bir yol girin + + + Yol geçersiz karakterler içeriyor + + + Yol çok uzun + + + '{0}/{1}' deposu zaten var. + + + Depo adını değiştirin veya farklı bir hesap seçip yeniden deneyin. + + + Özel depolar kotası aşıldı. + + + Bu konumda aynı ada sahip bir depo zaten var + + + Lütfen bir depo adı girin + + + Depo adı 100 karakterden kısa olmalıdır + + + {0} olarak oluşturulacak + + + Kimlik doğrulama kodunuzu görüntülemek için cihazınızda iki öğeli kimlik doğrulama uygulamasını açın. + + + Size SMS üzerinden kimlik doğrulama kodunuzun bulunduğu bir mesaj gönderdik. + + + İki öğeli kimlik doğrulama gerekiyor + + + Buraya bir oturum açma kimlik doğrulama kodu girin + + + Lütfen kullanıcı adınızı veya e-posta adresinizi girin + + + Kullanıcı adı veya e-posta adresi boşluk içeremez + + + Çekme İstekleri + + + Çekme İsteği + + + ekleyin + + + çatal oluştur + + + [geçersiz] + + + Gönderebilmeniz için önce çekmeniz gerekir + + + Çekilecek commit yok + + + Gönderilecek commit yok + + + *Açıklama sağlanmadı.* + + + Şunu kullanıma al: {0} + + + Kullanıma alma hedefi: {0} + + + {0} {1} dalından çekin + + + {0} {1} dalına gönder + + + uzak depo + + + yeniden adlandır + + + Kaynak depo artık kullanılamıyor. + + + Çalışma dizininizde kaydedilmemiş değişiklikler olduğundan kullanıma alınamıyor. + + + {0} alt modüllerini eşitle + + + PATH konumunda Git.exe dosyası bulunamadı. + +Lütfen şuradan Windows için Git yükleyin: +https://git-scm.com/download/win + + + Onaylandı + + + İstenen Değişiklikler + + + Açıklamalı + + + İlerliyor + + + Düzenleyiciye gitmek için Enter tuşuna basın + + + Düzenleyiciye gitmeden önce PR dalını kullanıma alın + + + Düzenleyiciye gitmek için Enter tuşuna basın (PR dalının kullanıma alınması gerekir) + + + Depo Çatalı Oluştur + + + Origin'i Değiştir + + + Bu incelemeyi iptal etmek istediğinizden emin misiniz? Tüm bekleyen açıklamalarınızı kaybedersiniz. + + + İncelemeyi İptal Et + + + Hedef yolunda bir dosya var. + + + Oturumun Kapatılması Gerekiyor + + + {0:N0} gün önce + + + {0:N0} gün önce + + + {0:N0} saat önce + + + {0:N0} saat önce + + + az önce + + + {0:N0} dakika önce + + + {0:N0} dakika önce + + + {0:N0} ay önce + + + {0:N0} ay önce + + + {0:N0} saniye önce + + + {0:N0} saniye önce + + + {0:N0} yıl önce + + + {0:N0} yıl önce + + + Geçersiz kimlik doğrulama kodu + + + Yeni bir kimlik doğrulama kodu almak için kodu yeniden girmeyi veya yeniden gönder düğmesine tıklamayı deneyin. + + + Kimlik doğrulama kodu gönderildi! + + + Kimlik doğrulama kodunu almadıysanız support@github.com adresine başvurun. + + + Gözat + + + github.com ile bağlantı kurulamadı + + + Sunucuya bağlanılamadı. + + + Oluştur + + + Açıklama (İsteğe Bağlı) + + + Tarayıcıda Aç + + + İptal + + + Gist oluşturuldu + + + Gist oluşturulamadı + + + Sağlayan: + + + Gizlilik + + + Anonim kullanım verileri göndererek geliştirmemize yardımcı olun + + + Panoya kopyalanamadı. Lütfen yeniden deneyin. + + + Bağlantı panoya kopyalandı + + + Depo başarıyla oluşturuldu. + + + Özel Gist + + + Dosya Adı + + + {0} oturumunuz açılmadığından belirli git işlemleri başarısız olabilir. [Şimdi oturum açın]({1}) + + + Wiki + + + Pulse + + + Yol + + + Konular + + + Grafikler + + + GitHub'da Yayımla + + + Açık kaynak ve özel projeler için güçlü işbirliği, kod incelemesi ve kod yönetimi. + + + Bağlan... + + + Kopya + + + Doğrula + + + İki öğeli kimlik doğrulama + + + Kaydol + + + Oturumu kapat + + + Kodu kayıtlı SMS Cihazınıza yeniden gönderin + + + Yeniden Gönder + + + Depo Adı + + + Bu deponun uzak deposu yok. GitHub'da yayımlamak için formu doldurun. + + + Yayımla + + + veya + + + Kimlik doğrulama kodunuzu görüntülemek için cihazınızda iki öğeli kimlik doğrulama uygulamasını açın. + + + Depo yok + + + Ad + + + Özel Depo + + + Yerel yol: + + + Lisans + + + Daha fazla bilgi + + + Git ignore + + + Depolarda ara + + + Depoların bir kısmı veya tümü yüklenmemiş olabilir. İletişim kutusunu kapatın ve yeniden deneyin. + + + Depolar yüklenirken bir hata oluştu + + + GitHub Enterprise sunucusu adresi + + + Ana bilgisayar kullanılamıyor veya bir GitHub Enterprise sunucusu değil. Adresi denetleyin ve yeniden deneyin. + + + Kullanıcı adı veya e-posta + + + Parola + + + Oturum aç + + + Lütfen internet bağlantınızı kontrol edin ve yeniden deneyin. + + + GitHub Enterprise kullanmıyor musunuz? + + + Hesabınız yok mu? + + + Başlık (gerekli) + + + Açıklama + + + Bu depoyu GitHub'da yayımlayarak açık kaynak ve özel projeler için güçlü işbirliği, kod incelemesi ve kod yönetiminden yararlanın. + + + Bu depo GitHub'da değil + + + Depo yok + + + Burada bir git deposu bulunamadı. Çalışmaya başlamak için bir git projesi açın veya bir projede "Dosya -> Kaynak Denetimine Ekle" seçeneğine tıklayın. + + + Hesap oluştur + + + Dalları filtrele + + + GitHub'da Yayımla + + + Kullanmaya Başlayın + + + Oturum aç + + + Oturum aç... + + + Yerel dal güncel + + + Değişiklikler ({0}) + + + Değişiklikleri Görüntüle + + + Varsayılan Eylem Olarak Dosyayı Karşılaştır + + + Dosyayı Görüntüle + + + Varsayılan Eylem Olarak Dosyayı Aç + + + Liste Görünümüne Geç + + + Ağaç Görünümüne Geç + + + {0} güncelleştirildi + + + Çekme İsteğini GitHub'da Görüntüle + + + Visual Studio için GitHub'a hoş geldiniz! [Eğitimimize](show-training) veya [belgelerimize](show-docs) göz atmaya ne dersiniz? + +[Bunu bir daha gösterme](dont-show-again) + + + Güncelleştirildi + + + Düzenleyici kenar boşluğunda PR açıklamalarını göster + + + Deneysel özellikler + + + Bu özellikler gelecekteki bir sürümde değişebilir + + + Çözümdeki Değişiklikleri Görüntüle + + + Dosyayı Çözümde Aç + + + Belirteç + + + İncelemenize devam edin + + + İncelemenizi ekleyin + + + Gözden Geçirenler + + + İnceleme açıklaması ekle + + + Tek bir açıklama ekleyin + + + Çatal Oluştur + + + Hata Ayıklama + + + İzleme Günlüğünü Etkinleştir + + + GitHub uzantısı Blend içinde kullanılamıyor + + + Açıklamayı güncelleştir + + + İptal + + + Bekliyor + + + İnceleme başlat + + + Buraya bir açıklama eklemek için değişikliklerinizi commit işlemi yapıp göndermelisiniz. + + + Önceki Açıklama + + + Sonraki Açıklama + + + Çekme İsteğini Görüntüle, Oluştur veya Kullanıma Al + + + Geri + + + Depo Çatalı Oluştur + + + Depo çatalı oluştur + + + Yerel deponuzun şu öğesini güncelleştirin: + + + işaret edecek şekilde + + + Çekme isteği oluştur + + + GitHub'da oturum açın + + + Çek + + + Anında İlet + + + Eşitle + + + yazdı + + + Şuna yönelik incelemenizi gönderin: + + + İnceleme özetiniz + + + Yalnızca açıklama + + + Onayla + + + İstek değişiklikleri + + + Açıklamalar + + + Süresi geçen açıklamalar + + + Yeni Oluştur + + + Atanan + + + Yazar + + + Tarayıcınızla oturum açın + + + + + + Yazara Göre Filtrele + + + Çatal Seçin + + + Bu açıklamayı silmek istediğinizden emin misiniz? + + + Açıklamayı Sil + + + Yeniden dene + + + Açık çekme isteği yok + + + Çekme istekleri GitHub'da bir depoya gönderdiğiniz değişiklikleri başkalarına duyurmanıza olanak tanır + + + Aramanızla eşleşen sonuç yok. + + + Başlamak için şunları yapabilirsiniz: + + + çekme isteği oluştur + + + '{0}' konumundaki depo açılsın mı? + + + Hedef URL'nin geçerli depodan farklı bir sahibi var. + + + Gidilecek etkin bir depo yok + + + Çalışma dosyası '{0}' konumundaki dosyadan farklı. Lütfen karşılık gelen dalı, çekme isteğini veya commit'i kullanıma alın. + + + Lütfen '{0}' deposunu açın ve yeniden deneyin + + + '{0}' kaynağından açılamadı. Şu anda yalnızca depo dosyalarına bağlantı kuran URL'ler destekleniyor. + + + Panoda GitHub URL'si bulunamadı + + + Hedef URL geçerli depoda bulunamadı. Getirme işlemi gerçekleştirdikten sonra yeniden deneyin. + + + Hedef yoldaki dizin boş değil. + + + Bu konumda bir depo zaten var ancak bu deponun "origin" adlı bir uzak deposu yok. + + + Bu konumda bir depo zaten var ancak bu depo {0} adlı bir uzak depoya sahip. + + + Zaten bu konuma kopyaladınız. Yerel depoyu açmak için 'Aç' seçeneğine tıklayın. + + + Depo için GitHub URL'si bulunamıyor + + + GitHub URL'lerinin bulunması için depoların tanımlı bir "origin" adlı uzak depoya sahip olması gerekir. + + + Lütfen mevcut uzak depolarınızdan birini 'origin' olarak adlandırın veya 'origin' adlı yeni bir uzak depo ekleyip getirin. Bu işlemi komut satırından veya aşağıdaki düğmeye tıklayarak gerçekleştirebilirsiniz. + + + Depoda '{0}' öğesine karşılık gelen dosya bulunamadı. Lütfen bir 'git fetch' işlemi gerçekleştirin ya da hedef çekme isteğini kullanıma alın. + + + En son gönderilen {0} + + + Açıklama + + + Çekme isteğini kapat + + + Sorunu kapat + + + Kapat ve açıklama ekle + + + Yeniden aç ve açıklama ekle + + + Sorunu yeniden aç + + + Çekme isteğini yeniden aç + + + {0} işleme + + + birkaç işleme ekledi + + + ve diğer kişiler + + + Bu konuşma çözümlendi olarak işaretlendi + + + Depolarınız + + + Ortak çalışan depoları + + + Katkıda bulunulan depolar + + + Bir URL arayın veya girin + + + + Gözat... + + + Kopyalama + + + Hesap Ekle/Değiştir + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.zh-CN.resx b/src/GitHub.Resources/Resources.zh-CN.resx new file mode 100644 index 0000000000..386d48870c --- /dev/null +++ b/src/GitHub.Resources/Resources.zh-CN.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 为新存储库选择包含文件夹。 + + + 从 GitHub 打开 + + + 无法连接到 github.com + + + 创建 GitHub Gist + + + 创建 {0} 存储库 + + + GistFromVisualStudio.cs + + + 请输入 Enterprise URL + + + 请输入有效的 Enterprise URL + + + 不是 Enterprise 服务器。请输入 Enterprise URL + + + (忘记了密码?) + + + 请确保使用密码而非个人访问令牌进行登录。 + + + 请检查用户名和密码,然后重试 + + + 登录失败。 + + + 连接到 GitHub + + + 请输入密码 + + + 已成功创建分支 **{0}** 的拉取请求,创建时间为 [{1}] ({2}) + + + 请输入拉取请求标题 + + + 源和目标分支不能相同 + + + 源分支在远程不存在,是否已推送该分支? + + + 请输入存储库路径 + + + 请输入有效的路径 + + + 路径包含无效的字符 + + + 路径过长 + + + 存储库“{0}/{1}”已存在。 + + + 请更改存储库名称或选择其他帐户,然后重试。 + + + 已超出专用存储库配额。 + + + 此位置已存在具有相同名称的存储库 + + + 请输入存储库名称 + + + 存储库名称的长度必须少于 100 个字符。 + + + 将创建为 {0} + + + 打开设备上的双因素身份验证应用以查看你的验证码。 + + + 我们已通过短信向你发送了验证码。 + + + 需要双因素身份验证 + + + 在此处输入验证码符号 + + + 请输入用户名或电子邮件地址 + + + 用户名或电子邮件地址不能包含空格 + + + 拉取请求 + + + 拉取请求 + + + 添加 + + + 分支 + + + [无效] + + + 必须先拉取然后才能推送 + + + 没有可拉取的提交 + + + 没有可推送的提交 + + + * 未提供说明。* + + + 签出 {0} + + + 签出到 {0} + + + 从分支 {1} 拉取 {0} + + + 推送到 {0} 分支 {1} + + + 远程库 + + + 重命名 + + + 源存储库不再可用。 + + + 你的工作目录包含未提交的更改,因此无法签出。 + + + 同步 {0} 子模块 + + + 路径上找不到 Git.exe。 + +请从以下位置安装适用于 Windows 的 Git: +https://git-scm.com/download/win + + + 已批准 + + + 已请求更改 + + + 已注释 + + + 正在进行 + + + 按 Enter 以导航到编辑器 + + + 请先签出 PR 分支,然后再导航到编辑器 + + + 按 Enter 以导航到编辑器(必须签出 PR 分支) + + + 分叉存储库 + + + 切换原点 + + + 确实要取消此评审吗? 将会丢失所有挂起的评论。 + + + 取消评审 + + + 目标路径中存在一个文件。 + + + 需要注销 + + + {0:N0} 天前 + + + {0:N0} 天前 + + + {0:N0} 小时前 + + + {0:N0} 小时前 + + + 此刻 + + + {0:N0} 分钟前 + + + {0:N0} 分钟前 + + + {0:N0} 个月前 + + + {0:N0} 个月前 + + + {0:N0} 秒前 + + + {0:N0} 秒前 + + + {0:N0} 年前 + + + {0:N0} 年前 + + + 验证码无效 + + + 请尝试重新输入代码,或单击“重新发送”按钮以获取新的验证码。 + + + 已发送验证码! + + + 如果未收到验证码,请联系 support@github.com。 + + + 浏览 + + + 无法连接到 github.com + + + 无法连接到服务器。 + + + 创建 + + + 说明(可选) + + + 在浏览器中打开 + + + 取消 + + + 已创建 Gist + + + 未能创建 Gist + + + 依据 + + + 隐私 + + + 请匿名发送使用情况数据,帮助我们不断改进 + + + 无法复制到剪贴板。请重试。 + + + 复制到剪贴板的链接 + + + 已成功创建存储库。 + + + 专用 Gist + + + 文件名 + + + 你未登录到 {0},因此某些 git 操作可能会失败。[立即登录]({1}) + + + Wiki + + + 脉冲 + + + 路径 + + + 问题 + + + 关系图 + + + 发布到 GitHub + + + 用于开放源和私有项目的功能强大的协作、代码评审和代码管理。 + + + 连接... + + + 克隆 + + + 验证 + + + 双重身份验证 + + + 注册 + + + 注销 + + + 再次将代码发送到你注册的 SMS 设备 + + + 重新发送 + + + 存储库名称 + + + 此存储库没有远程库。填写此表单以将其发布到 GitHub。 + + + 发布 + + + 或者 + + + 打开设备上的双因素身份验证应用以查看你的验证码。 + + + 没有存储库 + + + 名称 + + + 专用存储库 + + + 本地路径: + + + 许可证 + + + 了解详细信息 + + + Git 忽略 + + + 搜索存储库 + + + 可能未加载部分或所有存储库。请关闭对话框,然后重试。 + + + 加载存储库时出现错误 + + + GitHub Enterprise 服务器地址 + + + 主机不可用或不是 GitHub Enterprise 服务器。请检查地址,然后重试。 + + + 用户名或电子邮件 + + + 密码 + + + 登录 + + + 请检查你的 Internet 连接,然后重试。 + + + 没有 GitHub Enterprise? + + + 没有帐户? + + + 标题(必需) + + + 描述 + + + 将此存储库发布到 GitHub 并获取用于开放源和私有项目的功能强大的协作、代码评审和代码管理。 + + + 此存储库不在 GitHub 上 + + + 未找到任何存储库 + + + 此处找不到 git 存储库。请打开 git 项目或在项目中单击“文件”->“添加到源代码管理”以开始使用。 + + + 创建帐户 + + + 筛选分支 + + + 发布到 GitHub + + + 入门 + + + 登录 + + + 登录... + + + 最新的本地分支 + + + 更改({0}) + + + 查看更改 + + + 将文件比作默认操作 + + + 查看文件 + + + 将文件作为默认操作打开 + + + 切换到列表视图 + + + 切换到树状视图 + + + 已更新 {0} + + + 查看 GitHub 上的拉取请求 + + + 欢迎使用适用于 Visual Studio 的 GitHub! 何不看看我们的[培训](show-training)或[文档](show-docs)? + +[不再显示](dont-show-again) + + + 更新 + + + 在编辑器边距上显示 PR 注释 + + + 实验性功能 + + + 这些功能在未来版本中可能会更改 + + + 查看解决方案中的更改 + + + 在解决方案中打开文件 + + + 令牌 + + + 继续你的评审 + + + 添加你的评审 + + + 审阅者 + + + 添加评审评论 + + + 添加单个注释 + + + 分支 + + + 调试 + + + 启用跟踪日志记录 + + + GitHub 扩展在 Blend 中不可用 + + + 更新评论 + + + 取消 + + + 挂起 + + + 开始评审 + + + 必须提交并推送你的更改才能在此处添加评论。 + + + 上一个评论 + + + 下一个评论 + + + 查看、签出或创建拉取请求 + + + 上一步 + + + 分叉存储库 + + + 创建存储库分支 + + + 更新本地存储库的 + + + 指向 + + + 创建拉取请求 + + + 登录到 GitHub + + + 拉取 + + + 推送 + + + 同步 + + + 已写入 + + + 提交评审 + + + 评审摘要 + + + 仅注释 + + + 批准 + + + 请求更改 + + + 评论 + + + 过时的注释 + + + 新建 + + + 代理人 + + + 作者 + + + 使用浏览器登录 + + + 打开 + + + 按作者筛选 + + + 选择分支 + + + 是否确实要删除此评论? + + + 删除评论 + + + 重试 + + + 没有任何开放的拉取请求 + + + 通过拉取请求,可告知其他人发布到 GitHub 上的存储库的更改 + + + 没有与搜索匹配的结果。 + + + 若要开始,可以 + + + 创建拉取请求 + + + 要打开 {0} 中的存储库? + + + 目标 URL 的所有者与当前存储库的所有者不同。 + + + 没有可导航的任何活动存储库 + + + 工作文件不同于 {0} 处的文件。请签出相应的分支、拉取请求或提交。 + + + 请打开存储库 {0},然后重试 + + + 无法从 {0} 打开。当前仅支持链接到存储库文件的 URL。 + + + 在剪贴板中找不到 GitHub URL + + + 当前存储库中找不到目标 URL。请执行提取后重试。 + + + 目标路径中的目录不为空。 + + + 此位置已存在一个存储库,但该存储库不具有名为 "origin" 的远程库。 + + + 此位置已存在一个存储库,但该存储库具有名为 {0} 的远程库。 + + + 已克隆到此位置。单击“打开”打开本地存储库。 + + + 找不到存储库的 GitHub URL + + + 存储库必须定义名为 "origin" 的远程库才能找到其 GitHub URL。 + + + 请将其中一个现有远程库重命名为 "origin" 或添加一个名为 "origin" 的新远程库并提取。可从命令行或通过单击下面的按钮完成此操作。 + + + 在存储库中找不到对应于 {0} 的文件。请执行 "git fetch" 或签出目标拉取请求。 + + + 最近已推送 {0} + + + 注释 + + + 关闭拉取请求 + + + 关闭问题 + + + 关闭并注释 + + + 重新打开并注释 + + + 重新打开问题 + + + 重新打开拉取请求 + + + {0} 个提交 + + + 已添加部分提交 + + + 以及其他 + + + 此对话标记为已解决 + + + 你的存储库 + + + 协作者存储库 + + + 已参与存储库 + + + 搜索或输入 URL + + + + 浏览... + + + 克隆 + + + 添加/更改帐户 + + \ No newline at end of file diff --git a/src/GitHub.Resources/Resources.zh-TW.resx b/src/GitHub.Resources/Resources.zh-TW.resx new file mode 100644 index 0000000000..4a2f52e11b --- /dev/null +++ b/src/GitHub.Resources/Resources.zh-TW.resx @@ -0,0 +1,894 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 為新的存放庫選取包含資料夾。 + + + 從 GitHub 打開 + + + 無法連線到 github.com + + + 建立 GitHub Gist + + + 創建 {0} 存儲庫 + + + GistFromVisualStudio.cs + + + 請輸入 Enterprise URL + + + 請輸入有效的 Enterprise URL + + + 非 Enterprise 伺服器。請輸入 Enterprise URL + + + (忘記密碼?) + + + 請務必使用您的密碼,而非個人存取權杖來登入。 + + + 請檢查您的使用者名稱及密碼,然後再試一次 + + + 登入失敗。 + + + 連線到 GitHub + + + 請輸入您的密碼 + + + 已成功於 [{1}]({2}) 建立分支 **{0}** 的提取要求 + + + 請輸入提取要求的標題 + + + 來源及目標分支不可相同 + + + 遠端不存在來源分支,您有推送它嗎? + + + 請輸入存放庫路徑 + + + 請輸入有效的路徑 + + + 路徑包含無效字元 + + + 路徑太長 + + + 存放庫 '{0}/{1}' 已存在。 + + + 變更存放庫名稱或選取其他帳戶,然後再試一次。 + + + 已超過私人的存放庫配額。 + + + 此位置已存在同名的存放庫 + + + 請輸入存放庫名稱 + + + 存放庫名稱必須少於 100 個字元 + + + 將會建立為 {0} + + + 開啟您裝置上的雙重要素驗證應用程式,即可檢視驗證碼。 + + + 我們會透過簡訊傳送內含驗證碼的訊息給您。 + + + 需要雙重要素驗證 + + + 在此處輸入登入驗證碼 + + + 請輸入您的使用者名稱或電子郵件地址 + + + 使用者名稱或電子郵件地址不得有空格 + + + 提取要求 + + + 提取要求 + + + 新增 + + + 分支 + + + [無效] + + + 您必須先提取才能推送 + + + 沒有任何可供提取的認可 + + + 沒有任何可供推送的認可 + + + *未提供任何描述。* + + + 簽出 {0} + + + 簽出 {0} + + + 從 {0} 分支 {1} 提取 + + + 推送至 {0} 分支 {1} + + + 遠端存放庫 + + + 重新命名 + + + 來源存放庫已無法再使用。 + + + 因為您的工作目錄具有未認可的變更,所以無法簽出。 + + + 同步 {0} 子模組 + + + 在路徑上找不到 Git.exe。 + +請從以下網址安裝 Git for Windows: +https://git-scm.com/download/win + + + 已核准 + + + 已要求變更 + + + 已加上註解 + + + InProgress + + + 按 ENTER 可瀏覽至 [編輯器] + + + 在巡覽至 [編輯器] 前,簽出 PR 分支 + + + 按 ENTER 可瀏覽至 [編輯器] (必須簽出 PR 分支) + + + 分支存放庫 + + + 切換原點 + + + 確定要取消此檢閱嗎? 您將失去所有暫止的註解。 + + + 取消檢閱 + + + 檔案存在於目的地路徑。 + + + 必須登出 + + + {0:N0} 天前 + + + {0:N0} 天前 + + + {0:N0} 小時前 + + + {0:N0} 小時前 + + + 現在 + + + {0:N0} 分鐘前 + + + {0:N0} 分鐘前 + + + {0:N0} 個月前 + + + {0:N0} 個月前 + + + {0:N0} 秒前 + + + {0:N0} 秒前 + + + {0:N0} 年前 + + + {0:N0} 年前 + + + 驗證碼無效 + + + 請嘗試再次輸入驗證碼,或按一下 [重新傳送] 按鈕取得新的驗證碼。 + + + 已傳送驗證碼! + + + 若您無法收到驗證碼,請連絡 support@github.com。 + + + 瀏覽 + + + 無法連線到 github.com + + + 無法連線到伺服器。 + + + 建立 + + + 描述 (選擇性) + + + 以瀏覽器開啟 + + + 取消 + + + 已建立 Gist + + + 無法建立 Gist + + + 透過 + + + 隱私權 + + + 傳送匿名使用方式資料來協助我們改善 + + + 無法複製到剪貼簿。請再試一次。 + + + 複製到剪貼簿的連結 + + + 已成功建立存放庫。 + + + 私人 Gist + + + 檔案名稱 + + + 您未登入 {0},因此特定的 git 作業可能會失敗。[立即登入]({1}) + + + Wiki + + + 動態 + + + 路徑 + + + 問題 + + + 圖形 + + + 發佈至 GitHub + + + 為開放原始碼和私人專案提供之強大的共同作業、程式碼檢閱和程式碼管理。 + + + 連線... + + + 複製 + + + 驗證 + + + 雙重要素驗證 + + + 註冊 + + + 登出 + + + 再次傳送代碼到您已註冊的 SMS 裝置 + + + 重送 + + + 儲存機制名稱 + + + 此存放庫沒有遠端存放庫。請填寫表單以將其發佈至 GitHub。 + + + 發行 + + + + + + 開啟您裝置上的雙重要素驗證應用程式,即可檢視驗證碼。 + + + 沒有任何存放庫 + + + 名稱 + + + 私人存放庫 + + + 本機路徑: + + + 授權 + + + 進一步了解 + + + Git 忽略 + + + 搜尋存放庫 + + + 可能未載入部分或所有存放庫。請關閉對話方塊,並再試一次。 + + + 載入存放庫時發生錯誤 + + + GitHub Enterprise 伺服器位址 + + + 主機無法使用,或其不是 GitHub Enterprise 伺服器。請檢查位址,並再試一次。 + + + 使用者名稱或電子郵件 + + + 密碼 + + + 登入 + + + 請檢查您的網際網路連線,並再試一次。 + + + 沒有 GitHub Enterprise 嗎? + + + 沒有帳戶嗎? + + + 標題 (必要項) + + + 描述 + + + 將此存放庫發佈至 GitHub,為開放原始碼和私人專案取得強大的共同作業、程式碼檢閱和程式碼管理。 + + + 此存放庫不在 GitHub 上 + + + 沒有任何存放庫 + + + 這裡找不到 git 存放庫。請開啟 git 專案,或按一下專案中的 [檔案] -> [新增到原始檔控制],以便開始使用。 + + + 建立帳戶 + + + 篩選分支 + + + 發佈至 GitHub + + + 開始使用 + + + 登入 + + + 登入... + + + 最新的本機分支 + + + 變更 ({0}) + + + 檢視變更 + + + 以比較檔案作為預設動作 + + + 檢視檔案 + + + 開啟檔案作為預設動作 + + + 切換至清單檢視 + + + 切換至樹狀檢視 + + + 已更新 {0} + + + 檢視 GitHub 上的提取要求 + + + 歡迎使用適用於 Visual Studio 的 GitHub! 何不來看看我們的 [訓練](顯示訓練) 或 [文件](顯示文件)? + +[不要再顯示此項目](不要再顯示) + + + 已更新 + + + 在編輯器邊界上顯示 PR 註解 + + + 實驗性功能 + + + 這些功能在未來的版本中可能會變更 + + + 檢視解決方案中的變更 + + + 在解決方案中開啟檔案 + + + 語彙基元 + + + 繼續您的檢閱 + + + 新增您的檢閱 + + + 檢閱者 + + + 新增檢閱註解 + + + 新增單一註解 + + + 分支 + + + 偵錯 + + + 啟用追蹤記錄 + + + 無法在 Blend 內使用 GitHub 延伸模組 + + + 更新註解 + + + 取消 + + + 暫止 + + + 開始檢閱 + + + 您必須認可並推送變更,以在這裡新增註解。 + + + 上一個註解 + + + 下一個註解 + + + 檢視、簽出或建立提取要求 + + + 上一步 + + + 分支存放庫 + + + 派生存放庫 + + + 更新您本機存放庫的 + + + 指向 + + + 建立提取要求 + + + 登入 GitHub + + + 提取 + + + 推送 + + + 同步 + + + 已寫入 + + + 提交您的檢閱 + + + 您的檢閱摘要 + + + 僅限註解 + + + 核准 + + + 要求變更 + + + 註解 + + + 過期的註解 + + + 建立新的 + + + 受託人 + + + 作者 + + + 使用您的瀏覽器登入 + + + 開啟 + + + 依作者篩選 + + + 選取分支 + + + 您確定要刪除這個註解嗎? + + + 刪除註解 + + + 重試 + + + 沒有任何未處理的提取要求 + + + 提取要求可讓您告訴其他人您推送至 GitHub 上存放庫的變更 + + + 沒有任何結果符合您的搜尋。 + + + 若要開始使用,您可以 + + + 建立提取要求 + + + 要在 '{0}' 開啟存放庫嗎? + + + 目標 URL 與目前存放庫的擁有者不同。 + + + 沒有任何使用中的存放庫可供瀏覽 + + + 工作檔案與 '{0}' 中的檔案不同。請簽出對應的分支、提取要求或認可。 + + + 請開啟存放庫 '{0}',並再試一次 + + + 無法從 '{0}' 開啟。目前僅支援連結到存放庫檔案的 URL。 + + + 在剪貼簿中找不到 GitHub URL + + + 在目前存放庫中找不到目標 URL。請在執行擷取後再試一次。 + + + 在目的地路徑中的目錄並非空白。 + + + 存放庫已存在於此位置,但其不具有名為 "origin" 的遠端存放庫。 + + + 存放庫已存在於此位置,但其具有 {0} 的遠端存放庫。 + + + 您已複製到此位置。按一下 [開啟] 可開啟本機存放庫。 + + + 找不到存放庫的 GitHub URL + + + 存放庫必須定義名為 "origin" 的遠端存放庫,才能尋找其 GitHub URL。 + + + 請將其中一個現有的遠端存放庫重新命名為 'origin',或新增名為 'origin' 的遠端並擷取。此作業可從命令列或按一下下方的按鈕完成。 + + + 找不到存放庫中對應 '{0}' 的檔案。請執行 'git fetch' 或簽出目標提取要求。 + + + {0} 最近一次推送 + + + 註解 + + + 關閉提取要求 + + + 關閉問題 + + + 關閉並加上註解 + + + 重新開啟並加上註解 + + + 重新開啟問題 + + + 重新開啟提取要求 + + + {0} 項認可 + + + 已新增一些認可 + + + 及其他 + + + 此交談已標示為已解決 + + + 您的存放庫 + + + 共同作業者存放庫 + + + 已提供給存放庫 + + + 搜尋或輸入 URL + + + + 瀏覽... + + + 複製 + + + 新增/變更帳戶 + + \ No newline at end of file diff --git a/src/GitHub.Services.Vssdk/Commands/MenuCommandServiceExtensions.cs b/src/GitHub.Services.Vssdk/Commands/MenuCommandServiceExtensions.cs new file mode 100644 index 0000000000..f06dcc7d0f --- /dev/null +++ b/src/GitHub.Services.Vssdk/Commands/MenuCommandServiceExtensions.cs @@ -0,0 +1,100 @@ +using System; +using System.ComponentModel.Design; +using System.Windows.Input; +using GitHub.Commands; +using GitHub.Extensions; +using Microsoft.VisualStudio.Shell; + +namespace GitHub.Services.Vssdk.Commands +{ + /// + /// Extension methods for . + /// + public static class MenuCommandServiceExtensions + { + /// + /// Adds s or s to a menu. + /// + /// The menu command service. + /// The commands to add. + public static void AddCommands( + this IMenuCommandService service, + params IVsCommandBase[] commands) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(commands, nameof(commands)); + + foreach (MenuCommand command in commands) + { + service.AddCommand(command); + } + } + + /// + /// Binds an to a Visual Studio command. + /// + /// The menu command service. + /// The ID of the visual studio command. + /// The to bind + /// + /// If true, the visual studio command will be hidden when disabled. + /// + /// + /// This method wires up the to be executed when the Visual Studio + /// command is invoked, and for the 's + /// state to control the enabled/visible state of + /// the Visual Studio command. + /// + /// + /// The created . + /// + public static OleMenuCommand BindCommand( + this IMenuCommandService service, + CommandID id, + ICommand command, + bool hideWhenDisabled = false) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(id, nameof(id)); + Guard.ArgumentNotNull(command, nameof(command)); + + var bound = new BoundCommand(id, command, hideWhenDisabled); + service.AddCommand(bound); + return bound; + } + + class BoundCommand : OleMenuCommand + { + readonly ICommand inner; + readonly bool hideWhenDisabled; + + public BoundCommand(CommandID id, ICommand command, bool hideWhenDisabled) + : base(InvokeHandler, delegate { }, HandleBeforeQueryStatus, id) + { + Guard.ArgumentNotNull(id, nameof(id)); + Guard.ArgumentNotNull(command, nameof(command)); + + inner = command; + this.hideWhenDisabled = hideWhenDisabled; + inner.CanExecuteChanged += (s, e) => HandleBeforeQueryStatus(this, e); + } + + static void InvokeHandler(object sender, EventArgs e) + { + var command = sender as BoundCommand; + command?.inner.Execute((e as OleMenuCmdEventArgs)?.InValue); + } + + static void HandleBeforeQueryStatus(object sender, EventArgs e) + { + var command = sender as BoundCommand; + + if (command != null) + { + command.Enabled = command.inner.CanExecute(null); + command.Visible = command.hideWhenDisabled ? command.Enabled : true; + } + } + } + } +} diff --git a/src/GitHub.Services.Vssdk/Commands/VsCommand.cs b/src/GitHub.Services.Vssdk/Commands/VsCommand.cs new file mode 100644 index 0000000000..0c3e3da031 --- /dev/null +++ b/src/GitHub.Services.Vssdk/Commands/VsCommand.cs @@ -0,0 +1,85 @@ +using System; +using System.Windows.Input; +using GitHub.Commands; +using GitHub.Extensions; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.Services.Vssdk.Commands +{ + /// + /// Implements for s that don't accept a + /// parameter. + /// + /// + /// + /// This class derives from and implements + /// so that the command can be bound in the UI. + /// + /// + /// To implement a new command, inherit from this class and override the + /// method to provide the implementation of the command. + /// + /// + public abstract class VsCommand : VsCommandBase, IVsCommand + { + /// + /// Initializes a new instance of the class. + /// + /// The GUID of the group the command belongs to. + /// The numeric identifier of the command. + protected VsCommand(Guid commandSet, int commandId) + : base(commandSet, commandId) + { + } + + /// + /// Overridden by derived classes with the implementation of the command. + /// + /// A task that tracks the execution of the command. + public abstract Task Execute(); + + /// + protected sealed override void ExecuteUntyped(object parameter) + { + Execute().Forget(); + } + } + + /// + /// Implements for s that accept a parameter. + /// + /// The type of the parameter accepted by the command. + /// + /// This class derives from and implements + /// so that the command can be bound in the UI. + /// + /// + /// To implement a new command, inherit from this class and override the + /// method to provide the implementation of the command. + /// + public abstract class VsCommand : VsCommandBase, IVsCommand, ICommand + { + /// + /// Initializes a new instance of the class. + /// + /// The GUID of the group the command belongs to. + /// The numeric identifier of the command. + protected VsCommand(Guid commandSet, int commandId) + : base(commandSet, commandId) + { + } + + /// + /// Overridden by derived classes with the implementation of the command. + /// + /// /// The command parameter. + /// A task that tracks the execution of the command. + public abstract Task Execute(TParam parameter); + + /// + protected sealed override void ExecuteUntyped(object parameter) + { + Execute((TParam)parameter).Forget(); + } + } +} diff --git a/src/GitHub.Services.Vssdk/Commands/VsCommandBase.cs b/src/GitHub.Services.Vssdk/Commands/VsCommandBase.cs new file mode 100644 index 0000000000..ff8c8ecc38 --- /dev/null +++ b/src/GitHub.Services.Vssdk/Commands/VsCommandBase.cs @@ -0,0 +1,78 @@ +using System; +using System.ComponentModel.Design; +using System.Windows.Input; +using GitHub.Commands; +using Microsoft.VisualStudio.Shell; + +#pragma warning disable CA1033 // Interface methods should be callable by child types + +namespace GitHub.Services.Vssdk.Commands +{ + /// + /// Base class for and . + /// + public abstract class VsCommandBase : OleMenuCommand, IVsCommandBase + { + EventHandler canExecuteChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The GUID of the group the command belongs to. + /// The numeric identifier of the command. + protected VsCommandBase(Guid commandSet, int commandId) + : base(ExecHandler, delegate { }, QueryStatusHandler, new CommandID(commandSet, commandId)) + { + } + + /// + event EventHandler ICommand.CanExecuteChanged + { + add { canExecuteChanged += value; } + remove { canExecuteChanged -= value; } + } + + /// + bool ICommand.CanExecute(object parameter) + { + QueryStatus(); + return Enabled && Visible; + } + + /// + void ICommand.Execute(object parameter) + { + ExecuteUntyped(parameter); + } + + /// + /// When overridden in a derived class, executes the command after casting the passed + /// parameter to the correct type. + /// + /// The parameter + protected abstract void ExecuteUntyped(object parameter); + + protected override void OnCommandChanged(EventArgs e) + { + base.OnCommandChanged(e); + canExecuteChanged?.Invoke(this, e); + } + + protected virtual void QueryStatus() + { + } + + static void ExecHandler(object sender, EventArgs e) + { + var args = (OleMenuCmdEventArgs)e; + var command = sender as VsCommandBase; + command?.ExecuteUntyped(args.InValue); + } + + static void QueryStatusHandler(object sender, EventArgs e) + { + var command = sender as VsCommandBase; + command?.QueryStatus(); + } + } +} diff --git a/src/GitHub.Services.Vssdk/GitHub.Services.Vssdk.csproj b/src/GitHub.Services.Vssdk/GitHub.Services.Vssdk.csproj new file mode 100644 index 0000000000..64dd70a510 --- /dev/null +++ b/src/GitHub.Services.Vssdk/GitHub.Services.Vssdk.csproj @@ -0,0 +1,24 @@ + + + net46 + full + true + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.Services.Vssdk/Services/TippingService.cs b/src/GitHub.Services.Vssdk/Services/TippingService.cs new file mode 100644 index 0000000000..e743f0d241 --- /dev/null +++ b/src/GitHub.Services.Vssdk/Services/TippingService.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Windows; +using GitHub.Logging; +using Microsoft; +using Serilog; +using IServiceProvider = System.IServiceProvider; + +namespace GitHub.Services.Vssdk.Services +{ + /// + /// This service is a thin wrapper around . + /// + /// + /// The interface is public, but contained within the 'Microsoft.VisualStudio.Shell.UI.Internal' assembly. + /// To avoid a direct dependency on 'Microsoft.VisualStudio.Shell.UI.Internal', we use reflection to call this service. + /// + public class TippingService : ITippingService + { + static readonly ILogger log = LogManager.ForContext(); + + // This is the only supported ClientId + public static readonly Guid ClientId = new Guid("D5D3B674-05BB-4942-B8EC-C3D13B5BD6EE"); + public static readonly Guid IVsTippingServiceGuid = new Guid("756F1DC9-47FA-42C5-9C06-252B54148EB8"); + + readonly IServiceProvider serviceProvider; + + public TippingService(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + /// + public void RequestCalloutDisplay(Guid calloutId, string title, string message, + bool isPermanentlyDismissible, FrameworkElement targetElement, + Guid vsCommandGroupId, uint vsCommandId) + { + var screenPoint = !Splat.ModeDetector.InUnitTestRunner() ? + targetElement.PointToScreen(new Point(targetElement.ActualWidth / 2, 0)) : default; + var point = new Microsoft.VisualStudio.OLE.Interop.POINT { x = (int)screenPoint.X, y = (int)screenPoint.Y }; + RequestCalloutDisplay(ClientId, calloutId, title, message, isPermanentlyDismissible, + point, vsCommandGroupId, vsCommandId); + } + + // Available on Visual Studio 2015 + void RequestCalloutDisplay(Guid clientId, Guid calloutId, string title, string message, bool isPermanentlyDismissible, + Microsoft.VisualStudio.OLE.Interop.POINT anchor, Guid vsCommandGroupId, uint vsCommandId) + { + var tippingService = serviceProvider.GetService(typeof(SVsTippingService)); + if (tippingService == null) + { + log.Error("Can't find {ServiceType}", typeof(SVsTippingService)); + return; + } + + Assumes.Present(tippingService); + var parameterTypes = new Type[] { typeof(Guid), typeof(Guid), typeof(string), typeof(string), typeof(bool), + typeof(Microsoft.VisualStudio.OLE.Interop.POINT), typeof(Guid), typeof(uint) }; + var tippingServiceType = tippingService.GetType(); + var method = tippingServiceType.GetInterfaces() + .FirstOrDefault(i => i.GUID == IVsTippingServiceGuid)?.GetMethod("RequestCalloutDisplay", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + null, parameterTypes, null); + if (method == null) + { + log.Error("Couldn't find method on {Type} with parameters {Parameters}", tippingServiceType, parameterTypes); + return; + } + + var arguments = new object[] { clientId, calloutId, title, message, isPermanentlyDismissible, anchor, + vsCommandGroupId, vsCommandId }; + method.Invoke(tippingService, arguments); + } + } + +#pragma warning disable CA1715 // Identifiers should have correct prefix +#pragma warning disable CA1040 // Avoid empty interfaces + [Guid("DCCC6A2B-F300-4DA1-92E1-8BF4A5BCA795")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [TypeIdentifier] + [ComImport] + public interface SVsTippingService + { + } +#pragma warning restore CA1040 // Avoid empty interfaces +#pragma warning restore CA1715 // Identifiers should have correct prefix +} diff --git a/src/GitHub.StartPage/GitHub.StartPage.csproj b/src/GitHub.StartPage/GitHub.StartPage.csproj index ab18d73a9c..e3a4648d99 100644 --- a/src/GitHub.StartPage/GitHub.StartPage.csproj +++ b/src/GitHub.StartPage/GitHub.StartPage.csproj @@ -1,20 +1,15 @@  - - 14.0 + + $(VisualStudioVersion) $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) true - - Internal + - - ..\..\script\Key.snk - true - false - + Debug @@ -26,63 +21,45 @@ Properties GitHub.StartPage GitHub.StartPage - v4.6.1 + 7.3 + v4.6 true true true true true true + true + False + False + full + true - true - full false - bin\Debug\ - DEBUG;TRACE + TRACE;DEBUG prompt 4 - False - False - True - build + false + bin\Debug\ - pdbonly true - bin\Release\ TRACE prompt 4 - False - False - True - build + false + bin\Release\ Properties\SolutionInfo.cs + - - Key.snk - - - - Designer - - - Designer - - - - - {1ce2d235-8072-4649-ba5a-cfb1af8776e0} - ReactiveUI_Net45 - {e4ed0537-d1d9-44b6-9212-3096d7c3f7a1} GitHub.Exports.Reactive @@ -91,6 +68,10 @@ {9aea02db-02b5-409c-b0ca-115d05331a6b} GitHub.Exports + + {8d73575a-a89f-47cc-b153-b47dd06837f0} + GitHub.Logging + {346384dd-2445-4a28-af22-b45f3957bd89} GitHub.UI @@ -103,95 +84,38 @@ ..\..\lib\15.0\Microsoft.TeamFoundation.Git.Controls.dll - - ..\..\packages\Microsoft.VisualStudio.CoreUtility.15.0.25901-RC\lib\net45\Microsoft.VisualStudio.CoreUtility.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Imaging.15.0.25901-RC\lib\net45\Microsoft.VisualStudio.Imaging.dll - True - - - ..\..\packages\Microsoft.VisualStudio.OLE.Interop.7.10.6070\lib\Microsoft.VisualStudio.OLE.Interop.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.15.0.15.0.25901-RC\lib\Microsoft.VisualStudio.Shell.15.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Framework.15.0.25901-RC\lib\net45\Microsoft.VisualStudio.Shell.Framework.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Immutable.10.0.10.0.30319\lib\net40\Microsoft.VisualStudio.Shell.Immutable.10.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.7.10.6071\lib\Microsoft.VisualStudio.Shell.Interop.dll - True - - - True - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.10.0.10.0.30319\lib\Microsoft.VisualStudio.Shell.Interop.10.0.dll - True - - - True - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.11.0.11.0.61030\lib\Microsoft.VisualStudio.Shell.Interop.11.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.Shell.Interop.8.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.9.0.9.0.30729\lib\Microsoft.VisualStudio.Shell.Interop.9.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6070\lib\Microsoft.VisualStudio.TextManager.Interop.dll - True - - - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Threading.15.0.20-pre\lib\net45\Microsoft.VisualStudio.Threading.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Utilities.15.0.25901-RC\lib\net45\Microsoft.VisualStudio.Utilities.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Validation.15.0.11-pre\lib\net45\Microsoft.VisualStudio.Validation.dll - True - - - ..\..\packages\Rx-Core.2.2.5-custom\lib\net45\System.Reactive.Core.dll - True - - - ..\..\packages\Rx-Interfaces.2.2.5-custom\lib\net45\System.Reactive.Interfaces.dll - True - + + + + + + 15.8.75-pre + + + 15.8.33 + + + 15.8.3252 + runtime; build; native; contentfiles; analyzers + all + + + 2.5.0 + + + 0.12.0 + + + 4.0.0 + + - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - + \ No newline at end of file diff --git a/src/GitHub.UI.Reactive/Assets/Controls.xaml b/src/GitHub.UI.Reactive/Assets/Controls.xaml index 7308f377a9..38b5954eed 100644 --- a/src/GitHub.UI.Reactive/Assets/Controls.xaml +++ b/src/GitHub.UI.Reactive/Assets/Controls.xaml @@ -9,8 +9,6 @@ - - diff --git a/src/GitHub.UI.Reactive/Assets/Controls/BusyStateView.xaml b/src/GitHub.UI.Reactive/Assets/Controls/BusyStateView.xaml deleted file mode 100644 index fa95292b18..0000000000 --- a/src/GitHub.UI.Reactive/Assets/Controls/BusyStateView.xaml +++ /dev/null @@ -1,28 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/GitHub.UI.Reactive/Assets/Controls/ErrorMessageDisplay.xaml b/src/GitHub.UI.Reactive/Assets/Controls/ErrorMessageDisplay.xaml index 875e1d9580..95a0b02817 100644 --- a/src/GitHub.UI.Reactive/Assets/Controls/ErrorMessageDisplay.xaml +++ b/src/GitHub.UI.Reactive/Assets/Controls/ErrorMessageDisplay.xaml @@ -1,10 +1,9 @@  - - - - \ No newline at end of file diff --git a/src/GitHub.UI.Reactive/Assets/Controls/GitHubComboBox.xaml b/src/GitHub.UI.Reactive/Assets/Controls/GitHubComboBox.xaml index f1c641eb9c..0a0463db84 100644 --- a/src/GitHub.UI.Reactive/Assets/Controls/GitHubComboBox.xaml +++ b/src/GitHub.UI.Reactive/Assets/Controls/GitHubComboBox.xaml @@ -1,10 +1,10 @@  + xmlns:ghfvs="https://github.com/github/VisualStudio"> M 0,6 3.5,11 7,6 - + @@ -26,7 +26,7 @@ - - + @@ -60,7 +60,7 @@ - + - + + + + + + + + - + diff --git a/src/GitHub.UI.Reactive/Controls/TwoFactorInput.xaml.cs b/src/GitHub.UI.Reactive/Controls/TwoFactorInput.xaml.cs index 9377f6b65a..396d09abc1 100644 --- a/src/GitHub.UI.Reactive/Controls/TwoFactorInput.xaml.cs +++ b/src/GitHub.UI.Reactive/Controls/TwoFactorInput.xaml.cs @@ -4,16 +4,14 @@ using System.Windows.Controls; using System.Windows.Input; using GitHub.Extensions; -using NullGuard; using System.Globalization; -using GitHub.VisualStudio.TestAutomation; namespace GitHub.UI { public class TwoFactorInputToTextBox : ValueConverterMarkupExtension { - public override object Convert([AllowNull] object value, [AllowNull] Type targetType, - [AllowNull] object parameter, [AllowNull] CultureInfo culture) + public override object Convert(object value, Type targetType, + object parameter, CultureInfo culture) { return value is TwoFactorInput ? ((TwoFactorInput)value).TextBox : null; } @@ -44,7 +42,7 @@ public TwoFactorInput() six }; - foreach(var textBox in TextBoxes) + foreach (var textBox in TextBoxes) { SetupTextBox(textBox); } @@ -57,6 +55,9 @@ public IObservable TryFocus() private void OnPaste(object sender, DataObjectPastingEventArgs e) { + Guard.ArgumentNotNull(sender, nameof(sender)); + Guard.ArgumentNotNull(e, nameof(e)); + var isText = e.SourceDataObject.GetDataPresent(DataFormats.Text, true); if (!isText) return; @@ -80,21 +81,21 @@ void SetText(string text) var digits = text.Where(Char.IsDigit).ToList(); for (int i = 0; i < Math.Min(6, digits.Count); i++) { - TextBoxes[i].Text = digits[i].ToString(); + TextBoxes[i].Text = digits[i].ToString(CultureInfo.InvariantCulture); } SetValue(TextProperty, String.Join("", digits)); } - [AllowNull] public string Text { - [return: AllowNull] get { return (string)GetValue(TextProperty); } set { SetText(value); } } private void SetupTextBox(TextBox textBox) { + Guard.ArgumentNotNull(textBox, nameof(textBox)); + DataObject.AddPastingHandler(textBox, new DataObjectPastingEventHandler(OnPaste)); textBox.GotFocus += (sender, args) => textBox.SelectAll(); @@ -150,7 +151,7 @@ private void SetupTextBox(TextBox textBox) textBox.SelectAll(); } }; - + textBox.TextChanged += (sender, args) => { SetValue(TextProperty, String.Join("", GetTwoFactorCode())); @@ -169,7 +170,7 @@ bool MovePrevious() return MoveFocus(FocusNavigationDirection.Previous); } - bool MoveFocus(FocusNavigationDirection navigationDirection) + static bool MoveFocus(FocusNavigationDirection navigationDirection) { var traversalRequest = new TraversalRequest(navigationDirection); var keyboardFocus = Keyboard.FocusedElement as UIElement; @@ -183,6 +184,8 @@ bool MoveFocus(FocusNavigationDirection navigationDirection) private static string GetTextBoxValue(TextBox textBox) { + Guard.ArgumentNotNull(textBox, nameof(textBox)); + return String.IsNullOrEmpty(textBox.Text) ? " " : textBox.Text; } diff --git a/src/GitHub.UI.Reactive/Controls/Validation/UserErrorMessages.cs b/src/GitHub.UI.Reactive/Controls/Validation/UserErrorMessages.cs index 2694627aff..6a18dc42ff 100644 --- a/src/GitHub.UI.Reactive/Controls/Validation/UserErrorMessages.cs +++ b/src/GitHub.UI.Reactive/Controls/Validation/UserErrorMessages.cs @@ -1,20 +1,21 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Reactive.Disposables; using System.Reactive.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Media; -using GitHub.Extensions; using GitHub.Extensions.Reactive; -using NullGuard; using ReactiveUI; +using ReactiveUI.Legacy; namespace GitHub.UI { public class UserErrorMessages : UserControl { + [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] readonly IDisposable whenAnyShowingMessage; + + [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] readonly IDisposable whenAnyDataContext; public UserErrorMessages() @@ -32,24 +33,11 @@ public UserErrorMessages() { DataContext = result; }); - - Unloaded += (o, e) => - { - if (whenAnyShowingMessage != null) - { - whenAnyShowingMessage.Dispose(); - } - if (whenAnyDataContext != null) - { - whenAnyDataContext.Dispose(); - } - }; } - public static readonly DependencyProperty IconMarginProperty = DependencyProperty.Register("IconMargin", typeof(Thickness), typeof(UserErrorMessages), new PropertyMetadata(new Thickness(0,0,8,0))); + public static readonly DependencyProperty IconMarginProperty = DependencyProperty.Register("IconMargin", typeof(Thickness), typeof(UserErrorMessages), new PropertyMetadata(new Thickness(0, 0, 8, 0))); public Thickness IconMargin { - [return: AllowNull] get { return (Thickness)GetValue(IconMarginProperty); } set { SetValue(IconMarginProperty, value); } } @@ -57,7 +45,6 @@ public Thickness IconMargin public static readonly DependencyProperty MessageMarginProperty = DependencyProperty.Register("MessageMargin", typeof(Thickness), typeof(UserErrorMessages)); public Thickness MessageMargin { - [return: AllowNull] get { return (Thickness)GetValue(MessageMarginProperty); } set { SetValue(MessageMarginProperty, value); } } @@ -65,7 +52,6 @@ public Thickness MessageMargin public static readonly DependencyProperty IconProperty = DependencyProperty.Register("Icon", typeof(Octicon), typeof(UserErrorMessages), new PropertyMetadata(Octicon.stop)); public Octicon Icon { - [return: AllowNull] get { return (Octicon)GetValue(IconProperty); } set { SetValue(IconProperty, value); } } @@ -73,7 +59,6 @@ public Octicon Icon public static readonly DependencyProperty FillProperty = DependencyProperty.Register("Fill", typeof(Brush), typeof(UserErrorMessages), new PropertyMetadata(new SolidColorBrush(Color.FromRgb(0xe7, 0x4c, 0x3c)))); public Brush Fill { - [return: AllowNull] get { return (Brush)GetValue(FillProperty); } set { SetValue(FillProperty, value); } } @@ -81,7 +66,6 @@ public Brush Fill public static readonly DependencyProperty ErrorMessageFontWeightProperty = DependencyProperty.Register("ErrorMessageFontWeight", typeof(FontWeight), typeof(UserErrorMessages), new PropertyMetadata(FontWeights.Normal)); public FontWeight ErrorMessageFontWeight { - [return: AllowNull] get { return (FontWeight)GetValue(ErrorMessageFontWeightProperty); } set { SetValue(ErrorMessageFontWeightProperty, value); } } @@ -93,11 +77,10 @@ public bool IsShowingMessage private set { SetValue(IsShowingMessageProperty, value); } } +#pragma warning disable CS0618 // Type or member is obsolete public static readonly DependencyProperty UserErrorProperty = DependencyProperty.Register("UserError", typeof(UserError), typeof(UserErrorMessages)); - [AllowNull] public UserError UserError { - [return: AllowNull] get { return (UserError)GetValue(UserErrorProperty); } set { SetValue(UserErrorProperty, value); } } @@ -115,5 +98,6 @@ public IDisposable RegisterHandler(IObservable clearWhen) wher .Select(x => RecoveryOptionResult.CancelOperation); }); } +#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/GitHub.UI.Reactive/Controls/Validation/ValidationMessage.cs b/src/GitHub.UI.Reactive/Controls/Validation/ValidationMessage.cs index bce75e7bd1..de1a49b7af 100644 --- a/src/GitHub.UI.Reactive/Controls/Validation/ValidationMessage.cs +++ b/src/GitHub.UI.Reactive/Controls/Validation/ValidationMessage.cs @@ -9,7 +9,6 @@ using System.Windows.Media; using GitHub.Extensions.Reactive; using GitHub.Validation; -using NullGuard; using ReactiveUI; namespace GitHub.UI @@ -58,7 +57,6 @@ public bool IsShowingMessage public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(ValidationMessage)); public string Text { - [return: AllowNull] get { return (string)GetValue(TextProperty); } private set { SetValue(TextProperty, value); } } @@ -80,7 +78,6 @@ public double TextChangeThrottle public static readonly DependencyProperty ValidatesControlProperty = DependencyProperty.Register("ValidatesControl", typeof(TextBox), typeof(ValidationMessage), new PropertyMetadata(default(TextBox))); public TextBox ValidatesControl { - [return: AllowNull] get { return (TextBox)GetValue(ValidatesControlProperty); } set { SetValue(ValidatesControlProperty, value); } } @@ -88,7 +85,6 @@ public TextBox ValidatesControl public static readonly DependencyProperty ReactiveValidatorProperty = DependencyProperty.Register("ReactiveValidator", typeof(ReactivePropertyValidator), typeof(ValidationMessage)); public ReactivePropertyValidator ReactiveValidator { - [return: AllowNull] get { return (ReactivePropertyValidator)GetValue(ReactiveValidatorProperty); } set { SetValue(ReactiveValidatorProperty, value); } } @@ -96,7 +92,6 @@ public ReactivePropertyValidator ReactiveValidator public static readonly DependencyProperty IconProperty = DependencyProperty.Register("Icon", typeof(Octicon), typeof(ValidationMessage), new PropertyMetadata(Octicon.stop)); public Octicon Icon { - [return: AllowNull] get { return (Octicon) GetValue(IconProperty); } set { SetValue(IconProperty, value); } } @@ -105,16 +100,13 @@ public Octicon Icon DependencyProperty.Register("Fill", typeof(Brush), typeof(ValidationMessage), new PropertyMetadata(new SolidColorBrush(Color.FromRgb(0xe7, 0x4c, 0x3c)))); public Brush Fill { - [return: AllowNull] get { return (Brush)GetValue(FillProperty); } set { SetValue(FillProperty, value); } } public static readonly DependencyProperty ErrorAdornerTemplateProperty = DependencyProperty.Register("ErrorAdornerTemplate", typeof(string), typeof(ValidationMessage), new PropertyMetadata("validationTemplate")); - [AllowNull] public string ErrorAdornerTemplate { - [return: AllowNull] get { return (string)GetValue(ErrorAdornerTemplateProperty); } set { SetValue(ErrorAdornerTemplateProperty, value); } } diff --git a/src/GitHub.UI.Reactive/Controls/ViewBase.cs b/src/GitHub.UI.Reactive/Controls/ViewBase.cs new file mode 100644 index 0000000000..38a95271b9 --- /dev/null +++ b/src/GitHub.UI.Reactive/Controls/ViewBase.cs @@ -0,0 +1,68 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Automation.Peers; +using GitHub.ViewModels; +using ReactiveUI; +using System.Reactive.Linq; + +namespace GitHub.UI +{ + /// + /// Base class for views. + /// + public class ViewBase : UserControl, IViewFor + where TInterface : class, IViewModel + where TImplementor : class + { + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + "ViewModel", typeof(TInterface), typeof(TImplementor), new PropertyMetadata(null)); + + /// + /// Initializes a new instance of the class. + /// + public ViewBase() + { + DataContextChanged += (s, e) => ViewModel = (TInterface)e.NewValue; + this.WhenAnyValue(x => x.ViewModel).Skip(1).Subscribe(x => DataContext = x); + } + + /// + /// Gets or sets the control's data context as a typed view model. + /// + public TInterface ViewModel + { + get { return (TInterface)GetValue(ViewModelProperty); } + set { SetValue(ViewModelProperty, value); } + } + + /// + /// Gets or sets the control's data context as a typed view model. Required for interaction + /// with ReactiveUI. + /// + TInterface IViewFor.ViewModel + { + get { return ViewModel; } + set { ViewModel = value; } + } + + /// + /// Gets or sets the control's data context. Required for interaction with ReactiveUI. + /// + object IViewFor.ViewModel + { + get { return ViewModel; } + set { ViewModel = (TInterface)value; } + } + + /// + /// Add an automation peer to views and custom controls + /// They do not have automation peers or properties by default + /// https://stackoverflow.com/questions/30198109/automationproperties-automationid-on-custom-control-not-exposed + /// + protected override AutomationPeer OnCreateAutomationPeer() + { + return new UIElementAutomationPeer(this); + } + } +} diff --git a/src/GitHub.UI.Reactive/FodyWeavers.xml b/src/GitHub.UI.Reactive/FodyWeavers.xml deleted file mode 100644 index 9321cb912f..0000000000 --- a/src/GitHub.UI.Reactive/FodyWeavers.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj b/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj index 90b2010e8d..d6f8f9274d 100644 --- a/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj +++ b/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj @@ -1,233 +1,29 @@ - - - + - Debug - AnyCPU - {158B05E8-FDBC-4D71-B871-C96E28D5ADF5} - Library - Properties + net46 GitHub - GitHub.UI.Reactive - v4.6.1 - 512 - {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - - - 4 - true - ..\common\GitHubVS.ruleset - true - Internal - bin\$(Configuration)\ - - - - true + true full - false - DEBUG;TRACE - prompt - 4 - false - ..\common\GitHubVS.ruleset - true - true - - - pdbonly - true - TRACE - prompt - 4 - false - true - true - ..\common\GitHubVS.ruleset - - true - full - false - DEBUG;TRACE;XAML_DESIGNER - prompt - 4 - false - ..\common\GitHubVS.ruleset - true - true - - - ..\..\script\Key.snk - true - false + + + - - ..\..\packages\NullGuard.Fody.1.4.6\Lib\dotnet\NullGuard.dll - False - - - - - - - ..\..\packages\Ix_Experimental-Main.1.1.10823\lib\Net4\System.Interactive.dll - - - ..\..\packages\Rx-Core.2.2.5-custom\lib\net45\System.Reactive.Core.dll - True - - - ..\..\packages\Rx-Interfaces.2.2.5-custom\lib\net45\System.Reactive.Interfaces.dll - True - - - ..\..\packages\Rx-Linq.2.2.5-custom\lib\net45\System.Reactive.Linq.dll - True - - - ..\..\packages\Rx-PlatformServices.2.2.5-custom\lib\net45\System.Reactive.PlatformServices.dll - True - - - ..\..\packages\Rx-XAML.2.2.5-custom\lib\net45\System.Reactive.Windows.Threading.dll - True - - - - - - + + + - - Key.snk - - - - - TwoFactorInput.xaml - - - - - Properties\SolutionInfo.cs - - - - - - - - - - - - - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - - - - - - - - {665078f4-5875-431d-a2cc-421849b43328} - GithubVSTestAutomationIDs - - - {600998c4-54dd-4755-bfa8-6f44544d8e2e} - ReactiveUI.Events_Net45 - - - {1ce2d235-8072-4649-ba5a-cfb1af8776e0} - ReactiveUI_Net45 - - - {252ce1c2-027a-4445-a3c2-e4d6c80a935a} - Splat-Net45 - - - {e4ed0537-d1d9-44b6-9212-3096d7c3f7a1} - GitHub.Exports.Reactive - - - {9aea02db-02b5-409c-b0ca-115d05331a6b} - GitHub.Exports - - - {6559E128-8B40-49A5-85A8-05565ED0C7E3} - GitHub.Extensions.Reactive - - - {6afe2e2d-6db0-4430-a2ea-f5f5388d2f78} - GitHub.Extensions - - - {346384DD-2445-4A28-AF22-B45F3957BD89} - GitHub.UI - + + + + - - Designer - + - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - \ No newline at end of file diff --git a/src/GitHub.UI.Reactive/Properties/AssemblyInfo.cs b/src/GitHub.UI.Reactive/Properties/AssemblyInfo.cs index 6d63eb6435..bea18cb67f 100644 --- a/src/GitHub.UI.Reactive/Properties/AssemblyInfo.cs +++ b/src/GitHub.UI.Reactive/Properties/AssemblyInfo.cs @@ -1,6 +1,13 @@ -using System.Reflection; -using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Markup; -[assembly: AssemblyTitle("GitHub.UI.Recative")] -[assembly: AssemblyDescription("GitHub flavored WPF styles and controls that require Rx and RxUI")] -[assembly: Guid("885a491c-1d13-49e7-baa6-d61f424befcb")] +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) + )] + +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.UI")] diff --git a/src/GitHub.UI.Reactive/Validation/ReactiveValidatableObject.cs b/src/GitHub.UI.Reactive/Validation/ReactiveValidatableObject.cs index bc05b96036..3a87fabe60 100644 --- a/src/GitHub.UI.Reactive/Validation/ReactiveValidatableObject.cs +++ b/src/GitHub.UI.Reactive/Validation/ReactiveValidatableObject.cs @@ -8,10 +8,11 @@ using System.Reactive.Linq; using System.Reflection; using ReactiveUI; -using NullGuard; using GitHub.Services; using GitHub.Extensions; +#pragma warning disable CA1018 // Mark attributes with AttributeUsageAttribute + namespace GitHub.Validation { public class ReactiveValidatableObject : ReactiveObject, IDataErrorInfo @@ -24,8 +25,10 @@ public class ReactiveValidatableObject : ReactiveObject, IDataErrorInfo readonly Dictionary enabledProperties = new Dictionary(); bool validationEnabled = true; - public ReactiveValidatableObject([AllowNull]IGitHubServiceProvider serviceProvider) + public ReactiveValidatableObject(IGitHubServiceProvider serviceProvider) { + Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); + validatedProperties = typeValidatorsMap.GetOrAdd(GetType(), GetValidatedProperties); this.serviceProvider = serviceProvider; // This is allowed to be null. @@ -39,6 +42,8 @@ public string this[string propertyName] { get { + Guard.ArgumentNotNull(propertyName, nameof(propertyName)); + if (!validationEnabled || !enabledProperties.ContainsKey(propertyName)) return null; string errorMessage = GetErrorMessage(propertyName); @@ -113,6 +118,8 @@ IEnumerable EnableValidationForUnvalidatedProperties() // raise a property changed event. void TriggerValidationForProperty(string propertyName) { + Guard.ArgumentNotNull(propertyName, nameof(propertyName)); + this.RaisePropertyChanged(propertyName); } @@ -124,6 +131,8 @@ void TriggerValidationForAllProperties() string GetErrorMessage(string propertyName) { + Guard.ArgumentNotEmptyString(propertyName, nameof(propertyName)); + ValidatedProperty validatedProperty; if (!validatedProperties.TryGetValue(propertyName, out validatedProperty)) return null; // TODO: This would be a good place to do default data type validation as the need arises. @@ -147,6 +156,8 @@ public void SetErrorMessage(string propertyName, string errorMessage) static Dictionary GetValidatedProperties(Type type) { + Guard.ArgumentNotNull(type, nameof(type)); + return (from property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public) let validated = new ValidatedProperty(property) where validated.Validators.Any() @@ -159,6 +170,8 @@ class ValidatedProperty public ValidatedProperty(PropertyInfo property) { + Guard.ArgumentNotNull(property, nameof(property)); + Property = property; validators = property.GetCustomAttributes(typeof(ValidationAttribute), true).Cast().ToList(); Validators = validators.Where(v => !(v is ValidateIfAttribute)); @@ -167,11 +180,16 @@ public ValidatedProperty(PropertyInfo property) public void AddValidator(ValidationAttribute validator) { + Guard.ArgumentNotNull(validator, nameof(validator)); + validators.Add(validator); } public ValidationResult GetFirstValidationError(object instance, IServiceProvider serviceProvider) { + Guard.ArgumentNotNull(instance, nameof(instance)); + Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); + var validationContext = new ValidationContext(instance, serviceProvider, null) { MemberName = Property.Name }; if (ConditionalValidation != null && !ConditionalValidation.IsValidationRequired(validationContext)) @@ -204,12 +222,18 @@ sealed class SetErrorValidator : ValidationAttribute public SetErrorValidator(string errorMessage, object originalValue) { + Guard.ArgumentNotEmptyString(errorMessage, nameof(errorMessage)); + Guard.ArgumentNotNull(originalValue, nameof(originalValue)); + this.errorMessage = errorMessage; this.originalValue = originalValue; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { + Guard.ArgumentNotNull(value, nameof(value)); + Guard.ArgumentNotNull(validationContext, nameof(validationContext)); + if (originalValue.Equals(value)) return new ValidationResult(errorMessage); diff --git a/src/GitHub.UI.Reactive/Validation/ValidateIfAttribute.cs b/src/GitHub.UI.Reactive/Validation/ValidateIfAttribute.cs index 26446fdf55..7bac18c1d3 100644 --- a/src/GitHub.UI.Reactive/Validation/ValidateIfAttribute.cs +++ b/src/GitHub.UI.Reactive/Validation/ValidateIfAttribute.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.Reflection; using GitHub.Extensions; -using NullGuard; namespace GitHub.Validation { @@ -23,15 +22,22 @@ public ValidateIfAttribute(string dependentPropertyName) DependentPropertyName = dependentPropertyName; } - protected override ValidationResult IsValid([AllowNull]object value, [AllowNull]ValidationContext validationContext) + protected override ValidationResult IsValid(object value, ValidationContext validationContext) { return ValidationResult.Success; } public bool IsValidationRequired(ValidationContext validationContext) { + Guard.ArgumentNotNull(validationContext, nameof(validationContext)); + var instance = validationContext.ObjectInstance; - Debug.Assert(instance != null, "The ValidationContext does not allow null instances."); + + if (instance == null) + { + throw new ArgumentException("ValidationContext.ObjectInstance must not be null."); + } + var property = instance.GetType().GetProperty(DependentPropertyName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (property == null || property.PropertyType != typeof(bool)) { diff --git a/src/GitHub.UI.Reactive/packages.config b/src/GitHub.UI.Reactive/packages.config deleted file mode 100644 index 7df917354b..0000000000 --- a/src/GitHub.UI.Reactive/packages.config +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.UI/Assets/Buttons.xaml b/src/GitHub.UI/Assets/Buttons.xaml deleted file mode 100644 index dd442160d7..0000000000 --- a/src/GitHub.UI/Assets/Buttons.xaml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.UI/Assets/Controls.xaml b/src/GitHub.UI/Assets/Controls.xaml index 47977c775a..0f7c550f0f 100644 --- a/src/GitHub.UI/Assets/Controls.xaml +++ b/src/GitHub.UI/Assets/Controls.xaml @@ -6,12 +6,8 @@ mc:Ignorable="d"> - - - - + - @@ -19,17 +15,10 @@ Styles for standard windows controls --> - - - - - - - + + + + + + + + + + diff --git a/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml new file mode 100644 index 0000000000..6d10b00ade --- /dev/null +++ b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.UI/Assets/Controls/LightListBox.xaml b/src/GitHub.UI/Assets/Controls/LightListBox.xaml deleted file mode 100644 index 0e532890d5..0000000000 --- a/src/GitHub.UI/Assets/Controls/LightListBox.xaml +++ /dev/null @@ -1,99 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/GitHub.UI/Assets/Controls/RoundedCheckBox.xaml b/src/GitHub.UI/Assets/Controls/RoundedCheckBox.xaml deleted file mode 100644 index daf534e8a3..0000000000 --- a/src/GitHub.UI/Assets/Controls/RoundedCheckBox.xaml +++ /dev/null @@ -1,328 +0,0 @@ - - - - M 12,4.5 7,9.5 5,7.5 3.5,9 7,12.5 13.5,6 z - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.UI/Assets/Controls/ScrollViewerWithShadow.xaml b/src/GitHub.UI/Assets/Controls/ScrollViewerWithShadow.xaml index 695c731a70..7972420853 100644 --- a/src/GitHub.UI/Assets/Controls/ScrollViewerWithShadow.xaml +++ b/src/GitHub.UI/Assets/Controls/ScrollViewerWithShadow.xaml @@ -222,118 +222,6 @@ - - - diff --git a/src/GitHub.UI/Assets/TextBlocks.xaml b/src/GitHub.UI/Assets/TextBlocks.xaml deleted file mode 100644 index 4667085fc1..0000000000 --- a/src/GitHub.UI/Assets/TextBlocks.xaml +++ /dev/null @@ -1,283 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.UI/Controls/AppendingPathTextBox.cs b/src/GitHub.UI/Controls/AppendingPathTextBox.cs index 8ebc340dcd..52299831c4 100644 --- a/src/GitHub.UI/Controls/AppendingPathTextBox.cs +++ b/src/GitHub.UI/Controls/AppendingPathTextBox.cs @@ -1,7 +1,6 @@ using System; using System.Windows; using System.Windows.Controls; -using NullGuard; namespace GitHub.UI { @@ -12,14 +11,12 @@ public class AppendingPathTextBox : TextBox public string ParentFolderPath { - [return: AllowNull] get { return (string)GetValue(ParentFolderPathProperty); } set { SetValue(ParentFolderPathProperty, value); } } public string ChildFolderName { - [return: AllowNull] get { return (string)GetValue(ChildFolderNameProperty); } set { SetValue(ChildFolderNameProperty, value); } } @@ -29,7 +26,6 @@ public string ChildFolderName public Visibility PathSeparatorVisibility { - [return: AllowNull] get { return (Visibility)GetValue(PathSeparatorVisibilityProperty); } set { SetValue(PathSeparatorVisibilityProperty, value); } } @@ -39,7 +35,6 @@ public Visibility PathSeparatorVisibility public Visibility ChildFolderVisibility { - [return: AllowNull] get { return (Visibility)GetValue(ChildFolderVisibilityProperty); } set { SetValue(ChildFolderVisibilityProperty, value); } } diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs new file mode 100644 index 0000000000..013c3e40e9 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -0,0 +1,1616 @@ +// (c) Copyright Microsoft Corporation. +// (c) Copyright GitHub, Inc. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Markup; +using System.Windows.Media; +using GitHub.Extensions; +using GitHub.Helpers; +using GitHub.Models; +using GitHub.Services; +using GitHub.UI.Controls; +using GitHub.UI.Controls.AutoCompleteBox; +using GitHub.UI.Helpers; +using ReactiveUI; +using Control = System.Windows.Controls.Control; +using KeyEventArgs = System.Windows.Input.KeyEventArgs; + +namespace GitHub.UI +{ + /// + /// Represents a control that provides a text box for user input and a + /// drop-down that contains possible matches based on the input in the text + /// box. + /// + [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", + Justification = "It's a control. It'll be disposed when the app shuts down.")] + [SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", + Justification = "Large implementation keeps the components contained.")] + [ContentProperty("ItemsSource")] + public class AutoCompleteBox : Control, IUpdateVisualState, IPopupTarget + { + private const string elementSelector = "Selector"; + private const string elementPopup = "Popup"; + private const string elementTextBoxStyle = "TextBoxStyle"; + private const string elementItemContainerStyle = "ItemContainerStyle"; + + private readonly IDictionary eventSubscriptions = new Dictionary(); + private List suggestions; // local cached copy of the items data. + + /// + // Gets or sets the observable collection that contains references to + // all of the items in the generated view of data that is provided to + /// the selection-style control adapter. + /// + private ObservableCollection view; + + /// + /// Gets or sets a value to ignore a number of pending change handlers. + /// The value is decremented after each use. This is used to reset the + /// value of properties without performing any of the actions in their + /// change handlers. + /// + /// The int is important as a value because the TextBox + /// TextChanged event does not immediately fire, and this will allow for + /// nested property changes to be ignored. + private int ignoreTextPropertyChange; + private bool ignorePropertyChange; // indicates whether to ignore calling pending change handlers. + private bool userCalledPopulate; // indicates whether the user initiated the current populate call. + private bool popupHasOpened; // A value indicating whether the popup has been opened at least once. + // Helper that provides all of the standard interaction functionality. Making it internal for subclass access. + internal InteractionHelper Interaction { get; set; } + // BindingEvaluator that provides updated string values from a single binding. + /// A weak event listener for the collection changed event. + private WeakEventListener collectionChangedWeakEventListener; + bool supportsShortcutOriginalValue; // Used to save whether the text input allows shortcuts or not. + readonly Subject populatingSubject = new Subject(); + readonly IDpiManager dpiManager; + + /// + /// Initializes a new instance of the + /// class. + /// + public AutoCompleteBox() : this(DpiManager.Instance) + { + } + + public AutoCompleteBox(IDpiManager dpiManager) + { + Guard.ArgumentNotNull(dpiManager, "dpiManager"); + + CompletionOffset = 0; + IsEnabledChanged += ControlIsEnabledChanged; + Interaction = new InteractionHelper(this); + + // Creating the view here ensures that View is always != null + ClearView(); + + Populating = populatingSubject; + + Populating + .SelectMany(_ => + { + var advisor = Advisor ?? EmptyAutoCompleteAdvisor.Instance; + return advisor.GetAutoCompletionSuggestions(Text, TextBox.CaretIndex); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(result => + { + CompletionOffset = result.Offset; + ItemsSource = result.Suggestions; + PopulateComplete(); + }); + this.dpiManager = dpiManager; + } + + public IObservable Populating { get; private set; } + + public int CompletionOffset + { + get { return (int)GetValue(CompletionOffsetProperty); } + set { SetValue(CompletionOffsetProperty, value); } + } + + // Using a DependencyProperty as the backing store for CompletionOffset. This enables animation, styling, binding, etc... + public static readonly DependencyProperty CompletionOffsetProperty = + DependencyProperty.Register( + "CompletionOffset", + typeof(int), + typeof(AutoCompleteBox), + new PropertyMetadata(0)); + + public Point PopupPosition + { + get + { + var position = TextBox.GetPositionFromCharIndex(CompletionOffset); + var dpi = dpiManager.CurrentDpi; + double verticalOffset = 5.0 - TextBox.Margin.Bottom; + position.Offset(0, verticalOffset); // Vertically pad it. Yeah, Point is mutable. WTF? + return dpi.Scale(position); + } + } + + /// + /// Gets or sets the minimum delay, in milliseconds, after text is typed + /// in the text box before the + /// control + /// populates the list of possible matches in the drop-down. + /// + /// The minimum delay, in milliseconds, after text is typed in + /// the text box, but before the + /// populates + /// the list of possible matches in the drop-down. The default is 0. + /// The set value is less than 0. + public int MinimumPopulateDelay + { + get { return (int)GetValue(MinimumPopulateDelayProperty); } + set { SetValue(MinimumPopulateDelayProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty MinimumPopulateDelayProperty = + DependencyProperty.Register( + "MinimumPopulateDelay", + typeof(int), + typeof(AutoCompleteBox), + new PropertyMetadata(OnMinimumPopulateDelayPropertyChanged)); + + /// + /// MinimumPopulateDelayProperty property changed handler. Any current + /// dispatcher timer will be stopped. The timer will not be restarted + /// until the next TextUpdate call by the user. + /// + /// AutoCompleteTextBox that changed its + /// MinimumPopulateDelay. + /// Event arguments. + [SuppressMessage("Microsoft.Usage", "CA2208:InstantiateArgumentExceptionsCorrectly", + Justification = "The exception is most likely to be called through the CLR property setter.")] + private static void OnMinimumPopulateDelayPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + + if (source == null) return; + + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + int newValue = (int)e.NewValue; + if (newValue < 0) + { + source.ignorePropertyChange = true; + d.SetValue(e.Property, e.OldValue); + + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, + "Invalid value '{0}' for MinimumPopulateDelay", newValue), "e"); + } + + // Resubscribe to TextBox changes with new delay. The easiest way is to just set the TextBox to itself. + var textBox = source.TextBox; + source.TextBox = null; + source.TextBox = textBox; + } + + /// + /// Gets or sets the used + /// to display each item in the drop-down portion of the control. + /// + /// The used to + /// display each item in the drop-down. The default is null. + /// + /// You use the ItemTemplate property to specify the visualization + /// of the data objects in the drop-down portion of the AutoCompleteBox + /// control. If your AutoCompleteBox is bound to a collection and you + /// do not provide specific display instructions by using a + /// DataTemplate, the resulting UI of each item is a string + /// representation of each object in the underlying collection. + /// + public DataTemplate ItemTemplate + { + get { return GetValue(ItemTemplateProperty) as DataTemplate; } + set { SetValue(ItemTemplateProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemTemplateProperty = + DependencyProperty.Register( + "ItemTemplate", + typeof(DataTemplate), + typeof(AutoCompleteBox), + new PropertyMetadata(null)); + + /// + /// Gets or sets the that is + /// applied to the selection adapter contained in the drop-down portion + /// of the + /// control. + /// + /// The applied to the + /// selection adapter contained in the drop-down portion of the + /// control. + /// The default is null. + /// + /// The default selection adapter contained in the drop-down is a + /// ListBox control. + /// + public Style ItemContainerStyle + { + get { return GetValue(ItemContainerStyleProperty) as Style; } + set { SetValue(ItemContainerStyleProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemContainerStyleProperty = + DependencyProperty.Register( + elementItemContainerStyle, + typeof(Style), + typeof(AutoCompleteBox), + new PropertyMetadata(null, null)); + + /// + /// Gets or sets the applied to + /// the text box portion of the + /// control. + /// + /// The applied to the text + /// box portion of the + /// control. + /// The default is null. + public Style TextBoxStyle + { + get { return GetValue(TextBoxStyleProperty) as Style; } + set { SetValue(TextBoxStyleProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty TextBoxStyleProperty = + DependencyProperty.Register( + elementTextBoxStyle, + typeof(Style), + typeof(AutoCompleteBox), + new PropertyMetadata(null)); + + /// + /// Gets or sets the maximum height of the drop-down portion of the + /// control. + /// + /// The maximum height of the drop-down portion of the + /// control. + /// The default is . + /// The specified value is less than 0. + public double MaxDropDownHeight + { + get { return (double)GetValue(MaxDropDownHeightProperty); } + set { SetValue(MaxDropDownHeightProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty MaxDropDownHeightProperty = + DependencyProperty.Register( + "MaxDropDownHeight", + typeof(double), + typeof(AutoCompleteBox), + new PropertyMetadata(double.PositiveInfinity, OnMaxDropDownHeightPropertyChanged)); + + /// + /// MaxDropDownHeightProperty property changed handler. + /// + /// AutoCompleteTextBox that changed its MaxDropDownHeight. + /// Event arguments. + [SuppressMessage("Microsoft.Usage", "CA2208:InstantiateArgumentExceptionsCorrectly" + , Justification = "The exception will be called through a CLR setter in most cases.")] + private static void OnMaxDropDownHeightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + double newValue = (double)e.NewValue; + + // Revert to the old value if invalid (negative) + if (newValue < 0) + { + source.ignorePropertyChange = true; + source.SetValue(e.Property, e.OldValue); + + throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, + "Invalid value '{0}' for MaxDropDownHeight", e.NewValue), "e"); + } + + source.OnMaxDropDownHeightChanged(newValue); + } + + /// + /// Gets or sets a value indicating whether the drop-down portion of + /// the control is open. + /// + /// + /// True if the drop-down is open; otherwise, false. The default is + /// false. + /// + public bool IsDropDownOpen + { + get { return (bool)GetValue(IsDropDownOpenProperty); } + set + { + HandleShortcutSupport(value); + SetValue(IsDropDownOpenProperty, value); + } + } + + void HandleShortcutSupport(bool value) + { + if (TextBox == null) + { + return; + } + + var shortcutContainer = TextBox.Control as IShortcutContainer; + if (shortcutContainer != null) + { + shortcutContainer.SupportsKeyboardShortcuts = !value && supportsShortcutOriginalValue; + } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty IsDropDownOpenProperty = + DependencyProperty.Register( + "IsDropDownOpen", + typeof(bool), + typeof(AutoCompleteBox), + new PropertyMetadata(false, OnIsDropDownOpenPropertyChanged)); + + /// + /// IsDropDownOpenProperty property changed handler. + /// + /// AutoCompleteTextBox that changed its IsDropDownOpen. + /// Event arguments. + private static void OnIsDropDownOpenPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + + if (source == null) return; + + // Ignore the change if requested + if (source.ignorePropertyChange) + { + source.ignorePropertyChange = false; + return; + } + + bool oldValue = (bool)e.OldValue; + bool newValue = (bool)e.NewValue; + + if (!newValue) + { + source.ClosingDropDown(oldValue); + } + + source.UpdateVisualState(true); + } + + /// + /// Gets or sets a collection that is used to generate the items for the + /// drop-down portion of the + /// control. + /// + /// The collection that is used to generate the items of the + /// drop-down portion of the + /// control. + public IEnumerable ItemsSource + { + get { return GetValue(ItemsSourceProperty) as IEnumerable; } + set { SetValue(ItemsSourceProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty ItemsSourceProperty = + DependencyProperty.Register( + "ItemsSource", + typeof(IEnumerable), + typeof(AutoCompleteBox), + new PropertyMetadata(OnItemsSourcePropertyChanged)); + + /// + /// ItemsSourceProperty property changed handler. + /// + /// AutoCompleteBox that changed its ItemsSource. + /// Event arguments. + private static void OnItemsSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var autoComplete = d as AutoCompleteBox; + if (autoComplete == null) return; + autoComplete.OnItemsSourceChanged((IEnumerable)e.OldValue, (IEnumerable)e.NewValue); + } + + /// + /// Gets or sets the selected item in the drop-down. + /// + /// The selected item in the drop-down. + /// + /// If the IsTextCompletionEnabled property is true and text typed by + /// the user matches an item in the ItemsSource collection, which is + /// then displayed in the text box, the SelectedItem property will be + /// a null reference. + /// + public object SelectedItem + { + get { return GetValue(SelectedItemProperty); } + set { SetValue(SelectedItemProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier the + /// + /// dependency property. + public static readonly DependencyProperty SelectedItemProperty = + DependencyProperty.Register( + "SelectedItem", + typeof(object), + typeof(AutoCompleteBox), + new PropertyMetadata()); + + private void CancelSuggestion() + { + Debug.Assert(TextBox != null, "TextBox is somehow null"); + Debug.Assert(Text != null, "Text is somehow null"); + + DismissDropDown(); + + Debug.Assert(0 == TextBox.SelectionLength, "SelectionLength is what I think it is"); + } + + private void ExpandSuggestion(string value) + { + Debug.Assert(value != null, "The string passed into ExpandSuggestion should not be null"); + Debug.Assert(TextBox != null, "TextBox is somehow null"); + Debug.Assert(Text != null, "Text is somehow null"); + + var newText = TextBox.GetExpandedText(value, CompletionOffset); + UpdateTextValue(newText); + + // New caret index should be one space after the inserted text. + int newCaretIndex = CompletionOffset + value.Length + 1; + TextBox.CaretIndex = newCaretIndex; + Debug.Assert(newCaretIndex == TextBox.SelectionStart, + String.Format(CultureInfo.InvariantCulture, + "SelectionStart '{0}' should be the same as newCaretIndex '{1}'", + TextBox.SelectionStart, newCaretIndex)); + Debug.Assert(0 == TextBox.SelectionLength, + String.Format(CultureInfo.InvariantCulture, + "SelectionLength should be 0 but is '{0}' is what I think it is", + TextBox.SelectionStart)); + } + + /// + /// Gets or sets the text in the text box portion of the + /// control. + /// + /// The text in the text box portion of the + /// control. + public string Text + { + get { return GetValue(TextProperty) as string; } + set { SetValue(TextProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty TextProperty = + DependencyProperty.Register( + "Text", + typeof(string), + typeof(AutoCompleteBox), + new PropertyMetadata(string.Empty, OnTextPropertyChanged)); + + /// + /// TextProperty property changed handler. + /// + /// AutoCompleteBox that changed its Text. + /// Event arguments. + private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + + source.OnTextPropertyChanged((string) e.NewValue); + } + + /// + /// Gets or sets the drop down popup control. + /// + private PopupHelper DropDownPopup { get; set; } + + /// + /// The TextBox template part. + /// + private IAutoCompleteTextInput textInput; + + /// + /// The SelectionAdapter. + /// + private ISelectionAdapter adapter; + + /// + /// Gets or sets the Text template part. + /// + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + public IAutoCompleteTextInput TextBox + { + get { return textInput; } + set { UpdateTextBox(value); } + } + + void UpdateTextBox(IAutoCompleteTextInput value) + { + // Detach existing handlers + if (textInput != null) + { + UnsubscribeToEvent("SelectionChanged"); + UnsubscribeToEvent("OnTextBoxTextChanged"); + } + + textInput = value; + + // Attach handlers + if (textInput != null) + { + var shortcutContainer = textInput.Control as IShortcutContainer; + if (shortcutContainer != null) + { + supportsShortcutOriginalValue = shortcutContainer.SupportsKeyboardShortcuts; + } + + SubscribeToEvent("OnTextBoxTextChanged", + ObserveTextBoxChanges().Subscribe(shouldPopulate => + { + if (shouldPopulate) + { + PopulateDropDown(); + } + else + { + DismissDropDown(); + } + })); + + if (Text != null) + { + UpdateTextValue(Text); + } + } + } + + IObservable ObserveTextBoxChanges() + { + var distinctTextChanges = textInput + .TextChanged + .Select(_ => textInput.Text ?? "") + .DistinctUntilChanged(); + + if (MinimumPopulateDelay >= 0) + { + distinctTextChanges = distinctTextChanges + .Throttle(TimeSpan.FromMilliseconds(MinimumPopulateDelay), RxApp.MainThreadScheduler); + } + + return distinctTextChanges + .Select(text => { + bool userChangedTextBox = ignoreTextPropertyChange == 0; + if (ignoreTextPropertyChange > 0) ignoreTextPropertyChange--; + + return new { Text = text, ShouldPopulate = text.Length > 0 && userChangedTextBox }; + }) + .Do(textInfo => + { + userCalledPopulate = textInfo.ShouldPopulate; + UpdateAutoCompleteTextValue(textInfo.Text); + }) + .Select(textInfo => textInfo.ShouldPopulate); + } + + /// + /// Gets or sets the selection adapter used to populate the drop-down + /// with a list of selectable items. + /// + /// The selection adapter used to populate the drop-down with a + /// list of selectable items. + /// + /// You can use this property when you create an automation peer to sw + /// use with AutoCompleteBox or deriving from AutoCompleteBox to + /// create a custom control. + /// + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + public ISelectionAdapter SelectionAdapter + { + get { return adapter; } + set + { + if (adapter != null) + { + adapter.SelectionChanged -= OnAdapterSelectionChanged; + adapter.Commit -= OnAdapterSelectionComplete; + adapter.Cancel -= OnAdapterSelectionCanceled; + adapter.ItemsSource = null; + } + + adapter = value; + + if (adapter != null) + { + adapter.SelectionChanged += OnAdapterSelectionChanged; + adapter.Commit += OnAdapterSelectionComplete; + adapter.Cancel += OnAdapterSelectionCanceled; + adapter.ItemsSource = view; + } + } + } + + /// + /// Provides suggestions based on what's been typed. + /// + public IAutoCompleteAdvisor Advisor + { + get; + set; + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty AdvisorProperty = + DependencyProperty.Register( + "Advisor", + typeof(IAutoCompleteAdvisor), + typeof(AutoCompleteBox), + new PropertyMetadata(null, OnAdvisorPropertyChanged)); + + /// + /// AdvisorProperty property changed handler. + /// + /// AutoCompleteBox that changed its Advisor. + /// Event arguments. + private static void OnAdvisorPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var source = d as AutoCompleteBox; + if (source == null) return; + + source.Advisor = (IAutoCompleteAdvisor)e.NewValue; + } + + /// + /// Builds the visual tree for the + /// control + /// when a new template is applied. + /// + public override void OnApplyTemplate() + { + if (TextBox != null) + { + UnsubscribeToEvent("PreviewKeyDown"); + } + + if (DropDownPopup != null) + { + DropDownPopup.Closed -= OnDropDownClosed; + DropDownPopup.FocusChanged -= OnDropDownFocusChanged; + DropDownPopup.UpdateVisualStates -= OnDropDownPopupUpdateVisualStates; + DropDownPopup.BeforeOnApplyTemplate(); + DropDownPopup = null; + } + + base.OnApplyTemplate(); + + // Set the template parts. Individual part setters remove and add + // any event handlers. + var popup = GetTemplateChild(elementPopup) as Popup; + if (popup != null) + { + DropDownPopup = new PopupHelper(this, popup) + { + MaxDropDownHeight = MaxDropDownHeight + }; + DropDownPopup.AfterOnApplyTemplate(); + DropDownPopup.Closed += OnDropDownClosed; + DropDownPopup.FocusChanged += OnDropDownFocusChanged; + DropDownPopup.UpdateVisualStates += OnDropDownPopupUpdateVisualStates; + } + SelectionAdapter = GetSelectionAdapterPart(); + // TODO: eliminate duplication between these two elements... + TextBox = InputElement; + + if (TextBox != null) + { + SubscribeToEvent("PreviewKeyDown", TextBox.PreviewKeyDown.Subscribe(OnTextBoxPreviewKeyDown)); + } + + Interaction.OnApplyTemplateBase(); + + // If the drop down property indicates that the popup is open, + // flip its value to invoke the changed handler. + if (IsDropDownOpen && DropDownPopup != null && !DropDownPopup.IsOpen) + { + OpeningDropDown(); + } + } + + /// + /// Allows the popup wrapper to fire visual state change events. + /// + /// The source object. + /// The event data. + private void OnDropDownPopupUpdateVisualStates(object sender, EventArgs e) + { + UpdateVisualState(true); + } + + /// + /// Allows the popup wrapper to fire the FocusChanged event. + /// + /// The source object. + /// The event data. + private void OnDropDownFocusChanged(object sender, EventArgs e) + { + FocusChanged(HasFocus()); + } + + /// + /// Begin closing the drop-down. + /// + /// The original value. + private void ClosingDropDown(bool oldValue) + { + bool delayedClosingVisual = false; + if (DropDownPopup != null) + { + delayedClosingVisual = DropDownPopup.UsesClosingVisualState; + } + + if (view == null || view.Count == 0) + { + delayedClosingVisual = false; + } + + // Immediately close the drop down window: + // When a popup closed visual state is present, the code path is + // slightly different and the actual call to CloseDropDown will + // be called only after the visual state's transition is done + RaiseExpandCollapseAutomationEvent(oldValue, false); + if (!delayedClosingVisual) + { + CloseDropDown(); + } + + UpdateVisualState(true); + } + + private void OpeningDropDown() + { + OpenDropDown(); + + UpdateVisualState(true); + } + + /// + /// Raise an expand/collapse event through the automation peer. + /// + /// The old value. + /// The new value. + private void RaiseExpandCollapseAutomationEvent(bool oldValue, bool newValue) + { + var peer = UIElementAutomationPeer.FromElement(this) as AutoCompleteBoxAutomationPeer; + if (peer != null) + { + peer.RaiseExpandCollapseAutomationEvent(oldValue, newValue); + } + } + + /// + /// Handles the PreviewKeyDown event on the TextBox for WPF. + /// + /// The event data. + private void OnTextBoxPreviewKeyDown(EventPattern e) + { + OnKeyDown(e.EventArgs); + } + + /// + /// Connects to the DropDownPopup Closed event. + /// + /// The source object. + /// The event data. + private void OnDropDownClosed(object sender, EventArgs e) + { + // Force the drop down dependency property to be false. + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + } + + /// + /// Creates an + /// + /// + /// A + /// + /// for the + /// object. + protected override AutomationPeer OnCreateAutomationPeer() + { + return new AutoCompleteBoxAutomationPeer(this); + } + + /// + /// Handles the FocusChanged event. + /// + /// A value indicating whether the control + /// currently has the focus. + private void FocusChanged(bool hasFocus) + { + // The OnGotFocus & OnLostFocus are asynchronously and cannot + // reliably tell you that have the focus. All they do is let you + // know that the focus changed sometime in the past. To determine + // if you currently have the focus you need to do consult the + // FocusManager (see HasFocus()). + + if (!hasFocus) + { + IsDropDownOpen = false; + userCalledPopulate = false; + } + } + + /// + /// Determines whether the text box or drop-down portion of the + /// control has + /// focus. + /// + /// true to indicate the + /// has focus; + /// otherwise, false. + protected bool HasFocus() + { + var focused = + // For WPF, check if the element that has focus is within the control, as + // FocusManager.GetFocusedElement(this) will return null in such a case. + IsKeyboardFocusWithin ? Keyboard.FocusedElement as DependencyObject : FocusManager.GetFocusedElement(this) as DependencyObject; + + while (focused != null) + { + if (ReferenceEquals(focused, this)) + { + return true; + } + + // This helps deal with popups that may not be in the same + // visual tree + var parent = VisualTreeHelper.GetParent(focused); + if (parent == null) + { + // Try the logical parent. + var element = focused as FrameworkElement; + if (element != null) + { + parent = element.Parent; + } + } + focused = parent; + } + return false; + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnGotFocus(RoutedEventArgs e) + { + base.OnGotFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Handles change of keyboard focus, which is treated differently than control focus + /// + /// + protected override void OnIsKeyboardFocusWithinChanged(DependencyPropertyChangedEventArgs e) + { + base.OnIsKeyboardFocusWithinChanged(e); + FocusChanged((bool)e.NewValue); + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Handle the change of the IsEnabled property. + /// + /// The source object. + /// The event data. + private void ControlIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + bool isEnabled = (bool)e.NewValue; + if (!isEnabled) + { + IsDropDownOpen = false; + } + } + + /// + /// Returns the + /// part, if + /// possible. + /// + /// + /// A object, + /// if possible. Otherwise, null. + /// + [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", + Justification = "Following the GetTemplateChild pattern for the method.")] + protected virtual ISelectionAdapter GetSelectionAdapterPart() + { + var selector = GetTemplateChild(elementSelector) as Selector; + if (selector != null) + { + // Built in support for wrapping a Selector control + adapter = new SelectorSelectionAdapter(selector); + } + return adapter; + } + + /// + /// Populates the drop down + /// + private void PopulateDropDown() + { + populatingSubject.OnNext(Unit.Default); + } + + void DismissDropDown() + { + SelectedItem = null; + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + } + + /// + /// Converts the specified object to a string by using the + /// and + /// values + /// of the binding object specified by the + /// + /// property. + /// + /// The object to format as a string. + /// The string representation of the specified object. + /// + /// Override this method to provide a custom string conversion. + /// + protected virtual string FormatValue(object value) + { + return value == null ? string.Empty : value.ToString(); + } + + /// + /// Updates both the text box value and underlying text dependency + /// property value if and when they change. Automatically fires the + /// text changed events when there is a change. + /// + /// The new string value. + private void UpdateTextValue(string value) + { + UpdateAutoCompleteTextValue(value); + UpdateTextBoxValue(value); + } + + // Update the TextBox's Text dependency property + void UpdateTextBoxValue(string value) + { + var newValue = value ?? string.Empty; + + if (TextBox == null || TextBox.Text == newValue) + { + return; + } + + ignoreTextPropertyChange++; + TextBox.Text = newValue; + } + + void UpdateAutoCompleteTextValue(string value) + { + if (Text == value) return; + + ignoreTextPropertyChange++; + Text = value; + } + + /// + /// Handle the update of the text when the Text dependency property changes. + /// + /// The new text. + private void OnTextPropertyChanged(string newText) + { + // Only process this event if it is coming from someone outside + // setting the Text dependency property directly. + if (ignoreTextPropertyChange > 0) + { + ignoreTextPropertyChange--; + return; + } + + UpdateTextBoxValue(newText); + } + + /// + /// Notifies the + /// that the + /// + /// property has been set and the data can be filtered to provide + /// possible matches in the drop-down. + /// + /// + /// Call this method when you are providing custom population of + /// the drop-down portion of the AutoCompleteBox, to signal the control + /// that you are done with the population process. + /// Typically, you use PopulateComplete when the population process + /// is a long-running process and you want to cancel built-in filtering + /// of the ItemsSource items. In this case, you can handle the + /// Populated event and set PopulatingEventArgs.Cancel to true. + /// When the long-running process has completed you call + /// PopulateComplete to indicate the drop-down is populated. + /// + protected void PopulateComplete() + { + RefreshView(); + + if (SelectionAdapter != null && !Equals(SelectionAdapter.ItemsSource, view)) + { + SelectionAdapter.ItemsSource = view; + } + + bool isDropDownOpen = userCalledPopulate && (view.Count > 0); + if (isDropDownOpen != IsDropDownOpen) + { + ignorePropertyChange = true; + IsDropDownOpen = isDropDownOpen; + } + if (IsDropDownOpen) + { + OpeningDropDown(); + } + else + { + ClosingDropDown(true); + } + + // We always want to select the first suggestion after populating the drop down. + SelectFirstItem(); + } + + void SelectFirstItem() + { + if (!view.Any()) return; + + var newSelectedItem = view.First(); + SelectionAdapter.SelectedItem = newSelectedItem; + SelectedItem = newSelectedItem; + } + + + /// + /// A simple helper method to clear the view and ensure that a view + /// object is always present and not null. + /// + private void ClearView() + { + if (view == null) + { + view = new ObservableCollection(); + } + else + { + view.Clear(); + } + } + + /// + /// Walks through the items enumeration. Performance is not going to be perfect with the current implementation. + /// + private void RefreshView() + { + if (suggestions == null) + { + ClearView(); + return; + } + + int viewIndex = 0; + int viewCount = view.Count; + var items = suggestions; + foreach (var item in items) + { + if (viewCount > viewIndex && view[viewIndex] == item) + { + // Item is still in the view + viewIndex++; + } + else + { + // Insert the item + if (viewCount > viewIndex && view[viewIndex] != item) + { + // Replace item + // Unfortunately replacing via index throws a fatal + // exception: View[view_index] = item; + // Cost: O(n) vs O(1) + view.RemoveAt(viewIndex); + view.Insert(viewIndex, item); + viewIndex++; + } + else + { + // Add the item + if (viewIndex == viewCount) + { + // Constant time is preferred (Add). + view.Add(item); + } + else + { + view.Insert(viewIndex, item); + } + viewIndex++; + viewCount++; + } + } + } + } + + /// + /// Handle any change to the ItemsSource dependency property, update + /// the underlying ObservableCollection view, and set the selection + /// adapter's ItemsSource to the view if appropriate. + /// + /// The old enumerable reference. + /// The new enumerable reference. + private void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) + { + // Remove handler for oldValue.CollectionChanged (if present) + var oldValueINotifyCollectionChanged = oldValue as INotifyCollectionChanged; + if (null != oldValueINotifyCollectionChanged && null != collectionChangedWeakEventListener) + { + collectionChangedWeakEventListener.Detach(); + collectionChangedWeakEventListener = null; + } + + // Add handler for newValue.CollectionChanged (if possible) + var newValueINotifyCollectionChanged = newValue as INotifyCollectionChanged; + if (null != newValueINotifyCollectionChanged) + { + collectionChangedWeakEventListener = new WeakEventListener(this) + { + OnEventAction = + (instance, source, eventArgs) => instance.ItemsSourceCollectionChanged(eventArgs), + OnDetachAction = + weakEventListener => + newValueINotifyCollectionChanged.CollectionChanged -= weakEventListener.OnEvent + }; + newValueINotifyCollectionChanged.CollectionChanged += collectionChangedWeakEventListener.OnEvent; + } + + // Store a local cached copy of the data + suggestions = newValue == null ? null : new List(newValue.Cast().ToList()); + + // Clear and set the view on the selection adapter + ClearView(); + if (SelectionAdapter != null && !Equals(SelectionAdapter.ItemsSource, view)) + { + SelectionAdapter.ItemsSource = view; + } + if (IsDropDownOpen) + { + RefreshView(); + } + } + + /// + /// Method that handles the ObservableCollection.CollectionChanged event for the ItemsSource property. + /// + /// The event data. + private void ItemsSourceCollectionChanged(NotifyCollectionChangedEventArgs e) + { + // Update the cache + if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null) + { + for (int index = 0; index < e.OldItems.Count; index++) + { + suggestions.RemoveAt(e.OldStartingIndex); + } + } + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null && suggestions.Count >= e.NewStartingIndex) + { + for (int index = 0; index < e.NewItems.Count; index++) + { + suggestions.Insert(e.NewStartingIndex + index, e.NewItems[index]); + } + } + if (e.Action == NotifyCollectionChangedAction.Replace && e.NewItems != null && e.OldItems != null) + { + foreach (var t in e.NewItems) + { + suggestions[e.NewStartingIndex] = t; + } + } + + // Update the view + if (e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Replace) + { + if (e.OldItems != null) + { + foreach (var t in e.OldItems) + { + view.Remove(t); + } + } + } + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Significant changes to the underlying data. + ClearView(); + if (ItemsSource != null) + { + suggestions = new List(ItemsSource.Cast().ToList()); + } + } + + // Refresh the observable collection used in the selection adapter. + RefreshView(); + } + + /// + /// Handles the SelectionChanged event of the selection adapter. + /// + /// The source object. + /// The selection changed event data. + private void OnAdapterSelectionChanged(object sender, SelectionChangedEventArgs e) + { + SelectedItem = adapter.SelectedItem; + } + + /// + /// Handles the Commit event on the selection adapter. + /// + /// The source object. + /// The event data. + private void OnAdapterSelectionComplete(object sender, RoutedEventArgs e) + { + IsDropDownOpen = false; + + var selectedItem = SelectedItem; + + // Completion will update the selected value + ExpandSuggestion(selectedItem == null ? string.Empty : selectedItem.ToString()); + + // This forces the textbox to get keyboard focus, in the case where + // another part of the control may have temporarily received focus. + if (TextBox != null) + { + // Because LOL WPF focus shit, we need to make sure don't lose the caret index when we give this focus. + int caretIndex = TextBox.CaretIndex; + TextBox.Focus(); + TextBox.CaretIndex = caretIndex; + } + else + { + Focus(); + } + } + + /// + /// Handles the Cancel event on the selection adapter. + /// + /// The source object. + /// The event data. + private void OnAdapterSelectionCanceled(object sender, RoutedEventArgs e) + { + IsDropDownOpen = false; + + CancelSuggestion(); + + // This forces the textbox to get keyboard focus, in the case where + // another part of the control may have temporarily received focus. + if (TextBox != null) + { + TextBox.Focus(); + } + else + { + Focus(); + } + } + + /// + /// Handles MaxDropDownHeightChanged by re-arranging and updating the + /// popup arrangement. + /// + /// The new value. + [SuppressMessage("Microsoft.Usage", "CA1801:ReviewUnusedParameters", MessageId = "newValue", + Justification = "This makes it easy to add validation or other changes in the future.")] + private void OnMaxDropDownHeightChanged(double newValue) + { + if (DropDownPopup != null) + { + DropDownPopup.MaxDropDownHeight = newValue; + } + UpdateVisualState(true); + } + + private void OpenDropDown() + { + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = true; + } + popupHasOpened = true; + } + + private void CloseDropDown() + { + if (popupHasOpened) + { + if (SelectionAdapter != null) + { + SelectionAdapter.SelectedItem = null; + } + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = false; + } + } + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0")] + protected override void OnKeyDown(KeyEventArgs e) + { + if (e == null) + { + throw new ArgumentNullException("e"); + } + + base.OnKeyDown(e); + + if (e.Handled || !IsEnabled) + { + return; + } + + // The drop down is open, pass along the key event arguments to the + // selection adapter. If it isn't handled by the adapter's logic, + // then we handle some simple navigation scenarios for controlling + // the drop down. + if (IsDropDownOpen) + { + if (SelectionAdapter != null) + { + SelectionAdapter.HandleKeyDown(e); + if (e.Handled) + { + return; + } + } + + if (e.Key == Key.Escape) + { + OnAdapterSelectionCanceled(this, new RoutedEventArgs()); + e.Handled = true; + } + } + + // Standard drop down navigation + switch (e.Key) + { +// case Key.F4: +// IsDropDownOpen = !IsDropDownOpen; +// e.Handled = true; +// break; + + case Key.Enter: + if (IsDropDownOpen && SelectedItem != null) + { + OnAdapterSelectionComplete(this, new RoutedEventArgs()); + e.Handled = true; + } + + break; + } + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + void IUpdateVisualState.UpdateVisualState(bool useTransitions) + { + UpdateVisualState(useTransitions); + } + + /// + /// Update the current visual state of the button. + /// + /// + /// True to use transitions when updating the visual state, false to + /// snap directly to the new visual state. + /// + internal virtual void UpdateVisualState(bool useTransitions) + { + // Popup + VisualStateManager.GoToState(this, IsDropDownOpen ? VisualStates.StatePopupOpened : VisualStates.StatePopupClosed, useTransitions); + + // Handle the Common and Focused states + Interaction.UpdateVisualStateBase(useTransitions); + } + + private class EmptyAutoCompleteAdvisor : IAutoCompleteAdvisor + { + public static readonly IAutoCompleteAdvisor Instance = new EmptyAutoCompleteAdvisor(); + + private EmptyAutoCompleteAdvisor() + { + } + + public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) + { + return Observable.Empty(); + } + } + + private void SubscribeToEvent(string eventName, IDisposable disposable) + { + eventSubscriptions[eventName] = disposable; + } + + private void UnsubscribeToEvent(string eventName) + { + IDisposable disposable; + if (eventSubscriptions.TryGetValue(eventName, out disposable)) + { + disposable.Dispose(); + } + } + + public IAutoCompleteTextInput InputElement + { + get { return (IAutoCompleteTextInput)GetValue(InputElementProperty); } + set { SetValue(InputElementProperty, value); } + } + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DependencyProperty InputElementProperty = + DependencyProperty.Register( + "InputElement", + typeof(IAutoCompleteTextInput), + typeof(AutoCompleteBox)); + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs new file mode 100644 index 0000000000..5ac3965c47 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteBoxAutomationPeer.cs @@ -0,0 +1,300 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Automation.Provider; + +namespace GitHub.UI +{ + /// + /// Exposes AutoCompleteBox types to UI Automation. + /// + /// Stable + public sealed class AutoCompleteBoxAutomationPeer : FrameworkElementAutomationPeer, IValueProvider, IExpandCollapseProvider, ISelectionProvider + { + /// + /// The name reported as the core class name. + /// + private const string autoCompleteBoxClassNameCore = "AutoCompleteBox"; + + /// + /// Gets the AutoCompleteBox that owns this + /// AutoCompleteBoxAutomationPeer. + /// + private AutoCompleteBox OwnerAutoCompleteBox + { + get { return (AutoCompleteBox)Owner; } + } + + /// + /// Gets a value indicating whether the UI automation provider allows + /// more than one child element to be selected concurrently. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + /// True if multiple selection is allowed; otherwise, false. + bool ISelectionProvider.CanSelectMultiple + { + get { return false; } + } + + /// + /// Gets a value indicating whether the UI automation provider + /// requires at least one child element to be selected. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + /// True if selection is required; otherwise, false. + bool ISelectionProvider.IsSelectionRequired + { + get { return false; } + } + + /// + /// Initializes a new instance of the AutoCompleteBoxAutomationPeer + /// class. + /// + /// + /// The AutoCompleteBox that is associated with this + /// AutoCompleteBoxAutomationPeer. + /// + public AutoCompleteBoxAutomationPeer(AutoCompleteBox owner) + : base(owner) + { + } + + /// + /// Gets the control type for the AutoCompleteBox that is associated + /// with this AutoCompleteBoxAutomationPeer. This method is called by + /// GetAutomationControlType. + /// + /// ComboBox AutomationControlType. + protected override AutomationControlType GetAutomationControlTypeCore() + { + return AutomationControlType.ComboBox; + } + + /// + /// Gets the name of the AutoCompleteBox that is associated with this + /// AutoCompleteBoxAutomationPeer. This method is called by + /// GetClassName. + /// + /// The name AutoCompleteBox. + protected override string GetClassNameCore() + { + return autoCompleteBoxClassNameCore; + } + + /// + /// Gets the control pattern for the AutoCompleteBox that is associated + /// with this AutoCompleteBoxAutomationPeer. + /// + /// The desired PatternInterface. + /// The desired AutomationPeer or null. + public override object GetPattern(PatternInterface patternInterface) + { + object iface = null; + var owner = OwnerAutoCompleteBox; + + if (patternInterface == PatternInterface.Value) + { + iface = this; + } + else if (patternInterface == PatternInterface.ExpandCollapse) + { + iface = this; + } + else if (owner.SelectionAdapter != null) + { + var peer = owner.SelectionAdapter.CreateAutomationPeer(); + if (peer != null) + { + iface = peer.GetPattern(patternInterface); + } + } + + return iface ?? base.GetPattern(patternInterface); + } + + /// + /// Blocking method that returns after the element has been expanded. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + void IExpandCollapseProvider.Expand() + { + if (!IsEnabled()) + { + throw new ElementNotEnabledException(); + } + + OwnerAutoCompleteBox.IsDropDownOpen = true; + } + + /// + /// Blocking method that returns after the element has been collapsed. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + void IExpandCollapseProvider.Collapse() + { + if (!IsEnabled()) + { + throw new ElementNotEnabledException(); + } + + OwnerAutoCompleteBox.IsDropDownOpen = false; + } + + /// + /// Gets an element's current Collapsed or Expanded state. + /// + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + ExpandCollapseState IExpandCollapseProvider.ExpandCollapseState + { + get + { + return OwnerAutoCompleteBox.IsDropDownOpen ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed; + } + } + + /// + /// Raises the ExpandCollapse automation event. + /// + /// The old value. + /// The new value. + internal void RaiseExpandCollapseAutomationEvent(bool oldValue, bool newValue) + { + RaisePropertyChangedEvent( + ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty, + oldValue ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed, + newValue ? ExpandCollapseState.Expanded : ExpandCollapseState.Collapsed); + } + + /// + /// Sets the value of a control. + /// + /// The value to set. The provider is responsible + /// for converting the value to the appropriate data type. + void IValueProvider.SetValue(string value) + { + OwnerAutoCompleteBox.Text = value; + } + + /// + /// Gets a value indicating whether the value of a control is + /// read-only. + /// + /// True if the value is read-only; false if it can be modified. + bool IValueProvider.IsReadOnly + { + get + { + return !OwnerAutoCompleteBox.IsEnabled; + } + } + + /// + /// Gets the value of the control. + /// + /// The value of the control. + string IValueProvider.Value + { + get + { + return OwnerAutoCompleteBox.Text ?? string.Empty; + } + } + + /// + /// Gets the collection of child elements of the AutoCompleteBox that + /// are associated with this AutoCompleteBoxAutomationPeer. This method + /// is called by GetChildren. + /// + /// + /// A collection of automation peer elements, or an empty collection + /// if there are no child elements. + /// + [SuppressMessage("Microsoft.Design", "CA1002:DoNotExposeGenericLists", Justification = "Required by automation")] + protected override List GetChildrenCore() + { + var children = new List(); + var owner = OwnerAutoCompleteBox; + + // TextBox part. + var textBox = owner.TextBox; + if (textBox != null) + { + var peer = CreatePeerForElement(textBox.Control); + if (peer != null) + { + children.Insert(0, peer); + } + } + + // Include SelectionAdapter's children. + if (owner.SelectionAdapter != null) + { + var selectionAdapterPeer = owner.SelectionAdapter.CreateAutomationPeer(); + if (selectionAdapterPeer != null) + { + var listChildren = selectionAdapterPeer.GetChildren(); + if (listChildren != null) + { + children.AddRange(listChildren); + } + } + } + + return children; + } + + /// + /// Retrieves a UI automation provider for each child element that is + /// selected. + /// + /// An array of UI automation providers. + /// + /// This API supports the .NET Framework infrastructure and is not + /// intended to be used directly from your code. + /// + IRawElementProviderSimple[] ISelectionProvider.GetSelection() + { + if (OwnerAutoCompleteBox.SelectionAdapter != null) + { + var selectedItem = OwnerAutoCompleteBox.SelectionAdapter.SelectedItem; + if (selectedItem != null) + { + var uie = selectedItem as UIElement; + if (uie != null) + { + var peer = CreatePeerForElement(uie); + if (peer != null) + { + return new[] { ProviderFromPeer(peer) }; + } + } + } + } + + return new IRawElementProviderSimple[] { }; + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs new file mode 100644 index 0000000000..abad932547 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/AutoCompleteTextInputExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics; +using GitHub.Extensions; +using GitHub.Helpers; + +namespace GitHub.UI.Controls.AutoCompleteBox +{ + public static class AutoCompleteTextInputExtensions + { + /// + /// Given a text input and the current value, returns the expected new text. + /// + /// + /// + /// + /// + public static string GetExpandedText(this IAutoCompleteTextInput textInput, string value, int completionOffset) + { + Guard.ArgumentNotNull(textInput, "textInput"); + Guard.ArgumentNotNull(value, "value"); + + int caretIndex = textInput.CaretIndex; + int afterIndex = Math.Max(caretIndex, textInput.SelectionLength + textInput.SelectionStart); + int offset = completionOffset; + + var currentText = textInput.Text ?? ""; // Playing it safe + + if (offset > currentText.Length) throw new InvalidOperationException("The offset can't be larger than the current text length"); + if (afterIndex > currentText.Length) throw new InvalidOperationException("The afterIndex can't be larger than the current text length"); + + var before = currentText.Substring(0, offset); + var after = currentText.Substring(afterIndex); + string prefix = before + value + " "; + return prefix + after; + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs new file mode 100644 index 0000000000..95446edf45 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IAutoCompleteTextInput.cs @@ -0,0 +1,29 @@ +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reactive; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace GitHub.UI +{ + public interface IAutoCompleteTextInput : INotifyPropertyChanged + { + void Focus(); + [SuppressMessage("Microsoft.Naming", "CA1716:IdentifiersShouldNotMatchKeywords", MessageId = "Select", + Justification = "Matches the underlying control method name")] + void Select(int position, int length); + void SelectAll(); + int CaretIndex { get; set; } + int SelectionStart { get; } + int SelectionLength { get; } + string Text { get; set; } + IObservable> PreviewKeyDown { get; } + IObservable> SelectionChanged { get; } + IObservable> TextChanged { get; } + UIElement Control { get; } + Point GetPositionFromCharIndex(int charIndex); + Thickness Margin { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs new file mode 100644 index 0000000000..250fa9bca9 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IPopupTarget.cs @@ -0,0 +1,18 @@ + +using System.Windows; + +namespace GitHub.UI.Controls +{ + /// + /// Controls that implement this interface can specify where an associated popup should be located. + /// + /// + /// The PopupHelper is a generic class for managing Popups that align to the bottom of their associated control. + /// However, our AutoCompleteBox needs the Popup to align to where the completion is happening. Intellisense™ + /// controls behave in a similar fashion. We might find popups useful elsewhere. + /// + public interface IPopupTarget + { + Point PopupPosition { get; } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs b/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs new file mode 100644 index 0000000000..191d09675d --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/ISelectionAdapter.cs @@ -0,0 +1,73 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Collections; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Input; + +namespace GitHub.UI +{ + /// + /// Defines an item collection, selection members, and key handling for the + /// selection adapter contained in the drop-down portion of an + /// control. + /// + /// Stable + public interface ISelectionAdapter + { + /// + /// Gets or sets the selected item. + /// + /// The currently selected item. + object SelectedItem { get; set; } + + /// + /// Occurs when the + /// + /// property value changes. + /// + event SelectionChangedEventHandler SelectionChanged; + + /// + /// Gets or sets a collection that is used to generate content for the + /// selection adapter. + /// + /// The collection that is used to generate content for the + /// selection adapter. + IEnumerable ItemsSource { get; set; } + + /// + /// Occurs when a selected item is not cancelled and is committed as the + /// selected item. + /// + event RoutedEventHandler Commit; + + /// + /// Occurs when a selection has been canceled. + /// + event RoutedEventHandler Cancel; + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + void HandleKeyDown(KeyEventArgs e); + + /// + /// Returns an automation peer for the selection adapter, for use by the + /// Silverlight automation infrastructure. + /// + /// An automation peer for the selection adapter, if one is + /// available; otherwise, null. + AutomationPeer CreateAutomationPeer(); + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs b/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs new file mode 100644 index 0000000000..abbecd5f85 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/IUpdateVisualState.cs @@ -0,0 +1,23 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +namespace GitHub.UI +{ + /// + /// The IUpdateVisualState interface is used to provide the + /// InteractionHelper with access to the type's UpdateVisualState method. + /// + internal interface IUpdateVisualState + { + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + void UpdateVisualState(bool useTransitions); + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs b/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs new file mode 100644 index 0000000000..33dbe73cb8 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/InteractionHelper.cs @@ -0,0 +1,158 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; + +namespace GitHub.UI +{ + /// + /// The InteractionHelper provides controls with support for all of the + /// common interactions like mouse movement, mouse clicks, key presses, + /// etc., and also incorporates proper event semantics when the control is + /// disabled. + /// + internal sealed class InteractionHelper + { + /// + /// Gets the control the InteractionHelper is targeting. + /// + public Control Control { get; private set; } + + /// + /// Gets a value indicating whether the control has focus. + /// + public bool IsFocused { get; private set; } + + /// + /// Gets a value indicating whether the mouse is over the control. + /// + public bool IsMouseOver { get; private set; } + + /// + /// Gets a value indicating whether the mouse button is pressed down + /// over the control. + /// + public bool IsPressed { get; private set; } + + /// + /// Reference used to call UpdateVisualState on the base class. + /// + private readonly IUpdateVisualState updateVisualState; + + /// + /// Initializes a new instance of the InteractionHelper class. + /// + /// Control receiving interaction. + public InteractionHelper(Control control) + { + Debug.Assert(control != null, "control should not be null!"); + Control = control; + updateVisualState = control as IUpdateVisualState; + + // Wire up the event handlers for events without a virtual override + control.Loaded += OnLoaded; + control.IsEnabledChanged += OnIsEnabledChanged; + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + /// + /// UpdateVisualState works differently than the rest of the injected + /// functionality. Most of the other events are overridden by the + /// calling class which calls Allow, does what it wants, and then calls + /// Base. UpdateVisualState is the opposite because a number of the + /// methods in InteractionHelper need to trigger it in the calling + /// class. We do this using the IUpdateVisualState internal interface. + /// + private void UpdateVisualState(bool useTransitions) + { + if (updateVisualState != null) + { + updateVisualState.UpdateVisualState(useTransitions); + } + } + + /// + /// Update the visual state of the control. + /// + /// + /// A value indicating whether to automatically generate transitions to + /// the new state, or instantly transition to the new state. + /// + public void UpdateVisualStateBase(bool useTransitions) + { + // Handle the Common states + if (!Control.IsEnabled) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateDisabled, VisualStates.StateNormal); + } + else if (IsPressed) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StatePressed, VisualStates.StateMouseOver, VisualStates.StateNormal); + } + else if (IsMouseOver) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateMouseOver, VisualStates.StateNormal); + } + else + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateNormal); + } + + // Handle the Focused states + if (IsFocused) + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateFocused, VisualStates.StateUnfocused); + } + else + { + VisualStates.GoToState(Control, useTransitions, VisualStates.StateUnfocused); + } + } + + /// + /// Handle the control's Loaded event. + /// + /// The control. + /// Event arguments. + private void OnLoaded(object sender, RoutedEventArgs e) + { + UpdateVisualState(false); + } + + /// + /// Handle changes to the control's IsEnabled property. + /// + /// The control. + /// Event arguments. + private void OnIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + bool enabled = (bool)e.NewValue; + if (!enabled) + { + IsPressed = false; + IsMouseOver = false; + IsFocused = false; + } + + UpdateVisualState(true); + } + + /// + /// Update the visual state of the control when its template is changed. + /// + public void OnApplyTemplateBase() + { + UpdateVisualState(false); + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs b/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs new file mode 100644 index 0000000000..7ba010c413 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/PopupHelper.cs @@ -0,0 +1,280 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using GitHub.Extensions; +using GitHub.Helpers; +using GitHub.UI.Controls; + +namespace GitHub.UI +{ + /// + /// PopupHelper is a simple wrapper type that helps abstract platform + /// differences out of the Popup. + /// + internal class PopupHelper + { + /// + /// Gets a value indicating whether a visual popup state is being used + /// in the current template for the Closed state. Setting this value to + /// true will delay the actual setting of Popup.IsOpen to false until + /// after the visual state's transition for Closed is complete. + /// + public bool UsesClosingVisualState { get; private set; } + + /// + /// Gets or sets the parent control. + /// + private Control Parent { get; set; } + + /// + /// Gets or sets the maximum drop down height value. + /// + public double MaxDropDownHeight { get; set; } + + /// + /// Gets the Popup control instance. + /// + public Popup Popup { get; private set; } + + /// + /// Gets or sets a value indicating whether the actual Popup is open. + /// + public bool IsOpen + { + get { return Popup.IsOpen; } + set { Popup.IsOpen = value; } + } + + /// + /// Gets or sets the popup child framework element. Can be used if an + /// assumption is made on the child type. + /// + private FrameworkElement PopupChild { get; set; } + + /// + /// The Closed event is fired after the Popup closes. + /// + public event EventHandler Closed; + + /// + /// Fired when the popup children have a focus event change, allows the + /// parent control to update visual states or react to the focus state. + /// + public event EventHandler FocusChanged; + + /// + /// Fired when the popup children intercept an event that may indicate + /// the need for a visual state update by the parent control. + /// + public event EventHandler UpdateVisualStates; + + /// + /// Initializes a new instance of the PopupHelper class. + /// + /// The parent control. + public PopupHelper(Control parent) + { + Guard.ArgumentNotNull(parent, "parent"); + Parent = parent; + } + + /// + /// Initializes a new instance of the PopupHelper class. + /// + /// The parent control. + /// The Popup template part. + public PopupHelper(Control parent, Popup popup) + : this(parent) + { + Guard.ArgumentNotNull(parent, "parent"); + Guard.ArgumentNotNull(popup, "popup"); + + Popup = popup; + + var target = parent as IPopupTarget; + if (target != null) + { + popup.CustomPopupPlacementCallback += (size, targetSize, offset) => new[] + { + new CustomPopupPlacement(target.PopupPosition, PopupPrimaryAxis.Horizontal) + }; + } + } + + /// + /// Fires the Closed event. + /// + /// The event data. + private void OnClosed(EventArgs e) + { + var handler = Closed; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// Actually closes the popup after the VSM state animation completes. + /// + /// Event source. + /// Event arguments. + private void OnPopupClosedStateChanged(object sender, VisualStateChangedEventArgs e) + { + // Delayed closing of the popup until now + if (e != null && e.NewState != null && e.NewState.Name == VisualStates.StatePopupClosed) + { + if (Popup != null) + { + Popup.IsOpen = false; + } + OnClosed(EventArgs.Empty); + } + } + + /// + /// Should be called by the parent control before the base + /// OnApplyTemplate method is called. + /// + public void BeforeOnApplyTemplate() + { + if (UsesClosingVisualState) + { + // Unhook the event handler for the popup closed visual state group. + // This code is used to enable visual state transitions before + // actually setting the underlying Popup.IsOpen property to false. + VisualStateGroup groupPopupClosed = VisualStates.TryGetVisualStateGroup(Parent, VisualStates.GroupPopup); + if (null != groupPopupClosed) + { + groupPopupClosed.CurrentStateChanged -= OnPopupClosedStateChanged; + UsesClosingVisualState = false; + } + } + + if (Popup != null) + { + Popup.Closed -= Popup_Closed; + } + } + + /// + /// Should be called by the parent control after the base + /// OnApplyTemplate method is called. + /// + public void AfterOnApplyTemplate() + { + if (Popup != null) + { + Popup.Closed += Popup_Closed; + } + + var groupPopupClosed = VisualStates.TryGetVisualStateGroup(Parent, VisualStates.GroupPopup); + if (null != groupPopupClosed) + { + groupPopupClosed.CurrentStateChanged += OnPopupClosedStateChanged; + UsesClosingVisualState = true; + } + + // TODO: Consider moving to the DropDownPopup setter + // TODO: Although in line with other implementations, what happens + // when the template is swapped out? + if (Popup != null) + { + PopupChild = Popup.Child as FrameworkElement; + + if (PopupChild != null) + { + PopupChild.MinWidth = 203; // TODO: Make this configurable. + PopupChild.GotFocus += PopupChild_GotFocus; + PopupChild.LostFocus += PopupChild_LostFocus; + PopupChild.MouseEnter += PopupChild_MouseEnter; + PopupChild.MouseLeave += PopupChild_MouseLeave; + } + } + } + + /// + /// Connected to the Popup Closed event and fires the Closed event. + /// + /// The source object. + /// The event data. + private void Popup_Closed(object sender, EventArgs e) + { + OnClosed(EventArgs.Empty); + } + + /// + /// Connected to several events that indicate that the FocusChanged + /// event should bubble up to the parent control. + /// + /// The event data. + private void OnFocusChanged(EventArgs e) + { + EventHandler handler = FocusChanged; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// Fires the UpdateVisualStates event. + /// + /// The event data. + private void OnUpdateVisualStates(EventArgs e) + { + EventHandler handler = UpdateVisualStates; + if (handler != null) + { + handler(this, e); + } + } + + /// + /// The popup child has received focus. + /// + /// The source object. + /// The event data. + private void PopupChild_GotFocus(object sender, RoutedEventArgs e) + { + OnFocusChanged(EventArgs.Empty); + } + + /// + /// The popup child has lost focus. + /// + /// The source object. + /// The event data. + private void PopupChild_LostFocus(object sender, RoutedEventArgs e) + { + OnFocusChanged(EventArgs.Empty); + } + + /// + /// The popup child has had the mouse enter its bounds. + /// + /// The source object. + /// The event data. + private void PopupChild_MouseEnter(object sender, MouseEventArgs e) + { + OnUpdateVisualStates(EventArgs.Empty); + } + + /// + /// The mouse has left the popup child's bounds. + /// + /// The source object. + /// The event data. + private void PopupChild_MouseLeave(object sender, MouseEventArgs e) + { + OnUpdateVisualStates(EventArgs.Empty); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs new file mode 100644 index 0000000000..67def11de8 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/RichTextBoxAutoCompleteTextInput.cs @@ -0,0 +1,201 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Reactive; +using System.Reactive.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Markup; + +namespace GitHub.UI +{ + [ContentProperty("TextBox")] + public class RichTextBoxAutoCompleteTextInput : IAutoCompleteTextInput + { + private static readonly int newLineLength = Environment.NewLine.Length; + const int promptRichTextBoxCaretIndexAdjustments = 2; + RichTextBox textBox; + + public event PropertyChangedEventHandler PropertyChanged; + + TextPointer ContentStart + { + get { return textBox.Document.ContentStart; } + } + + TextPointer ContentEnd + { + get + { + // RichTextBox always appends a new line at the end. So we need to back that shit up. + return textBox.Document.ContentEnd.GetPositionAtOffset(-1 * newLineLength) + ?? textBox.Document.ContentEnd; + } + } + + public void Select(int position, int length) + { + var textRange = new TextRange(ContentStart, ContentEnd); + + if (textRange.Text.Length >= (position + length)) + { + var start = textRange.Start.GetPositionAtOffset(GetOffsetIndex(position), LogicalDirection.Forward); + var end = textRange.Start.GetPositionAtOffset(GetOffsetIndex(position + length), LogicalDirection.Backward); + if (start != null && end != null) + textBox.Selection.Select(start, end); + } + } + + public void SelectAll() + { + textBox.Selection.Select(ContentStart, ContentEnd); + } + + public int CaretIndex + { + get + { + var start = ContentStart; + var caret = textBox.CaretPosition; + var range = new TextRange(start, caret); + return range.Text.Length; + } + set + { + Select(value, 0); + Debug.Assert(value == CaretIndex, + String.Format(CultureInfo.InvariantCulture, + "I just set the caret index to '{0}' but it's '{1}'", value, CaretIndex)); + } + } + + public int SelectionStart + { + get + { + return new TextRange(ContentStart, textBox.Selection.Start).Text.Length; + } + } + + public int SelectionLength + { + get { return CaretIndex - SelectionStart; } + } + +#if DEBUG + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")] +#endif + public string Text + { + get + { + return new TextRange(ContentStart, ContentEnd).Text; + } + set + { + textBox.Document.Blocks.Clear(); + + if (!string.IsNullOrEmpty(value)) + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(value))) + { + var contents = new TextRange(ContentStart, ContentEnd); + contents.Load(stream, DataFormats.Text); + } + } + } + } + + public IObservable> PreviewKeyDown + { + get; + private set; + } + + public IObservable> SelectionChanged { get; private set; } + + public IObservable> TextChanged { get; private set; } + + public UIElement Control { get { return textBox; } } + + public Point GetPositionFromCharIndex(int charIndex) + { + var offset = new TextRange(ContentStart, textBox.CaretPosition) + .Start + .GetPositionAtOffset(charIndex, LogicalDirection.Forward); + + return offset != null + ? offset.GetCharacterRect(LogicalDirection.Forward).BottomLeft + : new Point(0, 0); + } + + public Thickness Margin + { + get { return textBox.Margin; } + set { textBox.Margin = value; } + } + + public void Focus() + { + Keyboard.Focus(textBox); + } + + public RichTextBox TextBox + { + get + { + return textBox; + } + set + { + if (value != textBox) + { + textBox = value; + + PreviewKeyDown = Observable.FromEventPattern( + h => textBox.PreviewKeyDown += h, + h => textBox.PreviewKeyDown -= h); + + SelectionChanged = Observable.FromEventPattern( + h => textBox.SelectionChanged += h, + h => textBox.SelectionChanged -= h); + + TextChanged = Observable.FromEventPattern( + h => textBox.TextChanged += h, + h => textBox.TextChanged -= h); + + NotifyPropertyChanged("Control"); + } + + } + } + + // This is a fudge factor needed because of PromptRichTextBox. When commit messages are 51 characters or more, + // The PromptRichTextBox applies a styling that fucks up the CaretPosition by 2. :( + // This method helps us account for that. + int GetOffsetIndex(int selectionEnd) + { + if (textBox is PromptRichTextBox && selectionEnd >= PromptRichTextBox.BadCommitMessageLength) + { + return selectionEnd + promptRichTextBoxCaretIndexAdjustments; + } + return selectionEnd; + } + + private void NotifyPropertyChanged(String info) + { + var propertyChanged = PropertyChanged; + if (propertyChanged != null) + { + propertyChanged(this, new PropertyChangedEventArgs(info)); + } + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs b/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs new file mode 100644 index 0000000000..e3d2aab6b0 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/SelectorSelectionAdapter.cs @@ -0,0 +1,354 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Windows; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; + +namespace GitHub.UI +{ + /// + /// Represents the selection adapter contained in the drop-down portion of + /// an control. + /// + /// Stable + public class SelectorSelectionAdapter : ISelectionAdapter + { + /// + /// The Selector instance. + /// + private Selector selector; + + /// + /// Gets or sets a value indicating whether the selection change event + /// should not be fired. + /// + private bool IgnoringSelectionChanged { get; set; } + + /// + /// Gets or sets the underlying control. + /// + /// The underlying control. + [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", + Justification = "We do validate the parameter. Code Analysis just doesn't see it.")] + public Selector SelectorControl + { + get { return selector; } + + set + { + if (selector != null) + { + selector.SelectionChanged -= OnSelectionChanged; + selector.MouseLeftButtonUp -= OnSelectorMouseLeftButtonUp; + } + + selector = value; + + if (selector != null) + { + selector.SelectionChanged += OnSelectionChanged; + selector.MouseLeftButtonUp += OnSelectorMouseLeftButtonUp; + } + } + } + + /// + /// Occurs when the property value changes. + /// + public event SelectionChangedEventHandler SelectionChanged; + + /// + /// Occurs when an item is selected and is committed to the underlying + /// control. + /// + public event RoutedEventHandler Commit; + + /// + /// Occurs when a selection is canceled before it is committed. + /// + public event RoutedEventHandler Cancel; + + /// + /// Initializes a new instance of the class. + /// + public SelectorSelectionAdapter() + { + } + + /// + /// Initializes a new instance of the class with the specified + /// + /// control. + /// + /// The + /// control + /// to wrap as a + /// . + public SelectorSelectionAdapter(Selector selector) + { + SelectorControl = selector; + } + + /// + /// Gets or sets the selected item of the selection adapter. + /// + /// The selected item of the underlying selection adapter. + public object SelectedItem + { + get + { + return SelectorControl == null ? null : SelectorControl.SelectedItem; + } + + set + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = value; + } + + // Attempt to reset the scroll viewer's position + if (value == null) + { + ResetScrollViewer(); + } + + IgnoringSelectionChanged = false; + } + } + + /// + /// Gets or sets a collection that is used to generate the content of + /// the selection adapter. + /// + /// The collection used to generate content for the selection + /// adapter. + public IEnumerable ItemsSource + { + get + { + return SelectorControl == null ? null : SelectorControl.ItemsSource; + } + set + { + if (SelectorControl != null) + { + SelectorControl.ItemsSource = value; + } + } + } + + /// + /// If the control contains a ScrollViewer, this will reset the viewer + /// to be scrolled to the top. + /// + private void ResetScrollViewer() + { + if (SelectorControl != null) + { + var sv = SelectorControl.GetLogicalChildrenBreadthFirst().OfType().FirstOrDefault(); + if (sv != null) + { + sv.ScrollToTop(); + } + } + } + + /// + /// Handles the mouse left button up event on the selector control. + /// + /// The source object. + /// The event data. + private void OnSelectorMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + OnCommit(); + } + + /// + /// Handles the SelectionChanged event on the Selector control. + /// + /// The source object. + /// The selection changed event data. + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IgnoringSelectionChanged) + { + return; + } + + var handler = SelectionChanged; + if (handler != null) + { + handler(sender, e); + } + } + + /// + /// Increments the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexIncrement() + { + if (SelectorControl != null) + { + SelectorControl.SelectedIndex = + SelectorControl.SelectedIndex + 1 >= SelectorControl.Items.Count + ? SelectorControl.Items.Count - 1 + : SelectorControl.SelectedIndex + 1; + } + } + + /// + /// Decrements the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexDecrement() + { + if (SelectorControl != null) + { + int index = SelectorControl.SelectedIndex; + if (index >= 1) + { + SelectorControl.SelectedIndex--; + } + else + { + SelectorControl.SelectedIndex = 0; + } + } + } + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + public void HandleKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + case Key.Tab: + case Key.Right: + OnCommit(); + e.Handled = true; + break; + + case Key.Up: + SelectedIndexDecrement(); + e.Handled = true; + break; + + case Key.Down: + if ((ModifierKeys.Alt & Keyboard.Modifiers) == ModifierKeys.None) + { + SelectedIndexIncrement(); + e.Handled = true; + } + break; + + case Key.Escape: + OnCancel(); + e.Handled = true; + break; + } + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCommit() + { + OnCommit(this, new RoutedEventArgs()); + } + + /// + /// Fires the Commit event. + /// + /// The source object. + /// The event data. + private void OnCommit(object sender, RoutedEventArgs e) + { + RoutedEventHandler handler = Commit; + if (handler != null) + { + handler(sender, e); + } + + AfterAdapterAction(); + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCancel() + { + OnCancel(this, new RoutedEventArgs()); + } + + /// + /// Fires the Cancel event. + /// + /// The source object. + /// The event data. + private void OnCancel(object sender, RoutedEventArgs e) + { + var handler = Cancel; + if (handler != null) + { + handler(sender, e); + } + + AfterAdapterAction(); + } + + /// + /// Change the selection after the actions are complete. + /// + private void AfterAdapterAction() + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = null; + SelectorControl.SelectedIndex = -1; + } + IgnoringSelectionChanged = false; + } + + /// + /// Returns an automation peer for the underlying + /// + /// control, for use by the Silverlight automation infrastructure. + /// + /// An automation peer for use by the Silverlight automation + /// infrastructure. + public AutomationPeer CreateAutomationPeer() + { + return selector != null ? UIElementAutomationPeer.CreatePeerForElement(selector) : null; + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs b/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs new file mode 100644 index 0000000000..3b46c0a877 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/TextBoxAutoCompleteTextInput.cs @@ -0,0 +1,121 @@ +using System; +using System.ComponentModel; +using System.Reactive; +using System.Reactive.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Markup; +using ReactiveUI.Wpf; +using ReactiveUI; + +namespace GitHub.UI +{ + [ContentProperty("TextBox")] + public class TextBoxAutoCompleteTextInput : IAutoCompleteTextInput + { + TextBox textBox; + + public event PropertyChangedEventHandler PropertyChanged; + + public void Select(int position, int length) + { + textBox.Select(position, length); + } + + public void SelectAll() + { + textBox.SelectAll(); + } + + public int CaretIndex + { + get { return textBox.CaretIndex; } + set { textBox.CaretIndex = value; } + } + + public int SelectionStart + { + get { return textBox.SelectionStart; } + set { textBox.SelectionStart = value; } + } + + public int SelectionLength + { + get { return textBox.SelectionLength; } + } + + public string Text + { + get { return textBox.Text; } + set { textBox.Text = value; } + } + + public IObservable> PreviewKeyDown + { + get; + private set; + } + + public IObservable> SelectionChanged { get; private set; } + public IObservable> TextChanged { get; private set; } + public UIElement Control { get { return textBox; } } + + public Point GetPositionFromCharIndex(int charIndex) + { + var position = textBox.GetRectFromCharacterIndex(charIndex).BottomLeft; + position.Offset(0, 10); // Vertically pad it. Yeah, Point is mutable. WTF? + return position; + } + + public void Focus() + { + Keyboard.Focus(textBox); + } + + public TextBox TextBox + { + get + { + return textBox; + } + set + { + if (value != textBox) + { + textBox = value; + + PreviewKeyDown = Observable.FromEventPattern( + h => textBox.PreviewKeyDown += h, + h => textBox.PreviewKeyDown -= h); + + SelectionChanged = Observable.FromEventPattern( + h => textBox.SelectionChanged += h, + h => textBox.SelectionChanged -= h); + + TextChanged = Observable.FromEventPattern( + h => textBox.TextChanged += h, + h => textBox.TextChanged -= h); + + NotifyPropertyChanged("Control"); + } + } + } + + public Thickness Margin + { + get { return textBox.Margin; } + set { textBox.Margin = value; } + } + + private void NotifyPropertyChanged(String info) + { + var propertyChanged = PropertyChanged; + if (propertyChanged != null) + { + propertyChanged(this, new PropertyChangedEventArgs(info)); + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs b/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs new file mode 100644 index 0000000000..6aa065fdaf --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/VisualStates.cs @@ -0,0 +1,409 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System.Diagnostics; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace GitHub.UI +{ + /// + /// Names and helpers for visual states in the controls. + /// + internal static class VisualStates + { + #region GroupCommon + /// + /// Common state group. + /// + public const string GroupCommon = "CommonStates"; + + /// + /// Normal state of the Common state group. + /// + public const string StateNormal = "Normal"; + + /// + /// Normal state of the Common state group. + /// + public const string StateReadOnly = "ReadOnly"; + + /// + /// MouseOver state of the Common state group. + /// + public const string StateMouseOver = "MouseOver"; + + /// + /// Pressed state of the Common state group. + /// + public const string StatePressed = "Pressed"; + + /// + /// Disabled state of the Common state group. + /// + public const string StateDisabled = "Disabled"; + #endregion GroupCommon + + #region GroupFocus + /// + /// Focus state group. + /// + public const string GroupFocus = "FocusStates"; + + /// + /// Unfocused state of the Focus state group. + /// + public const string StateUnfocused = "Unfocused"; + + /// + /// Focused state of the Focus state group. + /// + public const string StateFocused = "Focused"; + #endregion GroupFocus + + #region GroupSelection + /// + /// Selection state group. + /// + public const string GroupSelection = "SelectionStates"; + + /// + /// Selected state of the Selection state group. + /// + public const string StateSelected = "Selected"; + + /// + /// Unselected state of the Selection state group. + /// + public const string StateUnselected = "Unselected"; + + /// + /// Selected inactive state of the Selection state group. + /// + public const string StateSelectedInactive = "SelectedInactive"; + #endregion GroupSelection + + #region GroupExpansion + /// + /// Expansion state group. + /// + public const string GroupExpansion = "ExpansionStates"; + + /// + /// Expanded state of the Expansion state group. + /// + public const string StateExpanded = "Expanded"; + + /// + /// Collapsed state of the Expansion state group. + /// + public const string StateCollapsed = "Collapsed"; + #endregion GroupExpansion + + #region GroupPopup + /// + /// Popup state group. + /// + public const string GroupPopup = "PopupStates"; + + /// + /// Opened state of the Popup state group. + /// + public const string StatePopupOpened = "PopupOpened"; + + /// + /// Closed state of the Popup state group. + /// + public const string StatePopupClosed = "PopupClosed"; + #endregion + + #region GroupValidation + /// + /// ValidationStates state group. + /// + public const string GroupValidation = "ValidationStates"; + + /// + /// The valid state for the ValidationStates group. + /// + public const string StateValid = "Valid"; + + /// + /// Invalid, focused state for the ValidationStates group. + /// + public const string StateInvalidFocused = "InvalidFocused"; + + /// + /// Invalid, unfocused state for the ValidationStates group. + /// + public const string StateInvalidUnfocused = "InvalidUnfocused"; + #endregion + + #region GroupExpandDirection + /// + /// ExpandDirection state group. + /// + public const string GroupExpandDirection = "ExpandDirectionStates"; + + /// + /// Down expand direction state of ExpandDirection state group. + /// + public const string StateExpandDown = "ExpandDown"; + + /// + /// Up expand direction state of ExpandDirection state group. + /// + public const string StateExpandUp = "ExpandUp"; + + /// + /// Left expand direction state of ExpandDirection state group. + /// + public const string StateExpandLeft = "ExpandLeft"; + + /// + /// Right expand direction state of ExpandDirection state group. + /// + public const string StateExpandRight = "ExpandRight"; + #endregion + + #region GroupHasItems + /// + /// HasItems state group. + /// + public const string GroupHasItems = "HasItemsStates"; + + /// + /// HasItems state of the HasItems state group. + /// + public const string StateHasItems = "HasItems"; + + /// + /// NoItems state of the HasItems state group. + /// + public const string StateNoItems = "NoItems"; + #endregion GroupHasItems + + #region GroupIncrease + /// + /// Increment state group. + /// + public const string GroupIncrease = "IncreaseStates"; + + /// + /// State enabled for increment group. + /// + public const string StateIncreaseEnabled = "IncreaseEnabled"; + + /// + /// State disabled for increment group. + /// + public const string StateIncreaseDisabled = "IncreaseDisabled"; + #endregion GroupIncrease + + #region GroupDecrease + /// + /// Decrement state group. + /// + public const string GroupDecrease = "DecreaseStates"; + + /// + /// State enabled for decrement group. + /// + public const string StateDecreaseEnabled = "DecreaseEnabled"; + + /// + /// State disabled for decrement group. + /// + public const string StateDecreaseDisabled = "DecreaseDisabled"; + #endregion GroupDecrease + + #region GroupIteractionMode + /// + /// InteractionMode state group. + /// + public const string GroupInteractionMode = "InteractionModeStates"; + + /// + /// Edit of the DisplayMode state group. + /// + public const string StateEdit = "Edit"; + + /// + /// Display of the DisplayMode state group. + /// + public const string StateDisplay = "Display"; + #endregion GroupIteractionMode + + #region GroupLocked + /// + /// DisplayMode state group. + /// + public const string GroupLocked = "LockedStates"; + + /// + /// Edit of the DisplayMode state group. + /// + public const string StateLocked = "Locked"; + + /// + /// Display of the DisplayMode state group. + /// + public const string StateUnlocked = "Unlocked"; + #endregion GroupLocked + + #region GroupActive + /// + /// Active state. + /// + public const string StateActive = "Active"; + + /// + /// Inactive state. + /// + public const string StateInactive = "Inactive"; + + /// + /// Active state group. + /// + public const string GroupActive = "ActiveStates"; + #endregion GroupActive + + #region GroupWatermark + /// + /// Non-watermarked state. + /// + public const string StateUnwatermarked = "Unwatermarked"; + + /// + /// Watermarked state. + /// + public const string StateWatermarked = "Watermarked"; + + /// + /// Watermark state group. + /// + public const string GroupWatermark = "WatermarkStates"; + #endregion GroupWatermark + + #region GroupCalendarButtonFocus + /// + /// Unfocused state for Calendar Buttons. + /// + public const string StateCalendarButtonUnfocused = "CalendarButtonUnfocused"; + + /// + /// Focused state for Calendar Buttons. + /// + public const string StateCalendarButtonFocused = "CalendarButtonFocused"; + + /// + /// CalendarButtons Focus state group. + /// + public const string GroupCalendarButtonFocus = "CalendarButtonFocusStates"; + #endregion GroupCalendarButtonFocus + + #region GroupBusyStatus + /// + /// Busy state for BusyIndicator. + /// + public const string StateBusy = "Busy"; + + /// + /// Idle state for BusyIndicator. + /// + public const string StateIdle = "Idle"; + + /// + /// Busyness group name. + /// + public const string GroupBusyStatus = "BusyStatusStates"; + #endregion + + #region GroupVisibility + /// + /// Visible state name for BusyIndicator. + /// + public const string StateVisible = "Visible"; + + /// + /// Hidden state name for BusyIndicator. + /// + public const string StateHidden = "Hidden"; + + /// + /// BusyDisplay group. + /// + public const string GroupVisibility = "VisibilityStates"; + #endregion + + /// + /// Use VisualStateManager to change the visual state of the control. + /// + /// + /// Control whose visual state is being changed. + /// + /// + /// A value indicating whether to use transitions when updating the + /// visual state, or to snap directly to the new visual state. + /// + /// + /// Ordered list of state names and fallback states to transition into. + /// Only the first state to be found will be used. + /// + public static void GoToState(Control control, bool useTransitions, params string[] stateNames) + { + Debug.Assert(control != null, "control should not be null!"); + Debug.Assert(stateNames != null, "stateNames should not be null!"); + Debug.Assert(stateNames.Length > 0, "stateNames should not be empty!"); + + foreach (string name in stateNames) + { + if (VisualStateManager.GoToState(control, name, useTransitions)) + { + break; + } + } + } + + /// + /// Gets the implementation root of the Control. + /// + /// The DependencyObject. + /// + /// Implements Silverlight's corresponding internal property on Control. + /// + /// Returns the implementation root or null. + public static FrameworkElement GetImplementationRoot(DependencyObject dependencyObject) + { + Debug.Assert(dependencyObject != null, "DependencyObject should not be null."); + return (1 == VisualTreeHelper.GetChildrenCount(dependencyObject)) ? + VisualTreeHelper.GetChild(dependencyObject, 0) as FrameworkElement : + null; + } + + /// + /// This method tries to get the named VisualStateGroup for the + /// dependency object. The provided object's ImplementationRoot will be + /// looked up in this call. + /// + /// The dependency object. + /// The visual state group's name. + /// Returns null or the VisualStateGroup object. + public static VisualStateGroup TryGetVisualStateGroup(DependencyObject dependencyObject, string groupName) + { + var root = GetImplementationRoot(dependencyObject); + if (root == null) + { + return null; + } + + return VisualStateManager.GetVisualStateGroups(root) + .OfType() + .FirstOrDefault(group => string.CompareOrdinal(groupName, @group.Name) == 0); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs b/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs new file mode 100644 index 0000000000..b6cc0966c5 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/VisualTreeExtensions.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Windows; +using System.Windows.Media; + +namespace GitHub.UI +{ + public static class VisualTreeExtensions + { + /// + /// Retrieves all the visual children of a framework element. + /// + /// The parent framework element. + /// The visual children of the framework element. + internal static IEnumerable GetVisualChildren(this DependencyObject parent) + { + Debug.Assert(parent != null, "The parent cannot be null."); + + int childCount = VisualTreeHelper.GetChildrenCount(parent); + for (int counter = 0; counter < childCount; counter++) + { + yield return VisualTreeHelper.GetChild(parent, counter); + } + } + + /// + /// Retrieves all the logical children of a framework element using a + /// breadth-first search. A visual element is assumed to be a logical + /// child of another visual element if they are in the same namescope. + /// For performance reasons this method manually manages the queue + /// instead of using recursion. + /// + /// + /// License for this method. + /// + /// (c) Copyright Microsoft Corporation. + /// This source is subject to the Microsoft Public License (Ms-PL). + /// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. + /// All other rights reserved. + /// + /// The parent framework element. + /// The logical children of the framework element. + internal static IEnumerable GetLogicalChildrenBreadthFirst(this FrameworkElement parent) + { + Debug.Assert(parent != null, "The parent cannot be null."); + + var queue = new Queue(parent.GetVisualChildren().OfType()); + + while (queue.Count > 0) + { + var element = queue.Dequeue(); + yield return element; + + foreach (var visualChild in element.GetVisualChildren().OfType()) + { + queue.Enqueue(visualChild); + } + } + } + + internal static Window GetActiveWindow(this Application application) + { + var windows = application.Windows; + if (windows.Count == 0) return null; + return windows.Count == 1 + ? windows[0] // Optimization. I think this is the common case for us. + : windows.Cast().FirstOrDefault(x => x.IsActive); + } + } +} diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs b/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs new file mode 100644 index 0000000000..f9a76e5581 --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/WeakEventListener.cs @@ -0,0 +1,84 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993] for details. +// All other rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace GitHub.UI +{ + /// + /// Implements a weak event listener that allows the owner to be garbage + /// collected if its only remaining link is an event handler. + /// + /// Type of instance listening for the event. + /// Type of source for the event. + /// Type of event arguments for the event. + [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Used as link target in several projects.")] + internal class WeakEventListener where TInstance : class + { + /// + /// WeakReference to the instance listening for the event. + /// + private readonly WeakReference weakInstance; + + /// + /// Gets or sets the method to call when the event fires. + /// + public Action OnEventAction { get; set; } + + /// + /// Gets or sets the method to call when detaching from the event. + /// + public Action> OnDetachAction { get; set; } + + /// + /// Initializes a new instances of the WeakEventListener class. + /// + /// Instance subscribing to the event. + public WeakEventListener(TInstance instance) + { + if (null == instance) + { + throw new ArgumentNullException("instance"); + } + weakInstance = new WeakReference(instance); + } + + /// + /// Handler for the subscribed event calls OnEventAction to handle it. + /// + /// Event source. + /// Event arguments. + public void OnEvent(TSource source, TEventArgs eventArgs) + { + var target = (TInstance)weakInstance.Target; + if (null != target) + { + // Call registered action + if (null != OnEventAction) + { + OnEventAction(target, source, eventArgs); + } + } + else + { + // Detach from event + Detach(); + } + } + + /// + /// Detaches from the subscribed event. + /// + public void Detach() + { + if (null != OnDetachAction) + { + OnDetachAction(this); + OnDetachAction = null; + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/AutoCompleteBox/_README.md b/src/GitHub.UI/Controls/AutoCompleteBox/_README.md new file mode 100644 index 0000000000..c601dbfeda --- /dev/null +++ b/src/GitHub.UI/Controls/AutoCompleteBox/_README.md @@ -0,0 +1,40 @@ +# WPF Toolkit + +This folder contains code copied and adapted from the [WPF Toolkit](http://wpf.codeplex.com/) project under the MS-PL +license. + +This contains the AutoCompleteBox code. + +# LICENSE + +Microsoft Public License (Ms-PL) + +This license governs use of the accompanying software. If you use the software, you accept this license. If you do not accept the license, do not use the software. + +1. Definitions + +The terms "reproduce," "reproduction," "derivative works," and "distribution" have the same meaning here as under U.S. copyright law. + +A "contribution" is the original software, or any additions or changes to the software. + +A "contributor" is any person that distributes its contribution under this license. + +"Licensed patents" are a contributor's patent claims that read directly on its contribution. + +2. Grant of Rights + +(A) Copyright Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free copyright license to reproduce its contribution, prepare derivative works of its contribution, and distribute its contribution or any derivative works that you create. + +(B) Patent Grant- Subject to the terms of this license, including the license conditions and limitations in section 3, each contributor grants you a non-exclusive, worldwide, royalty-free license under its licensed patents to make, have made, use, sell, offer for sale, import, and/or otherwise dispose of its contribution in the software or derivative works of the contribution in the software. + +3. Conditions and Limitations + +(A) No Trademark License- This license does not grant you rights to use any contributors' name, logo, or trademarks. + +(B) If you bring a patent claim against any contributor over patents that you claim are infringed by the software, your patent license from such contributor to the software ends automatically. + +(C) If you distribute any portion of the software, you must retain all copyright, patent, trademark, and attribution notices that are present in the software. + +(D) If you distribute any portion of the software in source code form, you may do so only under this license by including a complete copy of this license with your distribution. If you distribute any portion of the software in compiled or object code form, you may only do so under a license that complies with this license. + +(E) The software is licensed "as-is." You bear the risk of using it. The contributors give no express warranties, guarantees or conditions. You may have additional consumer rights under your local laws which this license cannot change. To the extent permitted under your local laws, the contributors exclude the implied warranties of merchantability, fitness for a particular purpose and non-infringement. \ No newline at end of file diff --git a/src/GitHub.UI/Controls/Buttons/OcticonButton.cs b/src/GitHub.UI/Controls/Buttons/OcticonButton.cs index 247562366e..6d963995a2 100644 --- a/src/GitHub.UI/Controls/Buttons/OcticonButton.cs +++ b/src/GitHub.UI/Controls/Buttons/OcticonButton.cs @@ -1,5 +1,4 @@ -using NullGuard; -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -22,17 +21,16 @@ public double IconRotationAngle set { SetValue(IconRotationAngleProperty, value); } } - public static DependencyProperty DataProperty = + public static readonly DependencyProperty DataProperty = Path.DataProperty.AddOwner(typeof(OcticonButton)); public Geometry Data { - [return: AllowNull] get { return (Geometry)GetValue(DataProperty); } set { SetValue(DataProperty, value); } } - public static DependencyProperty IconProperty = + public static readonly DependencyProperty IconProperty = OcticonPath.IconProperty.AddOwner( typeof(OcticonButton), new FrameworkPropertyMetadata(defaultValue: Octicon.mark_github, flags: @@ -43,7 +41,6 @@ public Geometry Data public Octicon Icon { - [return: AllowNull] get { return (Octicon)GetValue(OcticonPath.IconProperty); } set { SetValue(OcticonPath.IconProperty, value); } } diff --git a/src/GitHub.UI/Controls/Buttons/OcticonCircleButton.cs b/src/GitHub.UI/Controls/Buttons/OcticonCircleButton.cs deleted file mode 100644 index 722b680d26..0000000000 --- a/src/GitHub.UI/Controls/Buttons/OcticonCircleButton.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Windows; -using System.Windows.Media; -using System.Windows.Shapes; -using NullGuard; - -namespace GitHub.UI -{ - public class OcticonCircleButton : OcticonButton - { - public static readonly DependencyProperty ShowSpinnerProperty = DependencyProperty.Register( - "ShowSpinner", typeof(bool), typeof(OcticonCircleButton)); - - public static readonly DependencyProperty IconForegroundProperty = DependencyProperty.Register( - "IconForeground", typeof(Brush), typeof(OcticonCircleButton)); - - public static readonly DependencyProperty ActiveBackgroundProperty = DependencyProperty.Register( - "ActiveBackground", typeof(Brush), typeof(OcticonCircleButton)); - - public static readonly DependencyProperty ActiveForegroundProperty = DependencyProperty.Register( - "ActiveForeground", typeof(Brush), typeof(OcticonCircleButton)); - - public static readonly DependencyProperty PressedBackgroundProperty = DependencyProperty.Register( - "PressedBackground", typeof(Brush), typeof(OcticonCircleButton)); - - public static readonly DependencyProperty IconSizeProperty = DependencyProperty.Register( - "IconSize", typeof(double), typeof(OcticonCircleButton), new FrameworkPropertyMetadata(16d, - FrameworkPropertyMetadataOptions.AffectsArrange | - FrameworkPropertyMetadataOptions.AffectsMeasure | - FrameworkPropertyMetadataOptions.AffectsRender)); - - public bool ShowSpinner - { - get { return (bool)GetValue(ShowSpinnerProperty); } - set { SetValue(ShowSpinnerProperty, value); } - } - - public Brush IconForeground - { - [return: AllowNull] - get { return (Brush)GetValue(IconForegroundProperty); } - set { SetValue(IconForegroundProperty, value); } - } - - public Brush ActiveBackground - { - [return: AllowNull] - get { return (Brush)GetValue(ActiveBackgroundProperty); } - set { SetValue(ActiveBackgroundProperty, value); } - } - - public Brush ActiveForeground - { - [return: AllowNull] - get { return (Brush)GetValue(ActiveForegroundProperty); } - set { SetValue(ActiveForegroundProperty, value); } - } - - public Brush PressedBackground - { - [return: AllowNull] - get { return (Brush)GetValue(PressedBackgroundProperty); } - set { SetValue(PressedBackgroundProperty, value); } - } - - public double IconSize - { - get { return (double)GetValue(IconSizeProperty); } - set { SetValue(IconSizeProperty, value); } - } - - static OcticonCircleButton() - { - Path.DataProperty.AddOwner(typeof(OcticonCircleButton)); - } - - static void OnIconChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - d.SetValue(Path.DataProperty, OcticonPath.GetGeometryForIcon((Octicon)e.NewValue)); - } - } -} diff --git a/src/GitHub.UI/Controls/Buttons/OcticonCircleButton.xaml b/src/GitHub.UI/Controls/Buttons/OcticonCircleButton.xaml deleted file mode 100644 index 09fa8bdb25..0000000000 --- a/src/GitHub.UI/Controls/Buttons/OcticonCircleButton.xaml +++ /dev/null @@ -1,298 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/src/GitHub.UI/Controls/FilterTextBox.cs b/src/GitHub.UI/Controls/FilterTextBox.cs index 7ffc7ee209..dafd704731 100644 --- a/src/GitHub.UI/Controls/FilterTextBox.cs +++ b/src/GitHub.UI/Controls/FilterTextBox.cs @@ -4,7 +4,6 @@ using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; -using NullGuard; namespace GitHub.UI { @@ -17,7 +16,6 @@ public class FilterTextBox : TextBox [DefaultValue("Filter")] public string PromptText { - [return: AllowNull] get { return (string)GetValue(PromptTextProperty); } set { SetValue(PromptTextProperty, value); } } diff --git a/src/GitHub.UI/Controls/HorizontalShadowDivider.xaml b/src/GitHub.UI/Controls/HorizontalShadowDivider.xaml deleted file mode 100644 index 7f3daa680a..0000000000 --- a/src/GitHub.UI/Controls/HorizontalShadowDivider.xaml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.UI/Controls/ImageButton.cs b/src/GitHub.UI/Controls/ImageButton.cs index 23f33d159b..d5fe602c6a 100644 --- a/src/GitHub.UI/Controls/ImageButton.cs +++ b/src/GitHub.UI/Controls/ImageButton.cs @@ -1,6 +1,8 @@ using System.Windows; using System.Windows.Media; +#pragma warning disable CA1720 // Identifier contains type name + namespace GitHub.UI { public class ImageButton : DependencyObject diff --git a/src/GitHub.UI/Controls/Markdown.cs b/src/GitHub.UI/Controls/Markdown.cs deleted file mode 100644 index 67a114afbe..0000000000 --- a/src/GitHub.UI/Controls/Markdown.cs +++ /dev/null @@ -1,984 +0,0 @@ -/* - * Based on https://github.com/theunrepentantgeek/Markdown.XAML (e4435c0291) - */ - -using GitHub.Extensions; -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Markup; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Shapes; - -namespace GitHub.UI -{ - public class Markdown : DependencyObject - { - /// - /// maximum nested depth of [] and () supported by the transform; implementation detail - /// - const int nestDepth = 6; - - /// - /// Tabs are automatically converted to spaces as part of the transform - /// this constant determines how "wide" those tabs become in spaces - /// - const int tabWidth = 4; - - const string markerUL = @"[*+-]"; - const string markerOL = @"\d+[.]"; - - int listLevel; - - /// - /// when true, bold and italic require non-word characters on either side - /// WARNING: this is a significant deviation from the markdown spec - /// - /// - public bool StrictBoldItalic { get; set; } - - public ICommand HyperlinkCommand - { - get { return (ICommand)GetValue(HyperlinkCommandProperty); } - set { SetValue(HyperlinkCommandProperty, value); } - } - - public static readonly DependencyProperty HyperlinkCommandProperty = - DependencyProperty.Register("HyperlinkCommand", typeof(ICommand), typeof(Markdown), new PropertyMetadata(null)); - - public Style DocumentStyle - { - get { return (Style)GetValue(DocumentStyleProperty); } - set { SetValue(DocumentStyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for DocumentStyle. This enables animation, styling, binding, etc... - public static readonly DependencyProperty DocumentStyleProperty = - DependencyProperty.Register("DocumentStyle", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style Heading1Style - { - get { return (Style)GetValue(Heading1StyleProperty); } - set { SetValue(Heading1StyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for Heading1Style. This enables animation, styling, binding, etc... - public static readonly DependencyProperty Heading1StyleProperty = - DependencyProperty.Register("Heading1Style", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style Heading2Style - { - get { return (Style)GetValue(Heading2StyleProperty); } - set { SetValue(Heading2StyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for Heading2Style. This enables animation, styling, binding, etc... - public static readonly DependencyProperty Heading2StyleProperty = - DependencyProperty.Register("Heading2Style", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style Heading3Style - { - get { return (Style)GetValue(Heading3StyleProperty); } - set { SetValue(Heading3StyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for Heading3Style. This enables animation, styling, binding, etc... - public static readonly DependencyProperty Heading3StyleProperty = - DependencyProperty.Register("Heading3Style", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style Heading4Style - { - get { return (Style)GetValue(Heading4StyleProperty); } - set { SetValue(Heading4StyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for Heading4Style. This enables animation, styling, binding, etc... - public static readonly DependencyProperty Heading4StyleProperty = - DependencyProperty.Register("Heading4Style", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style CodeStyle - { - get { return (Style)GetValue(CodeStyleProperty); } - set { SetValue(CodeStyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for CodeStyle. This enables animation, styling, binding, etc... - public static readonly DependencyProperty CodeStyleProperty = - DependencyProperty.Register("CodeStyle", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style LinkStyle - { - get { return (Style)GetValue(LinkStyleProperty); } - set { SetValue(LinkStyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for LinkStyle. This enables animation, styling, binding, etc... - public static readonly DependencyProperty LinkStyleProperty = - DependencyProperty.Register("LinkStyle", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style ImageStyle - { - get { return (Style)GetValue(ImageStyleProperty); } - set { SetValue(ImageStyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for ImageStyle. This enables animation, styling, binding, etc... - public static readonly DependencyProperty ImageStyleProperty = - DependencyProperty.Register("ImageStyle", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public Style SeparatorStyle - { - get { return (Style)GetValue(SeparatorStyleProperty); } - set { SetValue(SeparatorStyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for SeparatorStyle. This enables animation, styling, binding, etc... - public static readonly DependencyProperty SeparatorStyleProperty = - DependencyProperty.Register("SeparatorStyle", typeof(Style), typeof(Markdown), new PropertyMetadata(null)); - - public string AssetPathRoot - { - get { return (string)GetValue(AssetPathRootProperty); } - set { SetValue(AssetPathRootProperty, value); } - } - - public static readonly DependencyProperty AssetPathRootProperty = - DependencyProperty.Register("AssetPathRootRoot", typeof(string), typeof(Markdown), new PropertyMetadata(null)); - - public Markdown() - { - //HyperlinkCommand = NavigationCommands.GoToPage; - } - - public FlowDocument Transform(string text) - { - Guard.ArgumentNotNull(text, nameof(text)); - - text = Normalize(text); - var document = Create(RunBlockGamut(text)); - - if (DocumentStyle != null) - { - document.Style = DocumentStyle; - } - else - { - document.PagePadding = new Thickness(0); - } - - return document; - } - - /// - /// Perform transformations that form block-level tags like paragraphs, headers, and list items. - /// - IEnumerable RunBlockGamut(string text) - { - Guard.ArgumentNotNull(text, nameof(text)); - - return DoHeaders(text, - s1 => DoHorizontalRules(s1, - s2 => DoLists(s2, - sn => FormParagraphs(sn)))); - - //text = DoCodeBlocks(text); - //text = DoBlockQuotes(text); - - //// We already ran HashHTMLBlocks() before, in Markdown(), but that - //// was to escape raw HTML in the original Markdown source. This time, - //// we're escaping the markup we've just created, so that we don't wrap - ////

tags around block-level tags. - //text = HashHTMLBlocks(text); - - //text = FormParagraphs(text); - - //return text; - } - - ///

- /// Perform transformations that occur *within* block-level tags like paragraphs, headers, and list items. - /// - IEnumerable RunSpanGamut(string text) - { - Guard.ArgumentNotNull(text, nameof(text)); - - return DoCodeSpans(text, - s0 => DoImages(s0, - s1 => DoAnchors(s1, - s2 => DoItalicsAndBold(s2, - s3 => DoText(s3))))); - - //text = EscapeSpecialCharsWithinTagAttributes(text); - //text = EscapeBackslashes(text); - - //// Images must come first, because ![foo][f] looks like an anchor. - //text = DoImages(text); - //text = DoAnchors(text); - - //// Must come after DoAnchors(), because you can use < and > - //// delimiters in inline links like [this](). - //text = DoAutoLinks(text); - - //text = EncodeAmpsAndAngles(text); - //text = DoItalicsAndBold(text); - //text = DoHardBreaks(text); - - //return text; - } - - static Regex newlinesLeadingTrailing = new Regex(@"^\n+|\n+\z", RegexOptions.Compiled); - static Regex newlinesMultiple = new Regex(@"\n{2,}", RegexOptions.Compiled); - static Regex leadingWhitespace = new Regex(@"^[ ]*", RegexOptions.Compiled); - - /// - /// splits on two or more newlines, to form "paragraphs"; - /// - IEnumerable FormParagraphs(string text) - { - Guard.ArgumentNotNull(text, nameof(text)); - - // split on two or more newlines - string[] grafs = newlinesMultiple.Split(newlinesLeadingTrailing.Replace(text, "")); - - foreach (var g in grafs) - { - yield return Create(RunSpanGamut(g)); - } - } - - static string nestedBracketsPattern; - - /// - /// Reusable pattern to match balanced [brackets]. See Friedl's - /// "Mastering Regular Expressions", 2nd Ed., pp. 328-331. - /// - static string GetNestedBracketsPattern() - { - // in other words [this] and [this[also]] and [this[also[too]]] - // up to nestDepth - if (nestedBracketsPattern == null) - nestedBracketsPattern = - RepeatString(@" - (?> # Atomic matching - [^\[\]]+ # Anything other than brackets - | - \[ - ", nestDepth) + RepeatString( - @" \] - )*" - , nestDepth); - return nestedBracketsPattern; - } - - static string nestedParensPattern; - - /// - /// Reusable pattern to match balanced (parens). See Friedl's - /// "Mastering Regular Expressions", 2nd Ed., pp. 328-331. - /// - static string GetNestedParensPattern() - { - // in other words (this) and (this(also)) and (this(also(too))) - // up to nestDepth - if (nestedParensPattern == null) - nestedParensPattern = - RepeatString(@" - (?> # Atomic matching - [^()\s]+ # Anything other than parens or whitespace - | - \( - ", nestDepth) + RepeatString( - @" \) - )*" - , nestDepth); - return nestedParensPattern; - } - - static string nestedParensPatternWithWhiteSpace; - - /// - /// Reusable pattern to match balanced (parens), including whitespace. See Friedl's - /// "Mastering Regular Expressions", 2nd Ed., pp. 328-331. - /// - static string GetNestedParensPatternWithWhiteSpace() - { - // in other words (this) and (this(also)) and (this(also(too))) - // up to nestDepth - if (nestedParensPatternWithWhiteSpace == null) - nestedParensPatternWithWhiteSpace = - RepeatString(@" - (?> # Atomic matching - [^()]+ # Anything other than parens - | - \( - ", nestDepth) + RepeatString( - @" \) - )*" - , nestDepth); - return nestedParensPatternWithWhiteSpace; - } - - static Regex imageInline = new Regex(string.Format(@" - ( # wrap whole match in $1 - !\[ - ({0}) # link text = $2 - \] - \( # literal paren - [ ]* - ({1}) # href = $3 - [ ]* - ( # $4 - (['""]) # quote char = $5 - (.*?) # title = $6 - \5 # matching quote - #[ ]* # ignore any spaces between closing quote and ) - )? # title is optional - \) - )", GetNestedBracketsPattern(), GetNestedParensPatternWithWhiteSpace()), - RegexOptions.Singleline | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - - static Regex anchorInline = new Regex(string.Format(@" - ( # wrap whole match in $1 - \[ - ({0}) # link text = $2 - \] - \( # literal paren - [ ]* - ({1}) # href = $3 - [ ]* - ( # $4 - (['""]) # quote char = $5 - (.*?) # title = $6 - \5 # matching quote - [ ]* # ignore any spaces between closing quote and ) - )? # title is optional - \) - )", GetNestedBracketsPattern(), GetNestedParensPattern()), - RegexOptions.Singleline | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - - /// - /// Turn Markdown images into images - /// - /// - /// ![image alt](url) - /// - IEnumerable DoImages(string text, Func> defaultHandler) - { - Guard.ArgumentNotNull(text, nameof(text)); - - return Evaluate(text, imageInline, ImageInlineEvaluator, defaultHandler); - } - - Inline ImageInlineEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - string linkText = match.Groups[2].Value; - string url = match.Groups[3].Value; - BitmapImage imgSource = null; - try - { - if (!Uri.IsWellFormedUriString(url, UriKind.Absolute) && !System.IO.Path.IsPathRooted(url)) - { - url = System.IO.Path.Combine(AssetPathRoot ?? string.Empty, url); - } - - imgSource = new BitmapImage(new Uri(url, UriKind.RelativeOrAbsolute)); - } - catch (Exception) - { - return new Run("!" + url) { Foreground = Brushes.Red }; - } - - Image image = new Image { Source = imgSource, Tag = linkText }; - if (ImageStyle == null) - { - image.Margin = new Thickness(0); - } - else - { - image.Style = ImageStyle; - } - - // Bind size so document is updated when image is downloaded - if (imgSource.IsDownloading) - { - Binding binding = new Binding(nameof(BitmapImage.Width)); - binding.Source = imgSource; - binding.Mode = BindingMode.OneWay; - - BindingExpressionBase bindingExpression = BindingOperations.SetBinding(image, Image.WidthProperty, binding); - EventHandler downloadCompletedHandler = null; - downloadCompletedHandler = (sender, e) => - { - imgSource.DownloadCompleted -= downloadCompletedHandler; - bindingExpression.UpdateTarget(); - }; - imgSource.DownloadCompleted += downloadCompletedHandler; - } - else - { - image.Width = imgSource.Width; - } - - return new InlineUIContainer(image); - } - - /// - /// Turn Markdown link shortcuts into hyperlinks - /// - /// - /// [link text](url "title") - /// - IEnumerable DoAnchors(string text, Func> defaultHandler) - { - Guard.ArgumentNotNull(text, nameof(text)); - - // Next, inline-style links: [link text](url "optional title") or [link text](url "optional title") - return Evaluate(text, anchorInline, AnchorInlineEvaluator, defaultHandler); - } - - Inline AnchorInlineEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - string linkText = match.Groups[2].Value; - string url = match.Groups[3].Value; - string title = match.Groups[6].Value; - - var result = Create(RunSpanGamut(linkText)); - result.Command = HyperlinkCommand; - result.CommandParameter = url; - if (LinkStyle != null) - { - result.Style = LinkStyle; - } - - return result; - } - - static Regex headerSetext = new Regex(@" - ^(.+?) - [ ]* - \n - (=+|-+) # $1 = string of ='s or -'s - [ ]* - \n+", - RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - - static Regex headerAtx = new Regex(@" - ^(\#{1,6}) # $1 = string of #'s - [ ]* - (.+?) # $2 = Header text - [ ]* - \#* # optional closing #'s (not counted) - \n+", - RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - - /// - /// Turn Markdown headers into HTML header tags - /// - /// - /// Header 1 - /// ======== - /// - /// Header 2 - /// -------- - /// - /// # Header 1 - /// ## Header 2 - /// ## Header 2 with closing hashes ## - /// ... - /// ###### Header 6 - /// - IEnumerable DoHeaders(string text, Func> defaultHandler) - { - Guard.ArgumentNotNull(text, nameof(text)); - - return Evaluate(text, headerSetext, m => SetextHeaderEvaluator(m), - s => Evaluate(s, headerAtx, m => AtxHeaderEvaluator(m), defaultHandler)); - } - - Block SetextHeaderEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - string header = match.Groups[1].Value; - int level = match.Groups[2].Value.StartsWith("=") ? 1 : 2; - - //TODO: Style the paragraph based on the header level - return CreateHeader(level, RunSpanGamut(header.Trim())); - } - - Block AtxHeaderEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - string header = match.Groups[2].Value; - int level = match.Groups[1].Value.Length; - return CreateHeader(level, RunSpanGamut(header)); - } - - public Block CreateHeader(int level, IEnumerable content) - { - Guard.ArgumentNotNull(content, nameof(content)); - - var block = Create(content); - - switch (level) - { - case 1: - if (Heading1Style != null) - { - block.Style = Heading1Style; - } - break; - - case 2: - if (Heading2Style != null) - { - block.Style = Heading2Style; - } - break; - - case 3: - if (Heading3Style != null) - { - block.Style = Heading3Style; - } - break; - - case 4: - if (Heading4Style != null) - { - block.Style = Heading4Style; - } - break; - } - - return block; - } - - static Regex horizontalRules = new Regex(@" - ^[ ]{0,3} # Leading space - ([-*_]) # $1: First marker - (?> # Repeated marker group - [ ]{0,2} # Zero, one, or two spaces. - \1 # Marker character - ){2,} # Group repeated at least twice - [ ]* # Trailing spaces - $ # End of line. - ", RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - - /// - /// Turn Markdown horizontal rules into HTML hr tags - /// - /// - /// *** - /// * * * - /// --- - /// - - - - /// - IEnumerable DoHorizontalRules(string text, Func> defaultHandler) - { - Guard.ArgumentNotNull(text, nameof(text)); - - return Evaluate(text, horizontalRules, RuleEvaluator, defaultHandler); - } - - Block RuleEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - Line line = new Line(); - if (SeparatorStyle == null) - { - line.X2 = 1; - line.StrokeThickness = 1.0; - } - else - { - line.Style = SeparatorStyle; - } - - var container = new BlockUIContainer(line); - return container; - } - - static string wholeList = string.Format(@" - ( # $1 = whole list - ( # $2 - [ ]{{0,{1}}} - ({0}) # $3 = first list item marker - [ ]+ - ) - (?s:.+?) - ( # $4 - \z - | - \n{{2,}} - (?=\S) - (?! # Negative lookahead for another list item marker - [ ]* - {0}[ ]+ - ) - ) - )", string.Format("(?:{0}|{1})", markerUL, markerOL), tabWidth - 1); - - static Regex listNested = new Regex(@"^" + wholeList, - RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - - static Regex listTopLevel = new Regex(@"(?:(?<=\n\n)|\A\n?)" + wholeList, - RegexOptions.Multiline | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - - /// - /// Turn Markdown lists into HTML ul and ol and li tags - /// - IEnumerable DoLists(string text, Func> defaultHandler) - { - Guard.ArgumentNotNull(text, nameof(text)); - - // We use a different prefix before nested lists than top-level lists. - // See extended comment in ProcessListItems(). - if (listLevel > 0) - return Evaluate(text, listNested, ListEvaluator, defaultHandler); - else - return Evaluate(text, listTopLevel, ListEvaluator, defaultHandler); - } - - Block ListEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - string list = match.Groups[1].Value; - string listType = Regex.IsMatch(match.Groups[3].Value, markerUL) ? "ul" : "ol"; - - // Turn double returns into triple returns, so that we can make a - // paragraph for the last item in a list, if necessary: - list = Regex.Replace(list, @"\n{2,}", "\n\n\n"); - - var resultList = Create(ProcessListItems(list, listType == "ul" ? markerUL : markerOL)); - - resultList.MarkerStyle = listType == "ul" ? TextMarkerStyle.Disc : TextMarkerStyle.Decimal; - - return resultList; - } - - /// - /// Process the contents of a single ordered or unordered list, splitting it - /// into individual list items. - /// - IEnumerable ProcessListItems(string list, string marker) - { - // The listLevel global keeps track of when we're inside a list. - // Each time we enter a list, we increment it; when we leave a list, - // we decrement. If it's zero, we're not in a list anymore. - - // We do this because when we're not inside a list, we want to treat - // something like this: - - // I recommend upgrading to version - // 8. Oops, now this line is treated - // as a sub-list. - - // As a single paragraph, despite the fact that the second line starts - // with a digit-period-space sequence. - - // Whereas when we're inside a list (or sub-list), that line will be - // treated as the start of a sub-list. What a kludge, huh? This is - // an aspect of Markdown's syntax that's hard to parse perfectly - // without resorting to mind-reading. Perhaps the solution is to - // change the syntax rules such that sub-lists must start with a - // starting cardinal number; e.g. "1." or "a.". - - listLevel++; - try - { - // Trim trailing blank lines: - list = Regex.Replace(list, @"\n{2,}\z", "\n"); - - string pattern = string.Format( - @"(\n)? # leading line = $1 - (^[ ]*) # leading whitespace = $2 - ({0}) [ ]+ # list marker = $3 - ((?s:.+?) # list item text = $4 - (\n{{1,2}})) - (?= \n* (\z | \2 ({0}) [ ]+))", marker); - - var regex = new Regex(pattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Multiline); - var matches = regex.Matches(list); - foreach (Match m in matches) - { - yield return ListItemEvaluator(m); - } - } - finally - { - listLevel--; - } - } - - ListItem ListItemEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - string item = match.Groups[4].Value; - string leadingLine = match.Groups[1].Value; - - if (!String.IsNullOrEmpty(leadingLine) || Regex.IsMatch(item, @"\n{2,}")) - // we could correct any bad indentation here.. - return Create(RunBlockGamut(item)); - else - { - // recursion for sub-lists - return Create(RunBlockGamut(item)); - } - } - - static Regex codeSpan = new Regex(@" - (? - /// Turn Markdown `code spans` into HTML code tags - /// - IEnumerable DoCodeSpans(string text, Func> defaultHandler) - { - Guard.ArgumentNotNull(text, nameof(text)); - - // * You can use multiple backticks as the delimiters if you want to - // include literal backticks in the code span. So, this input: - // - // Just type ``foo `bar` baz`` at the prompt. - // - // Will translate to: - // - //

Just type foo `bar` baz at the prompt.

- // - // There's no arbitrary limit to the number of backticks you - // can use as delimters. If you need three consecutive backticks - // in your code, use four for delimiters, etc. - // - // * You can use spaces to get literal backticks at the edges: - // - // ... type `` `bar` `` ... - // - // Turns to: - // - // ... type `bar` ... - // - - return Evaluate(text, codeSpan, CodeSpanEvaluator, defaultHandler); - } - - Inline CodeSpanEvaluator(Match match) - { - Guard.ArgumentNotNull(match, nameof(match)); - - string span = match.Groups[2].Value; - span = Regex.Replace(span, @"^[ ]*", ""); // leading whitespace - span = Regex.Replace(span, @"[ ]*$", ""); // trailing whitespace - - var result = new Run(span); - if (CodeStyle != null) - { - result.Style = CodeStyle; - } - - return result; - } - - static Regex bold = new Regex(@"(\*\*|__) (?=\S) (.+?[*_]*) (?<=\S) \1", - RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline | RegexOptions.Compiled); - static Regex strictBold = new Regex(@"([\W_]|^) (\*\*|__) (?=\S) ([^\r]*?\S[\*_]*) \2 ([\W_]|$)", - RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline | RegexOptions.Compiled); - - static Regex italic = new Regex(@"(\*|_) (?=\S) (.+?) (?<=\S) \1", - RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline | RegexOptions.Compiled); - static Regex strictItalic = new Regex(@"([\W_]|^) (\*|_) (?=\S) ([^\r\*_]*?\S) \2 ([\W_]|$)", - RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline | RegexOptions.Compiled); - - /// - /// Turn Markdown *italics* and **bold** into HTML strong and em tags - /// - IEnumerable DoItalicsAndBold(string text, Func> defaultHandler) - { - Guard.ArgumentNotNull(text, nameof(text)); - - // must go first, then - if (StrictBoldItalic) - { - return Evaluate(text, strictBold, m => BoldEvaluator(m, 3), - s1 => Evaluate(s1, strictItalic, m => ItalicEvaluator(m, 3), - s2 => defaultHandler(s2))); - } - else - { - return Evaluate(text, bold, m => BoldEvaluator(m, 2), - s1 => Evaluate(s1, italic, m => ItalicEvaluator(m, 2), - s2 => defaultHandler(s2))); - } - } - - Inline ItalicEvaluator(Match match, int contentGroup) - { - Guard.ArgumentNotNull(match, nameof(match)); - - var content = match.Groups[contentGroup].Value; - return Create(RunSpanGamut(content)); - } - - Inline BoldEvaluator(Match match, int contentGroup) - { - Guard.ArgumentNotNull(match, nameof(match)); - var content = match.Groups[contentGroup].Value; - return Create(RunSpanGamut(content)); - } - - static Regex outDent = new Regex(@"^[ ]{1," + tabWidth + @"}", RegexOptions.Multiline | RegexOptions.Compiled); - - /// - /// Remove one level of line-leading spaces - /// - string Outdent(string block) - { - return outDent.Replace(block, ""); - } - - /// - /// convert all tabs to tabWidth spaces; - /// standardizes line endings from DOS (CR LF) or Mac (CR) to UNIX (LF); - /// makes sure text ends with a couple of newlines; - /// removes any blank lines (only spaces) in the text - /// - string Normalize(string text) - { - Guard.ArgumentNotNull(text, nameof(text)); - - var output = new StringBuilder(text.Length); - var line = new StringBuilder(); - bool valid = false; - - for (int i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '\n': - if (valid) - output.Append(line); - output.Append('\n'); - line.Length = 0; - valid = false; - break; - case '\r': - if ((i < text.Length - 1) && (text[i + 1] != '\n')) - { - if (valid) - output.Append(line); - output.Append('\n'); - line.Length = 0; - valid = false; - } - break; - case '\t': - int width = (tabWidth - line.Length % tabWidth); - for (int k = 0; k < width; k++) - line.Append(' '); - break; - case '\x1A': - break; - default: - if (!valid && text[i] != ' ') - valid = true; - line.Append(text[i]); - break; - } - } - - if (valid) - output.Append(line); - output.Append('\n'); - - // add two newlines to the end before return - return output.Append("\n\n").ToString(); - } - - /// - /// this is to emulate what's evailable in PHP - /// - static string RepeatString(string text, int count) - { - Guard.ArgumentNotNull(text, nameof(text)); - - var sb = new StringBuilder(text.Length * count); - for (int i = 0; i < count; i++) - sb.Append(text); - return sb.ToString(); - } - - TResult Create(IEnumerable content) - where TResult : IAddChild, new() - { - var result = new TResult(); - foreach (var c in content) - { - result.AddChild(c); - } - - return result; - } - - IEnumerable Evaluate(string text, Regex expression, Func build, Func> rest) - { - Guard.ArgumentNotNull(text, nameof(text)); - - var matches = expression.Matches(text); - var index = 0; - foreach (Match m in matches) - { - if (m.Index > index) - { - var prefix = text.Substring(index, m.Index - index); - foreach (var t in rest(prefix)) - { - yield return t; - } - } - - yield return build(m); - - index = m.Index + m.Length; - } - - if (index < text.Length) - { - var suffix = text.Substring(index, text.Length - index); - foreach (var t in rest(suffix)) - { - yield return t; - } - } - } - - static Regex eoln = new Regex("\\s+"); - - public IEnumerable DoText(string text) - { - Guard.ArgumentNotNull(text, nameof(text)); - - var t = eoln.Replace(text, " "); - yield return new Run(t); - } - } -} diff --git a/src/GitHub.UI/Controls/MarkdownViewer.cs b/src/GitHub.UI/Controls/MarkdownViewer.cs deleted file mode 100644 index ae79ac7429..0000000000 --- a/src/GitHub.UI/Controls/MarkdownViewer.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Documents; -using System.Windows.Input; - -namespace GitHub.UI -{ - public class MarkdownViewer : FlowDocumentScrollViewer - { - public static readonly DependencyProperty MarkdownProperty = - DependencyProperty.Register("Markdown", typeof(Markdown), typeof(MarkdownViewer), new PropertyMetadata(null)); - - public static readonly DependencyProperty RawContentProperty = - DependencyProperty.Register("RawContent", typeof(string), typeof(MarkdownViewer), new PropertyMetadata(null, UpdateDocument)); - - public Markdown Markdown - { - get { return (Markdown)GetValue(MarkdownProperty); } - set { SetValue(MarkdownProperty, value); } - } - - public string RawContent - { - get { return (string)GetValue(RawContentProperty); } - set { SetValue(RawContentProperty, value); } - } - - public ICommand HyperlinkCommand - { - get { return (ICommand)GetValue(HyperlinkCommandProperty); } - set { SetValue(HyperlinkCommandProperty, value); } - } - - public static readonly DependencyProperty HyperlinkCommandProperty = - Markdown.HyperlinkCommandProperty.AddOwner(typeof(MarkdownViewer), - new FrameworkPropertyMetadata(UpdateLinkedProperties)); - - public Style DocumentStyle - { - get { return (Style)GetValue(DocumentStyleProperty); } - set { SetValue(DocumentStyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for DocumentStyle. This enables animation, styling, binding, etc... - public static readonly DependencyProperty DocumentStyleProperty = - Markdown.DocumentStyleProperty.AddOwner(typeof(MarkdownViewer), - new FrameworkPropertyMetadata(UpdateLinkedProperties)); - - public Style LinkStyle - { - get { return (Style)GetValue(LinkStyleProperty); } - set { SetValue(LinkStyleProperty, value); } - } - - // Using a DependencyProperty as the backing store for LinkStyle. This enables animation, styling, binding, etc... - public static readonly DependencyProperty LinkStyleProperty = - Markdown.LinkStyleProperty.AddOwner(typeof(MarkdownViewer), - new FrameworkPropertyMetadata(UpdateLinkedProperties)); - - public MarkdownViewer() - { - Markdown = new Markdown(); - } - static void UpdateLinkedProperties(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - var c = (MarkdownViewer)d; - c.Markdown?.SetCurrentValue(e.Property, e.NewValue); - } - - static void UpdateDocument(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - var control = (MarkdownViewer)d; - var raw = e.NewValue as string; - control.SetCurrentValue(DocumentProperty, raw != null ? control.Markdown?.Transform(raw) : null); - } - } -} diff --git a/src/GitHub.UI/Controls/Octicons/OcticonImage.cs b/src/GitHub.UI/Controls/Octicons/OcticonImage.cs index a2937631bc..e009a5a2ec 100644 --- a/src/GitHub.UI/Controls/Octicons/OcticonImage.cs +++ b/src/GitHub.UI/Controls/Octicons/OcticonImage.cs @@ -1,6 +1,5 @@ using System.Windows; using System.Windows.Controls; -using NullGuard; namespace GitHub.UI { @@ -8,12 +7,11 @@ public class OcticonImage : Control { public Octicon Icon { - [return: AllowNull] get { return (Octicon)GetValue(OcticonPath.IconProperty); } set { SetValue(OcticonPath.IconProperty, value); } } - public static DependencyProperty IconProperty = + public static readonly DependencyProperty IconProperty = OcticonPath.IconProperty.AddOwner(typeof(OcticonImage)); } } diff --git a/src/GitHub.UI/Controls/Octicons/OcticonImage.xaml b/src/GitHub.UI/Controls/Octicons/OcticonImage.xaml index d0faf05f0c..54d7cba55d 100644 --- a/src/GitHub.UI/Controls/Octicons/OcticonImage.xaml +++ b/src/GitHub.UI/Controls/Octicons/OcticonImage.xaml @@ -12,7 +12,9 @@ - + - /// Looks up a localized string similar to M7 6H1c-0.55 0-1 0.45-1 1v5h2v3c0 0.55 0.45 1 1 1h2c0.55 0 1-0.45 1-1V12h2V7c0-0.55-0.45-1-1-1z m0 5h-1V9h-1v6H3V9h-1v2H1V7h6v4z m0-8C7 1.34 5.66 0 4 0S1 1.34 1 3s1.34 3 3 3 3-1.34 3-3zM4 5c-1.11 0-2-0.89-2-2S2.89 1 4 1s2 0.89 2 2-0.89 2-2 2z. + /// Looks up a localized string similar to M 12 14.002 a 0.998 0.998 0 0 1 -0.998 0.998 H 1.001 A 1 1 0 0 1 0 13.999 V 13 c 0 -2.633 4 -4 4 -4 s 0.229 -0.409 0 -1 c -0.841 -0.62 -0.944 -1.59 -1 -4 c 0.173 -2.413 1.867 -3 3 -3 s 2.827 0.586 3 3 c -0.056 2.41 -0.159 3.38 -1 4 c -0.229 0.59 0 1 0 1 s 4 1.367 4 4 v 1.002 Z. /// internal static string person { get { diff --git a/src/GitHub.UI/Controls/Octicons/OcticonPaths.resx b/src/GitHub.UI/Controls/Octicons/OcticonPaths.resx index c95de24c1f..288efa37c3 100644 --- a/src/GitHub.UI/Controls/Octicons/OcticonPaths.resx +++ b/src/GitHub.UI/Controls/Octicons/OcticonPaths.resx @@ -463,7 +463,7 @@ M0 12v3h3l8-8-3-3L0 12z m3 2H1V12h1v1h1v1z m10.3-9.3l-1.3 1.3-3-3 1.3-1.3c0.39-0.39 1.02-0.39 1.41 0l1.59 1.59c0.39 0.39 0.39 1.02 0 1.41z - M7 6H1c-0.55 0-1 0.45-1 1v5h2v3c0 0.55 0.45 1 1 1h2c0.55 0 1-0.45 1-1V12h2V7c0-0.55-0.45-1-1-1z m0 5h-1V9h-1v6H3V9h-1v2H1V7h6v4z m0-8C7 1.34 5.66 0 4 0S1 1.34 1 3s1.34 3 3 3 3-1.34 3-3zM4 5c-1.11 0-2-0.89-2-2S2.89 1 4 1s2 0.89 2 2-0.89 2-2 2z + M 12 14.002 a 0.998 0.998 0 0 1 -0.998 0.998 H 1.001 A 1 1 0 0 1 0 13.999 V 13 c 0 -2.633 4 -4 4 -4 s 0.229 -0.409 0 -1 c -0.841 -0.62 -0.944 -1.59 -1 -4 c 0.173 -2.413 1.867 -3 3 -3 s 2.827 0.586 3 3 c -0.056 2.41 -0.159 3.38 -1 4 c -0.229 0.59 0 1 0 1 s 4 1.367 4 4 v 1.002 Z M10 1.2v0.8l0.5 1-4.5 3H2.2c-0.44 0-0.67 0.53-0.34 0.86l3.14 3.14L1 15l5-4 3.14 3.14c0.33 0.33 0.86 0.09 0.86-0.34V10l3-4.5 1 0.5h0.8c0.44 0 0.67-0.53 0.34-0.86L10.86 0.86c-0.33-0.33-0.86-0.09-0.86 0.34z diff --git a/src/GitHub.UI/Controls/Panels/FixedAspectRatioPanel.cs b/src/GitHub.UI/Controls/Panels/FixedAspectRatioPanel.cs index fc3db35062..5800458903 100644 --- a/src/GitHub.UI/Controls/Panels/FixedAspectRatioPanel.cs +++ b/src/GitHub.UI/Controls/Panels/FixedAspectRatioPanel.cs @@ -2,7 +2,6 @@ using System.ComponentModel; using System.Windows; using System.Windows.Controls; -using NullGuard; namespace GitHub.UI { @@ -31,14 +30,12 @@ public double AspectRatio public HorizontalAlignment HorizontalContentAlignment { - [return: AllowNull] get { return (HorizontalAlignment)GetValue(Control.HorizontalContentAlignmentProperty); } set { SetValue(Control.HorizontalContentAlignmentProperty, value); } } public VerticalAlignment VerticalContentAlignment { - [return: AllowNull] get { return (VerticalAlignment)GetValue(Control.VerticalContentAlignmentProperty); } set { SetValue(Control.VerticalContentAlignmentProperty, value); } } diff --git a/src/GitHub.UI/Controls/PromptRichTextBox.cs b/src/GitHub.UI/Controls/PromptRichTextBox.cs index b67505b750..08546672b1 100644 --- a/src/GitHub.UI/Controls/PromptRichTextBox.cs +++ b/src/GitHub.UI/Controls/PromptRichTextBox.cs @@ -7,7 +7,6 @@ using System.Windows.Controls; using System.Windows.Documents; using System.Windows.Media; -using NullGuard; namespace GitHub.UI { @@ -41,7 +40,6 @@ public PromptRichTextBox() public Brush PromptForeground { - [return: AllowNull] get { return (Brush)GetValue(PromptForegroundProperty); } set { SetValue(PromptForegroundProperty, value); } } @@ -50,7 +48,6 @@ public Brush PromptForeground [DefaultValue("")] public string PromptText { - [return: AllowNull] get { return (string)GetValue(PromptTextProperty); } set { SetValue(PromptTextProperty, value); } } @@ -59,7 +56,6 @@ public string PromptText [DefaultValue("")] public string Text { - [return: AllowNull] get { return (string)GetValue(TextProperty); } set { SetValue(TextProperty, value ?? ""); } } @@ -68,7 +64,6 @@ public string Text [DefaultValue("")] public string CharacterLimitToolTipWarning { - [return: AllowNull] get { return (string)GetValue(CharacterLimitToolTipWarningProperty); } set { SetValue(CharacterLimitToolTipWarningProperty, value); } } @@ -116,8 +111,12 @@ static void OnPaste(object sender, DataObjectPastingEventArgs e) var content = SetDocumentContent(document, rtf, DataFormats.Rtf); + if (content.Text == null) + { + throw new GitHubLogicException("WPF ensures this is not null. WPF failed on the job"); + } + var d = new DataObject(); - Debug.Assert(content.Text != null, "WPF ensures this is not null. WPF failed on the job"); var textContent = content.Text; if (!richTextBox.AcceptsReturn) { diff --git a/src/GitHub.UI/Controls/PromptTextBox.cs b/src/GitHub.UI/Controls/PromptTextBox.cs index 279fe3d1d8..5fbcd75812 100644 --- a/src/GitHub.UI/Controls/PromptTextBox.cs +++ b/src/GitHub.UI/Controls/PromptTextBox.cs @@ -1,20 +1,34 @@ using System.ComponentModel; using System.Windows; using System.Windows.Controls; -using NullGuard; namespace GitHub.UI { public class PromptTextBox : TextBox, IShortcutContainer { + public static readonly DependencyProperty IconContentProperty = + DependencyProperty.RegisterAttached(nameof(IconContent), typeof(object), typeof(PromptTextBox)); + public static readonly DependencyProperty IconContentTemplateProperty = + DependencyProperty.RegisterAttached(nameof(IconContentTemplate), typeof(DataTemplate), typeof(PromptTextBox)); public static readonly DependencyProperty PromptTextProperty = - DependencyProperty.Register("PromptText", typeof(string), typeof(PromptTextBox), new UIPropertyMetadata("")); + DependencyProperty.Register(nameof(PromptText), typeof(string), typeof(PromptTextBox), new UIPropertyMetadata("")); + + public object IconContent + { + get { return GetValue(IconContentProperty); } + set { SetValue(IconContentProperty, value); } + } + + public object IconContentTemplate + { + get { return GetValue(IconContentTemplateProperty); } + set { SetValue(IconContentTemplateProperty, value); } + } [Localizability(LocalizationCategory.Text)] [DefaultValue("")] public string PromptText { - [return: AllowNull] get { return (string)GetValue(PromptTextProperty); } set { SetValue(PromptTextProperty, value); } } diff --git a/src/GitHub.UI/Controls/ScrollingVerticalStackPanel.cs b/src/GitHub.UI/Controls/ScrollingVerticalStackPanel.cs new file mode 100644 index 0000000000..5ca669ca68 --- /dev/null +++ b/src/GitHub.UI/Controls/ScrollingVerticalStackPanel.cs @@ -0,0 +1,224 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; + +namespace GitHub.UI.Controls +{ + /// + /// A vertical stack panel which implements its own logical scrolling, allowing controls to be + /// fixed horizontally in the scroll area. + /// + /// + /// This panel is needed by the PullRequestDetailsView because of #1698: there is no default + /// panel in WPF which allows the horizontal scrollbar to always be present at the bottom while + /// also making the PR description etc be fixed horizontally (non-scrollable) in the viewport. + /// + public class ScrollingVerticalStackPanel : Panel, IScrollInfo + { + const int lineSize = 16; + const int mouseWheelSize = 48; + + /// + /// Attached property which when set to True on a child control, will cause it to be fixed + /// horizontally within the scrollable viewport. + /// + public static readonly DependencyProperty IsFixedProperty = + DependencyProperty.RegisterAttached( + "IsFixed", + typeof(bool), + typeof(ScrollingVerticalStackPanel), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsMeasure)); + + public bool CanHorizontallyScroll + { + get { return true; } + set { } + } + + public bool CanVerticallyScroll + { + get { return true; } + set { } + } + + public double ExtentHeight { get; private set; } + public double ExtentWidth { get; private set; } + public double HorizontalOffset { get; private set; } + public double VerticalOffset { get; private set; } + public double ViewportHeight { get; private set; } + public double ViewportWidth { get; private set; } + public ScrollViewer ScrollOwner { get; set; } + + [SuppressMessage("Microsoft.Design", "CA1011:ConsiderPassingBaseTypesAsParameters", Justification = "Can only be applied to controls")] + public static bool GetIsFixed(FrameworkElement control) + { + return (bool)control.GetValue(IsFixedProperty); + } + + [SuppressMessage("Microsoft.Design", "CA1011:ConsiderPassingBaseTypesAsParameters", Justification = "Can only be applied to controls")] + public static void SetIsFixed(FrameworkElement control, bool value) + { + control.SetValue(IsFixedProperty, value); + } + + public void LineDown() => SetVerticalOffset(VerticalOffset + lineSize); + public void LineLeft() => SetHorizontalOffset(HorizontalOffset - lineSize); + public void LineRight() => SetHorizontalOffset(HorizontalOffset + lineSize); + public void LineUp() => SetVerticalOffset(VerticalOffset - lineSize); + public void MouseWheelDown() => SetVerticalOffset(VerticalOffset + mouseWheelSize); + public void MouseWheelLeft() => SetHorizontalOffset(HorizontalOffset - mouseWheelSize); + public void MouseWheelRight() => SetHorizontalOffset(HorizontalOffset + mouseWheelSize); + public void MouseWheelUp() => SetVerticalOffset(VerticalOffset - mouseWheelSize); + public void PageDown() => SetVerticalOffset(VerticalOffset + ViewportHeight); + public void PageLeft() => SetHorizontalOffset(HorizontalOffset - ViewportWidth); + public void PageRight() => SetHorizontalOffset(HorizontalOffset + ViewportWidth); + public void PageUp() => SetVerticalOffset(VerticalOffset - ViewportHeight); + + public Rect MakeVisible(Visual visual, Rect rectangle) + { + var transform = visual.TransformToVisual(this); + var rect = transform.TransformBounds(rectangle); + var offsetX = HorizontalOffset; + var offsetY = VerticalOffset; + + if (rect.Bottom > ViewportHeight) + { + var delta = rect.Bottom - ViewportHeight; + offsetY += delta; + rect.Y -= delta; + } + + if (rect.Y < 0) + { + offsetY += rect.Y; + } + + // We technially should be trying to also show the right-hand side of the rect here + // using the same technique that we just used to show the bottom of the rect above, + // but in the case of the PR details view, the left hand side of the item is much + // more important than the right hand side and it actually feels better to not do + // this. If this control is used elsewhere and this behavior is required, we could + // put in a switch to enable it. + + if (rect.X < 0) + { + offsetX += rect.X; + } + + SetHorizontalOffset(offsetX); + SetVerticalOffset(offsetY); + + return rect; + } + + public void SetHorizontalOffset(double offset) + { + var value = Math.Max(0, Math.Min(offset, ExtentWidth - ViewportWidth)); + + if (value != HorizontalOffset) + { + HorizontalOffset = value; + InvalidateArrange(); + } + } + + public void SetVerticalOffset(double offset) + { + var value = Math.Max(0, Math.Min(offset, ExtentHeight - ViewportHeight)); + + if (value != VerticalOffset) + { + VerticalOffset = value; + InvalidateArrange(); + } + } + + protected override void ParentLayoutInvalidated(UIElement child) + { + base.ParentLayoutInvalidated(child); + } + + protected override Size MeasureOverride(Size availableSize) + { + var maxWidth = 0.0; + var height = 0.0; + + foreach (FrameworkElement child in Children) + { + var isFixed = GetIsFixed(child); + var childConstraint = new Size( + isFixed ? availableSize.Width : double.PositiveInfinity, + double.PositiveInfinity); + child.Measure(childConstraint); + + if (height - VerticalOffset < availableSize.Height) + { + maxWidth = Math.Max(maxWidth, child.DesiredSize.Width); + } + + height += child.DesiredSize.Height; + } + + UpdateScrollInfo(new Size(maxWidth, height), availableSize); + + return new Size( + Math.Min(maxWidth, availableSize.Width), + Math.Min(height, availableSize.Height)); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var y = -VerticalOffset; + var thisRect = new Rect(finalSize); + var visibleMaxWidth = 0.0; + + foreach (FrameworkElement child in Children) + { + var isFixed = GetIsFixed(child); + var x = isFixed ? 0 : -HorizontalOffset; + var width = child.DesiredSize.Width; + + if (isFixed) + { + switch (child.HorizontalAlignment) + { + case HorizontalAlignment.Stretch: + width = finalSize.Width; + break; + case HorizontalAlignment.Right: + x = finalSize.Width - child.DesiredSize.Width; + break; + case HorizontalAlignment.Center: + x = (finalSize.Width - child.DesiredSize.Width) / 2; + break; + } + } + + var childRect = new Rect(x, y, width, child.DesiredSize.Height); + child.Arrange(childRect); + y += child.DesiredSize.Height; + + if (childRect.IntersectsWith(thisRect) && childRect.Right > visibleMaxWidth) + { + visibleMaxWidth = childRect.Right; + } + } + + UpdateScrollInfo(new Size(visibleMaxWidth, ExtentHeight), new Size(finalSize.Width, finalSize.Height)); + return finalSize; + } + + void UpdateScrollInfo(Size extent, Size viewport) + { + ExtentWidth = extent.Width; + ExtentHeight = extent.Height; + ScrollOwner?.InvalidateScrollInfo(); + ViewportWidth = viewport.Width; + ViewportHeight = viewport.Height; + ScrollOwner?.InvalidateScrollInfo(); + } + } +} diff --git a/src/GitHub.UI/Controls/SecurePasswordBox.cs b/src/GitHub.UI/Controls/SecurePasswordBox.cs index e3dd556964..c49dfb0528 100644 --- a/src/GitHub.UI/Controls/SecurePasswordBox.cs +++ b/src/GitHub.UI/Controls/SecurePasswordBox.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Globalization; using System.Windows.Controls; -using NullGuard; namespace GitHub.UI { @@ -30,7 +29,6 @@ public class SecurePasswordBox : PromptTextBox /// protected string BaseText { - [return: AllowNull] get { return base.Text; } set { @@ -43,10 +41,8 @@ protected string BaseText /// /// Clean Password /// - [AllowNull] public new string Text { - [return: AllowNull] get { return password; } set { @@ -79,13 +75,22 @@ protected override void OnTextChanged(TextChangedEventArgs e) { if (currentText[i] != pwdChar) { - Debug.Assert(password != null, "Password can't be null here"); + if (password == null) + { + throw new GitHubLogicException("Password can't be null here"); + } + // Replace or insert char string currentCharacter = currentText[i].ToString(CultureInfo.InvariantCulture); password = BaseText.Length == password.Length ? password.Remove(i, 1).Insert(i, currentCharacter) : password.Insert(i, currentCharacter); } } - Debug.Assert(password != null, "Password can't be null here"); + + if (password == null) + { + throw new GitHubLogicException("Password can't be null here"); + } + BaseText = new string(pwdChar, password.Length); SelectionStart = selStart; } diff --git a/src/GitHub.UI/Controls/ToggleButtons/OcticonCircleToggleButton.cs b/src/GitHub.UI/Controls/ToggleButtons/OcticonCircleToggleButton.cs deleted file mode 100644 index ece53dd549..0000000000 --- a/src/GitHub.UI/Controls/ToggleButtons/OcticonCircleToggleButton.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Windows; -using System.Windows.Controls.Primitives; - -namespace GitHub.UI -{ - public class OcticonCircleToggleButton: OcticonToggleButton - { - static OcticonCircleToggleButton() - { - EventManager.RegisterClassHandler( - typeof(OcticonCircleToggleButton), ClickEvent, new RoutedEventHandler(OnButtonClick)); - } - - static void OnButtonClick(object sender, RoutedEventArgs args) - { - ((OcticonCircleToggleButton)sender).OnToggle(); - } - - protected override void OnAccessKey(System.Windows.Input.AccessKeyEventArgs e) - { - base.OnAccessKey(e); - - OnToggle(); - } - } -} \ No newline at end of file diff --git a/src/GitHub.UI/Controls/ToggleButtons/OcticonCircleToggleButton.xaml b/src/GitHub.UI/Controls/ToggleButtons/OcticonCircleToggleButton.xaml deleted file mode 100644 index 6b2defdbd8..0000000000 --- a/src/GitHub.UI/Controls/ToggleButtons/OcticonCircleToggleButton.xaml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsCheckBoxStyle.xaml b/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsCheckBoxStyle.xaml new file mode 100644 index 0000000000..77b700438f --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsCheckBoxStyle.xaml @@ -0,0 +1,105 @@ + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsComboBoxStyle.xaml b/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsComboBoxStyle.xaml new file mode 100644 index 0000000000..dbd52c3208 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsComboBoxStyle.xaml @@ -0,0 +1,374 @@ + + + M 0 0 L 3 3 L 6 0 Z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsTextBoxStyle.xaml b/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsTextBoxStyle.xaml new file mode 100644 index 0000000000..1a1b1a4992 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/CommonControlsTextBoxStyle.xaml @@ -0,0 +1,34 @@ + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Dark/CommonControlsColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Dark/CommonControlsColors.xaml new file mode 100644 index 0000000000..7aa39f74b1 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Dark/CommonControlsColors.xaml @@ -0,0 +1,198 @@ + + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF555555 + #FF555555 + #FF007ACC + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF007ACC + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF656565 + #FF656565 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF1F1F20 + #FF1F1F20 + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF999999 + #FF999999 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF999999 + #FF999999 + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF656565 + #FF656565 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1F1F1C + #FF1F1F1C + #19000000 + #19000000 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #66007ACC + #66007ACC + #FFFFFFFF + #FFFFFFFF + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF555555 + #FF555555 + #FF555555 + #FF555555 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Dark/EnvironmentColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Dark/EnvironmentColors.xaml new file mode 100644 index 0000000000..0259d3ca88 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Dark/EnvironmentColors.xaml @@ -0,0 +1,1921 @@ + + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF000000 + #FF000000 + #FF999999 + #FF999999 + #80525252 + #80525252 + #FF656565 + #FF656565 + #80525252 + #80525252 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E42 + #FF3E3E42 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF0097FB + #FF0097FB + #FFD0D0D0 + #FFD0D0D0 + #FF000000 + #FF000000 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF464646 + #FF464646 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF0F2F9 + #FFF0F2F9 + #FFD3DCEF + #FFD3DCEF + #FFCCCC66 + #FFCCCC66 + #FFFFFFCC + #FFFFFFCC + #FF000000 + #FF000000 + #FFD2D2D2 + #FFD2D2D2 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF00008B + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFF7F0F0 + #FFF7F0F0 + #FFEDDADC + #FFEDDADC + #FFFFFFFF + #FFFFFFFF + #FF0054E3 + #FF0054E3 + #FFDDD6EF + #FFDDD6EF + #FF266035 + #FF266035 + #FFFFFFFF + #FFFFFFFF + #FF716F64 + #FF716F64 + #FFF3F7F0 + #FFF3F7F0 + #FFE6F0DB + #FFE6F0DB + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFB0764F + #FFB0764F + #FF716F64 + #FF716F64 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD8D8D8 + #FFD8D8D8 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD6ECEF + #FFD6ECEF + #FFFF0000 + #FFFF0000 + #FFF8F4E9 + #FFF8F4E9 + #FFF0E9D2 + #FFF0E9D2 + #FF333337 + #FF333337 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF656565 + #FF656565 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF656565 + #FF656565 + #FFF1F1F1 + #FFF1F1F1 + #FF46464A + #FF46464A + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3399FF + #FF3399FF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FF999999 + #FF999999 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FF1B1B1C + #FF1B1B1C + #FF333334 + #FF333334 + #FFF1F1F1 + #FFF1F1F1 + #FF333334 + #FF333334 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FF007ACC + #FF007ACC + #FF999999 + #FF999999 + #FF333337 + #FF333337 + #FF999999 + #FF999999 + #FFFFFFFF + #FFFFFFFF + #FF999999 + #FF999999 + #FF0097FB + #FF0097FB + #FF999999 + #FF999999 + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF434346 + #FF434346 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF222222 + #FF222222 + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFFEFCC8 + #FFFEFCC8 + #FF555555 + #FF555555 + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF333337 + #FF333337 + #003F3F46 + #003F3F46 + #FF424245 + #FF424245 + #FF4D4D50 + #FF4D4D50 + #FF505051 + #FF505051 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FFF1F1F1 + #FFF1F1F1 + #FF2C2C2F + #FF2C2C2F + #FF37373A + #FF37373A + #FF3D3D3F + #FF3D3D3F + #FF7A7A7A + #FF7A7A7A + #FF333337 + #FF333337 + #FF656565 + #FF656565 + #FF252526 + #FF252526 + #FF46464A + #FF46464A + #FF3F3F46 + #FF3F3F46 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF2F4F8 + #FFF2F4F8 + #FF000000 + #FF000000 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFBCC7D8 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF656565 + #FF656565 + #FF999999 + #FF999999 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FFF1F1F1 + #FFF1F1F1 + #72000000 + #72000000 + #FF2D2D30 + #FF2D2D30 + #FF333337 + #FF333337 + #FF252526 + #FF252526 + #FF0097FB + #FF0097FB + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFFF8C00 + #FFFF8C00 + #FFFF8C00 + #FFFF8C00 + #FF656565 + #FF656565 + #FF656565 + #FF656565 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FFF1F1F1 + #FFF1F1F1 + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FF555555 + #FF555555 + #FF555555 + #FF555555 + #FFF1F1F1 + #FFF1F1F1 + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FFF1F1F1 + #FFF1F1F1 + #FFE1D3E4 + #FFE1D3E4 + #FFB064AB + #FFB064AB + #FFB064AB + #FFB064AB + #FFFFFFFF + #FFFFFFFF + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FFFFFFFF + #FFFFFFFF + #FF555555 + #FF555555 + #FF555555 + #FF555555 + #FFF1F1F1 + #FFF1F1F1 + #FF555555 + #FF555555 + #FFE1D3E4 + #FFE1D3E4 + #FF6D6D70 + #FF6D6D70 + #FFD0E6F5 + #FFD0E6F5 + #FF6D6D70 + #FF6D6D70 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF1C97EA + #FF1C97EA + #FFD0E6F5 + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF999999 + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF000000 + #FF000000 + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF1B293E + #FF1B293E + #FF0066CC + #FF0066CC + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF7AC1FF + #FF7AC1FF + #FFC8C8C8 + #FFC8C8C8 + #00000000 + #00000000 + #FF3F3F46 + #FF3F3F46 + + + #FF2D2D30 + #FF2D2D30 + #FF656565 + #FF656565 + #FFFEFCC8 + #FFFEFCC8 + #FF1E1E1E + #FF1E1E1E + #001E1E1E + #001E1E1E + #00F1F1F1 + #00F1F1F1 + #FF0E639C + #FF0E639C + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFCA5100 + #FFCA5100 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF434346 + #FF434346 + #FF2D2D30 + #FF2D2D30 + #99999999 + #99999999 + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #FFFFFFFF + #FFFFFFFF + #7F000000 + #7F000000 + #FF333337 + #FF333337 + #002D2D30 + #002D2D30 + #FF1B1B1C + #FF1B1B1C + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF7D7D7D + #FF7D7D7D + #FF3399FF + #FF3399FF + #FFC6C6C6 + #FFC6C6C6 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF0097FB + #FF0097FB + #FF656565 + #FF656565 + #FF55AAFF + #FF55AAFF + #FFFFFFFF + #FFFFFFFF + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF2D2D30 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF333337 + #FF333337 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #FFFEFCC8 + #FFFEFCC8 + #FFFEFCC8 + #FFFEFCC8 + #FF252526 + #FF252526 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF999999 + #FF999999 + #FF555558 + #FF555558 + #FF1C97EA + #FF1C97EA + #FF007ACC + #FF007ACC + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF1C1C1C + #FF1C1C1C + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF686868 + #FF686868 + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF9E9E9E + #FF9E9E9E + #FFEFEBEF + #FFEFEBEF + #FF9E9E9E + #FF9E9E9E + #FF9E9E9E + #FF9E9E9E + #FFEFEBEF + #FFEFEBEF + #FFEFEBEF + #FFEFEBEF + #FF333337 + #FF333337 + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FFE5C365 + #FFE5C365 + #FFFFEFBB + #FFFFEFBB + #FFE5C365 + #FFE5C365 + #FFFEFCC8 + #FFFEFCC8 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF4169E1 + #FF4169E1 + #FF96A9DD + #FF96A9DD + #FFE122DF + #FFE122DF + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF434346 + #FF434346 + #FF1F1F22 + #FF1F1F22 + #FF1F1F22 + #FF1F1F22 + #FF999999 + #FF999999 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FFF30506 + #FFF30506 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF30506 + #FFF30506 + #FFF30506 + #FFF30506 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF363639 + #FF363639 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF28282B + #FF28282B + #FF28282B + #FF28282B + #FFF1F1F1 + #FFF1F1F1 + #FFF30506 + #FFF30506 + #FFF1F1F1 + #FFF1F1F1 + #FF0097FB + #FF0097FB + #FF88CCFE + #FF88CCFE + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FF55AAFF + #FF55AAFF + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FF55AAFF + #FF55AAFF + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F3F + #FF3F3F3F + #FF464646 + #FF464646 + #FF999999 + #FF999999 + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FFCA5100 + #FFCA5100 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #4C000000 + #4C000000 + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF464646 + #FF464646 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF46464A + #FF46464A + #FF59A8DE + #FF59A8DE + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFD0D0D0 + #FFD0D0D0 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF656565 + #FF656565 + #FF3E3E40 + #FF3E3E40 + #FF656565 + #FF656565 + #FF333337 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF424245 + #FF424245 + #FFF1F1F1 + #FFF1F1F1 + #FF4D4D50 + #FF4D4D50 + #FF717171 + #FF717171 + #FF252526 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FF393939 + #FF393939 + #FF393939 + #FF393939 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF2D2D30 + #FF2D2D30 + #99999999 + #99999999 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF55AAFF + #FF55AAFF + #FF0097FB + #FF0097FB + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF0097FB + #FF0097FB + #FF3F3F46 + #FF3F3F46 + #FFD0D0D0 + #FFD0D0D0 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF705829 + #FF705829 + #FFB0A781 + #FFB0A781 + #FFA19667 + #FFA19667 + #FFA79432 + #FFA79432 + #FFD0D4B7 + #FFD0D4B7 + #FFBFC749 + #FFBFC749 + #FFCAB22D + #FFCAB22D + #FFFBF7C8 + #FFFBF7C8 + #FFE2E442 + #FFE2E442 + #FF5D8039 + #FF5D8039 + #FFB1C97B + #FFB1C97B + #FF9FB861 + #FF9FB861 + #FF8E5478 + #FF8E5478 + #FFE2B1CD + #FFE2B1CD + #FFCB98B6 + #FFCB98B6 + #FFAD1C2B + #FFAD1C2B + #FFFF9F99 + #FFFF9F99 + #FFFF7971 + #FFFF7971 + #FF779AB6 + #FF779AB6 + #FFC6D4DF + #FFC6D4DF + #FFB8CCD7 + #FFB8CCD7 + #FF427094 + #FF427094 + #FFA0B7C9 + #FFA0B7C9 + #FF89ABBD + #FF89ABBD + #FF5386BF + #FF5386BF + #FFB9D4EE + #FFB9D4EE + #FFA1C7E7 + #FFA1C7E7 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + + + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Dark/Theme.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Dark/Theme.xaml new file mode 100644 index 0000000000..0284cc36ae --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Dark/Theme.xaml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Dark/ThemedDialogColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Dark/ThemedDialogColors.xaml new file mode 100644 index 0000000000..ca9038bb89 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Dark/ThemedDialogColors.xaml @@ -0,0 +1,66 @@ + + #00000000 + #00000000 + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF0097FB + #FF0097FB + #FF656565 + #FF656565 + #FF55AAFF + #FF55AAFF + #FF0097FB + #FF0097FB + #FF656565 + #FF656565 + #FF3F3F40 + #FF3F3F40 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #00000000 + #00000000 + #00000000 + #00000000 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Dark/TreeViewColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Dark/TreeViewColors.xaml new file mode 100644 index 0000000000..305d7c8e70 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Dark/TreeViewColors.xaml @@ -0,0 +1,48 @@ + + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FFE51400 + #FFE51400 + #FFFFFFFF + #FFFFFFFF + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFFEFCC8 + #FFFEFCC8 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFE51400 + #FFE51400 + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Dark/VsBrushes.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Dark/VsBrushes.xaml new file mode 100644 index 0000000000..a267c97c8d --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Dark/VsBrushes.xaml @@ -0,0 +1,826 @@ + + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF0097FB + #FFD0D0D0 + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF464646 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FFF1F1F1 + #FF3F3F46 + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF2D2D30 + #FF999999 + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3399FF + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF1B1B1C + #FF007ACC + #FF333337 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF2D2D30 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #72555555 + #72555555 + #72555555 + #72555555 + #FF007ACC + #FF2D2D30 + #FF3399FF + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FFF1F1F1 + #FF2D2D30 + #FF222222 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFFEFCC8 + #FF555555 + #FF0097FB + #FF0097FB + #FF0097FB + #FF333337 + #FF424245 + #FF4D4D50 + #FF505051 + #FFF1F1F1 + #FF333337 + #FFF1F1F1 + #FF2C2C2F + #FF37373A + #FF3D3D3F + #FF7A7A7A + #FF333337 + #FF656565 + #FF252526 + #FF46464A + #FF3F3F46 + #FF656565 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FF1B1B1C + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF007ACC + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FF999999 + #FF3F3F46 + #FF434346 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #72000000 + #FF333337 + #FF252526 + #FF0097FB + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFFF8C00 + #FFFF8C00 + #FF656565 + #FF656565 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFF1F1F1 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF999999 + #FF2D2D30 + #FFF1F1F1 + #FF000000 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FF3F3F46 + #FF2D2D30 + #FF656565 + #FFFEFCC8 + #FF1E1E1E + #FF333337 + #FF1B1B1C + #FFF1F1F1 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3399FF + #FF3399FF + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF333337 + #FF252526 + #FF252526 + #FF3E3E40 + #FF3E3E40 + #FF0097FB + #FF55AAFF + #FF0097FB + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF252526 + #FF252526 + #FF2D2D30 + #FFFEFCC8 + #FFFEFCC8 + #FF252526 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF686868 + #FF9E9E9E + #FFEFEBEF + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFEFCC8 + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FF252526 + #FFF1F1F1 + #FF434346 + #FF1F1F22 + #FF1F1F22 + #FF999999 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FF252526 + #FF252526 + #FF28282B + #FF28282B + #FFF1F1F1 + #FFF30506 + #FFF1F1F1 + #FF0097FB + #FF88CCFE + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF464646 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFD0D0D0 + #FF252526 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF252526 + #FF3F3F46 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FF393939 + #FF393939 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF55AAFF + #FF252526 + #FF0097FB + #FFD0D0D0 + #FFF1F1F1 + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FF252526 + #FF2D2D30 + #FFF1F1F1 + #FFFFFFFF + #FF000000 + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Dark/VsColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Dark/VsColors.xaml new file mode 100644 index 0000000000..8fc8748b7d --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Dark/VsColors.xaml @@ -0,0 +1,505 @@ + + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF0097FB + #FFD0D0D0 + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF464646 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FFF1F1F1 + #FF3F3F46 + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF2D2D30 + #FF999999 + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3399FF + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF1B1B1C + #FF007ACC + #FF333337 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF2D2D30 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #72555555 + #72555555 + #72555555 + #72555555 + #FF007ACC + #FF2D2D30 + #FF3399FF + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FFF1F1F1 + #FF2D2D30 + #FF222222 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFFEFCC8 + #FF555555 + #FF0097FB + #FF0097FB + #FF0097FB + #FF333337 + #FF424245 + #FF4D4D50 + #FF505051 + #FFF1F1F1 + #FF333337 + #FFF1F1F1 + #FF2C2C2F + #FF37373A + #FF3D3D3F + #FF7A7A7A + #FF333337 + #FF656565 + #FF252526 + #FF46464A + #FF3F3F46 + #FF656565 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FF1B1B1C + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF007ACC + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FF999999 + #FF3F3F46 + #FF434346 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #72000000 + #FF333337 + #FF252526 + #FF0097FB + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFFF8C00 + #FFFF8C00 + #FF656565 + #FF656565 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFF1F1F1 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF999999 + #FF2D2D30 + #FFF1F1F1 + #FF000000 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FF3F3F46 + #FF2D2D30 + #FF656565 + #FFFEFCC8 + #FF1E1E1E + #FF333337 + #FF1B1B1C + #FFF1F1F1 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3399FF + #FF3399FF + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF333337 + #FF252526 + #FF252526 + #FF3E3E40 + #FF3E3E40 + #FF0097FB + #FF55AAFF + #FF0097FB + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF252526 + #FF252526 + #FF2D2D30 + #FFFEFCC8 + #FFFEFCC8 + #FF252526 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF686868 + #FF9E9E9E + #FFEFEBEF + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFEFCC8 + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FF252526 + #FFF1F1F1 + #FF434346 + #FF1F1F22 + #FF1F1F22 + #FF999999 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FF252526 + #FF252526 + #FF28282B + #FF28282B + #FFF1F1F1 + #FFF30506 + #FFF1F1F1 + #FF0097FB + #FF88CCFE + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF464646 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFD0D0D0 + #FF252526 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF252526 + #FF3F3F46 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FF393939 + #FF393939 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF55AAFF + #FF252526 + #FF0097FB + #FFD0D0D0 + #FFF1F1F1 + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FF252526 + #FF2D2D30 + #FFF1F1F1 + #FFFFFFFF + #FF000000 + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/FontScalingLabelStyle.xaml b/src/GitHub.VisualStudio.TestApp/Themes/FontScalingLabelStyle.xaml new file mode 100644 index 0000000000..15a6a5a2a2 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/FontScalingLabelStyle.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/FontScalingTextBlockStyle.xaml b/src/GitHub.VisualStudio.TestApp/Themes/FontScalingTextBlockStyle.xaml new file mode 100644 index 0000000000..1992aa4407 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/FontScalingTextBlockStyle.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/CommonControlsColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/CommonControlsColors.xaml new file mode 100644 index 0000000000..a5bf613bc4 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/CommonControlsColors.xaml @@ -0,0 +1,198 @@ + + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF00002F + #FF00002F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF00002F + #FF00002F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF37006E + #FF37006E + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/EnvironmentColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/EnvironmentColors.xaml new file mode 100644 index 0000000000..d460c2af17 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/EnvironmentColors.xaml @@ -0,0 +1,1588 @@ + + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF37006E + #FF37006E + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFC0C0C0 + #FFC0C0C0 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FFFFFFFF + #00C800C8 + #00C800C8 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF00529B + #FF00529B + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF008000 + #FF008000 + + + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #00C800C8 + #00C800C8 + #00C800C8 + #00C800C8 + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #00000000 + #00000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #7F000000 + #7F000000 + #FF808080 + #FF808080 + #00C800C8 + #00C800C8 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #00000000 + #00000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFC0C0C0 + #FFC0C0C0 + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFC0C0C0 + #FFC0C0C0 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFF00FF + #FFFF00FF + #FFFF00FF + #FFFF00FF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + + + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/Theme.xaml b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/Theme.xaml new file mode 100644 index 0000000000..0284cc36ae --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/Theme.xaml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/ThemedDialogColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/ThemedDialogColors.xaml new file mode 100644 index 0000000000..fbae4aa2a5 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/ThemedDialogColors.xaml @@ -0,0 +1,66 @@ + + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/TreeViewColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/TreeViewColors.xaml new file mode 100644 index 0000000000..c9ba4b4d84 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/TreeViewColors.xaml @@ -0,0 +1,48 @@ + + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/VsBrushes.xaml b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/VsBrushes.xaml new file mode 100644 index 0000000000..022e4ad948 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/VsBrushes.xaml @@ -0,0 +1,554 @@ + + #FF808080 + #FF808080 + #FFC0C0C0 + #FF000000 + #FF000000 + #FFFFFF00 + #FF37006E + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF1AEBFF + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FF000000 + #FF808080 + #FF1AEBFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FF008000 + #FF000000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF008000 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF008000 + #FF000000 + #FF000000 + #FF008000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFC0C0C0 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FFFFFF00 + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFF00FF + #FFFF00FF + #FF37006E + #FF008000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/VsColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/VsColors.xaml new file mode 100644 index 0000000000..abd1f56ce5 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/HighContrast/VsColors.xaml @@ -0,0 +1,505 @@ + + #FF808080 + #FF808080 + #FFC0C0C0 + #FF000000 + #FF000000 + #FFFFFF00 + #FF37006E + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF1AEBFF + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FF000000 + #FF808080 + #FF1AEBFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FF008000 + #FF000000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF008000 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF008000 + #FF000000 + #FF000000 + #FF008000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFC0C0C0 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FFFFFF00 + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFF00FF + #FFFF00FF + #FF37006E + #FF008000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Light/CommonControlsColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Light/CommonControlsColors.xaml new file mode 100644 index 0000000000..9f5b1146dc --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Light/CommonControlsColors.xaml @@ -0,0 +1,198 @@ + + #FFECECF0 + #FFECECF0 + #FF1E1E1E + #FF1E1E1E + #FFACACAC + #FFACACAC + #FF3399FF + #FF3399FF + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF007ACC + #FF007ACC + #FFECECF0 + #FFECECF0 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFA2A4A5 + #FFA2A4A5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFEFEFE + #FFFEFEFE + #FFF6F6F6 + #FFF6F6F6 + #FFF3F9FF + #FFF3F9FF + #FFF3F9FF + #FFF3F9FF + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FFC6C6C6 + #FFC6C6C6 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FF717171 + #FF717171 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFF6F6F6 + #FFF6F6F6 + #19000000 + #19000000 + #FFCCCEDB + #FFCCCEDB + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #66007ACC + #66007ACC + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Light/EnvironmentColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Light/EnvironmentColors.xaml new file mode 100644 index 0000000000..295f6239fa --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Light/EnvironmentColors.xaml @@ -0,0 +1,1588 @@ + + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF525252 + #FF525252 + #FFFFFFFF + #FFFFFFFF + #FF525252 + #FF525252 + #80525252 + #80525252 + #FFFFFFFF + #FFFFFFFF + #80525252 + #80525252 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF0E70C0 + #FF0E70C0 + #FF444444 + #FF444444 + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEBD + #FFCCCEBD + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFD8D8E0 + #FFD8D8E0 + #FFCCCEBD + #FFCCCEBD + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFF0F2F9 + #FFF0F2F9 + #FFD3DCEF + #FFD3DCEF + #FFCCCC66 + #FFCCCC66 + #FFFFFFCC + #FFFFFFCC + #FF000000 + #FF000000 + #FFD2D2D2 + #FFD2D2D2 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF00008B + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFF7F0F0 + #FFF7F0F0 + #FFEDDADC + #FFEDDADC + #FFFFFFFF + #FFFFFFFF + #FF0054E3 + #FF0054E3 + #FFDDD6EF + #FFDDD6EF + #FF266035 + #FF266035 + #FFFFFFFF + #FFFFFFFF + #FF716F64 + #FF716F64 + #FFF3F7F0 + #FFF3F7F0 + #FFE6F0DB + #FFE6F0DB + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFB0764F + #FFB0764F + #FF716F64 + #FF716F64 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD8D8D8 + #FFD8D8D8 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD6ECEF + #FFD6ECEF + #FFFF0000 + #FFFF0000 + #FFF8F4E9 + #FFF8F4E9 + #FFF0E9D2 + #FFF0E9D2 + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF717171 + #FF717171 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + #FF999999 + #FF999999 + #FF999999 + #FF999999 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF3399FF + #FF3399FF + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF007ACC + #FF007ACC + #FFF6F6F6 + #FFF6F6F6 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FF0E70C0 + #FF0E70C0 + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFE0E3E6 + #FFE0E3E6 + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FFE0E3E6 + #FFE0E3E6 + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FFFDFBAC + #FFFDFBAC + #FF717171 + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFCCCEDB + #00CCCEDB + #00CCCEDB + #FFE7E8EC + #FFE7E8EC + #FFCCCEDB + #FFCCCEDB + #FFEDEEF0 + #FFEDEEF0 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFD6D8DC + #FFD6D8DC + #FFCCCEDB + #FFCCCEDB + #FFEDEEF0 + #FFEDEEF0 + #FFA2A4A5 + #FFA2A4A5 + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FFF5F5F5 + #FFF5F5F5 + #FF999999 + #FF999999 + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF0E70C0 + #FF0E70C0 + #FF007ACC + #FF007ACC + #FFF2F4F8 + #FFF2F4F8 + #FF000000 + #FF000000 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFBCC7D8 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FFE7E8EC + #FFE7E8EC + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #72000000 + #72000000 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FF0E70C0 + #FF0E70C0 + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFFFA300 + #FFFFA300 + #FFFFA300 + #FFFFA300 + #FFA2A4A5 + #FFA2A4A5 + #FFA2A4A5 + #FFA2A4A5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FF2D2D2D + #FF2D2D2D + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FF717171 + #FF717171 + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FF2D2D2D + #FF2D2D2D + #FFE1D3E4 + #FFE1D3E4 + #FFB064AB + #FFB064AB + #FFB064AB + #FFB064AB + #FFFFFFFF + #FFFFFFFF + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FFFFFFFF + #FFFFFFFF + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FF717171 + #FF717171 + #FF717171 + #FF717171 + #FFE1D3E4 + #FFE1D3E4 + #FF6D6D70 + #FF6D6D70 + #FFD0E6F5 + #FFD0E6F5 + #FF6D6D70 + #FF6D6D70 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF1C97EA + #FF1C97EA + #FFD0E6F5 + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF717171 + #FF717171 + #FFEFEFE2 + #FFEFEFE2 + #FF1E1E1E + #FF1E1E1E + #FFF0F0F0 + #FFF0F0F0 + #FFDEE1E7 + #FFDEE1E7 + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF000000 + #FF000000 + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF1B293E + #FF1B293E + #FF0066CC + #FF0066CC + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FF00529B + #FF00529B + #FF424242 + #FF424242 + #00000000 + #00000000 + #FFCCCEDB + #FFCCCEDB + + + #FFEEEEF2 + #FFEEEEF2 + #FFA2A4A5 + #FFA2A4A5 + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FF1E1E1E + #00F5F5F5 + #00F5F5F5 + #001E1E1E + #001E1E1E + #FF9B9FB9 + #FF9B9FB9 + #FFEEEEF2 + #FFEEEEF2 + #FF525252 + #FF525252 + #FF9B9FB9 + #FF9B9FB9 + #FF9B9FB9 + #FF9B9FB9 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF9B9FB9 + #FF9B9FB9 + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #99525252 + #99525252 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #FFFFFFFF + #FFFFFFFF + #7F000000 + #7F000000 + #FFCCCEDB + #FFCCCEDB + #00EEEEF2 + #00EEEEF2 + #FFF6F6F6 + #FFF6F6F6 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FF1E1E1E + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFCCCED8 + #FFCCCED8 + #FF636363 + #FF636363 + #FF3399FF + #FF3399FF + #FFD0D0D0 + #FFD0D0D0 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FF0E70C0 + #FF0E70C0 + #FFA2A4A5 + #FFA2A4A5 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFFCFCFC + #FFFCFCFC + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #FFFDFBAC + #FFFDFBAC + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF868999 + #FF868999 + #FFF5F5F5 + #FFF5F5F5 + #FF1C97EA + #FF1C97EA + #FF007ACC + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFFFFFFF + #FFFFFFFF + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FF686868 + #FF686868 + #FF5B5B5B + #FF5B5B5B + #FF686868 + #FF686868 + #FF686868 + #FF686868 + #FF5B5B5B + #FF5B5B5B + #FF5B5B5B + #FF5B5B5B + #FFFCFCFC + #FFFCFCFC + #FFFCFCFC + #FFFCFCFC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFE5C365 + #FFE5C365 + #FFFFEFBB + #FFFFEFBB + #FFE5C365 + #FFE5C365 + #FFFDFBAC + #FFFDFBAC + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF4169E1 + #FF4169E1 + #FF96A9DD + #FF96A9DD + #FFE122DF + #FFE122DF + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FF999999 + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFF30506 + #FFF30506 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF30506 + #FFF30506 + #FFF30506 + #FFF30506 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF363639 + #FF363639 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FFF30506 + #FFF30506 + #FF555555 + #FF555555 + #FF007ACC + #FF007ACC + #FF77AAFF + #FF77AAFF + #FF1E1E1E + #FF1E1E1E + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF555555 + #FF555555 + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF000000 + #FF000000 + #FF3F3F3F + #FF3F3F3F + #FF464646 + #FF464646 + #FF999999 + #FF999999 + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FFCA5100 + #FFCA5100 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #4C000000 + #4C000000 + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF0F0F0 + #FFF0F0F0 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFD8D8E0 + #FFD8D8E0 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEBD + #FFCCCEBD + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF999999 + #FF999999 + #FF59A8DE + #FF59A8DE + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF444444 + #FF444444 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFA2A4A5 + #FFA2A4A5 + #FFC9DEF5 + #FFC9DEF5 + #FFA2A4A5 + #FFA2A4A5 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF6F6F6 + #FFF6F6F6 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FFF7F7F9 + #FFF7F7F9 + #FFF7F7F9 + #FFF7F7F9 + #FF717171 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF525252 + #FF525252 + #FFEEEEF2 + #FFEEEEF2 + #99525252 + #99525252 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF0E70C0 + #FF0E70C0 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFCCCEDB + #FF444444 + #FF444444 + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF705829 + #FF705829 + #FFB0A781 + #FFB0A781 + #FFA19667 + #FFA19667 + #FFA79432 + #FFA79432 + #FFD0D4B7 + #FFD0D4B7 + #FFBFC749 + #FFBFC749 + #FFCAB22D + #FFCAB22D + #FFFBF7C8 + #FFFBF7C8 + #FFE2E442 + #FFE2E442 + #FF5D8039 + #FF5D8039 + #FFB1C97B + #FFB1C97B + #FF9FB861 + #FF9FB861 + #FF8E5478 + #FF8E5478 + #FFE2B1CD + #FFE2B1CD + #FFCB98B6 + #FFCB98B6 + #FFAD1C2B + #FFAD1C2B + #FFFF9F99 + #FFFF9F99 + #FFFF7971 + #FFFF7971 + #FF779AB6 + #FF779AB6 + #FFC6D4DF + #FFC6D4DF + #FFB8CCD7 + #FFB8CCD7 + #FF427094 + #FF427094 + #FFA0B7C9 + #FFA0B7C9 + #FF89ABBD + #FF89ABBD + #FF5386BF + #FF5386BF + #FFB9D4EE + #FFB9D4EE + #FFA1C7E7 + #FFA1C7E7 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + + + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Light/Theme.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Light/Theme.xaml new file mode 100644 index 0000000000..0284cc36ae --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Light/Theme.xaml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Light/ThemedDialogColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Light/ThemedDialogColors.xaml new file mode 100644 index 0000000000..af98768985 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Light/ThemedDialogColors.xaml @@ -0,0 +1,66 @@ + + #00000000 + #00000000 + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FF717171 + #FF717171 + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF0E70C0 + #FF0E70C0 + #FFA2A4A5 + #FFA2A4A5 + #FF007ACC + #FF007ACC + #FF0E70C0 + #FF0E70C0 + #FFA2A4A5 + #FFA2A4A5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #00000000 + #00000000 + #00000000 + #00000000 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFFBFBFB + #FFFBFBFB + #FF1E1E1E + #FF1E1E1E + #FFEFEFF2 + #FFEFEFF2 + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Light/TreeViewColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Light/TreeViewColors.xaml new file mode 100644 index 0000000000..2eed34a26b --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Light/TreeViewColors.xaml @@ -0,0 +1,48 @@ + + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFE51400 + #FFE51400 + #FF000000 + #FF000000 + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFD0F7FF + #FFD0F7FF + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFE51400 + #FFE51400 + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Light/VsBrushes.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Light/VsBrushes.xaml new file mode 100644 index 0000000000..fc402c491b --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Light/VsBrushes.xaml @@ -0,0 +1,554 @@ + + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF0E70C0 + #FF444444 + #FFFFFFFF + #FFEEEEF2 + #FFCCCEBD + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFD8D8E0 + #FFCCCEBD + #FF1E1E1E + #FF1E1E1E + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFEEEEF2 + #FF717171 + #FF999999 + #FF999999 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF3399FF + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFF6F6F6 + #FF007ACC + #FFE0E3E6 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFEEEEF2 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FFEEEEF2 + #FF3399FF + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FF1E1E1E + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FFFDFBAC + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFE7E8EC + #FFCCCEDB + #FFEDEEF0 + #FF1E1E1E + #FFCCCEDB + #FF1E1E1E + #FFD6D8DC + #FFCCCEDB + #FFEDEEF0 + #FFA2A4A5 + #FFCCCEDB + #FFA2A4A5 + #FFF5F5F5 + #FF999999 + #FFCCCEDB + #FFA2A4A5 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FFE7E8EC + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF007ACC + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #72000000 + #FFCCCEDB + #FFF5F5F5 + #FF0E70C0 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFFFA300 + #FFFFA300 + #FFA2A4A5 + #FFA2A4A5 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF1E1E1E + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF717171 + #FFEFEFE2 + #FF1E1E1E + #FFF0F0F0 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFA2A4A5 + #FFFDFBAC + #FF1E1E1E + #FFCCCEDB + #FFF6F6F6 + #FF1E1E1E + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FFFEFEFE + #FFFEFEFE + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FF1E1E1E + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFFEFEFE + #FFFEFEFE + #FF0E70C0 + #FF007ACC + #FF0E70C0 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FF686868 + #FF5B5B5B + #FFFCFCFC + #FFFCFCFC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFDFBAC + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FFF5F5F5 + #FF1E1E1E + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF30506 + #FF555555 + #FF007ACC + #FF77AAFF + #FF1E1E1E + #FF999999 + #FF007ACC + #FF555555 + #FF999999 + #FF007ACC + #FF000000 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FFF0F0F0 + #FFEEEEF2 + #FFCCCEDB + #FFD8D8E0 + #FFEEEEF2 + #FFCCCEBD + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF444444 + #FFF5F5F5 + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFCCCEDB + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFF7F7F9 + #FFF7F7F9 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FFF5F5F5 + #FF0E70C0 + #FF444444 + #FF1E1E1E + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FFF5F5F5 + #FFEEEEF2 + #FF1E1E1E + #FFFFFFFF + #FF000000 + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/Light/VsColors.xaml b/src/GitHub.VisualStudio.TestApp/Themes/Light/VsColors.xaml new file mode 100644 index 0000000000..6aa201c194 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/Light/VsColors.xaml @@ -0,0 +1,505 @@ + + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF0E70C0 + #FF444444 + #FFFFFFFF + #FFEEEEF2 + #FFCCCEBD + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFD8D8E0 + #FFCCCEBD + #FF1E1E1E + #FF1E1E1E + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFEEEEF2 + #FF717171 + #FF999999 + #FF999999 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF3399FF + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFF6F6F6 + #FF007ACC + #FFE0E3E6 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFEEEEF2 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FFEEEEF2 + #FF3399FF + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FF1E1E1E + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FFFDFBAC + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFE7E8EC + #FFCCCEDB + #FFEDEEF0 + #FF1E1E1E + #FFCCCEDB + #FF1E1E1E + #FFD6D8DC + #FFCCCEDB + #FFEDEEF0 + #FFA2A4A5 + #FFCCCEDB + #FFA2A4A5 + #FFF5F5F5 + #FF999999 + #FFCCCEDB + #FFA2A4A5 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FFE7E8EC + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF007ACC + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #72000000 + #FFCCCEDB + #FFF5F5F5 + #FF0E70C0 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFFFA300 + #FFFFA300 + #FFA2A4A5 + #FFA2A4A5 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF1E1E1E + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF717171 + #FFEFEFE2 + #FF1E1E1E + #FFF0F0F0 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFA2A4A5 + #FFFDFBAC + #FF1E1E1E + #FFCCCEDB + #FFF6F6F6 + #FF1E1E1E + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FFFEFEFE + #FFFEFEFE + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FF1E1E1E + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFFEFEFE + #FFFEFEFE + #FF0E70C0 + #FF007ACC + #FF0E70C0 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FF686868 + #FF5B5B5B + #FFFCFCFC + #FFFCFCFC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFDFBAC + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FFF5F5F5 + #FF1E1E1E + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF30506 + #FF555555 + #FF007ACC + #FF77AAFF + #FF1E1E1E + #FF999999 + #FF007ACC + #FF555555 + #FF999999 + #FF007ACC + #FF000000 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FFF0F0F0 + #FFEEEEF2 + #FFCCCEDB + #FFD8D8E0 + #FFEEEEF2 + #FFCCCEBD + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF444444 + #FFF5F5F5 + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFCCCEDB + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFF7F7F9 + #FFF7F7F9 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FFF5F5F5 + #FF0E70C0 + #FF444444 + #FF1E1E1E + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FFF5F5F5 + #FFEEEEF2 + #FF1E1E1E + #FFFFFFFF + #FF000000 + diff --git a/src/GitHub.VisualStudio.TestApp/Themes/ThemedDialogDefaultStyles.xaml b/src/GitHub.VisualStudio.TestApp/Themes/ThemedDialogDefaultStyles.xaml new file mode 100644 index 0000000000..fe50c2cb4e --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Themes/ThemedDialogDefaultStyles.xaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.TestApp/Views/ExternalGitHubDialogWindow.xaml b/src/GitHub.VisualStudio.TestApp/Views/ExternalGitHubDialogWindow.xaml new file mode 100644 index 0000000000..1c6c8dbd98 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Views/ExternalGitHubDialogWindow.xaml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/src/GitHub.VisualStudio.TestApp/Views/ExternalGitHubDialogWindow.xaml.cs b/src/GitHub.VisualStudio.TestApp/Views/ExternalGitHubDialogWindow.xaml.cs new file mode 100644 index 0000000000..a3d0cb0fa2 --- /dev/null +++ b/src/GitHub.VisualStudio.TestApp/Views/ExternalGitHubDialogWindow.xaml.cs @@ -0,0 +1,19 @@ +using System; +using System.Windows; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + /// + /// The main window for GitHub for Visual Studio's dialog. + /// + public partial class ExternalGitHubDialogWindow : Window + { + public ExternalGitHubDialogWindow(IGitHubDialogWindowViewModel viewModel) + { + DataContext = viewModel; + viewModel.Done.Subscribe(_ => Close()); + InitializeComponent(); + } + } +} diff --git a/src/GitHub.VisualStudio.UI.16/GitHub.VisualStudio.UI.16.csproj b/src/GitHub.VisualStudio.UI.16/GitHub.VisualStudio.UI.16.csproj new file mode 100644 index 0000000000..6a39634b7c --- /dev/null +++ b/src/GitHub.VisualStudio.UI.16/GitHub.VisualStudio.UI.16.csproj @@ -0,0 +1,71 @@ + + + + + Debug + AnyCPU + {00423E7A-0838-4BE1-9263-181006DFF96B} + Library + GitHub.VisualStudio.UI._16 + GitHub.VisualStudio.UI.16 + v4.7.2 + 512 + {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 4 + true + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + 4.0 + + + + + + + + MSBuild:Compile + Designer + + + + + Properties\SolutionInfo.cs + + + Code + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI.16/Properties/AssemblyInfo.cs b/src/GitHub.VisualStudio.UI.16/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a73cb70d12 --- /dev/null +++ b/src/GitHub.VisualStudio.UI.16/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Reflection; + +[assembly: AssemblyTitle("GitHub.VisualStudio.UI.16")] +[assembly: AssemblyDescription("GitHub Extension UI for Visual Studio 2019")] diff --git a/src/GitHub.VisualStudio.UI.16/Resources/icons/mark_github.xaml b/src/GitHub.VisualStudio.UI.16/Resources/icons/mark_github.xaml new file mode 100644 index 0000000000..c364f48ea7 --- /dev/null +++ b/src/GitHub.VisualStudio.UI.16/Resources/icons/mark_github.xaml @@ -0,0 +1,19 @@ + + + + + #424242 + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Base/TeamExplorerBase.cs b/src/GitHub.VisualStudio.UI/Base/TeamExplorerBase.cs index 59cab2bd17..7f022105c5 100644 --- a/src/GitHub.VisualStudio.UI/Base/TeamExplorerBase.cs +++ b/src/GitHub.VisualStudio.UI/Base/TeamExplorerBase.cs @@ -2,7 +2,6 @@ using System.ComponentModel; using System.Diagnostics; using GitHub.Primitives; -using NullGuard; using GitHub.Services; using GitHub.Extensions; @@ -12,21 +11,20 @@ public abstract class TeamExplorerBase : NotificationAwareObject, IDisposable { public static readonly Guid TeamExplorerConnectionsSectionId = new Guid("ef6a7a99-f01f-4c91-ad31-183c1354dd97"); - [AllowNull] protected IServiceProvider TEServiceProvider { - [return: AllowNull] get; set; } protected IGitHubServiceProvider ServiceProvider { - [return: AllowNull] get; } protected TeamExplorerBase(IGitHubServiceProvider serviceProvider) { + Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); + ServiceProvider = serviceProvider; } @@ -42,12 +40,17 @@ protected virtual void Dispose(bool disposing) protected static void OpenInBrowser(Lazy browser, Uri uri) { + Guard.ArgumentNotNull(browser, nameof(browser)); + Guard.ArgumentNotNull(uri, nameof(uri)); + OpenInBrowser(browser.Value, uri); } protected static void OpenInBrowser(IVisualStudioBrowser browser, Uri uri) { - Debug.Assert(browser != null, "Could not create a browser helper instance."); + Guard.ArgumentNotNull(browser, nameof(browser)); + Guard.ArgumentNotNull(uri, nameof(uri)); + browser?.OpenUrl(uri); } } diff --git a/src/GitHub.VisualStudio.UI/Base/TeamExplorerGitRepoInfo.cs b/src/GitHub.VisualStudio.UI/Base/TeamExplorerGitRepoInfo.cs index c3a885cfef..b93a5bdd76 100644 --- a/src/GitHub.VisualStudio.UI/Base/TeamExplorerGitRepoInfo.cs +++ b/src/GitHub.VisualStudio.UI/Base/TeamExplorerGitRepoInfo.cs @@ -2,7 +2,6 @@ using GitHub.Primitives; using GitHub.Services; using GitHub.VisualStudio.Helpers; -using NullGuard; namespace GitHub.VisualStudio.Base { @@ -15,11 +14,9 @@ public TeamExplorerGitRepoInfo(IGitHubServiceProvider serviceProvider) : base(se activeRepoName = string.Empty; } - ILocalRepositoryModel activeRepo; - [AllowNull] - public ILocalRepositoryModel ActiveRepo + LocalRepositoryModel activeRepo; + public LocalRepositoryModel ActiveRepo { - [return: AllowNull] get { return activeRepo; } set { @@ -34,10 +31,9 @@ public ILocalRepositoryModel ActiveRepo /// /// Represents the web URL of the repository on GitHub.com, even if the origin is an SSH address. /// - [AllowNull] public UriString ActiveRepoUri { - [return: AllowNull] get { return activeRepoUri; } + get { return activeRepoUri; } set { activeRepoUri = value; this.RaisePropertyChange(); } } diff --git a/src/GitHub.VisualStudio.UI/Base/TeamExplorerItemBase.cs b/src/GitHub.VisualStudio.UI/Base/TeamExplorerItemBase.cs index 1602f9030d..b143dd9018 100644 --- a/src/GitHub.VisualStudio.UI/Base/TeamExplorerItemBase.cs +++ b/src/GitHub.VisualStudio.UI/Base/TeamExplorerItemBase.cs @@ -6,22 +6,22 @@ using GitHub.Primitives; using GitHub.Services; using GitHub.VisualStudio.Helpers; -using NullGuard; using GitHub.ViewModels; using GitHub.VisualStudio.UI; +using GitHub.Extensions; +using System.ComponentModel; namespace GitHub.VisualStudio.Base { public class TeamExplorerItemBase : TeamExplorerGitRepoInfo, IServiceProviderAware { + readonly ITeamExplorerServiceHolder holder; readonly ISimpleApiClientFactory apiFactory; - protected ITeamExplorerServiceHolder holder; ISimpleApiClient simpleApiClient; - [AllowNull] public ISimpleApiClient SimpleApiClient { - [return: AllowNull] get { return simpleApiClient; } + get { return simpleApiClient; } set { if (simpleApiClient != value && value == null) @@ -30,39 +30,34 @@ public ISimpleApiClient SimpleApiClient } } - protected ISimpleApiClientFactory ApiFactory => apiFactory; - - public TeamExplorerItemBase(IGitHubServiceProvider serviceProvider, ITeamExplorerServiceHolder holder) - : base(serviceProvider) + public TeamExplorerItemBase(IGitHubServiceProvider serviceProvider, ISimpleApiClientFactory apiFactory, + ITeamExplorerServiceHolder holder) : this(serviceProvider, holder) { - this.holder = holder; + Guard.ArgumentNotNull(apiFactory, nameof(apiFactory)); + + this.apiFactory = apiFactory; } - public TeamExplorerItemBase(IGitHubServiceProvider serviceProvider, - ISimpleApiClientFactory apiFactory, ITeamExplorerServiceHolder holder) + public TeamExplorerItemBase(IGitHubServiceProvider serviceProvider, ITeamExplorerServiceHolder holder) : base(serviceProvider) { - this.apiFactory = apiFactory; + Guard.ArgumentNotNull(holder, nameof(holder)); + this.holder = holder; } public virtual void Initialize(IServiceProvider serviceProvider) { -#if DEBUG - //VsOutputLogger.WriteLine("{0:HHmmssff}\t{1} Initialize", DateTime.Now, GetType()); -#endif + Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); + TEServiceProvider = serviceProvider; Debug.Assert(holder != null, "Could not get an instance of TeamExplorerServiceHolder"); if (holder == null) return; holder.ServiceProvider = TEServiceProvider; SubscribeToRepoChanges(); -#if DEBUG - //VsOutputLogger.WriteLine("{0:HHmmssff}\t{1} Initialize DONE", DateTime.Now, GetType()); -#endif } - public virtual void Execute() { } @@ -71,24 +66,50 @@ public virtual void Invalidate() { } - void SubscribeToRepoChanges() + bool subscribedToRepoChanges = false; + protected void SubscribeToRepoChanges() + { + if (!subscribedToRepoChanges) + { + subscribedToRepoChanges = true; + UpdateRepoOnMainThread(holder.TeamExplorerContext.ActiveRepository); + holder.TeamExplorerContext.PropertyChanged += TeamExplorerContext_PropertyChanged; + } + } + + void TeamExplorerContext_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(holder.TeamExplorerContext.ActiveRepository)) + { + UpdateRepoOnMainThread(holder.TeamExplorerContext.ActiveRepository); + } + } + + void UpdateRepoOnMainThread(LocalRepositoryModel repo) { - holder.Subscribe(this, (ILocalRepositoryModel repo) => + holder.JoinableTaskContext.Factory.RunAsync(async () => { - var changed = !Equals(ActiveRepo, repo); - ActiveRepo = repo; - RepoChanged(changed); - }); + await holder.JoinableTaskContext.Factory.SwitchToMainThreadAsync(); + UpdateRepo(repo); + }).Task.Forget(); } void Unsubscribe() { - holder.Unsubscribe(this); + holder.TeamExplorerContext.PropertyChanged -= TeamExplorerContext_PropertyChanged; + if (TEServiceProvider != null) holder.ClearServiceProvider(TEServiceProvider); } - protected virtual void RepoChanged(bool changed) + void UpdateRepo(LocalRepositoryModel repo) + { + ActiveRepo = repo; + RepoChanged(); + Invalidate(); + } + + protected virtual void RepoChanged() { var repo = ActiveRepo; if (repo != null) @@ -102,17 +123,13 @@ protected virtual void RepoChanged(bool changed) } } - protected async Task GetRepositoryOrigin() + protected async Task GetRepositoryOrigin(UriString uri) { - if (ActiveRepo == null) - return RepositoryOrigin.NonGitRepository; - - var uri = ActiveRepoUri; if (uri == null) return RepositoryOrigin.Other; Debug.Assert(apiFactory != null, "apiFactory cannot be null. Did you call the right constructor?"); - SimpleApiClient = apiFactory.Create(uri); + SimpleApiClient = await apiFactory.Create(uri); var isdotcom = HostAddress.IsGitHubDotComUri(uri.ToRepositoryUrl()); @@ -124,7 +141,7 @@ protected async Task GetRepositoryOrigin() { var repo = await SimpleApiClient.GetRepository(); - if ((repo.FullName == ActiveRepoName || repo.Id == 0) && SimpleApiClient.IsEnterprise()) + if ((repo.FullName == ActiveRepoName || repo.Id == 0) && await SimpleApiClient.IsEnterprise()) { return RepositoryOrigin.Enterprise; } @@ -133,13 +150,19 @@ protected async Task GetRepositoryOrigin() return RepositoryOrigin.Other; } - protected async Task IsAGitHubRepo() + protected async Task IsAGitHubRepo(UriString uri) { - var origin = await GetRepositoryOrigin(); + var origin = await GetRepositoryOrigin(uri); return origin == RepositoryOrigin.DotCom || origin == RepositoryOrigin.Enterprise; } - protected bool IsUserAuthenticated() + protected async Task IsAGitHubDotComRepo(UriString uri) + { + var origin = await GetRepositoryOrigin(uri); + return origin == RepositoryOrigin.DotCom; + } + + protected async Task IsUserAuthenticated() { if (SimpleApiClient == null) { @@ -150,7 +173,7 @@ protected bool IsUserAuthenticated() if (uri == null) return false; - SimpleApiClient = apiFactory.Create(uri); + SimpleApiClient = await apiFactory.Create(uri); } return SimpleApiClient?.IsAuthenticated() ?? false; @@ -185,12 +208,10 @@ public bool IsVisible } string text; - [AllowNull] public string Text { get { return text; } set { text = value; this.RaisePropertyChange(); } } - } } \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Colors.cs b/src/GitHub.VisualStudio.UI/Colors.cs index f9966c2d5d..a28bf75a7e 100644 --- a/src/GitHub.VisualStudio.UI/Colors.cs +++ b/src/GitHub.VisualStudio.UI/Colors.cs @@ -1,21 +1,22 @@ using Microsoft.VisualStudio.PlatformUI; using System; +using System.Windows; using System.Windows.Media; namespace GitHub.VisualStudio.Helpers { public static class Colors { - public static Color RedNavigationItem = Color.FromRgb(0xF0, 0x50, 0x33); - public static Color BlueNavigationItem = Color.FromRgb(0x00, 0x79, 0xCE); - public static Color LightBlueNavigationItem = Color.FromRgb(0x00, 0x9E, 0xCE); - public static Color DarkPurpleNavigationItem = Color.FromRgb(0x68, 0x21, 0x7A); - public static Color GrayNavigationItem = Color.FromRgb(0x73, 0x82, 0x8C); - public static Color YellowNavigationItem = Color.FromRgb(0xF9, 0xC9, 0x00); - public static Color PurpleNavigationItem = Color.FromRgb(0xAE, 0x3C, 0xBA); + public static readonly Color RedNavigationItem = Color.FromRgb(0xF0, 0x50, 0x33); + public static readonly Color BlueNavigationItem = Color.FromRgb(0x00, 0x79, 0xCE); + public static readonly Color LightBlueNavigationItem = Color.FromRgb(0x00, 0x9E, 0xCE); + public static readonly Color DarkPurpleNavigationItem = Color.FromRgb(0x68, 0x21, 0x7A); + public static readonly Color GrayNavigationItem = Color.FromRgb(0x73, 0x82, 0x8C); + public static readonly Color YellowNavigationItem = Color.FromRgb(0xF9, 0xC9, 0x00); + public static readonly Color PurpleNavigationItem = Color.FromRgb(0xAE, 0x3C, 0xBA); - public static Color LightThemeNavigationItem = Color.FromRgb(66, 66, 66); - public static Color DarkThemeNavigationItem = Color.FromRgb(200, 200, 200); + public static readonly Color LightThemeNavigationItem = Color.FromRgb(66, 66, 66); + public static readonly Color DarkThemeNavigationItem = Color.FromRgb(200, 200, 200); public static int ToInt32(this Color color) { @@ -28,31 +29,28 @@ public static Color ToColor(this System.Drawing.Color color) } - static Color AccentMediumDarkTheme = Color.FromRgb(45, 45, 48); - static Color AccentMediumLightTheme = Color.FromRgb(238, 238, 242); - static Color AccentMediumBlueTheme = Color.FromRgb(255, 236, 181); + static readonly Color AccentMediumDarkTheme = Color.FromRgb(45, 45, 48); + static readonly Color AccentMediumLightTheme = Color.FromRgb(238, 238, 242); + static readonly Color AccentMediumBlueTheme = Color.FromRgb(255, 236, 181); public static string DetectTheme() { - try + if (Application.Current?.TryFindResource(EnvironmentColors.AccentMediumColorKey) is Color cc) { - var color = VSColorTheme.GetThemedColor(EnvironmentColors.AccentMediumColorKey); - var cc = color.ToColor(); if (cc == AccentMediumBlueTheme) return "Blue"; if (cc == AccentMediumLightTheme) return "Light"; if (cc == AccentMediumDarkTheme) return "Dark"; + var color = System.Drawing.Color.FromArgb(cc.A, cc.R, cc.R, cc.B); var brightness = color.GetBrightness(); var dark = brightness < 0.5f; return dark ? "Dark" : "Light"; } - // this throws in design time and when running outside of VS - catch (ArgumentNullException) - { - return "Dark"; - } + + // When Visual Studio resources aren't active + return "Dark"; } } } diff --git a/src/GitHub.VisualStudio.UI/Constants.cs b/src/GitHub.VisualStudio.UI/Constants.cs index 0378a5e916..e72248209b 100644 --- a/src/GitHub.VisualStudio.UI/Constants.cs +++ b/src/GitHub.VisualStudio.UI/Constants.cs @@ -1,4 +1,6 @@ -namespace GitHub.VisualStudio.UI +#pragma warning disable CA1707 // Identifiers should not contain underscores + +namespace GitHub.VisualStudio.UI { public static class Constants { diff --git a/src/GitHub.VisualStudio.UI/FodyWeavers.xml b/src/GitHub.VisualStudio.UI/FodyWeavers.xml deleted file mode 100644 index 685d2e71ef..0000000000 --- a/src/GitHub.VisualStudio.UI/FodyWeavers.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj b/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj index dabb2e7e2e..03a99b06bd 100644 --- a/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj +++ b/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj @@ -1,254 +1,43 @@ - - - + - Debug - AnyCPU - {D1DFBB0C-B570-4302-8F1E-2E3A19C41961} - Library - Properties - GitHub.VisualStudio.UI - GitHub.VisualStudio.UI - v4.6.1 - 512 - ..\..\build\$(Configuration)\ - Internal - 4 - true - ..\common\GitHubVS.ruleset - true - true - - - - - true + net46 + true full - false - TRACE;DEBUG;CODE_ANALYSIS - prompt - - - pdbonly - true - TRACE - prompt - - true - full - false - TRACE;DEBUG;CODE_ANALYSIS;XAML_DESIGNER - prompt - True - True - true - - - ..\..\script\Key.snk - true - false + + + - - ..\..\packages\Microsoft.VisualStudio.Shell.14.0.14.3.25407\lib\Microsoft.VisualStudio.Shell.14.0.dll - True - - - ..\..\packages\Microsoft.VisualStudio.Shell.Immutable.11.0.11.0.50727\lib\net45\Microsoft.VisualStudio.Shell.Immutable.11.0.dll - True - - - ..\..\packages\NullGuard.Fody.1.4.6\lib\dotnet\NullGuard.dll - False + + ..\..\lib\14.0\Microsoft.VisualStudio.Shell.ViewManager.dll - - - - - + - - - - - - + + + + - - - - - - - - - Resources.resx - True - True - - - - - AccountAvatar.xaml - - - InfoPanel.xaml - - - - GitHubConnectContent.xaml - - - GitHubHomeContent.xaml - - - GitHubInvitationContent.xaml - - - Key.snk - - - Properties\SolutionInfo.cs - - - + + + - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - - - MSBuild:Compile - Designer - + + + + - - {665078f4-5875-431d-a2cc-421849b43328} - GithubVSTestAutomationIDs - - - {08dd4305-7787-4823-a53f-4d0f725a07f3} - Octokit - - - {1CE2D235-8072-4649-BA5A-CFB1AF8776E0} - ReactiveUI_Net45 - - - {1a1da411-8d1f-4578-80a6-04576bea2dc5} - GitHub.App - - - {9AEA02DB-02B5-409C-B0CA-115D05331A6B} - GitHub.Exports - - - {346384dd-2445-4a28-af22-b45f3957bd89} - GitHub.UI - - - - - - - - PublicResXFileCodeGenerator - Resources.Designer.cs + + MSBuild:Compile Designer - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - + true + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/GlobalSuppressions.cs b/src/GitHub.VisualStudio.UI/GlobalSuppressions.cs new file mode 100644 index 0000000000..dac8ea36d1 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Discouraged for VSSDK projects.")] diff --git a/src/GitHub.VisualStudio.UI/Helpers/SharedDictionaryManager.cs b/src/GitHub.VisualStudio.UI/Helpers/SharedDictionaryManager.cs deleted file mode 100644 index 93a63e74b7..0000000000 --- a/src/GitHub.VisualStudio.UI/Helpers/SharedDictionaryManager.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Windows; -using Microsoft.VisualStudio.PlatformUI; -using GitHub.VisualStudio.Helpers; -using GitHub.Helpers; - -namespace GitHub.VisualStudio.UI.Helpers -{ - public class SharedDictionaryManager : ResourceDictionary - { - public SharedDictionaryManager() - { - currentTheme = Colors.DetectTheme(); - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] - string currentTheme; - -#if !XAML_DESIGNER - static readonly Dictionary resourceDicts = new Dictionary(); - static string baseThemeUri = "pack://application:,,,/GitHub.VisualStudio.UI;component/Styles/"; - - Uri sourceUri; - bool themed = false; - public new Uri Source - { - get { return sourceUri; } - set - { - if (value.ToString() == "pack://application:,,,/GitHub.VisualStudio.UI;component/Styles/ThemeDesignTime.xaml") - { - if (!themed) - { - themed = true; - VSColorTheme.ThemeChanged += OnThemeChange; - } - value = new Uri(baseThemeUri + "Theme" + currentTheme + ".xaml"); - } - - sourceUri = value; - ResourceDictionary ret; - if (resourceDicts.TryGetValue(value, out ret)) - { - if (ret != this) - { - MergedDictionaries.Add(ret); - return; - } - } - base.Source = value; - if (ret == null) - resourceDicts.Add(value, this); - } - } - - void OnThemeChange(ThemeChangedEventArgs e) - { - var uri = new Uri(baseThemeUri + "Theme" + currentTheme + ".xaml"); - ResourceDictionary ret; - if (resourceDicts.TryGetValue(uri, out ret)) - MergedDictionaries.Remove(ret); - currentTheme = Colors.DetectTheme(); - Source = uri; - } -#endif - } -} \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Helpers/ThemeDictionaryManager.cs b/src/GitHub.VisualStudio.UI/Helpers/ThemeDictionaryManager.cs new file mode 100644 index 0000000000..36b3cf10d1 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Helpers/ThemeDictionaryManager.cs @@ -0,0 +1,78 @@ +using System; +using System.Windows; +using System.ComponentModel; +using Microsoft.VisualStudio.PlatformUI; +using GitHub.VisualStudio.Helpers; +using GitHub.UI.Helpers; + +#pragma warning disable CA1010 // Collections should implement generic interface + +namespace GitHub.VisualStudio.UI.Helpers +{ + public class ThemeDictionaryManager : SharedDictionaryManager, IDisposable + { + static bool isInDesignMode = DesignerProperties.GetIsInDesignMode(new DependencyObject()); + Uri baseThemeUri; + + public override Uri Source + { + get { return base.Source; } + set + { + InitTheme(value); + base.Source = GetCurrentThemeUri(); + } + } + + void InitTheme(Uri themeUri) + { + if (baseThemeUri == null) + { + baseThemeUri = themeUri; + if (!isInDesignMode) + { + VSColorTheme.ThemeChanged += OnThemeChange; + } + } + } + + void OnThemeChange(ThemeChangedEventArgs e) + { + base.Source = GetCurrentThemeUri(); + } + + Uri GetCurrentThemeUri() + { + if (isInDesignMode) + { + return baseThemeUri; + } + + var currentTheme = Colors.DetectTheme(); + return new Uri(baseThemeUri, "Theme" + currentTheme + ".xaml"); + } + + bool disposed; + private void Dispose(bool disposing) + { + if (disposed) return; + if (disposing) + { + disposed = true; + if (baseThemeUri != null) + { + baseThemeUri = null; + if (!isInDesignMode) + { + VSColorTheme.ThemeChanged -= OnThemeChange; + } + } + } + } + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs b/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs index 1bbc0c3d84..83e5092a04 100644 --- a/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs +++ b/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs @@ -1,7 +1,8 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using System.Windows.Markup; -[assembly: AssemblyTitle("GitHub.VisualStudio.UI")] -[assembly: AssemblyDescription("GitHub.VisualStudio.UI")] -[assembly: Guid("d1dfbb0c-b570-4302-8f1e-2e3a19c41961")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.UI")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.UI.Controls")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.UI.Views")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.Views")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.Views.Dialog")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.Views.GitHubPane")] diff --git a/src/GitHub.VisualStudio.UI/Properties/DesignTimeResources.xaml b/src/GitHub.VisualStudio.UI/Properties/DesignTimeResources.xaml new file mode 100644 index 0000000000..10e06ccb27 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Properties/DesignTimeResources.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Resources.Designer.cs b/src/GitHub.VisualStudio.UI/Resources.Designer.cs deleted file mode 100644 index c66e527fac..0000000000 --- a/src/GitHub.VisualStudio.UI/Resources.Designer.cs +++ /dev/null @@ -1,846 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace GitHub.VisualStudio.UI { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - public class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GitHub.VisualStudio.UI.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to Invalid authentication code. - /// - public static string authenticationFailedLabelContent { - get { - return ResourceManager.GetString("authenticationFailedLabelContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Try entering the code again or clicking the resend button to get a new authentication code.. - /// - public static string authenticationFailedLabelMessage { - get { - return ResourceManager.GetString("authenticationFailedLabelMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Authentication code sent!. - /// - public static string authenticationSentLabelContent { - get { - return ResourceManager.GetString("authenticationSentLabelContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to If you do not receive the authentication code, contact support@github.com.. - /// - public static string authenticationSentLabelMessage { - get { - return ResourceManager.GetString("authenticationSentLabelMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Powerful collaboration, code review, and code management for open source and private projects.. - /// - public static string BlurbText { - get { - return ResourceManager.GetString("BlurbText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Browse. - /// - public static string browsePathButtonContent { - get { - return ResourceManager.GetString("browsePathButtonContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Cancel. - /// - public static string CancelLink { - get { - return ResourceManager.GetString("CancelLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Changes ({0}). - /// - public static string ChangesCountFormat { - get { - return ResourceManager.GetString("ChangesCountFormat", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Clone. - /// - public static string CloneLink { - get { - return ResourceManager.GetString("CloneLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Compare File. - /// - public static string CompareFile { - get { - return ResourceManager.GetString("CompareFile", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Compare File as Default Action. - /// - public static string CompareFileAsDefaultAction { - get { - return ResourceManager.GetString("CompareFileAsDefaultAction", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Could not connect to github.com. - /// - public static string couldNotConnectToGitHubText { - get { - return ResourceManager.GetString("couldNotConnectToGitHubText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Could not connect to the server.. - /// - public static string couldNotConnectToTheServerText { - get { - return ResourceManager.GetString("couldNotConnectToTheServerText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Create an account. - /// - public static string CreateAccountLink { - get { - return ResourceManager.GetString("CreateAccountLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Create. - /// - public static string CreateLink { - get { - return ResourceManager.GetString("CreateLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Description. - /// - public static string Description { - get { - return ResourceManager.GetString("Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Description (Optional). - /// - public static string DescriptionOptional { - get { - return ResourceManager.GetString("DescriptionOptional", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Don’t have an account? . - /// - public static string dontHaveAnAccountText { - get { - return ResourceManager.GetString("dontHaveAnAccountText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Don’t have GitHub Enterprise? . - /// - public static string dontHaveGitHubEnterpriseText { - get { - return ResourceManager.GetString("dontHaveGitHubEnterpriseText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please check your internet connection and try again.. - /// - public static string dotComConnectionFailedMessageMessage { - get { - return ResourceManager.GetString("dotComConnectionFailedMessageMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The host isn't available or is not a GitHub Enterprise server. Check the address and try again.. - /// - public static string enterpriseConnectingFailedMessage { - get { - return ResourceManager.GetString("enterpriseConnectingFailedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to GitHub Enterprise server address. - /// - public static string enterpriseUrlPromptText { - get { - return ResourceManager.GetString("enterpriseUrlPromptText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Could not copy to the clipboard. Please try again.. - /// - public static string Error_FailedToCopyToClipboard { - get { - return ResourceManager.GetString("Error_FailedToCopyToClipboard", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to File Name. - /// - public static string fileNameText { - get { - return ResourceManager.GetString("fileNameText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Filter branches. - /// - public static string filterBranchesText { - get { - return ResourceManager.GetString("filterBranchesText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Search repositories. - /// - public static string filterTextPromptText { - get { - return ResourceManager.GetString("filterTextPromptText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to (forgot your password?). - /// - public static string ForgotPasswordLink { - get { - return ResourceManager.GetString("ForgotPasswordLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Get Started. - /// - public static string GetStartedText { - get { - return ResourceManager.GetString("GetStartedText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Gist created. - /// - public static string gistCreatedMessage { - get { - return ResourceManager.GetString("gistCreatedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Failed to create gist. - /// - public static string gistCreationFailedMessage { - get { - return ResourceManager.GetString("gistCreationFailedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Connect…. - /// - public static string GitHubInvitationSectionConnectLabel { - get { - return ResourceManager.GetString("GitHubInvitationSectionConnectLabel", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish to GitHub. - /// - public static string GitHubPublishSectionTitle { - get { - return ResourceManager.GetString("GitHubPublishSectionTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Graphs. - /// - public static string GraphsNavigationItemText { - get { - return ResourceManager.GetString("GraphsNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Git ignore. - /// - public static string ignoreTemplateListText { - get { - return ResourceManager.GetString("ignoreTemplateListText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Issues. - /// - public static string IssuesNavigationItemText { - get { - return ResourceManager.GetString("IssuesNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Learn more. - /// - public static string learnMoreLink { - get { - return ResourceManager.GetString("learnMoreLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to License. - /// - public static string licenseListText { - get { - return ResourceManager.GetString("licenseListText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Link copied to clipboard. - /// - public static string LinkCopiedToClipboardMessage { - get { - return ResourceManager.GetString("LinkCopiedToClipboardMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Some or all repositories may not have loaded. Close the dialog and try again.. - /// - public static string loadingFailedMessageContent { - get { - return ResourceManager.GetString("loadingFailedMessageContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to An error occurred while loading repositories. - /// - public static string loadingFailedMessageMessage { - get { - return ResourceManager.GetString("loadingFailedMessageMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Local branch up to date. - /// - public static string LocalBranchUpToDate { - get { - return ResourceManager.GetString("LocalBranchUpToDate", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Local path. - /// - public static string localPathText { - get { - return ResourceManager.GetString("localPathText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Check your username and password, then try again. - /// - public static string LoginFailedMessage { - get { - return ResourceManager.GetString("LoginFailedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign in failed.. - /// - public static string LoginFailedText { - get { - return ResourceManager.GetString("LoginFailedText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign in. - /// - public static string LoginLink { - get { - return ResourceManager.GetString("LoginLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Private Repository. - /// - public static string makePrivateContent { - get { - return ResourceManager.GetString("makePrivateContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Private Gist. - /// - public static string makePrivateGist { - get { - return ResourceManager.GetString("makePrivateGist", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Name. - /// - public static string nameText { - get { - return ResourceManager.GetString("nameText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No repositories. - /// - public static string noRepositoriesMessageText { - get { - return ResourceManager.GetString("noRepositoriesMessageText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to This repository is not on GitHub. - /// - public static string NotAGitHubRepository { - get { - return ResourceManager.GetString("NotAGitHubRepository", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish this repository to GitHub and get powerful collaboration, code review, and code management for open source and private projects.. - /// - public static string NotAGitHubRepositoryMessage { - get { - return ResourceManager.GetString("NotAGitHubRepositoryMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No repository. - /// - public static string NotAGitRepository { - get { - return ResourceManager.GetString("NotAGitRepository", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to We couldn't find a git repository here. Open a git project or click "File -> Add to Source Control" in a project to get started.. - /// - public static string NotAGitRepositoryMessage { - get { - return ResourceManager.GetString("NotAGitRepositoryMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You are not signed in to {0}, so certain git operations may fail. [Sign in now]({1}). - /// - public static string NotLoggedInMessage { - get { - return ResourceManager.GetString("NotLoggedInMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open File. - /// - public static string OpenFile { - get { - return ResourceManager.GetString("OpenFile", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open File as Default Action. - /// - public static string OpenFileAsDefaultAction { - get { - return ResourceManager.GetString("OpenFileAsDefaultAction", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open in Browser. - /// - public static string openInBrowser { - get { - return ResourceManager.GetString("openInBrowser", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to View Pull Request on GitHub. - /// - public static string OpenPROnGitHubToolTip { - get { - return ResourceManager.GetString("OpenPROnGitHubToolTip", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open the two-factor authentication app on your device to view your authentication code.. - /// - public static string openTwoFactorAuthAppText { - get { - return ResourceManager.GetString("openTwoFactorAuthAppText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Help us improve by sending anonymous usage data. - /// - public static string Options_MetricsLabel { - get { - return ResourceManager.GetString("Options_MetricsLabel", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Privacy. - /// - public static string Options_PrivacyTitle { - get { - return ResourceManager.GetString("Options_PrivacyTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to or. - /// - public static string orText { - get { - return ResourceManager.GetString("orText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Password. - /// - public static string PasswordPrompt { - get { - return ResourceManager.GetString("PasswordPrompt", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Path. - /// - public static string pathText { - get { - return ResourceManager.GetString("pathText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to by. - /// - public static string prOpenedByText { - get { - return ResourceManager.GetString("prOpenedByText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to opened. - /// - public static string prOpenedText { - get { - return ResourceManager.GetString("prOpenedText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish. - /// - public static string publishText { - get { - return ResourceManager.GetString("publishText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish to GitHub. - /// - public static string PublishToGitHubButton { - get { - return ResourceManager.GetString("PublishToGitHubButton", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pull Requests. - /// - public static string PullRequestsNavigationItemText { - get { - return ResourceManager.GetString("PullRequestsNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pulse. - /// - public static string PulseNavigationItemText { - get { - return ResourceManager.GetString("PulseNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to This repository does not have a remote. Fill out the form to publish it to GitHub.. - /// - public static string RepoDoesNotHaveRemoteText { - get { - return ResourceManager.GetString("RepoDoesNotHaveRemoteText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Repository Name. - /// - public static string RepoNameText { - get { - return ResourceManager.GetString("RepoNameText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Repository created successfully.. - /// - public static string RepositoryPublishedMessage { - get { - return ResourceManager.GetString("RepositoryPublishedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Resend. - /// - public static string resendCodeButtonContent { - get { - return ResourceManager.GetString("resendCodeButtonContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Send the code to your registered SMS Device again. - /// - public static string resendCodeButtonToolTip { - get { - return ResourceManager.GetString("resendCodeButtonToolTip", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign in.... - /// - public static string SignInCallToAction { - get { - return ResourceManager.GetString("SignInCallToAction", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign in. - /// - public static string SignInLink { - get { - return ResourceManager.GetString("SignInLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign out. - /// - public static string SignOutLink { - get { - return ResourceManager.GetString("SignOutLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign up. - /// - public static string SignUpLink { - get { - return ResourceManager.GetString("SignUpLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Switch to List View. - /// - public static string SwitchToListView { - get { - return ResourceManager.GetString("SwitchToListView", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Switch to Tree View. - /// - public static string SwitchToTreeView { - get { - return ResourceManager.GetString("SwitchToTreeView", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Title (required). - /// - public static string TitleRequired { - get { - return ResourceManager.GetString("TitleRequired", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Two-factor authentication. - /// - public static string twoFactorAuthText { - get { - return ResourceManager.GetString("twoFactorAuthText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to updated {0}. - /// - public static string UpdatedFormat { - get { - return ResourceManager.GetString("UpdatedFormat", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Username or email. - /// - public static string UserNameOrEmailPromptText { - get { - return ResourceManager.GetString("UserNameOrEmailPromptText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Verify. - /// - public static string verifyText { - get { - return ResourceManager.GetString("verifyText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Wiki. - /// - public static string WikiNavigationItemText { - get { - return ResourceManager.GetString("WikiNavigationItemText", resourceCulture); - } - } - } -} diff --git a/src/GitHub.VisualStudio.UI/Resources.resx b/src/GitHub.VisualStudio.UI/Resources.resx deleted file mode 100644 index 1e0b6aede1..0000000000 --- a/src/GitHub.VisualStudio.UI/Resources.resx +++ /dev/null @@ -1,381 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Invalid authentication code - - - Try entering the code again or clicking the resend button to get a new authentication code. - - - Authentication code sent! - - - If you do not receive the authentication code, contact support@github.com. - - - Browse - - - Could not connect to github.com - - - Could not connect to the server. - - - Create - - - Description (Optional) - - - Open in Browser - - - Cancel - - - Gist created - - - Failed to create gist - - - by - - - opened - - - Privacy - - - Help us improve by sending anonymous usage data - - - Could not copy to the clipboard. Please try again. - - - Link copied to clipboard - - - Repository created successfully. - - - Private Gist - - - File Name - - - You are not signed in to {0}, so certain git operations may fail. [Sign in now]({1}) - - - Wiki - - - Pulse - - - Pull Requests - - - Path - - - Issues - - - Graphs - - - Publish to GitHub - - - Powerful collaboration, code review, and code management for open source and private projects. - - - Connect… - - - Clone - - - Verify - - - Two-factor authentication - - - Sign up - - - Sign out - - - Send the code to your registered SMS Device again - - - Resend - - - Repository Name - - - This repository does not have a remote. Fill out the form to publish it to GitHub. - - - Publish - - - or - - - Open the two-factor authentication app on your device to view your authentication code. - - - No repositories - - - Name - - - Private Repository - - - Sign in failed. - - - Local path - - - License - - - Learn more - - - Git ignore - - - Search repositories - - - Some or all repositories may not have loaded. Close the dialog and try again. - - - An error occurred while loading repositories - - - GitHub Enterprise server address - - - The host isn't available or is not a GitHub Enterprise server. Check the address and try again. - - - Username or email - - - Password - - - Check your username and password, then try again - - - Sign in - - - (forgot your password?) - - - Please check your internet connection and try again. - - - Don’t have GitHub Enterprise? - - - Don’t have an account? - - - Title (required) - - - Description - - - Publish this repository to GitHub and get powerful collaboration, code review, and code management for open source and private projects. - - - This repository is not on GitHub - - - No repository - - - We couldn't find a git repository here. Open a git project or click "File -> Add to Source Control" in a project to get started. - - - Create an account - - - Filter branches - - - Publish to GitHub - - - Get Started - - - Sign in - - - Sign in... - - - Local branch up to date - - - Changes ({0}) - - - Compare File - - - Compare File as Default Action - - - Open File - - - Open File as Default Action - - - Switch to List View - - - Switch to Tree View - - - updated {0} - - - View Pull Request on GitHub - - \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/SharedDictionary.xaml b/src/GitHub.VisualStudio.UI/SharedDictionary.xaml index 1bd0cad6ab..b73034a15b 100644 --- a/src/GitHub.VisualStudio.UI/SharedDictionary.xaml +++ b/src/GitHub.VisualStudio.UI/SharedDictionary.xaml @@ -1,20 +1,25 @@ + xmlns:theme="clr-namespace:GitHub.VisualStudio.UI.Helpers" + xmlns:cache="clr-namespace:GitHub.UI.Helpers;assembly=GitHub.UI"> - + + + + + + diff --git a/src/GitHub.UI/Assets/Controls/FilterTextBox.xaml b/src/GitHub.VisualStudio.UI/Styles/FilterTextBox.xaml similarity index 90% rename from src/GitHub.UI/Assets/Controls/FilterTextBox.xaml rename to src/GitHub.VisualStudio.UI/Styles/FilterTextBox.xaml index 50a912fd5b..f8607edc50 100644 --- a/src/GitHub.UI/Assets/Controls/FilterTextBox.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/FilterTextBox.xaml @@ -1,11 +1,12 @@  + xmlns:ui="clr-namespace:GitHub.UI;assembly=GitHub.UI" + xmlns:vs="clr-namespace:Microsoft.VisualStudio.PlatformUI;assembly=Microsoft.VisualStudio.Shell.14.0"> + @@ -40,8 +49,7 @@ - - + diff --git a/src/GitHub.VisualStudio.UI/Styles/GitHubComboBox.xaml b/src/GitHub.VisualStudio.UI/Styles/GitHubComboBox.xaml index 49c34c6053..b3cbd32e7b 100644 --- a/src/GitHub.VisualStudio.UI/Styles/GitHubComboBox.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/GitHubComboBox.xaml @@ -1,6 +1,6 @@  diff --git a/src/GitHub.UI/Assets/Controls/GitHubLinkButton.xaml b/src/GitHub.VisualStudio.UI/Styles/GitHubLinkButton.xaml similarity index 76% rename from src/GitHub.UI/Assets/Controls/GitHubLinkButton.xaml rename to src/GitHub.VisualStudio.UI/Styles/GitHubLinkButton.xaml index ea19b949ac..322bb6183b 100644 --- a/src/GitHub.UI/Assets/Controls/GitHubLinkButton.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/GitHubLinkButton.xaml @@ -1,9 +1,10 @@  - - - + + - + + + + + + + + + + + diff --git a/src/GitHub.UI/Controls/Buttons/OcticonButton.xaml b/src/GitHub.VisualStudio.UI/Styles/OcticonButton.xaml similarity index 92% rename from src/GitHub.UI/Controls/Buttons/OcticonButton.xaml rename to src/GitHub.VisualStudio.UI/Styles/OcticonButton.xaml index 6927e0bcf7..e3d2e49ad5 100644 --- a/src/GitHub.UI/Controls/Buttons/OcticonButton.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/OcticonButton.xaml @@ -1,8 +1,8 @@ - + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/CommonControlsCheckBoxStyle.xaml b/src/GitHub.VisualStudio.UI/Themes/CommonControlsCheckBoxStyle.xaml new file mode 100644 index 0000000000..53720fe62a --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/CommonControlsCheckBoxStyle.xaml @@ -0,0 +1,105 @@ + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/CommonControlsComboBoxStyle.xaml b/src/GitHub.VisualStudio.UI/Themes/CommonControlsComboBoxStyle.xaml new file mode 100644 index 0000000000..334110e0f2 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/CommonControlsComboBoxStyle.xaml @@ -0,0 +1,374 @@ + + + M 0 0 L 3 3 L 6 0 Z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/CommonControlsTextBoxStyle.xaml b/src/GitHub.VisualStudio.UI/Themes/CommonControlsTextBoxStyle.xaml new file mode 100644 index 0000000000..d1e926fb76 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/CommonControlsTextBoxStyle.xaml @@ -0,0 +1,34 @@ + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/Dark/CommonControlsColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Dark/CommonControlsColors.xaml new file mode 100644 index 0000000000..d70cccd429 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Dark/CommonControlsColors.xaml @@ -0,0 +1,198 @@ + + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF555555 + #FF555555 + #FF007ACC + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF007ACC + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF656565 + #FF656565 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF1F1F20 + #FF1F1F20 + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF999999 + #FF999999 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF999999 + #FF999999 + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF656565 + #FF656565 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1F1F1C + #FF1F1F1C + #19000000 + #19000000 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #66007ACC + #66007ACC + #FFFFFFFF + #FFFFFFFF + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF555555 + #FF555555 + #FF555555 + #FF555555 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/Dark/EnvironmentColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Dark/EnvironmentColors.xaml new file mode 100644 index 0000000000..03e417f48f --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Dark/EnvironmentColors.xaml @@ -0,0 +1,1921 @@ + + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF000000 + #FF000000 + #FF999999 + #FF999999 + #80525252 + #80525252 + #FF656565 + #FF656565 + #80525252 + #80525252 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E42 + #FF3E3E42 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF0097FB + #FF0097FB + #FFD0D0D0 + #FFD0D0D0 + #FF000000 + #FF000000 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF464646 + #FF464646 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF0F2F9 + #FFF0F2F9 + #FFD3DCEF + #FFD3DCEF + #FFCCCC66 + #FFCCCC66 + #FFFFFFCC + #FFFFFFCC + #FF000000 + #FF000000 + #FFD2D2D2 + #FFD2D2D2 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF00008B + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFF7F0F0 + #FFF7F0F0 + #FFEDDADC + #FFEDDADC + #FFFFFFFF + #FFFFFFFF + #FF0054E3 + #FF0054E3 + #FFDDD6EF + #FFDDD6EF + #FF266035 + #FF266035 + #FFFFFFFF + #FFFFFFFF + #FF716F64 + #FF716F64 + #FFF3F7F0 + #FFF3F7F0 + #FFE6F0DB + #FFE6F0DB + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFB0764F + #FFB0764F + #FF716F64 + #FF716F64 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD8D8D8 + #FFD8D8D8 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD6ECEF + #FFD6ECEF + #FFFF0000 + #FFFF0000 + #FFF8F4E9 + #FFF8F4E9 + #FFF0E9D2 + #FFF0E9D2 + #FF333337 + #FF333337 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF656565 + #FF656565 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF656565 + #FF656565 + #FFF1F1F1 + #FFF1F1F1 + #FF46464A + #FF46464A + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3399FF + #FF3399FF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FF999999 + #FF999999 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FF1B1B1C + #FF1B1B1C + #FF333334 + #FF333334 + #FFF1F1F1 + #FFF1F1F1 + #FF333334 + #FF333334 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FF007ACC + #FF007ACC + #FF999999 + #FF999999 + #FF333337 + #FF333337 + #FF999999 + #FF999999 + #FFFFFFFF + #FFFFFFFF + #FF999999 + #FF999999 + #FF0097FB + #FF0097FB + #FF999999 + #FF999999 + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #72555555 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF434346 + #FF434346 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF222222 + #FF222222 + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFFEFCC8 + #FFFEFCC8 + #FF555555 + #FF555555 + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF333337 + #FF333337 + #003F3F46 + #003F3F46 + #FF424245 + #FF424245 + #FF4D4D50 + #FF4D4D50 + #FF505051 + #FF505051 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FFF1F1F1 + #FFF1F1F1 + #FF2C2C2F + #FF2C2C2F + #FF37373A + #FF37373A + #FF3D3D3F + #FF3D3D3F + #FF7A7A7A + #FF7A7A7A + #FF333337 + #FF333337 + #FF656565 + #FF656565 + #FF252526 + #FF252526 + #FF46464A + #FF46464A + #FF3F3F46 + #FF3F3F46 + #FF656565 + #FF656565 + #FFFFFFFF + #FFFFFFFF + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF2F4F8 + #FFF2F4F8 + #FF000000 + #FF000000 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFBCC7D8 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF007ACC + #FF007ACC + #FF333337 + #FF333337 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF1F1F20 + #FF1F1F20 + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF434346 + #FF434346 + #FF434346 + #FF434346 + #FF656565 + #FF656565 + #FF999999 + #FF999999 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF434346 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF333337 + #FFF1F1F1 + #FFF1F1F1 + #72000000 + #72000000 + #FF2D2D30 + #FF2D2D30 + #FF333337 + #FF333337 + #FF252526 + #FF252526 + #FF0097FB + #FF0097FB + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFFF8C00 + #FFFF8C00 + #FFFF8C00 + #FFFF8C00 + #FF656565 + #FF656565 + #FF656565 + #FF656565 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FFF1F1F1 + #FFF1F1F1 + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FF555555 + #FF555555 + #FF555555 + #FF555555 + #FFF1F1F1 + #FFF1F1F1 + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FF1B1B1C + #FFF1F1F1 + #FFF1F1F1 + #FFE1D3E4 + #FFE1D3E4 + #FFB064AB + #FFB064AB + #FFB064AB + #FFB064AB + #FFFFFFFF + #FFFFFFFF + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FFFFFFFF + #FFFFFFFF + #FF555555 + #FF555555 + #FF555555 + #FF555555 + #FFF1F1F1 + #FFF1F1F1 + #FF555555 + #FF555555 + #FFE1D3E4 + #FFE1D3E4 + #FF6D6D70 + #FF6D6D70 + #FFD0E6F5 + #FFD0E6F5 + #FF6D6D70 + #FF6D6D70 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF1C97EA + #FF1C97EA + #FFD0E6F5 + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF999999 + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF000000 + #FF000000 + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF1B293E + #FF1B293E + #FF0066CC + #FF0066CC + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF7AC1FF + #FF7AC1FF + #FFC8C8C8 + #FFC8C8C8 + #00000000 + #00000000 + #FF3F3F46 + #FF3F3F46 + + + #FF2D2D30 + #FF2D2D30 + #FF656565 + #FF656565 + #FFFEFCC8 + #FFFEFCC8 + #FF1E1E1E + #FF1E1E1E + #001E1E1E + #001E1E1E + #00F1F1F1 + #00F1F1F1 + #FF0E639C + #FF0E639C + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFCA5100 + #FFCA5100 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF434346 + #FF434346 + #FF2D2D30 + #FF2D2D30 + #99999999 + #99999999 + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #FFFFFFFF + #FFFFFFFF + #7F000000 + #7F000000 + #FF333337 + #FF333337 + #002D2D30 + #002D2D30 + #FF1B1B1C + #FF1B1B1C + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF7D7D7D + #FF7D7D7D + #FF3399FF + #FF3399FF + #FFC6C6C6 + #FFC6C6C6 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF0097FB + #FF0097FB + #FF656565 + #FF656565 + #FF55AAFF + #FF55AAFF + #FFFFFFFF + #FFFFFFFF + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF0097FB + #FF0097FB + #FF0097FB + #FF0097FB + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF2D2D30 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF333337 + #FF333337 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #72555555 + #72555555 + #72555555 + #72555555 + #FFFFFFFF + #FFFFFFFF + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #66FFFFFF + #FFFEFCC8 + #FFFEFCC8 + #FFFEFCC8 + #FFFEFCC8 + #FF252526 + #FF252526 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF999999 + #FF999999 + #FF555558 + #FF555558 + #FF1C97EA + #FF1C97EA + #FF007ACC + #FF007ACC + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF1C1C1C + #FF1C1C1C + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF686868 + #FF686868 + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF9E9E9E + #FF9E9E9E + #FFEFEBEF + #FFEFEBEF + #FF9E9E9E + #FF9E9E9E + #FF9E9E9E + #FF9E9E9E + #FFEFEBEF + #FFEFEBEF + #FFEFEBEF + #FFEFEBEF + #FF333337 + #FF333337 + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FFE5C365 + #FFE5C365 + #FFFFEFBB + #FFFFEFBB + #FFE5C365 + #FFE5C365 + #FFFEFCC8 + #FFFEFCC8 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF4169E1 + #FF4169E1 + #FF96A9DD + #FF96A9DD + #FFE122DF + #FFE122DF + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF434346 + #FF434346 + #FF1F1F22 + #FF1F1F22 + #FF1F1F22 + #FF1F1F22 + #FF999999 + #FF999999 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FFF30506 + #FFF30506 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF30506 + #FFF30506 + #FFF30506 + #FFF30506 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF363639 + #FF363639 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF28282B + #FF28282B + #FF28282B + #FF28282B + #FFF1F1F1 + #FFF1F1F1 + #FFF30506 + #FFF30506 + #FFF1F1F1 + #FFF1F1F1 + #FF0097FB + #FF0097FB + #FF88CCFE + #FF88CCFE + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FF55AAFF + #FF55AAFF + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FF55AAFF + #FF55AAFF + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F3F + #FF3F3F3F + #FF464646 + #FF464646 + #FF999999 + #FF999999 + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FFCA5100 + #FFCA5100 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #4C000000 + #4C000000 + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF464646 + #FF464646 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF46464A + #FF46464A + #FF59A8DE + #FF59A8DE + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFD0D0D0 + #FFD0D0D0 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF656565 + #FF656565 + #FF3E3E40 + #FF3E3E40 + #FF656565 + #FF656565 + #FF333337 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF424245 + #FF424245 + #FFF1F1F1 + #FFF1F1F1 + #FF4D4D50 + #FF4D4D50 + #FF717171 + #FF717171 + #FF252526 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FF393939 + #FF393939 + #FF393939 + #FF393939 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FF2D2D30 + #FF2D2D30 + #99999999 + #99999999 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF55AAFF + #FF55AAFF + #FF0097FB + #FF0097FB + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF0097FB + #FF0097FB + #FF3F3F46 + #FF3F3F46 + #FFD0D0D0 + #FFD0D0D0 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF705829 + #FF705829 + #FFB0A781 + #FFB0A781 + #FFA19667 + #FFA19667 + #FFA79432 + #FFA79432 + #FFD0D4B7 + #FFD0D4B7 + #FFBFC749 + #FFBFC749 + #FFCAB22D + #FFCAB22D + #FFFBF7C8 + #FFFBF7C8 + #FFE2E442 + #FFE2E442 + #FF5D8039 + #FF5D8039 + #FFB1C97B + #FFB1C97B + #FF9FB861 + #FF9FB861 + #FF8E5478 + #FF8E5478 + #FFE2B1CD + #FFE2B1CD + #FFCB98B6 + #FFCB98B6 + #FFAD1C2B + #FFAD1C2B + #FFFF9F99 + #FFFF9F99 + #FFFF7971 + #FFFF7971 + #FF779AB6 + #FF779AB6 + #FFC6D4DF + #FFC6D4DF + #FFB8CCD7 + #FFB8CCD7 + #FF427094 + #FF427094 + #FFA0B7C9 + #FFA0B7C9 + #FF89ABBD + #FF89ABBD + #FF5386BF + #FF5386BF + #FFB9D4EE + #FFB9D4EE + #FFA1C7E7 + #FFA1C7E7 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + + + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/Dark/ThemedDialogColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Dark/ThemedDialogColors.xaml new file mode 100644 index 0000000000..e5c559dbe3 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Dark/ThemedDialogColors.xaml @@ -0,0 +1,66 @@ + + #00000000 + #00000000 + #00000000 + #00000000 + #FFF1F1F1 + #FFF1F1F1 + #FF999999 + #FF999999 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF0097FB + #FF0097FB + #FF656565 + #FF656565 + #FF55AAFF + #FF55AAFF + #FF0097FB + #FF0097FB + #FF656565 + #FF656565 + #FF3F3F40 + #FF3F3F40 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #00000000 + #00000000 + #00000000 + #00000000 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FFF1F1F1 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/Dark/TreeViewColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Dark/TreeViewColors.xaml new file mode 100644 index 0000000000..5bd259ebcd --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Dark/TreeViewColors.xaml @@ -0,0 +1,48 @@ + + #FF252526 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FFE51400 + #FFE51400 + #FFFFFFFF + #FFFFFFFF + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFFEFCC8 + #FFFEFCC8 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFE51400 + #FFE51400 + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/Dark/VsBrushes.xaml b/src/GitHub.VisualStudio.UI/Themes/Dark/VsBrushes.xaml new file mode 100644 index 0000000000..399f40ffd1 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Dark/VsBrushes.xaml @@ -0,0 +1,826 @@ + + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF0097FB + #FFD0D0D0 + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF464646 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FFF1F1F1 + #FF3F3F46 + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF2D2D30 + #FF999999 + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3399FF + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF1B1B1C + #FF007ACC + #FF333337 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF2D2D30 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #72555555 + #72555555 + #72555555 + #72555555 + #FF007ACC + #FF2D2D30 + #FF3399FF + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FFF1F1F1 + #FF2D2D30 + #FF222222 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFFEFCC8 + #FF555555 + #FF0097FB + #FF0097FB + #FF0097FB + #FF333337 + #FF424245 + #FF4D4D50 + #FF505051 + #FFF1F1F1 + #FF333337 + #FFF1F1F1 + #FF2C2C2F + #FF37373A + #FF3D3D3F + #FF7A7A7A + #FF333337 + #FF656565 + #FF252526 + #FF46464A + #FF3F3F46 + #FF656565 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FF1B1B1C + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF007ACC + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FF999999 + #FF3F3F46 + #FF434346 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #72000000 + #FF333337 + #FF252526 + #FF0097FB + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFFF8C00 + #FFFF8C00 + #FF656565 + #FF656565 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFF1F1F1 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF999999 + #FF2D2D30 + #FFF1F1F1 + #FF000000 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FF3F3F46 + #FF2D2D30 + #FF656565 + #FFFEFCC8 + #FF1E1E1E + #FF333337 + #FF1B1B1C + #FFF1F1F1 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3399FF + #FF3399FF + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF333337 + #FF252526 + #FF252526 + #FF3E3E40 + #FF3E3E40 + #FF0097FB + #FF55AAFF + #FF0097FB + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF252526 + #FF252526 + #FF2D2D30 + #FFFEFCC8 + #FFFEFCC8 + #FF252526 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF686868 + #FF9E9E9E + #FFEFEBEF + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFEFCC8 + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FF252526 + #FFF1F1F1 + #FF434346 + #FF1F1F22 + #FF1F1F22 + #FF999999 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FF252526 + #FF252526 + #FF28282B + #FF28282B + #FFF1F1F1 + #FFF30506 + #FFF1F1F1 + #FF0097FB + #FF88CCFE + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF464646 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFD0D0D0 + #FF252526 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF252526 + #FF3F3F46 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FF393939 + #FF393939 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF55AAFF + #FF252526 + #FF0097FB + #FFD0D0D0 + #FFF1F1F1 + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FF252526 + #FF2D2D30 + #FFF1F1F1 + #FFFFFFFF + #FF000000 + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Themes/Dark/VsColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Dark/VsColors.xaml new file mode 100644 index 0000000000..d3abe4d276 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Dark/VsColors.xaml @@ -0,0 +1,505 @@ + + #FF3F3F46 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF0097FB + #FFD0D0D0 + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF464646 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FFF1F1F1 + #FF3F3F46 + #FF007ACC + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF2D2D30 + #FF999999 + #FF46464A + #FF46464A + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3399FF + #FF1B1B1C + #FF1B1B1C + #FF333337 + #FF1B1B1C + #FF007ACC + #FF333337 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF2D2D30 + #FF999999 + #FF007ACC + #FF007ACC + #FF007ACC + #72555555 + #72555555 + #72555555 + #72555555 + #FF007ACC + #FF2D2D30 + #FF3399FF + #FF2D2D30 + #FFF1F1F1 + #FFF1F1F1 + #FF656565 + #FFF1F1F1 + #FF2D2D30 + #FF222222 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFFEFCC8 + #FF555555 + #FF0097FB + #FF0097FB + #FF0097FB + #FF333337 + #FF424245 + #FF4D4D50 + #FF505051 + #FFF1F1F1 + #FF333337 + #FFF1F1F1 + #FF2C2C2F + #FF37373A + #FF3D3D3F + #FF7A7A7A + #FF333337 + #FF656565 + #FF252526 + #FF46464A + #FF3F3F46 + #FF656565 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FF1B1B1C + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF252526 + #FF007ACC + #FF333337 + #FF434346 + #FF2D2D30 + #FF434346 + #FF434346 + #FF999999 + #FF3F3F46 + #FF434346 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF434346 + #FF007ACC + #FF1B1B1C + #FF1B1B1C + #FF333337 + #72000000 + #FF333337 + #FF252526 + #FF0097FB + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFFF8C00 + #FFFF8C00 + #FF656565 + #FF656565 + #FF2D2D30 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF2D2D30 + #FF2D2D30 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFF1F1F1 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF999999 + #FF2D2D30 + #FFF1F1F1 + #FF000000 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FF3F3F46 + #FF2D2D30 + #FF656565 + #FFFEFCC8 + #FF1E1E1E + #FF333337 + #FF1B1B1C + #FFF1F1F1 + #FF252526 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3399FF + #FF3399FF + #FF3E3E40 + #FF3E3E40 + #FFF1F1F1 + #FF3E3E40 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FFF1F1F1 + #FF3F3F46 + #FF252526 + #FFF1F1F1 + #FF3E3E40 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FFF1F1F1 + #FF333337 + #FF252526 + #FF252526 + #FF3E3E40 + #FF3E3E40 + #FF0097FB + #FF55AAFF + #FF0097FB + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF252526 + #FFF1F1F1 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF2D2D30 + #FF252526 + #FF252526 + #FF2D2D30 + #FFFEFCC8 + #FFFEFCC8 + #FF252526 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF3E3E42 + #FF686868 + #FF686868 + #FF686868 + #FF9E9E9E + #FFEFEBEF + #FF333337 + #FF333337 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF3F3F46 + #FF007ACC + #FF252526 + #FF252526 + #FF252526 + #FFF1F1F1 + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFEFCC8 + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FF252526 + #FFF1F1F1 + #FF434346 + #FF1F1F22 + #FF1F1F22 + #FF999999 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FF464646 + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FF252526 + #FF252526 + #FF28282B + #FF28282B + #FFF1F1F1 + #FFF30506 + #FFF1F1F1 + #FF0097FB + #FF88CCFE + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF999999 + #FF55AAFF + #FFF1F1F1 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FF000000 + #FF2D2D30 + #FF3F3F46 + #FF464646 + #FF2D2D30 + #FF3F3F46 + #FF2D2D30 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FFD0D0D0 + #FF252526 + #FF333337 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF252526 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF252526 + #FF3F3F46 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FF393939 + #FF393939 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FFF1F1F1 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF3E3E40 + #FF3E3E40 + #FF3E3E40 + #FF55AAFF + #FF252526 + #FF0097FB + #FFD0D0D0 + #FFF1F1F1 + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FF252526 + #FF2D2D30 + #FFF1F1F1 + #FFFFFFFF + #FF000000 + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/FontScalingLabelStyle.xaml b/src/GitHub.VisualStudio.UI/Themes/FontScalingLabelStyle.xaml new file mode 100644 index 0000000000..b1cd2df882 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/FontScalingLabelStyle.xaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/FontScalingTextBlockStyle.xaml b/src/GitHub.VisualStudio.UI/Themes/FontScalingTextBlockStyle.xaml new file mode 100644 index 0000000000..e1d59a1b49 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/FontScalingTextBlockStyle.xaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/Generic.xaml b/src/GitHub.VisualStudio.UI/Themes/Generic.xaml new file mode 100644 index 0000000000..3203d021a4 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Generic.xaml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Themes/HighContrast/CommonControlsColors.xaml b/src/GitHub.VisualStudio.UI/Themes/HighContrast/CommonControlsColors.xaml new file mode 100644 index 0000000000..f0734228b1 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/HighContrast/CommonControlsColors.xaml @@ -0,0 +1,198 @@ + + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF00002F + #FF00002F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF00002F + #FF00002F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF37006E + #FF37006E + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + diff --git a/src/GitHub.VisualStudio.UI/Themes/HighContrast/EnvironmentColors.xaml b/src/GitHub.VisualStudio.UI/Themes/HighContrast/EnvironmentColors.xaml new file mode 100644 index 0000000000..1fde95b09c --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/HighContrast/EnvironmentColors.xaml @@ -0,0 +1,1588 @@ + + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF37006E + #FF37006E + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFC0C0C0 + #FFC0C0C0 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FFFFFFFF + #00C800C8 + #00C800C8 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF00529B + #FF00529B + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF008000 + #FF008000 + + + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #00C800C8 + #00C800C8 + #00C800C8 + #00C800C8 + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #00000000 + #00000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #7F000000 + #7F000000 + #FF808080 + #FF808080 + #00C800C8 + #00C800C8 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #00000000 + #00000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFC0C0C0 + #FFC0C0C0 + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFC0C0C0 + #FFC0C0C0 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FFFF00FF + #FFFF00FF + #FFFF00FF + #FFFF00FF + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FF00002F + #FF00002F + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF008000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + + + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Themes/HighContrast/ThemedDialogColors.xaml b/src/GitHub.VisualStudio.UI/Themes/HighContrast/ThemedDialogColors.xaml new file mode 100644 index 0000000000..fd60c10fe1 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/HighContrast/ThemedDialogColors.xaml @@ -0,0 +1,66 @@ + + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FF808080 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF3FF23F + #FF3FF23F + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF808080 + diff --git a/src/GitHub.VisualStudio.UI/Themes/HighContrast/TreeViewColors.xaml b/src/GitHub.VisualStudio.UI/Themes/HighContrast/TreeViewColors.xaml new file mode 100644 index 0000000000..44f348589e --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/HighContrast/TreeViewColors.xaml @@ -0,0 +1,48 @@ + + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + #FFFFFF00 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFF00 + #FFFFFF00 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF37006E + #FF37006E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF3FF23F + #FF3FF23F + diff --git a/src/GitHub.VisualStudio.UI/Themes/HighContrast/VsBrushes.xaml b/src/GitHub.VisualStudio.UI/Themes/HighContrast/VsBrushes.xaml new file mode 100644 index 0000000000..fa878c33a4 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/HighContrast/VsBrushes.xaml @@ -0,0 +1,554 @@ + + #FF808080 + #FF808080 + #FFC0C0C0 + #FF000000 + #FF000000 + #FFFFFF00 + #FF37006E + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF1AEBFF + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FF000000 + #FF808080 + #FF1AEBFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FF008000 + #FF000000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF008000 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF008000 + #FF000000 + #FF000000 + #FF008000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFC0C0C0 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FFFFFF00 + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFF00FF + #FFFF00FF + #FF37006E + #FF008000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Themes/HighContrast/VsColors.xaml b/src/GitHub.VisualStudio.UI/Themes/HighContrast/VsColors.xaml new file mode 100644 index 0000000000..1ccb3ea8e0 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/HighContrast/VsColors.xaml @@ -0,0 +1,505 @@ + + #FF808080 + #FF808080 + #FFC0C0C0 + #FF000000 + #FF000000 + #FFFFFF00 + #FF37006E + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF000000 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3FF23F + #FF1AEBFF + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FFFFFFFF + #FF000000 + #FF808080 + #FF1AEBFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FF000000 + #FF808080 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF3FF23F + #FF3FF23F + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF008000 + #FF008000 + #FFFFFFFF + #FFFFFFFF + #FF008000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FF008000 + #FF000000 + #FF008000 + #FF008000 + #FF008000 + #FF008000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FF3FF23F + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF008000 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF008000 + #FF000000 + #FF000000 + #FF008000 + #FF000000 + #FFFFFF00 + #FFFFFF00 + #FFFFFF00 + #FF808080 + #FF808080 + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF808080 + #FF000000 + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FFC0C0C0 + #FFC0C0C0 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF808080 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFC0C0C0 + #FF00002F + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF808080 + #FF000000 + #FF000000 + #FFFFFF00 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF1AEBFF + #FFFFFF00 + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FFFFFFFF + #FF000000 + #FF808080 + #FFFFFF00 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FFFFFFFF + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF008000 + #FFFFFFFF + #FF000000 + #FFC0C0C0 + #FFFFFFFF + #FF808080 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF808080 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FF1AEBFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFF00FF + #FFFF00FF + #FF37006E + #FF008000 + #FF000000 + #FF000000 + #FF1AEBFF + #FF1AEBFF + #FF808080 + #FF000000 + #FF000000 + #FF1AEBFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + diff --git a/src/GitHub.VisualStudio.UI/Themes/Light/CommonControlsColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Light/CommonControlsColors.xaml new file mode 100644 index 0000000000..ad69cd171e --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Light/CommonControlsColors.xaml @@ -0,0 +1,198 @@ + + #FFECECF0 + #FFECECF0 + #FF1E1E1E + #FF1E1E1E + #FFACACAC + #FFACACAC + #FF3399FF + #FF3399FF + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF007ACC + #FF007ACC + #FFECECF0 + #FFECECF0 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFA2A4A5 + #FFA2A4A5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFEFEFE + #FFFEFEFE + #FFF6F6F6 + #FFF6F6F6 + #FFF3F9FF + #FFF3F9FF + #FFF3F9FF + #FFF3F9FF + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FFC6C6C6 + #FFC6C6C6 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FF717171 + #FF717171 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFF6F6F6 + #FFF6F6F6 + #19000000 + #19000000 + #FFCCCEDB + #FFCCCEDB + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #66007ACC + #66007ACC + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + diff --git a/src/GitHub.VisualStudio.UI/Themes/Light/EnvironmentColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Light/EnvironmentColors.xaml new file mode 100644 index 0000000000..6ad0ec8bbf --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Light/EnvironmentColors.xaml @@ -0,0 +1,1588 @@ + + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF525252 + #FF525252 + #FFFFFFFF + #FFFFFFFF + #FF525252 + #FF525252 + #80525252 + #80525252 + #FFFFFFFF + #FFFFFFFF + #80525252 + #80525252 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF0E70C0 + #FF0E70C0 + #FF444444 + #FF444444 + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEBD + #FFCCCEBD + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFD8D8E0 + #FFD8D8E0 + #FFCCCEBD + #FFCCCEBD + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFF0F2F9 + #FFF0F2F9 + #FFD3DCEF + #FFD3DCEF + #FFCCCC66 + #FFCCCC66 + #FFFFFFCC + #FFFFFFCC + #FF000000 + #FF000000 + #FFD2D2D2 + #FFD2D2D2 + #FF808080 + #FF808080 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF00008B + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFF7F0F0 + #FFF7F0F0 + #FFEDDADC + #FFEDDADC + #FFFFFFFF + #FFFFFFFF + #FF0054E3 + #FF0054E3 + #FFDDD6EF + #FFDDD6EF + #FF266035 + #FF266035 + #FFFFFFFF + #FFFFFFFF + #FF716F64 + #FF716F64 + #FFF3F7F0 + #FFF3F7F0 + #FFE6F0DB + #FFE6F0DB + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFB0764F + #FFB0764F + #FF716F64 + #FF716F64 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD8D8D8 + #FFD8D8D8 + #FF808080 + #FF808080 + #FF716F64 + #FF716F64 + #FFD6ECEF + #FFD6ECEF + #FFFF0000 + #FFFF0000 + #FFF8F4E9 + #FFF8F4E9 + #FFF0E9D2 + #FFF0E9D2 + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF717171 + #FF717171 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FFA2A4A5 + #FFA2A4A5 + #FF1E1E1E + #FF1E1E1E + #FF999999 + #FF999999 + #FF999999 + #FF999999 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF3399FF + #FF3399FF + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF007ACC + #FF007ACC + #FFF6F6F6 + #FFF6F6F6 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FF0E70C0 + #FF0E70C0 + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFE0E3E6 + #FFE0E3E6 + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FFE0E3E6 + #FFE0E3E6 + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF717171 + #FF717171 + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FFFDFBAC + #FFFDFBAC + #FF717171 + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFCCCEDB + #00CCCEDB + #00CCCEDB + #FFE7E8EC + #FFE7E8EC + #FFCCCEDB + #FFCCCEDB + #FFEDEEF0 + #FFEDEEF0 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFD6D8DC + #FFD6D8DC + #FFCCCEDB + #FFCCCEDB + #FFEDEEF0 + #FFEDEEF0 + #FFA2A4A5 + #FFA2A4A5 + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FFF5F5F5 + #FFF5F5F5 + #FF999999 + #FF999999 + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FFFFFFFF + #FFFFFFFF + #FF0E70C0 + #FF0E70C0 + #FF007ACC + #FF007ACC + #FFF2F4F8 + #FFF2F4F8 + #FF000000 + #FF000000 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFBCC7D8 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FFE7E8EC + #FFE7E8EC + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFA2A4A5 + #FFA2A4A5 + #FF717171 + #FF717171 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #72000000 + #72000000 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FF0E70C0 + #FF0E70C0 + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFFFA300 + #FFFFA300 + #FFFFA300 + #FFFFA300 + #FFA2A4A5 + #FFA2A4A5 + #FFA2A4A5 + #FFA2A4A5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FF2D2D2D + #FF2D2D2D + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FF717171 + #FF717171 + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FF442359 + #FF442359 + #FF442359 + #FF442359 + #FFFFFFFF + #FFFFFFFF + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FFB7B9C5 + #FF2D2D2D + #FF2D2D2D + #FFE1D3E4 + #FFE1D3E4 + #FFB064AB + #FFB064AB + #FFB064AB + #FFB064AB + #FFFFFFFF + #FFFFFFFF + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FF9B4F96 + #FFFFFFFF + #FFFFFFFF + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FFE6E7ED + #FF717171 + #FF717171 + #FF717171 + #FF717171 + #FFE1D3E4 + #FFE1D3E4 + #FF6D6D70 + #FF6D6D70 + #FFD0E6F5 + #FFD0E6F5 + #FF6D6D70 + #FF6D6D70 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF1C97EA + #FF1C97EA + #FFD0E6F5 + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FF717171 + #FF68217A + #FF68217A + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF717171 + #FF717171 + #FFEFEFE2 + #FFEFEFE2 + #FF1E1E1E + #FF1E1E1E + #FFF0F0F0 + #FFF0F0F0 + #FFDEE1E7 + #FFDEE1E7 + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF0066CC + #FF0066CC + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FFFFFFFF + #FFFFFFFF + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF000000 + #FF000000 + #FFA8B3C2 + #FFA8B3C2 + #FF000000 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + #FFDEE1E7 + #FFDEE1E7 + #FF1B293E + #FF1B293E + #FF0066CC + #FF0066CC + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FFF0F0F0 + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FF00529B + #FF00529B + #FF424242 + #FF424242 + #00000000 + #00000000 + #FFCCCEDB + #FFCCCEDB + + + #FFEEEEF2 + #FFEEEEF2 + #FFA2A4A5 + #FFA2A4A5 + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FF1E1E1E + #00F5F5F5 + #00F5F5F5 + #001E1E1E + #001E1E1E + #FF9B9FB9 + #FF9B9FB9 + #FFEEEEF2 + #FFEEEEF2 + #FF525252 + #FF525252 + #FF9B9FB9 + #FF9B9FB9 + #FF9B9FB9 + #FF9B9FB9 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF9B9FB9 + #FF9B9FB9 + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #99525252 + #99525252 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #FFFFFFFF + #FFFFFFFF + #7F000000 + #7F000000 + #FFCCCEDB + #FFCCCEDB + #00EEEEF2 + #00EEEEF2 + #FFF6F6F6 + #FFF6F6F6 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FF1E1E1E + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FFCCCED8 + #FFCCCED8 + #FF636363 + #FF636363 + #FF3399FF + #FF3399FF + #FFD0D0D0 + #FFD0D0D0 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FFFEFEFE + #FF0E70C0 + #FF0E70C0 + #FFA2A4A5 + #FFA2A4A5 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFFCFCFC + #FFFCFCFC + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #FF865FC5 + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #D8FFFFFF + #FF007ACC + #FF007ACC + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #66000000 + #FFFDFBAC + #FFFDFBAC + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF868999 + #FF868999 + #FFF5F5F5 + #FFF5F5F5 + #FF1C97EA + #FF1C97EA + #FF007ACC + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFFFFFFF + #FFFFFFFF + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FF686868 + #FF686868 + #FF5B5B5B + #FF5B5B5B + #FF686868 + #FF686868 + #FF686868 + #FF686868 + #FF5B5B5B + #FF5B5B5B + #FF5B5B5B + #FF5B5B5B + #FFFCFCFC + #FFFCFCFC + #FFFCFCFC + #FFFCFCFC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFE5C365 + #FFE5C365 + #FFFFEFBB + #FFFFEFBB + #FFE5C365 + #FFE5C365 + #FFFDFBAC + #FFFDFBAC + #FF000000 + #FF000000 + #FF000000 + #FF000000 + #FF4169E1 + #FF4169E1 + #FF96A9DD + #FF96A9DD + #FFE122DF + #FFE122DF + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FF999999 + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFF30506 + #FFF30506 + #FF0097FB + #FF0097FB + #FF55AAFF + #FF55AAFF + #FFF30506 + #FFF30506 + #FFF30506 + #FFF30506 + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF363639 + #FF363639 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF1F1F1 + #FFF30506 + #FFF30506 + #FF555555 + #FF555555 + #FF007ACC + #FF007ACC + #FF77AAFF + #FF77AAFF + #FF1E1E1E + #FF1E1E1E + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF555555 + #FF555555 + #FF999999 + #FF999999 + #FF007ACC + #FF007ACC + #FF000000 + #FF000000 + #FF3F3F3F + #FF3F3F3F + #FF464646 + #FF464646 + #FF999999 + #FF999999 + #FF0E639C + #FF0E639C + #FFFFFFFF + #FFFFFFFF + #FFCA5100 + #FFCA5100 + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #4C000000 + #4C000000 + #FFFFFFFF + #FFFFFFFF + #FF68217A + #FF68217A + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF0F0F0 + #FFF0F0F0 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FFD8D8E0 + #FFD8D8E0 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEBD + #FFCCCEBD + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF999999 + #FF999999 + #FF59A8DE + #FF59A8DE + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF444444 + #FF444444 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFA2A4A5 + #FFA2A4A5 + #FFC9DEF5 + #FFC9DEF5 + #FFA2A4A5 + #FFA2A4A5 + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF6F6F6 + #FFF6F6F6 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF0E6198 + #FF0E6198 + #FFFFFFFF + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFFFFFFF + #FFF7F7F9 + #FFF7F7F9 + #FFF7F7F9 + #FFF7F7F9 + #FF717171 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF525252 + #FF525252 + #FFEEEEF2 + #FFEEEEF2 + #99525252 + #99525252 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FF0E70C0 + #FF0E70C0 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFCCCEDB + #FF444444 + #FF444444 + #FF1E1E1E + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF705829 + #FF705829 + #FFB0A781 + #FFB0A781 + #FFA19667 + #FFA19667 + #FFA79432 + #FFA79432 + #FFD0D4B7 + #FFD0D4B7 + #FFBFC749 + #FFBFC749 + #FFCAB22D + #FFCAB22D + #FFFBF7C8 + #FFFBF7C8 + #FFE2E442 + #FFE2E442 + #FF5D8039 + #FF5D8039 + #FFB1C97B + #FFB1C97B + #FF9FB861 + #FF9FB861 + #FF8E5478 + #FF8E5478 + #FFE2B1CD + #FFE2B1CD + #FFCB98B6 + #FFCB98B6 + #FFAD1C2B + #FFAD1C2B + #FFFF9F99 + #FFFF9F99 + #FFFF7971 + #FFFF7971 + #FF779AB6 + #FF779AB6 + #FFC6D4DF + #FFC6D4DF + #FFB8CCD7 + #FFB8CCD7 + #FF427094 + #FF427094 + #FFA0B7C9 + #FFA0B7C9 + #FF89ABBD + #FF89ABBD + #FF5386BF + #FF5386BF + #FFB9D4EE + #FFB9D4EE + #FFA1C7E7 + #FFA1C7E7 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFFFFFFF + #FFFFFFFF + #FF000000 + #FF000000 + + + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Themes/Light/ThemedDialogColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Light/ThemedDialogColors.xaml new file mode 100644 index 0000000000..1fb82d7fdc --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Light/ThemedDialogColors.xaml @@ -0,0 +1,66 @@ + + #00000000 + #00000000 + #00000000 + #00000000 + #FF1E1E1E + #FF1E1E1E + #FF717171 + #FF717171 + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF0E70C0 + #FF0E70C0 + #FFA2A4A5 + #FFA2A4A5 + #FF007ACC + #FF007ACC + #FF0E70C0 + #FF0E70C0 + #FFA2A4A5 + #FFA2A4A5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFCCCEDB + #00000000 + #00000000 + #00000000 + #00000000 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FF1E1E1E + #FF1E1E1E + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FF1E1E1E + #FFFBFBFB + #FFFBFBFB + #FF1E1E1E + #FF1E1E1E + #FFEFEFF2 + #FFEFEFF2 + diff --git a/src/GitHub.VisualStudio.UI/Themes/Light/TreeViewColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Light/TreeViewColors.xaml new file mode 100644 index 0000000000..93b53f590d --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Light/TreeViewColors.xaml @@ -0,0 +1,48 @@ + + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFE51400 + #FFE51400 + #FF000000 + #FF000000 + #FF3399FF + #FF3399FF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFD0F7FF + #FFD0F7FF + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF1E1E1E + #FF007ACC + #FF007ACC + #FFE51400 + #FFE51400 + diff --git a/src/GitHub.VisualStudio.UI/Themes/Light/VsBrushes.xaml b/src/GitHub.VisualStudio.UI/Themes/Light/VsBrushes.xaml new file mode 100644 index 0000000000..d4d6c95333 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Light/VsBrushes.xaml @@ -0,0 +1,554 @@ + + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF0E70C0 + #FF444444 + #FFFFFFFF + #FFEEEEF2 + #FFCCCEBD + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFD8D8E0 + #FFCCCEBD + #FF1E1E1E + #FF1E1E1E + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFEEEEF2 + #FF717171 + #FF999999 + #FF999999 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF3399FF + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFF6F6F6 + #FF007ACC + #FFE0E3E6 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFEEEEF2 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FFEEEEF2 + #FF3399FF + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FF1E1E1E + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FFFDFBAC + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFE7E8EC + #FFCCCEDB + #FFEDEEF0 + #FF1E1E1E + #FFCCCEDB + #FF1E1E1E + #FFD6D8DC + #FFCCCEDB + #FFEDEEF0 + #FFA2A4A5 + #FFCCCEDB + #FFA2A4A5 + #FFF5F5F5 + #FF999999 + #FFCCCEDB + #FFA2A4A5 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FFE7E8EC + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF007ACC + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #72000000 + #FFCCCEDB + #FFF5F5F5 + #FF0E70C0 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFFFA300 + #FFFFA300 + #FFA2A4A5 + #FFA2A4A5 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF1E1E1E + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF717171 + #FFEFEFE2 + #FF1E1E1E + #FFF0F0F0 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFA2A4A5 + #FFFDFBAC + #FF1E1E1E + #FFCCCEDB + #FFF6F6F6 + #FF1E1E1E + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FFFEFEFE + #FFFEFEFE + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FF1E1E1E + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFFEFEFE + #FFFEFEFE + #FF0E70C0 + #FF007ACC + #FF0E70C0 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FF686868 + #FF5B5B5B + #FFFCFCFC + #FFFCFCFC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFDFBAC + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FFF5F5F5 + #FF1E1E1E + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF30506 + #FF555555 + #FF007ACC + #FF77AAFF + #FF1E1E1E + #FF999999 + #FF007ACC + #FF555555 + #FF999999 + #FF007ACC + #FF000000 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FFF0F0F0 + #FFEEEEF2 + #FFCCCEDB + #FFD8D8E0 + #FFEEEEF2 + #FFCCCEBD + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF444444 + #FFF5F5F5 + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFCCCEDB + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFF7F7F9 + #FFF7F7F9 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FFF5F5F5 + #FF0E70C0 + #FF444444 + #FF1E1E1E + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FFF5F5F5 + #FFEEEEF2 + #FF1E1E1E + #FFFFFFFF + #FF000000 + #00FFFFFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Themes/Light/VsColors.xaml b/src/GitHub.VisualStudio.UI/Themes/Light/VsColors.xaml new file mode 100644 index 0000000000..a485f4f7db --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Light/VsColors.xaml @@ -0,0 +1,505 @@ + + #FFCCCEDB + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF0E70C0 + #FF444444 + #FFFFFFFF + #FFEEEEF2 + #FFCCCEBD + #FFF5F5F5 + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFD8D8E0 + #FFCCCEBD + #FF1E1E1E + #FF1E1E1E + #FFF0F2F9 + #FFD3DCEF + #FFCCCC66 + #FFFFFFCC + #FF000000 + #FFD2D2D2 + #FF808080 + #FF000000 + #FFFFFFFF + #FF00008B + #FF000000 + #FF000000 + #FF000000 + #FFFFFFFF + #FFF7F0F0 + #FFEDDADC + #FFFFFFFF + #FF0054E3 + #FFDDD6EF + #FF266035 + #FFFFFFFF + #FF716F64 + #FFF3F7F0 + #FFE6F0DB + #FF808080 + #FF716F64 + #FFB0764F + #FF716F64 + #FF808080 + #FF716F64 + #FFD8D8D8 + #FF808080 + #FF716F64 + #FFD6ECEF + #FFFF0000 + #FFF8F4E9 + #FFF0E9D2 + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFEEEEF2 + #FF717171 + #FF999999 + #FF999999 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF3399FF + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #FFF6F6F6 + #FF007ACC + #FFE0E3E6 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFEEEEF2 + #FF717171 + #FF007ACC + #FF007ACC + #FF007ACC + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF007ACC + #FFEEEEF2 + #FF3399FF + #FFEEEEF2 + #FF1E1E1E + #FF1E1E1E + #FFA2A4A5 + #FF1E1E1E + #FFEEEEF2 + #FFCCCEDB + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF717171 + #FFFDFBAC + #FF717171 + #FF0E70C0 + #FF0E70C0 + #FF0E70C0 + #FFCCCEDB + #FFE7E8EC + #FFCCCEDB + #FFEDEEF0 + #FF1E1E1E + #FFCCCEDB + #FF1E1E1E + #FFD6D8DC + #FFCCCEDB + #FFEDEEF0 + #FFA2A4A5 + #FFCCCEDB + #FFA2A4A5 + #FFF5F5F5 + #FF999999 + #FFCCCEDB + #FFA2A4A5 + #FFFFFFFF + #FFF2F4F8 + #FF000000 + #FF4A6184 + #FF4A6184 + #FFBCC7D8 + #FFFFFFFF + #FF000000 + #FFE7E8EC + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FF007ACC + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FFFFFFFF + #FF007ACC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FF1E1E1E + #FFF6F6F6 + #FFF6F6F6 + #FFCCCEDB + #72000000 + #FFCCCEDB + #FFF5F5F5 + #FF0E70C0 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFFFA300 + #FFFFA300 + #FFA2A4A5 + #FFA2A4A5 + #FFEEEEF2 + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FFEEEEF2 + #FFEEEEF2 + #FF1C97EA + #FFD0E6F5 + #FF1C97EA + #FF1C97EA + #FFFFFFFF + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF717171 + #FF007ACC + #FF007ACC + #FFD0E6F5 + #FF0E639C + #FF0E639C + #FF0E639C + #FF0E639C + #FFFFFFFF + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FF1E1E1E + #FF000000 + #FFFFFFFF + #FF000000 + #FFFFFFFF + #FF717171 + #FFEFEFE2 + #FF1E1E1E + #FFF0F0F0 + #FFDEE1E7 + #FF0066CC + #FF000000 + #FFFFFFFF + #FF0066CC + #FF000000 + #FFFFFFFF + #FFA8B3C2 + #FFFFFFFF + #FFA8B3C2 + #FF000000 + #FFDEE1E7 + #FF000000 + #FFA8B3C2 + #FF000000 + #FFFFFFFF + #FF000000 + #FFDEE1E7 + #FF1B293E + #FF0066CC + #FF0066CC + #FFF0F0F0 + #FF000000 + #FF000000 + #FF3399FF + #FFFFFFFF + #FFCCCEDB + #FFEEEEF2 + #FFA2A4A5 + #FFFDFBAC + #FF1E1E1E + #FFCCCEDB + #FFF6F6F6 + #FF1E1E1E + #FFF5F5F5 + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF3399FF + #FF3399FF + #FFFEFEFE + #FFFEFEFE + #FF1E1E1E + #FFFEFEFE + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FF1E1E1E + #FFFEFEFE + #FFCCCEDB + #FFCCCEDB + #FFCCCEDB + #FF1E1E1E + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFFEFEFE + #FFFEFEFE + #FF0E70C0 + #FF007ACC + #FF0E70C0 + #FFEEEEF2 + #FFEEEEF2 + #FF1E1E1E + #FFF5F5F5 + #FF1E1E1E + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FF3399FF + #FFEEEEF2 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFFDFBAC + #FFFDFBAC + #FF1E1E1E + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFC2C3C9 + #FFC2C3C9 + #FFC2C3C9 + #FF686868 + #FF5B5B5B + #FFFCFCFC + #FFFCFCFC + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FF007ACC + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFE5C365 + #FFFFEFBB + #FFE5C365 + #FFFDFBAC + #FF000000 + #FF000000 + #FF4169E1 + #FF96A9DD + #FFE122DF + #FFF5F5F5 + #FF1E1E1E + #FF999999 + #FF2D2D30 + #FF2D2D30 + #FF999999 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FFF1F1F1 + #FF007ACC + #FF007ACC + #FFF30506 + #FF0097FB + #FF55AAFF + #FFF30506 + #FF007ACC + #FFFFFFFF + #FF363639 + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFFFFFFF + #FFF1F1F1 + #FFF30506 + #FF555555 + #FF007ACC + #FF77AAFF + #FF1E1E1E + #FF999999 + #FF007ACC + #FF555555 + #FF999999 + #FF007ACC + #FF000000 + #FF3F3F3F + #FF464646 + #FF999999 + #FFFFFFFF + #FFF0F0F0 + #FFEEEEF2 + #FFCCCEDB + #FFD8D8E0 + #FFEEEEF2 + #FFCCCEBD + #FFEEEEF2 + #FF007ACC + #FF007ACC + #FF007ACC + #FF007ACC + #FFFFFFFF + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FF444444 + #FFF5F5F5 + #FFCCCEDB + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFF5F5F5 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFF5F5F5 + #FFCCCEDB + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF0E6198 + #FFFFFFFF + #FF52B0EF + #FF52B0EF + #FFFFFFFF + #FFF7F7F9 + #FFF7F7F9 + #FF717171 + #FFF5F5F5 + #FFF5F5F5 + #FF1E1E1E + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFEEEEF2 + #FFC9DEF5 + #FFC9DEF5 + #FFC9DEF5 + #FF1E1E1E + #FFF5F5F5 + #FF0E70C0 + #FF444444 + #FF1E1E1E + #FF705829 + #FFB0A781 + #FFA19667 + #FFA79432 + #FFD0D4B7 + #FFBFC749 + #FFCAB22D + #FFFBF7C8 + #FFE2E442 + #FF5D8039 + #FFB1C97B + #FF9FB861 + #FF8E5478 + #FFE2B1CD + #FFCB98B6 + #FFAD1C2B + #FFFF9F99 + #FFFF7971 + #FF779AB6 + #FFC6D4DF + #FFB8CCD7 + #FF427094 + #FFA0B7C9 + #FF89ABBD + #FF5386BF + #FFB9D4EE + #FFA1C7E7 + #FFF5F5F5 + #FFEEEEF2 + #FF1E1E1E + #FFFFFFFF + #FF000000 + diff --git a/src/GitHub.VisualStudio.UI/Themes/ThemedDialogDefaultStyles.xaml b/src/GitHub.VisualStudio.UI/Themes/ThemedDialogDefaultStyles.xaml new file mode 100644 index 0000000000..7340e9f3ed --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/ThemedDialogDefaultStyles.xaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml b/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml index 1a3f97dd83..ef3defbc5d 100644 --- a/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml +++ b/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml @@ -2,18 +2,29 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:GitHub.VisualStudio.UI.Controls" + Name="root" MinWidth="16" MinHeight="16"> - - - - - + + + + + + + + + + - - + + diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml.cs b/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml.cs index 20eaacfa00..7570da7e40 100644 --- a/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml.cs +++ b/src/GitHub.VisualStudio.UI/UI/Controls/AccountAvatar.xaml.cs @@ -1,28 +1,52 @@ using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; using GitHub.Models; -using NullGuard; namespace GitHub.VisualStudio.UI.Controls { - [NullGuard(ValidationFlags.None)] - public partial class AccountAvatar : UserControl + public partial class AccountAvatar : UserControl, ICommandSource { public static readonly DependencyProperty AccountProperty = DependencyProperty.Register( nameof(Account), - typeof(IAccount), + typeof(object), typeof(AccountAvatar)); + public static readonly DependencyProperty CommandProperty = + ButtonBase.CommandProperty.AddOwner(typeof(AccountAvatar)); + public static readonly DependencyProperty CommandParameterProperty = + ButtonBase.CommandParameterProperty.AddOwner(typeof(AccountAvatar)); + public static readonly DependencyProperty CommandTargetProperty = + ButtonBase.CommandTargetProperty.AddOwner(typeof(AccountAvatar)); public AccountAvatar() { InitializeComponent(); } - public IAccount Account + public object Account { - get { return (IAccount)GetValue(AccountProperty); } + get { return GetValue(AccountProperty); } set { SetValue(AccountProperty, value); } } + + public ICommand Command + { + get { return (ICommand)GetValue(CommandProperty); } + set { SetValue(CommandProperty, value); } + } + + public object CommandParameter + { + get { return GetValue(CommandParameterProperty); } + set { SetValue(CommandParameterProperty, value); } + } + + public IInputElement CommandTarget + { + get { return (IInputElement)GetValue(CommandTargetProperty); } + set { SetValue(CommandTargetProperty, value); } + } } } diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/HorizontalShadowDivider.xaml b/src/GitHub.VisualStudio.UI/UI/Controls/HorizontalShadowDivider.xaml new file mode 100644 index 0000000000..a60aca039a --- /dev/null +++ b/src/GitHub.VisualStudio.UI/UI/Controls/HorizontalShadowDivider.xaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.UI/Controls/HorizontalShadowDivider.xaml.cs b/src/GitHub.VisualStudio.UI/UI/Controls/HorizontalShadowDivider.xaml.cs similarity index 85% rename from src/GitHub.UI/Controls/HorizontalShadowDivider.xaml.cs rename to src/GitHub.VisualStudio.UI/UI/Controls/HorizontalShadowDivider.xaml.cs index 56ccc5cc8a..5f36af88fd 100644 --- a/src/GitHub.UI/Controls/HorizontalShadowDivider.xaml.cs +++ b/src/GitHub.VisualStudio.UI/UI/Controls/HorizontalShadowDivider.xaml.cs @@ -2,7 +2,7 @@ using System.Windows; using System.Windows.Controls; -namespace GitHub.UI +namespace GitHub.VisualStudio.UI.Controls { public partial class HorizontalShadowDivider : UserControl { diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.cs b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.cs new file mode 100644 index 0000000000..14e43e0215 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.cs @@ -0,0 +1,107 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using GitHub.Services; +using GitHub.UI; + +namespace GitHub.VisualStudio.UI.Controls +{ + /// + /// Displays informational or error message markdown in a banner. + /// + public class InfoPanel : Control + { + public static readonly DependencyProperty MessageProperty = + DependencyProperty.Register( + nameof(Message), + typeof(string), + typeof(InfoPanel)); + + public static readonly DependencyProperty IconProperty = + DependencyProperty.Register( + nameof(Icon), + typeof(Octicon), + typeof(InfoPanel), + new FrameworkPropertyMetadata(Octicon.info)); + + public static readonly DependencyProperty ShowCloseButtonProperty = + DependencyProperty.Register( + nameof(ShowCloseButton), + typeof(bool), + typeof(InfoPanel)); + + static IVisualStudioBrowser browser; + Button closeButton; + + static InfoPanel() + { + DefaultStyleKeyProperty.OverrideMetadata( + typeof(InfoPanel), + new FrameworkPropertyMetadata(typeof(InfoPanel))); + DockPanel.DockProperty.OverrideMetadata( + typeof(InfoPanel), + new FrameworkPropertyMetadata(Dock.Top)); + } + + public InfoPanel() + { + var commandBinding = new CommandBinding(Markdig.Wpf.Commands.Hyperlink); + commandBinding.Executed += OpenHyperlink; + CommandBindings.Add(commandBinding); + } + + /// + /// Gets or sets the message in markdown. + /// + public string Message + { + get => (string)GetValue(MessageProperty); + set => SetValue(MessageProperty, value); + } + + /// + /// Gets or sets the icon to display. + /// + public Octicon Icon + { + get => (Octicon)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public bool ShowCloseButton + { + get => (bool)GetValue(ShowCloseButtonProperty); + set => SetValue(ShowCloseButtonProperty, value); + } + + static IVisualStudioBrowser Browser + { + get + { + if (browser == null) + browser = Services.GitHubServiceProvider.TryGetService(); + return browser; + } + } + public override void OnApplyTemplate() + { + base.OnApplyTemplate(); + closeButton = (Button)Template.FindName("PART_CloseButton", this); + closeButton.Click += CloseButtonClicked; + } + + void CloseButtonClicked(object sender, RoutedEventArgs e) + { + Message = null; + } + + void OpenHyperlink(object sender, ExecutedRoutedEventArgs e) + { + var url = e.Parameter.ToString(); + + if (!string.IsNullOrEmpty(url)) + Browser.OpenUrl(new Uri(url)); + } + } +} diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml index e35903b15a..68a1a19f5a 100644 --- a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml +++ b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml @@ -1,67 +1,71 @@ - + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml.cs b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml.cs deleted file mode 100644 index deb222bc3e..0000000000 --- a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml.cs +++ /dev/null @@ -1,120 +0,0 @@ -using GitHub.UI; -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using GitHub.ViewModels; -using System.ComponentModel; -using GitHub.Services; -using GitHub.Extensions; -using System.Windows.Input; -using GitHub.Primitives; -using GitHub.VisualStudio.Helpers; -using Colors = System.Windows.Media.Colors; - -namespace GitHub.VisualStudio.UI.Controls -{ - [NullGuard.NullGuard(NullGuard.ValidationFlags.None)] - public partial class InfoPanel : UserControl, IInfoPanel, INotifyPropertyChanged, INotifyPropertySource - { - static SolidColorBrush WarningColorBrush = new SolidColorBrush(Colors.DarkRed); - static SolidColorBrush InfoColorBrush = new SolidColorBrush(Colors.Black); - - static readonly DependencyProperty MessageProperty = - DependencyProperty.Register(nameof(Message), typeof(string), typeof(InfoPanel), new PropertyMetadata(String.Empty, UpdateMessage)); - - static readonly DependencyProperty MessageTypeProperty = - DependencyProperty.Register(nameof(MessageType), typeof(MessageType), typeof(InfoPanel), new PropertyMetadata(MessageType.Information, UpdateIcon)); - - public string Message - { - get { return (string)GetValue(MessageProperty); } - set { SetValue(MessageProperty, value); } - } - - public MessageType MessageType - { - get { return (MessageType)GetValue(MessageTypeProperty); } - set { SetValue(MessageTypeProperty, value); } - } - - Octicon icon; - public Octicon Icon - { - get { return icon; } - private set { icon = value; RaisePropertyChanged(nameof(Icon)); } - } - - Brush iconColor; - public Brush IconColor - { - get { return iconColor; } - private set { iconColor = value; RaisePropertyChanged(nameof(IconColor)); } - } - - ICommand linkCommand; - public ICommand LinkCommand - { - get { return linkCommand; } - set { linkCommand = value; RaisePropertyChanged(nameof(LinkCommand)); } - } - - static InfoPanel() - { - WarningColorBrush.Freeze(); - InfoColorBrush.Freeze(); - } - - static IVisualStudioBrowser browser; - static IVisualStudioBrowser Browser - { - get - { - if (browser == null) - browser = Services.GitHubServiceProvider.TryGetService(); - return browser; - } - } - - public InfoPanel() - { - InitializeComponent(); - - DataContext = this; - Icon = Octicon.info; - IconColor = InfoColorBrush; - - LinkCommand = new RelayCommand(x => - { - if (!String.IsNullOrEmpty(x as string)) - Browser.OpenUrl(new Uri((string)x)); - }); - } - - static void UpdateMessage(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - var control = (InfoPanel)d; - var msg = e.NewValue as string; - control.Visibility = String.IsNullOrEmpty(msg) ? Visibility.Collapsed : Visibility.Visible; - } - - static void UpdateIcon(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - var control = (InfoPanel)d; - control.Icon = (MessageType)e.NewValue == MessageType.Warning ? Octicon.alert : Octicon.info; - control.IconColor = control.Icon == Octicon.alert ? WarningColorBrush : InfoColorBrush; - } - - void Dismiss_Click(object sender, RoutedEventArgs e) - { - SetCurrentValue(MessageProperty, String.Empty); - } - - public event PropertyChangedEventHandler PropertyChanged; - - public void RaisePropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } -} diff --git a/src/GitHub.UI/Assets/Controls/LightModalViewTabControl.xaml b/src/GitHub.VisualStudio.UI/UI/Controls/LightModalViewTabControl.xaml similarity index 90% rename from src/GitHub.UI/Assets/Controls/LightModalViewTabControl.xaml rename to src/GitHub.VisualStudio.UI/UI/Controls/LightModalViewTabControl.xaml index edfb7b1f7e..3b50817815 100644 --- a/src/GitHub.UI/Assets/Controls/LightModalViewTabControl.xaml +++ b/src/GitHub.VisualStudio.UI/UI/Controls/LightModalViewTabControl.xaml @@ -1,5 +1,5 @@  + + + + + + + + + + You're about to fork the + + / + + repository to + + / + . + + + This operation will: + + + + + + + + + + + + + + + + + + + + + + + origin + + + + + + + + + + + + + + Add an upstream remote pointing to + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Views/Dialog/GistCreationView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/Dialog/GistCreationView.xaml.cs new file mode 100644 index 0000000000..e27722ba1a --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/Dialog/GistCreationView.xaml.cs @@ -0,0 +1,62 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Windows; +using System.Windows.Threading; +using GitHub.Exports; +using GitHub.Extensions; +using GitHub.Services; +using GitHub.UI; +using GitHub.ViewModels.Dialog; +using ReactiveUI; + +namespace GitHub.VisualStudio.Views.Dialog +{ + public class GenericGistCreationView : ViewBase + { } + + [ExportViewFor(typeof(IGistCreationViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class GistCreationView : GenericGistCreationView + { + [ImportingConstructor] + public GistCreationView( + INotificationDispatcher notifications, + IGitHubServiceProvider serviceProvider) + { + InitializeComponent(); + + this.WhenActivated(d => + { + errorMessage.Visibility = Visibility.Collapsed; + + d(this.Bind(ViewModel, vm => vm.Description, v => v.descriptionTextBox.Text)); + d(this.Bind(ViewModel, vm => vm.FileName, v => v.fileNameTextBox.Text)); + d(this.Bind(ViewModel, vm => vm.IsPrivate, v => v.makePrivate.IsChecked)); + d(this.BindCommand(ViewModel, vm => vm.CreateGist, v => v.createGistButton)); + + d(this.Bind(ViewModel, vm => vm.Account, v => v.accountStackPanel.DataContext)); + + ViewModel.CreateGist + .Where(x => x != null) + .Subscribe(gist => + { + var browser = serviceProvider.TryGetService(); + browser?.OpenUrl(new Uri(gist.HtmlUrl)); + + var ns = serviceProvider.TryGetService(); + ns?.ShowMessage(GitHub.Resources.gistCreatedMessage); + }); + + d(notifications.Listen() + .Where(n => n.Type == Notification.NotificationType.Error) + .ObserveOnDispatcher(DispatcherPriority.Normal) + .Subscribe(n => + { + errorMessage.Visibility = Visibility.Visible; + errorMessageText.Text = n.Message; + })); + }); + } + } +} diff --git a/src/GitHub.VisualStudio.UI/Views/Dialog/GitHubDialogWindow.xaml b/src/GitHub.VisualStudio.UI/Views/Dialog/GitHubDialogWindow.xaml new file mode 100644 index 0000000000..4530bc66eb --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/Dialog/GitHubDialogWindow.xaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Views/Dialog/GitHubDialogWindow.xaml.cs b/src/GitHub.VisualStudio.UI/Views/Dialog/GitHubDialogWindow.xaml.cs new file mode 100644 index 0000000000..411e0c579a --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/Dialog/GitHubDialogWindow.xaml.cs @@ -0,0 +1,34 @@ +using System; +using System.Windows; +using System.Windows.Input; +using GitHub.ViewModels.Dialog; +using Microsoft.VisualStudio.PlatformUI; + +namespace GitHub.VisualStudio.Views.Dialog +{ + /// + /// The main window for GitHub for Visual Studio's dialog. + /// + public partial class GitHubDialogWindow : DialogWindow + { + public GitHubDialogWindow(IGitHubDialogWindowViewModel viewModel) + { + DataContext = viewModel; + viewModel.Done.Subscribe(_ => Close()); + InitializeComponent(); + } + + void CloseButton_Click(object sender, RoutedEventArgs e) + { + Close(); + } + + void Border_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton == MouseButton.Left) + { + DragMove(); + } + } + } +} diff --git a/src/GitHub.VisualStudio.UI/Views/Dialog/LogOutRequiredView.xaml b/src/GitHub.VisualStudio.UI/Views/Dialog/LogOutRequiredView.xaml new file mode 100644 index 0000000000..161230922b --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/Dialog/LogOutRequiredView.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/NoRemoteOriginView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NoRemoteOriginView.xaml.cs new file mode 100644 index 0000000000..62610a76c2 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NoRemoteOriginView.xaml.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.Composition; +using GitHub.UI; +using GitHub.Exports; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + public class GenericNoRemoteOriginView : ViewBase + { + } + + [ExportViewFor(typeof(INoRemoteOriginViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class NoRemoteOriginView : GenericNoRemoteOriginView + { + public NoRemoteOriginView() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitHubRepositoryView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitHubRepositoryView.xaml new file mode 100644 index 0000000000..e778334fc0 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitHubRepositoryView.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitHubRepositoryView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitHubRepositoryView.xaml.cs new file mode 100644 index 0000000000..a51b3e0f53 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitHubRepositoryView.xaml.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.Composition; +using GitHub.Exports; +using GitHub.UI; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + public class GenericNotAGitHubRepositoryView : ViewBase + { + } + + [ExportViewFor(typeof(INotAGitHubRepositoryViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class NotAGitHubRepositoryView : GenericNotAGitHubRepositoryView + { + public NotAGitHubRepositoryView() + { + this.InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitRepositoryView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitRepositoryView.xaml new file mode 100644 index 0000000000..c4b11c4f74 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitRepositoryView.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitRepositoryView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitRepositoryView.xaml.cs new file mode 100644 index 0000000000..47510b84bf --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/NotAGitRepositoryView.xaml.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.Composition; +using GitHub.Exports; +using GitHub.UI; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + public class GenericNotAGitRepositoryView : ViewBase + { + } + + [ExportViewFor(typeof(INotAGitRepositoryViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + + public partial class NotAGitRepositoryView : GenericNotAGitRepositoryView + { + public NotAGitRepositoryView() + { + this.InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestAnnotationsView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestAnnotationsView.xaml new file mode 100644 index 0000000000..58887eb111 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestAnnotationsView.xaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + for + + + # + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestAnnotationsView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestAnnotationsView.xaml.cs new file mode 100644 index 0000000000..f49803f39e --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestAnnotationsView.xaml.cs @@ -0,0 +1,34 @@ +using System; +using System.ComponentModel.Composition; +using System.Windows.Controls; +using System.Windows.Input; +using GitHub.Exports; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using Microsoft.VisualStudio.Shell; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + [ExportViewFor(typeof(IPullRequestAnnotationsViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class PullRequestAnnotationsView : UserControl + { + public PullRequestAnnotationsView() + { + InitializeComponent(); + } + + void OpenHyperlink(object sender, ExecutedRoutedEventArgs e) + { + Uri uri; + + if (Uri.TryCreate(e.Parameter?.ToString(), UriKind.Absolute, out uri)) + { + Browser.OpenUrl(uri); + } + } + + [Import] + public IVisualStudioBrowser Browser { get; set; } + } +} diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCheckView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCheckView.xaml new file mode 100644 index 0000000000..f018147c27 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCheckView.xaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCheckView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCheckView.xaml.cs new file mode 100644 index 0000000000..0bb8fe8d92 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCheckView.xaml.cs @@ -0,0 +1,36 @@ +using System; +using System.ComponentModel.Composition; +using GitHub.Exports; +using GitHub.Services; +using GitHub.UI; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + public class GenericPullRequestCheckView : ViewBase { } + + [ExportViewFor(typeof(IPullRequestCheckViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class PullRequestCheckView : GenericPullRequestCheckView + { + public PullRequestCheckView() + { + InitializeComponent(); + + this.WhenActivated(d => + { + d(ViewModel.OpenDetailsUrl.Subscribe(_ => DoOpenDetailsUrl())); + }); + } + + [Import] + IVisualStudioBrowser VisualStudioBrowser { get; set; } + + void DoOpenDetailsUrl() + { + var browser = VisualStudioBrowser; + browser.OpenUrl(ViewModel.DetailsUrl); + } + } +} diff --git a/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml new file mode 100644 index 0000000000..e992e56df0 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/GitHubPane/PullRequestCreationView.xaml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -109,24 +168,40 @@ DefaultDisabled Pull Requests + .GitHub.PullRequests + .GitHub.PullRequests + + @@ -137,16 +212,32 @@ DynamicVisibility Create a GitHub Gist + .GitHub.CreateGist + .GitHub.CreateGist + + + + @@ -157,14 +248,42 @@ DynamicVisibility Copy link to clipboard + .GitHub.CopyLink + .GitHub.CopyLink + + + + + + + - + @@ -191,10 +310,26 @@ - + + + + + + + + + + + + + + + + + @@ -217,18 +352,27 @@ + + + + + + + + + - + @@ -239,13 +383,17 @@ + + + + - + @@ -255,17 +403,20 @@ + + + @@ -274,9 +425,12 @@ + + + - + diff --git a/src/GitHub.VisualStudio/GitHubPackage.cs b/src/GitHub.VisualStudio/GitHubPackage.cs index b082f69da3..eaed98aa81 100644 --- a/src/GitHub.VisualStudio/GitHubPackage.cs +++ b/src/GitHub.VisualStudio/GitHubPackage.cs @@ -1,73 +1,134 @@ using System; +using System.Collections.Generic; using System.ComponentModel.Composition; +using System.ComponentModel.Design; using System.Runtime.InteropServices; -using GitHub.Extensions; -using GitHub.Models; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Commands; +using GitHub.Exports; +using GitHub.Info; +using GitHub.Logging; +using GitHub.Primitives; using GitHub.Services; +using GitHub.Services.Vssdk.Commands; +using GitHub.Settings; +using GitHub.ViewModels.Documents; +using GitHub.ViewModels.GitHubPane; +using GitHub.VisualStudio.Commands; +using GitHub.Services.Vssdk.Commands; +using GitHub.Services.Vssdk.Services; +using GitHub.ViewModels.GitHubPane; +using GitHub.VisualStudio.Helpers; +using GitHub.VisualStudio.Settings; using GitHub.VisualStudio.UI; using Microsoft.VisualStudio; +using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; -using Octokit; -using GitHub.Helpers; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; +using Serilog; using Task = System.Threading.Tasks.Task; -using GitHub.VisualStudio.Menus; -using System.ComponentModel.Design; -using GitHub.ViewModels; +using Microsoft; +using static System.FormattableString; namespace GitHub.VisualStudio { [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] - [InstalledProductRegistration("#110", "#112", System.AssemblyVersionInformation.Version, IconResourceID = 400)] - [Guid(GuidList.guidGitHubPkgString)] + [InstalledProductRegistration("#110", "#112", ExtensionInformation.Version)] + [Guid(Guids.guidGitHubPkgString)] [ProvideMenuResource("Menus.ctmenu", 1)] - // this is the Git service GUID, so we load whenever it loads - [ProvideAutoLoad(Guids.GitSccProviderId)] - [ProvideToolWindow(typeof(GitHubPane), Orientation = ToolWindowOrientation.Right, Style = VsDockStyle.Tabbed, Window = EnvDTE.Constants.vsWindowKindSolutionExplorer)] + [ProvideAutoLoad(Guids.GitContextPkgString, PackageAutoLoadFlags.BackgroundLoad)] [ProvideOptionPage(typeof(OptionsPage), "GitHub for Visual Studio", "General", 0, 0, supportsAutomation: true)] public class GitHubPackage : AsyncPackage { + static readonly ILogger log = LogManager.ForContext(); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] - readonly IServiceProvider serviceProvider; - - public GitHubPackage() + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) { - serviceProvider = this; + LogVersionInformation(); + await base.InitializeAsync(cancellationToken, progress); + + await InitializeLoggingAsync(); + await GetServiceAsync(typeof(IUsageTracker)); + + // Avoid delays when there is ongoing UI activity. + // See: https://github.com/github/VisualStudio/issues/1537 + await JoinableTaskFactory.RunAsync(VsTaskRunContext.UIThreadNormalPriority, InitializeMenus); } - public GitHubPackage(IServiceProvider serviceProvider) + async Task InitializeLoggingAsync() { - this.serviceProvider = serviceProvider; + var packageSettings = await GetServiceAsync(typeof(IPackageSettings)) as IPackageSettings; + LogManager.EnableTraceLogging(packageSettings?.EnableTraceLogging ?? false); + if (packageSettings != null) + { + packageSettings.PropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(packageSettings.EnableTraceLogging)) + { + LogManager.EnableTraceLogging(packageSettings.EnableTraceLogging); + } + }; + } } - protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) + void LogVersionInformation() { - await base.InitializeAsync(cancellationToken, progress); - - await GetServiceAsync(typeof(IUsageTracker)); - - InitializeMenus().Forget(); + var packageVersion = ApplicationInfo.GetPackageVersion(this); + var hostVersionInfo = ApplicationInfo.GetHostVersionInfo(); + log.Information("Initializing GitHub Extension v{PackageVersion} in {$FileDescription} ({$ProductVersion})", + packageVersion, hostVersionInfo.FileDescription, hostVersionInfo.ProductVersion); } async Task InitializeMenus() { - var menus = await GetServiceAsync(typeof(IMenuProvider)) as IMenuProvider; + IVsCommandBase[] commands; + if (ExportForVisualStudioProcessAttribute.IsVisualStudioProcess()) + { + var componentModel = (IComponentModel)(await GetServiceAsync(typeof(SComponentModel))); + Assumes.Present(componentModel); - await ThreadingHelper.SwitchToMainThreadAsync(); + var exports = componentModel.DefaultExportProvider; + commands = new IVsCommandBase[] + { + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue(), + exports.GetExportedValue() + }; + } + else + { + // Show info message box when executed in non-Visual Studio process + var message = Resources.BlendDialogText; + commands = new IVsCommandBase[] + { + new ShowMessageBoxCommand(AddConnectionCommand.CommandSet, AddConnectionCommand.CommandId, this, message), + new ShowMessageBoxCommand(ShowGitHubPaneCommand.CommandSet, ShowGitHubPaneCommand.CommandId, this, message) + }; + } - foreach (var menu in menus.Menus) - serviceProvider.AddCommandHandler(menu.Guid, menu.CmdId, (s, e) => menu.Activate()); + await JoinableTaskFactory.SwitchToMainThreadAsync(); + var menuService = (IMenuCommandService)(await GetServiceAsync(typeof(IMenuCommandService))); + Assumes.Present(menuService); - foreach (var menu in menus.DynamicMenus) - serviceProvider.AddCommandHandler(menu.Guid, menu.CmdId, menu.CanShow, () => menu.Activate()); + menuService.AddCommands(commands); } async Task EnsurePackageLoaded(Guid packageGuid) { + await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); + var shell = await GetServiceAsync(typeof(SVsShell)) as IVsShell; if (shell != null) { @@ -77,67 +138,117 @@ async Task EnsurePackageLoaded(Guid packageGuid) } } - [Export(typeof(IGitHubClient))] - public class GHClient : GitHubClient + [PartCreationPolicy(CreationPolicy.Shared)] + public class ServiceProviderExports { + static readonly ILogger log = LogManager.ForContext(); + readonly IServiceProvider serviceProvider; + [ImportingConstructor] - public GHClient(IProgram program) - : base(program.ProductHeader) + public ServiceProviderExports([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + [ExportForVisualStudioProcess] + public ILoginManager LoginManager => GetService(); + + [ExportForVisualStudioProcess] + public IGitHubServiceProvider GitHubServiceProvider => GetService(); + + [ExportForVisualStudioProcess] + public IUsageTracker UsageTracker => GetService(); + + [ExportForVisualStudioProcess] + public IVSGitExt VSGitExt => GetService(); + + [ExportForVisualStudioProcess] + public IPackageSettings PackageSettings => GetService(); + + [ExportForVisualStudioProcess] + public ITippingService TippingService + { + get + { + var tippingService = GetService(); + if (tippingService == null) + { + // GetService() was returning null on Visual Studio 2015, so fall back to using new TippingService(...) + log.Warning("Couldn't find service of type {Type}, using new TippingService(...) instead", typeof(ITippingService)); + tippingService = new TippingService(serviceProvider); + } + + return tippingService; + } + } + + T GetService() where T : class { + var service = (T)serviceProvider.GetService(typeof(T)); + if (service == null) + { + log.Error("Couldn't find service of type {Type}", typeof(T)); + return null; + } + + return service; } } - [NullGuard.NullGuard(NullGuard.ValidationFlags.None)] [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] - [ProvideService(typeof(IMenuProvider), IsAsyncQueryable = true)] + [ProvideService(typeof(ILoginManager), IsAsyncQueryable = true)] [ProvideService(typeof(IGitHubServiceProvider), IsAsyncQueryable = true)] [ProvideService(typeof(IUsageTracker), IsAsyncQueryable = true)] - [ProvideService(typeof(IUIProvider), IsAsyncQueryable = true)] + [ProvideService(typeof(IPackageSettings), IsAsyncQueryable = true)] + [ProvideService(typeof(IUsageService), IsAsyncQueryable = true)] + [ProvideService(typeof(IVSGitExt), IsAsyncQueryable = true)] [ProvideService(typeof(IGitHubToolWindowManager))] + [ProvideService(typeof(ITippingService))] + [ProvideToolWindow(typeof(IssueishDocumentPane), DocumentLikeTool = true, MultiInstances = true)] [Guid(ServiceProviderPackageId)] public sealed class ServiceProviderPackage : AsyncPackage, IServiceProviderPackage, IGitHubToolWindowManager { public const string ServiceProviderPackageId = "D5CE1488-DEDE-426D-9E5B-BFCCFBE33E53"; - const string StartPagePreview4PackageId = "3b764d23-faf7-486f-94c7-b3accc44a70d"; - const string StartPagePreview5PackageId = "3b764d23-faf7-486f-94c7-b3accc44a70e"; + static readonly ILogger log = LogManager.ForContext(); - Version vsversion; - Version VSVersion + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) { - get - { - if (vsversion == null) - { - var asm = typeof(ITaskList).Assembly; - try - { - // this will return Microsoft.VisualStudio.Shell.Immutable.14.0 in VS15 - // but Microsoft.VisualStudio.Shell.Framework in Dev15 - var vinfo = FileVersionInfo.GetVersionInfo(asm.Location); - vsversion = new Version(vinfo.FileMajorPart, vinfo.FileMinorPart, vinfo.FileBuildPart, vinfo.FilePrivatePart); - } - catch - { - // something wrong, fallback to assembly version - vsversion = asm.GetName().Version; - } - } - return vsversion; - } - } + await CheckBindingPathsAsync(); - protected override Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) - { AddService(typeof(IGitHubServiceProvider), CreateService, true); + AddService(typeof(IVSGitExt), CreateService, true); AddService(typeof(IUsageTracker), CreateService, true); - AddService(typeof(IMenuProvider), CreateService, true); - AddService(typeof(IUIProvider), CreateService, true); + AddService(typeof(IUsageService), CreateService, true); + AddService(typeof(ILoginManager), CreateService, true); AddService(typeof(IGitHubToolWindowManager), CreateService, true); - return Task.CompletedTask; + AddService(typeof(IPackageSettings), CreateService, true); + AddService(typeof(ITippingService), CreateService, true); + } + +#if DEBUG + async Task CheckBindingPathsAsync() + { + try + { + // When running in the Exp instance, ensure there is only one active binding path. + // This is necessary when the regular (AllUsers) extension is also installed. + // See: https://github.com/github/VisualStudio/issues/2006 + await JoinableTaskFactory.SwitchToMainThreadAsync(); + BindingPathHelper.CheckBindingPaths(GetType().Assembly, this); + } + catch (Exception e) + { + log.Error(e, nameof(CheckBindingPathsAsync)); + } } +#else + Task CheckBindingPathsAsync() => Task.CompletedTask; +#endif - public IViewHost ShowHomePane() + public async Task ShowGitHubPane() { + await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); + var pane = ShowToolWindow(new Guid(GitHubPane.GitHubPaneGuid)); if (pane == null) return null; @@ -146,31 +257,74 @@ public IViewHost ShowHomePane() { ErrorHandler.Failed(frame.Show()); } - return pane as IViewHost; + + var gitHubPane = (GitHubPane)pane; + return await gitHubPane.GetViewModelAsync(); + } + + public async Task ShowIssueishDocumentPane( + HostAddress address, + string owner, + string repository, + int number) + { + var id = Invariant($"{address.WebUri}|{owner}/{repository}#{number}"); + var pane = GetOrCreateToolWindow(id); + + if (pane != null && pane.Frame is IVsWindowFrame frame) + { + ErrorHandler.ThrowOnFailure(frame.Show()); + return await pane.GetViewModelAsync(); + } + + return null; + } + + T GetOrCreateToolWindow(string id) where T : AsyncPaneBase + { + for (var i = 0; i < int.MaxValue; ++i) + { + var result = (T)FindToolWindow(typeof(T), i, false); + + if (result != null && result.Id == id) + { + return result; + } + else if (result == null) + { + result = (T)FindToolWindow(typeof(T), i, true); + result.Id = id; + return result; + } + } + + return null; } static ToolWindowPane ShowToolWindow(Guid windowGuid) { + ThreadHelper.ThrowIfNotOnUIThread(); + IVsWindowFrame frame; - if (ErrorHandler.Failed(Services.UIShell.FindToolWindow((uint) __VSCREATETOOLWIN.CTW_fForceCreate, + if (ErrorHandler.Failed(Services.UIShell.FindToolWindow((uint)__VSCREATETOOLWIN.CTW_fForceCreate, ref windowGuid, out frame))) { - VsOutputLogger.WriteLine("Unable to find or create GitHubPane '" + UI.GitHubPane.GitHubPaneGuid + "'"); + log.Error("Unable to find or create GitHubPane '{Guid}'", UI.GitHubPane.GitHubPaneGuid); return null; } if (ErrorHandler.Failed(frame.Show())) { - VsOutputLogger.WriteLine("Unable to show GitHubPane '" + UI.GitHubPane.GitHubPaneGuid + "'"); + log.Error("Unable to show GitHubPane '{Guid}'", UI.GitHubPane.GitHubPaneGuid); return null; } object docView = null; - if (ErrorHandler.Failed(frame.GetProperty((int) __VSFPROPID.VSFPROPID_DocView, out docView))) + if (ErrorHandler.Failed(frame.GetProperty((int)__VSFPROPID.VSFPROPID_DocView, out docView))) { - VsOutputLogger.WriteLine("Unable to grab instance of GitHubPane '" + UI.GitHubPane.GitHubPaneGuid + "'"); + log.Error("Unable to grab instance of GitHubPane '{Guid}'", UI.GitHubPane.GitHubPaneGuid); return null; } - return docView as GitHubPane; + return docView as ToolWindowPane; } async Task CreateService(IAsyncServiceContainer container, CancellationToken cancellationToken, Type serviceType) @@ -188,29 +342,90 @@ async Task CreateService(IAsyncServiceContainer container, CancellationT await result.Initialize(); return result; } - else if (serviceType == typeof(IMenuProvider)) + else if (serviceType == typeof(ILoginManager)) + { + // These services are got through MEF and we will take a performance hit if ILoginManager is requested during + // InitializeAsync. TODO: We can probably make LoginManager a normal MEF component rather than a service. + var serviceProvider = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; + Assumes.Present(serviceProvider); + + var keychain = serviceProvider.GetService(); + var oauthListener = serviceProvider.GetService(); + + // HACK: We need to make sure this is run on the main thread. We really + // shouldn't be injecting a view model concern into LoginManager - this + // needs to be refactored. See #1398. +#pragma warning disable VSTHRD011 // Use AsyncLazy + var lazy2Fa = new Lazy(() => + JoinableTaskFactory.Run(async () => + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + return serviceProvider.GetService(); + })); +#pragma warning restore VSTHRD011 // Use AsyncLazy + + return new LoginManager( + keychain, + lazy2Fa, + oauthListener, + ApiClientConfiguration.ClientId, + ApiClientConfiguration.ClientSecret, + ApiClientConfiguration.MinimumScopes, + ApiClientConfiguration.RequestedScopes, + ApiClientConfiguration.AuthorizationNote, + ApiClientConfiguration.MachineFingerprint); + } + else if (serviceType == typeof(IUsageService)) { var sp = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; - return new MenuProvider(sp); + Assumes.Present(sp); + + var environment = new Rothko.Environment(); + return new UsageService(sp, environment, ThreadHelper.JoinableTaskContext); } else if (serviceType == typeof(IUsageTracker)) { - var uiProvider = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; - return new UsageTracker(uiProvider); + var usageService = await GetServiceAsync(typeof(IUsageService)) as IUsageService; + var serviceProvider = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; + var settings = await GetServiceAsync(typeof(IPackageSettings)) as IPackageSettings; + + Assumes.Present(usageService); + Assumes.Present(serviceProvider); + Assumes.Present(settings); + + // Only use Visual Studio Telemetry on VS 2017 and above + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + var dte = await GetServiceAsync(typeof(EnvDTE.DTE)) as EnvDTE.DTE; + Assumes.Present(dte); + var vsTelemetry = new Version(dte.Version) >= new Version(15, 0); + + return new UsageTracker(serviceProvider, usageService, settings, ThreadHelper.JoinableTaskContext, vsTelemetry); } - else if (serviceType == typeof(IUIProvider)) + else if (serviceType == typeof(IVSGitExt)) { - var sp = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; - return new UIProvider(sp); + var vsVersion = ApplicationInfo.GetHostVersionInfo().FileMajorPart; + return new VSGitExtFactory(vsVersion, this, GitService.GitServiceHelper, ThreadHelper.JoinableTaskContext).Create(); } else if (serviceType == typeof(IGitHubToolWindowManager)) { return this; } + else if (serviceType == typeof(IPackageSettings)) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + var sp = new ServiceProvider(Services.Dte as Microsoft.VisualStudio.OLE.Interop.IServiceProvider); + return new PackageSettings(sp); + } + else if (serviceType == typeof(ITippingService)) + { + return new TippingService(this); + } // go the mef route else { var sp = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; + Assumes.Present(sp); + return sp.TryGetService(serviceType); } } diff --git a/src/GitHub.VisualStudio/GitHubPanePackage.cs b/src/GitHub.VisualStudio/GitHubPanePackage.cs new file mode 100644 index 0000000000..3738e32a40 --- /dev/null +++ b/src/GitHub.VisualStudio/GitHubPanePackage.cs @@ -0,0 +1,24 @@ +using System; +using System.Runtime.InteropServices; +using GitHub.VisualStudio.UI; +using Microsoft.VisualStudio.Imaging; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace GitHub.VisualStudio +{ + /// + /// This is the host package for the tool window. + /// + /// + /// This package mustn't use MEF. + /// See: https://github.com/github/VisualStudio/issues/1550 + /// + [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] + [Guid(Guids.GitHubPanePackageId)] + [ProvideToolWindow(typeof(GitHubPane), Orientation = ToolWindowOrientation.Right, + Style = VsDockStyle.Tabbed, Window = EnvDTE.Constants.vsWindowKindSolutionExplorer)] + public sealed class GitHubPanePackage : AsyncPackage + { + } +} \ No newline at end of file diff --git a/src/GitHub.VisualStudio/GlobalSuppressions.cs b/src/GitHub.VisualStudio/GlobalSuppressions.cs index 180dfc9508..cb28ad5aac 100644 --- a/src/GitHub.VisualStudio/GlobalSuppressions.cs +++ b/src/GitHub.VisualStudio/GlobalSuppressions.cs @@ -8,4 +8,8 @@ // Suppression File". You do not need to add suppressions to this // file manually. -[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1701:ResourceStringCompoundWordsShouldBeCasedCorrectly", MessageId = "GitHub", Scope = "resource", Target = "VSPackage.resources")] +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Microsoft.Naming", "CA1701:ResourceStringCompoundWordsShouldBeCasedCorrectly", MessageId = "GitHub", Scope = "resource", Target = "VSPackage.resources")] +[assembly: SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Discouraged for VSSDK projects.")] +[assembly: SuppressMessage("Style", "VSTHRD200:Use \"Async\" suffix for async methods")] diff --git a/src/GitHub.VisualStudio/Helpers/ActiveDocumentSnapshot.cs b/src/GitHub.VisualStudio/Helpers/ActiveDocumentSnapshot.cs index e62fa2c2d3..0525cd0a9c 100644 --- a/src/GitHub.VisualStudio/Helpers/ActiveDocumentSnapshot.cs +++ b/src/GitHub.VisualStudio/Helpers/ActiveDocumentSnapshot.cs @@ -3,7 +3,7 @@ using Microsoft.VisualStudio.TextManager.Interop; using System; using System.ComponentModel.Composition; -using System.Diagnostics; +using GitHub.Logging; namespace GitHub.VisualStudio { @@ -18,12 +18,14 @@ public class ActiveDocumentSnapshot : IActiveDocumentSnapshot [ImportingConstructor] public ActiveDocumentSnapshot([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider) { + ThreadHelper.ThrowIfNotOnUIThread(); + StartLine = EndLine = -1; var document = Services.Dte2?.ActiveDocument; Name = document.FullName.Equals(document.ProjectItem.FileNames[1], StringComparison.OrdinalIgnoreCase) ? document.ProjectItem.FileNames[1] : document.FullName; var textManager = serviceProvider.GetService(typeof(SVsTextManager)) as IVsTextManager; - Debug.Assert(textManager != null, "No SVsTextManager service available"); + Log.Assert(textManager != null, "No SVsTextManager service available"); if (textManager == null) return; IVsTextView view; @@ -31,6 +33,17 @@ public ActiveDocumentSnapshot([Import(typeof(SVsServiceProvider))] IServiceProvi if (ErrorHandler.Succeeded(textManager.GetActiveView(0, null, out view)) && ErrorHandler.Succeeded(view.GetSelection(out anchorLine, out anchorCol, out endLine, out endCol))) { + // Ignore the bottom anchor or end line if it has zero width (starts on column 0) + // This prevents non-visible parts of the selection from being inclused in the range + if (anchorLine < endLine && endCol == 0) + { + endLine--; + } + else if (anchorLine > endLine && anchorCol == 0) + { + anchorLine--; + } + StartLine = anchorLine + 1; EndLine = endLine + 1; } diff --git a/src/GitHub.VisualStudio/Helpers/BindingPathHelper.cs b/src/GitHub.VisualStudio/Helpers/BindingPathHelper.cs new file mode 100644 index 0000000000..da47e8c755 --- /dev/null +++ b/src/GitHub.VisualStudio/Helpers/BindingPathHelper.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Diagnostics; +using System.Globalization; +using System.Collections.Generic; +using GitHub.Logging; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Shell.Settings; +using Microsoft.VisualStudio.Settings; +using Serilog; + +namespace GitHub.VisualStudio.Helpers +{ + /// + /// This a workaround for extensions that define a ProvideBindingPath attribute and + /// install for AllUsers. + /// + /// + /// Extensions that are installed for AllUsers, will also be installed for all + /// instances of Visual Studio - including the experimental (Exp) instance which + /// is used in development. This isn't a problem so long as all features that + /// exist in the AllUsers extension, also exist in the extension that is being + /// developed. + /// + /// When an extension uses the ProvideBindingPath attribute, the binding path for + /// the AllUsers extension gets installed at the same time as the one in development. + /// This doesn't matter when an assembly is strong named and is loaded using its + /// full name (including version number). When an assembly is loaded using its + /// simple name, assemblies from the AllUsers extension can end up loaded at the + /// same time as the extension being developed. This can happen when an assembly + /// is loaded from XAML or an .imagemanifest. + /// + /// This is a workaround for that issue. The + /// method will check to see if a reference assembly could be loaded from an alternative + /// binding path. It will return any alternative paths that is finds. + /// See https://github.com/github/VisualStudio/issues/1995 + /// + public static class BindingPathHelper + { + static readonly ILogger log = LogManager.ForContext(typeof(BindingPathHelper)); + + internal static void CheckBindingPaths(Assembly assembly, IServiceProvider serviceProvider) + { + log.Information("Looking for assembly on wrong binding path"); + + ThreadHelper.CheckAccess(); + var bindingPaths = FindBindingPaths(serviceProvider); + var bindingPath = FindRedundantBindingPaths(bindingPaths, assembly.Location) + .FirstOrDefault(); + if (bindingPath == null) + { + log.Information("No incorrect binding path found"); + return; + } + + // Log what has been detected + log.Warning("Found assembly on wrong binding path {BindingPath}", bindingPath); + + var message = string.Format(CultureInfo.CurrentCulture, @"Found assembly on wrong binding path: +{0} + +Would you like to learn more about this issue?", bindingPath); + var action = VsShellUtilities.ShowMessageBox(serviceProvider, message, "GitHub for Visual Studio", OLEMSGICON.OLEMSGICON_WARNING, + OLEMSGBUTTON.OLEMSGBUTTON_YESNO, OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + if (action == 6) // Yes = 6, No = 7 + { + Process.Start("https://github.com/github/VisualStudio/issues/2006"); + } + } + + /// + /// Find any alternative binding path that might have been installed by an AllUsers extension. + /// + /// A list of binding paths to search + /// A reference assembly that has been loaded from the correct path. + /// A list of redundant binding paths. + public static IList FindRedundantBindingPaths(IEnumerable bindingPaths, string assemblyLocation) + { + var fileName = Path.GetFileName(assemblyLocation); + return bindingPaths + .Select(p => (path: p, file: Path.Combine(p, fileName))) + .Where(pf => File.Exists(pf.file)) + .Where(pf => !pf.file.Equals(assemblyLocation, StringComparison.OrdinalIgnoreCase)) + .Select(pf => pf.path) + .ToList(); + } + + /// + /// Find Visual Studio's list of binding paths. + /// + /// A list of binding paths. + public static IEnumerable FindBindingPaths(IServiceProvider serviceProvider) + { + const string bindingPaths = "BindingPaths"; + var manager = new ShellSettingsManager(serviceProvider); + var store = manager.GetReadOnlySettingsStore(SettingsScope.Configuration); + foreach (var guid in store.GetSubCollectionNames(bindingPaths)) + { + var guidPath = Path.Combine(bindingPaths, guid); + foreach (var path in store.GetPropertyNames(guidPath)) + { + yield return path; + } + } + } + } +} diff --git a/src/GitHub.VisualStudio/Helpers/Browser.cs b/src/GitHub.VisualStudio/Helpers/Browser.cs index d141426ca9..6d5c6491a9 100644 --- a/src/GitHub.VisualStudio/Helpers/Browser.cs +++ b/src/GitHub.VisualStudio/Helpers/Browser.cs @@ -41,7 +41,12 @@ public void OpenUrl(Uri uri) { __VSCREATEWEBBROWSER createFlags = __VSCREATEWEBBROWSER.VSCWB_AutoShow; VSPREVIEWRESOLUTION resolution = VSPREVIEWRESOLUTION.PR_Default; - int result = ErrorHandler.CallWithCOMConvention(() => service.CreateExternalWebBrowser((uint)createFlags, resolution, uri.AbsoluteUri)); + int result = ErrorHandler.CallWithCOMConvention(() => + { + ThreadHelper.ThrowIfNotOnUIThread(); + return service.CreateExternalWebBrowser((uint)createFlags, resolution, uri.AbsoluteUri); + }); + if (ErrorHandler.Succeeded(result)) return; } diff --git a/src/GitHub.VisualStudio/Helpers/SharedDictionaryManager.cs b/src/GitHub.VisualStudio/Helpers/SharedDictionaryManager.cs deleted file mode 100644 index d0de072d42..0000000000 --- a/src/GitHub.VisualStudio/Helpers/SharedDictionaryManager.cs +++ /dev/null @@ -1,30 +0,0 @@ -using GitHub.Helpers; -using System; -using System.Collections.Generic; -using System.Windows; - -namespace GitHub.VisualStudio.Helpers -{ - public class SharedDictionaryManager : ResourceDictionary - { - static readonly Dictionary resourceDicts = new Dictionary(); - - Uri sourceUri; - public new Uri Source - { - get { return sourceUri; } - set - { - sourceUri = value; - ResourceDictionary ret; - if (resourceDicts.TryGetValue(value, out ret)) - { - MergedDictionaries.Add(ret); - return; - } - base.Source = value; - resourceDicts.Add(value, this); - } - } - } -} diff --git a/src/GitHub.VisualStudio/Helpers/VisualTreeExtensions.cs b/src/GitHub.VisualStudio/Helpers/VisualTreeExtensions.cs deleted file mode 100644 index db1c3d4135..0000000000 --- a/src/GitHub.VisualStudio/Helpers/VisualTreeExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Windows.Media; -using GitHub.Extensions; - -namespace GitHub.VisualStudio.Helpers -{ - public static class VisualTreeExtensions - { - public static IEnumerable GetSelfAndVisualAncestors(this Visual visual) - { - Guard.ArgumentNotNull(visual, nameof(visual)); - - return Enumerable.Repeat(visual, 1).Concat(GetVisualAncestors(visual)); - } - - public static IEnumerable GetVisualAncestors(this Visual visual) - { - Guard.ArgumentNotNull(visual, nameof(visual)); - - while (true) - { - visual = VisualTreeHelper.GetParent(visual) as Visual; - - if (visual != null) - yield return visual; - else - break; - } - } - } -} diff --git a/src/GitHub.VisualStudio/IServiceProviderPackage.cs b/src/GitHub.VisualStudio/IServiceProviderPackage.cs index 0b048d81ed..92206942f8 100644 --- a/src/GitHub.VisualStudio/IServiceProviderPackage.cs +++ b/src/GitHub.VisualStudio/IServiceProviderPackage.cs @@ -1,17 +1,8 @@ -using GitHub.ViewModels; -using System; -using System.Runtime.InteropServices; +using System; namespace GitHub.VisualStudio { public interface IServiceProviderPackage : IServiceProvider, Microsoft.VisualStudio.Shell.IAsyncServiceProvider { } - - [Guid("FC9EC5B5-C297-4548-A229-F8E16365543C")] - [ComVisible(true)] - public interface IGitHubToolWindowManager - { - IViewHost ShowHomePane(); - } } diff --git a/src/GitHub.VisualStudio/Menus/AddConnection.cs b/src/GitHub.VisualStudio/Menus/AddConnection.cs deleted file mode 100644 index 21df487d4a..0000000000 --- a/src/GitHub.VisualStudio/Menus/AddConnection.cs +++ /dev/null @@ -1,23 +0,0 @@ -using GitHub.Services; -using GitHub.UI; -using NullGuard; -using System; - -namespace GitHub.VisualStudio.Menus -{ - public class AddConnection: MenuBase, IMenuHandler - { - public AddConnection(IGitHubServiceProvider serviceProvider) - : base(serviceProvider) - { - } - - public Guid Guid => GuidList.guidGitHubCmdSet; - public int CmdId => PkgCmdIDList.addConnectionCommand; - - public void Activate([AllowNull]object data = null) - { - StartFlow(UIControllerFlow.Authentication); - } - } -} diff --git a/src/GitHub.VisualStudio/Menus/CopyLink.cs b/src/GitHub.VisualStudio/Menus/CopyLink.cs deleted file mode 100644 index fba808ad65..0000000000 --- a/src/GitHub.VisualStudio/Menus/CopyLink.cs +++ /dev/null @@ -1,49 +0,0 @@ -using GitHub.Services; -using GitHub.VisualStudio.UI; -using NullGuard; -using System; -using System.Windows; - -namespace GitHub.VisualStudio.Menus -{ - public class CopyLink : LinkMenuBase, IDynamicMenuHandler - { - - public CopyLink(IGitHubServiceProvider serviceProvider) - : base(serviceProvider) - { - } - - public Guid Guid => GuidList.guidContextMenuSet; - public int CmdId => PkgCmdIDList.copyLinkCommand; - - public async void Activate([AllowNull]object data = null) - { - var isgithub = await IsGitHubRepo(); - if (!isgithub) - return; - - var link = await GenerateLink(); - if (link == null) - return; - try - { - Clipboard.SetText(link); - var ns = ServiceProvider.TryGetService(); - ns?.ShowMessage(Resources.LinkCopiedToClipboardMessage); - await UsageTracker.IncrementLinkToGitHubCount(); - } - catch - { - var ns = ServiceProvider.TryGetService(); - ns?.ShowMessage(Resources.Error_FailedToCopyToClipboard); - } - } - - public bool CanShow() - { - var githubRepoCheckTask = IsCurrentFileInGitHubRepository(); - return githubRepoCheckTask.Wait(250) ? githubRepoCheckTask.Result : false; - } - } -} diff --git a/src/GitHub.VisualStudio/Menus/CreateGist.cs b/src/GitHub.VisualStudio/Menus/CreateGist.cs deleted file mode 100644 index 36f0d8190a..0000000000 --- a/src/GitHub.VisualStudio/Menus/CreateGist.cs +++ /dev/null @@ -1,34 +0,0 @@ -using GitHub.Services; -using GitHub.UI; -using NullGuard; -using System; -using System.Diagnostics; - -namespace GitHub.VisualStudio.Menus -{ - public class CreateGist : MenuBase, IDynamicMenuHandler - { - readonly Lazy selectedTextProvider; - ISelectedTextProvider SelectedTextProvider => selectedTextProvider.Value; - - public CreateGist(IGitHubServiceProvider serviceProvider) - : base(serviceProvider) - { - selectedTextProvider = new Lazy(() => ServiceProvider.TryGetService()); - } - - public Guid Guid { get { return GuidList.guidContextMenuSet; } } - public int CmdId { get { return PkgCmdIDList.createGistCommand; } } - - public bool CanShow() - { - Debug.Assert(SelectedTextProvider != null, "Could not get an instance of ISelectedTextProvider"); - return !String.IsNullOrWhiteSpace(SelectedTextProvider?.GetSelectedText()); - } - - public void Activate([AllowNull] object data) - { - StartFlow(UIControllerFlow.Gist); - } - } -} diff --git a/src/GitHub.VisualStudio/Menus/LinkMenuBase.cs b/src/GitHub.VisualStudio/Menus/LinkMenuBase.cs deleted file mode 100644 index 34e9a1d840..0000000000 --- a/src/GitHub.VisualStudio/Menus/LinkMenuBase.cs +++ /dev/null @@ -1,60 +0,0 @@ -using GitHub.Primitives; -using GitHub.Services; -using System; -using System.Threading.Tasks; -using System.IO; -using GitHub.Models; - -namespace GitHub.VisualStudio.Menus -{ - public class LinkMenuBase: MenuBase - { - readonly Lazy usageTracker; - - protected IUsageTracker UsageTracker => usageTracker.Value; - - public LinkMenuBase(IGitHubServiceProvider serviceProvider) - : base(serviceProvider) - { - usageTracker = new Lazy(() => ServiceProvider.TryGetService()); - } - - protected async Task IsCurrentFileInGitHubRepository() - { - if (!await IsGitHubRepo()) - return false; - - var activeDocument = ServiceProvider.TryGetService(); - - return activeDocument != null && - IsFileDescendantOfDirectory(activeDocument.Name, ActiveRepo.LocalPath); - } - - protected Task GenerateLink() - { - var repo = ActiveRepo; - var activeDocument = ServiceProvider.TryGetService(); - if (activeDocument == null) - return null; - return repo.GenerateUrl(activeDocument.Name, activeDocument.StartLine, activeDocument.EndLine); - } - - // Taken from http://stackoverflow.com/a/26012991/6448 - public static bool IsFileDescendantOfDirectory(string file, string directory) - { - var fileInfo = new FileInfo(file); - var directoryInfo = new DirectoryInfo(directory); - - // https://connect.microsoft.com/VisualStudio/feedback/details/777308/inconsistent-behavior-of-fullname-when-provided-path-ends-with-a-backslash - string path = directoryInfo.FullName.TrimEnd(Path.DirectorySeparatorChar); - DirectoryInfo dir = fileInfo.Directory; - while (dir != null) - { - if (dir.FullName.TrimEnd(Path.DirectorySeparatorChar).Equals(path, StringComparison.OrdinalIgnoreCase)) - return true; - dir = dir.Parent; - } - return false; - } - } -} diff --git a/src/GitHub.VisualStudio/Menus/MenuBase.cs b/src/GitHub.VisualStudio/Menus/MenuBase.cs deleted file mode 100644 index f2f256178e..0000000000 --- a/src/GitHub.VisualStudio/Menus/MenuBase.cs +++ /dev/null @@ -1,118 +0,0 @@ -using GitHub.Api; -using GitHub.Models; -using GitHub.Primitives; -using GitHub.Services; -using GitHub.UI; -using NullGuard; -using System; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; - -namespace GitHub.VisualStudio -{ - public abstract class MenuBase - { - readonly IGitHubServiceProvider serviceProvider; - readonly Lazy apiFactory; - - protected IGitHubServiceProvider ServiceProvider { get { return serviceProvider; } } - - protected ILocalRepositoryModel ActiveRepo { get; private set; } - - protected ISimpleApiClient simpleApiClient; - - [AllowNull] - protected ISimpleApiClient SimpleApiClient - { - [return: AllowNull] - get { return simpleApiClient; } - set - { - if (simpleApiClient != value && value == null) - ApiFactory.ClearFromCache(simpleApiClient); - simpleApiClient = value; - } - } - - protected ISimpleApiClientFactory ApiFactory => apiFactory.Value; - - protected MenuBase() - {} - - protected MenuBase(IGitHubServiceProvider serviceProvider) - { - this.serviceProvider = serviceProvider; - apiFactory = new Lazy(() => ServiceProvider.TryGetService()); - } - - protected ILocalRepositoryModel GetActiveRepo() - { - var activeRepo = ServiceProvider.TryGetService()?.ActiveRepo; - // activeRepo can be null at this point because it is set elsewhere as the result of async operation that may not have completed yet. - if (activeRepo == null) - { - var path = ServiceProvider.TryGetService()?.GetActiveRepoPath() ?? String.Empty; - try - { - activeRepo = !string.IsNullOrEmpty(path) ? new LocalRepositoryModel(path) : null; - } - catch (Exception ex) - { - VsOutputLogger.WriteLine(string.Format(CultureInfo.CurrentCulture, "Error loading the repository from '{0}'. {1}", path, ex)); - } - } - return activeRepo; - } - - protected void StartFlow(UIControllerFlow controllerFlow) - { - IConnection connection = null; - if (controllerFlow != UIControllerFlow.Authentication) - { - var activeRepo = GetActiveRepo(); - connection = ServiceProvider.TryGetService()?.Connections - .FirstOrDefault(c => activeRepo?.CloneUrl?.RepositoryName != null && c.HostAddress.Equals(HostAddress.Create(activeRepo.CloneUrl))); - } - ServiceProvider.TryGetService().RunInDialog(controllerFlow, connection); - } - - void RefreshRepo() - { - ActiveRepo = ServiceProvider.TryGetService().ActiveRepo; - - if (ActiveRepo == null) - { - var vsGitServices = ServiceProvider.TryGetService(); - string path = vsGitServices?.GetActiveRepoPath() ?? String.Empty; - try - { - ActiveRepo = !String.IsNullOrEmpty(path) ? new LocalRepositoryModel(path) : null; - } - catch (Exception ex) - { - VsOutputLogger.WriteLine(string.Format(CultureInfo.CurrentCulture, "{0}: Error loading the repository from '{1}'. {2}", GetType(), path, ex)); - } - } - } - - protected async Task IsGitHubRepo() - { - RefreshRepo(); - - var uri = ActiveRepo?.CloneUrl; - if (uri == null) - return false; - - SimpleApiClient = ApiFactory.Create(uri); - - var isdotcom = HostAddress.IsGitHubDotComUri(uri.ToRepositoryUrl()); - if (!isdotcom) - { - var repo = await SimpleApiClient.GetRepository(); - return (repo.FullName == ActiveRepo.Name || repo.Id == 0) && SimpleApiClient.IsEnterprise(); - } - return isdotcom; - } - } -} \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Menus/MenuProvider.cs b/src/GitHub.VisualStudio/Menus/MenuProvider.cs deleted file mode 100644 index e9508e895e..0000000000 --- a/src/GitHub.VisualStudio/Menus/MenuProvider.cs +++ /dev/null @@ -1,57 +0,0 @@ -using GitHub.Services; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel.Composition; -using System.Linq; -using System; -using Microsoft.VisualStudio.Shell; - -namespace GitHub.VisualStudio.Menus -{ - /// - /// This is a thin MEF wrapper around the MenuProvider - /// which is registered as a global VS service. This class just - /// redirects every request to the actual service, and can be - /// thrown away as soon as the caller is done (no state is kept) - /// - [Export(typeof(IMenuProvider))] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class MenuProviderDispatcher : IMenuProvider - { - readonly IMenuProvider theRealProvider; - - [ImportingConstructor] - public MenuProviderDispatcher([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider) - { - theRealProvider = serviceProvider.GetService(typeof(IMenuProvider)) as IMenuProvider; - } - - public IReadOnlyCollection DynamicMenus => theRealProvider.DynamicMenus; - - public IReadOnlyCollection Menus => theRealProvider.Menus; - } - - internal class MenuProvider : IMenuProvider - { - public IReadOnlyCollection Menus { get; } - - public IReadOnlyCollection DynamicMenus { get; } - - public MenuProvider(IGitHubServiceProvider serviceProvider) - { - Menus = new List - { - new AddConnection(serviceProvider), - new OpenPullRequests(serviceProvider), - new ShowGitHubPane(serviceProvider) - }; - - DynamicMenus = new List - { - new CopyLink(serviceProvider), - new CreateGist(serviceProvider), - new OpenLink(serviceProvider) - }; - } - } -} diff --git a/src/GitHub.VisualStudio/Menus/OpenLink.cs b/src/GitHub.VisualStudio/Menus/OpenLink.cs deleted file mode 100644 index 8eb1a0214a..0000000000 --- a/src/GitHub.VisualStudio/Menus/OpenLink.cs +++ /dev/null @@ -1,38 +0,0 @@ -using GitHub.Services; -using NullGuard; -using System; - -namespace GitHub.VisualStudio.Menus -{ - public class OpenLink: LinkMenuBase, IDynamicMenuHandler - { - public OpenLink(IGitHubServiceProvider serviceProvider) - : base(serviceProvider) - { - } - - public Guid Guid => GuidList.guidContextMenuSet; - public int CmdId => PkgCmdIDList.openLinkCommand; - - public async void Activate([AllowNull]object data = null) - { - var isgithub = await IsGitHubRepo(); - if (!isgithub) - return; - - var link = await GenerateLink(); - if (link == null) - return; - var browser = ServiceProvider.TryGetService(); - browser?.OpenUrl(link.ToUri()); - - await UsageTracker.IncrementOpenInGitHubCount(); - } - - public bool CanShow() - { - var githubRepoCheckTask = IsCurrentFileInGitHubRepository(); - return githubRepoCheckTask.Wait(250) ? githubRepoCheckTask.Result : false; - } - } -} diff --git a/src/GitHub.VisualStudio/Menus/OpenPullRequests.cs b/src/GitHub.VisualStudio/Menus/OpenPullRequests.cs deleted file mode 100644 index c4aeb80d17..0000000000 --- a/src/GitHub.VisualStudio/Menus/OpenPullRequests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using GitHub.Exports; -using GitHub.UI; -using GitHub.VisualStudio.UI; -using NullGuard; -using System; -using GitHub.Services; - -namespace GitHub.VisualStudio.Menus -{ - [ExportMenu(MenuType = MenuType.OpenPullRequests)] - public class OpenPullRequests : MenuBase, IMenuHandler - { - public OpenPullRequests(IGitHubServiceProvider serviceProvider) - : base(serviceProvider) - { - } - - public Guid Guid => GuidList.guidGitHubCmdSet; - public int CmdId => PkgCmdIDList.openPullRequestsCommand; - - public void Activate([AllowNull]object data = null) - { - var host = ServiceProvider.TryGetService().ShowHomePane(); - host?.ShowView(new ViewWithData(UIControllerFlow.PullRequestList)); - } - } -} diff --git a/src/GitHub.VisualStudio/Menus/ShowGitHubPane.cs b/src/GitHub.VisualStudio/Menus/ShowGitHubPane.cs deleted file mode 100644 index adf16b139a..0000000000 --- a/src/GitHub.VisualStudio/Menus/ShowGitHubPane.cs +++ /dev/null @@ -1,23 +0,0 @@ -using GitHub.VisualStudio.UI; -using NullGuard; -using System; -using GitHub.Services; - -namespace GitHub.VisualStudio.Menus -{ - public class ShowGitHubPane: MenuBase, IMenuHandler - { - public ShowGitHubPane(IGitHubServiceProvider serviceProvider) - : base(serviceProvider) - { - } - - public Guid Guid => GuidList.guidGitHubCmdSet; - public int CmdId => PkgCmdIDList.showGitHubPaneCommand; - - public void Activate([AllowNull]object data = null) - { - ServiceProvider.TryGetService()?.ShowHomePane(); - } - } -} diff --git a/src/GitHub.VisualStudio/NLog.config b/src/GitHub.VisualStudio/NLog.config deleted file mode 100644 index 465d160f37..0000000000 --- a/src/GitHub.VisualStudio/NLog.config +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.VisualStudio/PkgCmdID.cs b/src/GitHub.VisualStudio/PkgCmdID.cs deleted file mode 100644 index 6f1de4813b..0000000000 --- a/src/GitHub.VisualStudio/PkgCmdID.cs +++ /dev/null @@ -1,20 +0,0 @@ -// PkgCmdID.cs -// MUST match PkgCmdID.h -namespace GitHub.VisualStudio -{ - public static class PkgCmdIDList - { - public const int addConnectionCommand = 0x110; - public const int idGitHubToolbar = 0x1120; - public const int showGitHubPaneCommand = 0x200; - public const int openPullRequestsCommand = 0x201; - public const int backCommand = 0x300; - public const int forwardCommand = 0x301; - public const int refreshCommand = 0x302; - public const int pullRequestCommand = 0x310; - public const int createGistCommand = 0x400; - public const int openLinkCommand = 0x100; - public const int copyLinkCommand = 0x101; - public const int githubCommand = 0x320; - }; -} \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Properties/AssemblyInfo.cs b/src/GitHub.VisualStudio/Properties/AssemblyInfo.cs index 5c65611365..adb135bd5e 100644 --- a/src/GitHub.VisualStudio/Properties/AssemblyInfo.cs +++ b/src/GitHub.VisualStudio/Properties/AssemblyInfo.cs @@ -1,5 +1,7 @@ -using System.Reflection; +using System; +using System.Reflection; using System.Runtime.InteropServices; +using System.Windows.Markup; [assembly: AssemblyTitle("GitHub.VisualStudio")] [assembly: AssemblyDescription("GitHub for Visual Studio VSPackage")] diff --git a/src/GitHub.VisualStudio/Resources.Designer.cs b/src/GitHub.VisualStudio/Resources.Designer.cs deleted file mode 100644 index a224bebbd7..0000000000 --- a/src/GitHub.VisualStudio/Resources.Designer.cs +++ /dev/null @@ -1,603 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace GitHub.VisualStudio { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - public class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GitHub.VisualStudio.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to Invalid authentication code. - /// - public static string authenticationFailedLabelContent { - get { - return ResourceManager.GetString("authenticationFailedLabelContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Try entering the code again or clicking the resend button to get a new authentication code.. - /// - public static string authenticationFailedLabelMessage { - get { - return ResourceManager.GetString("authenticationFailedLabelMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Authentication code sent!. - /// - public static string authenticationSentLabelContent { - get { - return ResourceManager.GetString("authenticationSentLabelContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to If you do not receive the authentication code, contact support@github.com.. - /// - public static string authenticationSentLabelMessage { - get { - return ResourceManager.GetString("authenticationSentLabelMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Powerful collaboration, code review, and code management for open source and private projects.. - /// - public static string BlurbText { - get { - return ResourceManager.GetString("BlurbText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Browse. - /// - public static string browsePathButtonContent { - get { - return ResourceManager.GetString("browsePathButtonContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Cancel. - /// - public static string CancelLink { - get { - return ResourceManager.GetString("CancelLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Clone. - /// - public static string CloneLink { - get { - return ResourceManager.GetString("CloneLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Could not connect to github.com. - /// - public static string couldNotConnectToGitHubText { - get { - return ResourceManager.GetString("couldNotConnectToGitHubText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Could not connect to the server.. - /// - public static string couldNotConnectToTheServerText { - get { - return ResourceManager.GetString("couldNotConnectToTheServerText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Create. - /// - public static string CreateLink { - get { - return ResourceManager.GetString("CreateLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Description (Optional). - /// - public static string descriptionOptionalText { - get { - return ResourceManager.GetString("descriptionOptionalText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Description. - /// - public static string descriptionText { - get { - return ResourceManager.GetString("descriptionText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Don’t have an account? . - /// - public static string dontHaveAnAccountText { - get { - return ResourceManager.GetString("dontHaveAnAccountText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Don’t have GitHub Enterprise? . - /// - public static string dontHaveGitHubEnterpriseText { - get { - return ResourceManager.GetString("dontHaveGitHubEnterpriseText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Please check your internet connection and try again.. - /// - public static string dotComConnectionFailedMessageMessage { - get { - return ResourceManager.GetString("dotComConnectionFailedMessageMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The host isn't available or is not a GitHub Enterprise server. Check the address and try again.. - /// - public static string enterpriseConnectingFailedMessage { - get { - return ResourceManager.GetString("enterpriseConnectingFailedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to GitHub Enterprise server address. - /// - public static string enterpriseUrlPromptText { - get { - return ResourceManager.GetString("enterpriseUrlPromptText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to File Name. - /// - public static string fileNameText { - get { - return ResourceManager.GetString("fileNameText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Search repositories. - /// - public static string filterTextPromptText { - get { - return ResourceManager.GetString("filterTextPromptText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to (forgot your password?). - /// - public static string ForgotPasswordLink { - get { - return ResourceManager.GetString("ForgotPasswordLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Get Started. - /// - public static string GetStartedLink { - get { - return ResourceManager.GetString("GetStartedLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Failed to create gist. - /// - public static string gistCreationFailedMessage { - get { - return ResourceManager.GetString("gistCreationFailedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Connect…. - /// - public static string GitHubInvitationSectionConnectLabel { - get { - return ResourceManager.GetString("GitHubInvitationSectionConnectLabel", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish to GitHub. - /// - public static string GitHubPublishSectionTitle { - get { - return ResourceManager.GetString("GitHubPublishSectionTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Graphs. - /// - public static string GraphsNavigationItemText { - get { - return ResourceManager.GetString("GraphsNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Git ignore. - /// - public static string ignoreTemplateListText { - get { - return ResourceManager.GetString("ignoreTemplateListText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Issues. - /// - public static string IssuesNavigationItemText { - get { - return ResourceManager.GetString("IssuesNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Learn more. - /// - public static string learnMoreLink { - get { - return ResourceManager.GetString("learnMoreLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to License. - /// - public static string licenseListText { - get { - return ResourceManager.GetString("licenseListText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Some or all repositories may not have loaded. Close the dialog and try again.. - /// - public static string loadingFailedMessageContent { - get { - return ResourceManager.GetString("loadingFailedMessageContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to An error occurred while loading repositories. - /// - public static string loadingFailedMessageMessage { - get { - return ResourceManager.GetString("loadingFailedMessageMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Local path. - /// - public static string localPathText { - get { - return ResourceManager.GetString("localPathText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Check your username and password, then try again. - /// - public static string LoginFailedMessage { - get { - return ResourceManager.GetString("LoginFailedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Login failed.. - /// - public static string LoginFailedText { - get { - return ResourceManager.GetString("LoginFailedText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Login. - /// - public static string LoginLink { - get { - return ResourceManager.GetString("LoginLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Private Repository. - /// - public static string makePrivateContent { - get { - return ResourceManager.GetString("makePrivateContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Private Gist. - /// - public static string makePrivateGist { - get { - return ResourceManager.GetString("makePrivateGist", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Name. - /// - public static string nameText { - get { - return ResourceManager.GetString("nameText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to To use this feature you need to sign in again.. - /// - public static string needLogout { - get { - return ResourceManager.GetString("needLogout", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No repositories. - /// - public static string noRepositoriesMessageText { - get { - return ResourceManager.GetString("noRepositoriesMessageText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to You are not logged in to {0}, so certain git operations may fail. [Login now]({1}). - /// - public static string NotLoggedInMessage { - get { - return ResourceManager.GetString("NotLoggedInMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Open the two-factor authentication app on your device to view your authentication code.. - /// - public static string openTwoFactorAuthAppText { - get { - return ResourceManager.GetString("openTwoFactorAuthAppText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to or. - /// - public static string orText { - get { - return ResourceManager.GetString("orText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Password. - /// - public static string PasswordPrompt { - get { - return ResourceManager.GetString("PasswordPrompt", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Path. - /// - public static string pathText { - get { - return ResourceManager.GetString("pathText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Publish. - /// - public static string publishText { - get { - return ResourceManager.GetString("publishText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pull Requests. - /// - public static string PullRequestsNavigationItemText { - get { - return ResourceManager.GetString("PullRequestsNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pulse. - /// - public static string PulseNavigationItemText { - get { - return ResourceManager.GetString("PulseNavigationItemText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to This repository does not have a remote. Fill out the form to publish it to GitHub.. - /// - public static string RepoDoesNotHaveRemoteText { - get { - return ResourceManager.GetString("RepoDoesNotHaveRemoteText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Repository Name. - /// - public static string RepoNameText { - get { - return ResourceManager.GetString("RepoNameText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Repository created successfully.. - /// - public static string RepositoryPublishedMessage { - get { - return ResourceManager.GetString("RepositoryPublishedMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Resend. - /// - public static string resendCodeButtonContent { - get { - return ResourceManager.GetString("resendCodeButtonContent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Send the code to your registered SMS Device again. - /// - public static string resendCodeButtonToolTip { - get { - return ResourceManager.GetString("resendCodeButtonToolTip", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign out. - /// - public static string SignOutLink { - get { - return ResourceManager.GetString("SignOutLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sign up. - /// - public static string SignUpLink { - get { - return ResourceManager.GetString("SignUpLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Two-factor authentication. - /// - public static string twoFactorAuthText { - get { - return ResourceManager.GetString("twoFactorAuthText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Username or email. - /// - public static string UserNameOrEmailPromptText { - get { - return ResourceManager.GetString("UserNameOrEmailPromptText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Verify. - /// - public static string verifyText { - get { - return ResourceManager.GetString("verifyText", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Wiki. - /// - public static string WikiNavigationItemText { - get { - return ResourceManager.GetString("WikiNavigationItemText", resourceCulture); - } - } - } -} diff --git a/src/GitHub.VisualStudio/Resources/icons/clippy.xaml b/src/GitHub.VisualStudio/Resources/icons/clippy.xaml index 5371b68612..0ef4213aa6 100644 --- a/src/GitHub.VisualStudio/Resources/icons/clippy.xaml +++ b/src/GitHub.VisualStudio/Resources/icons/clippy.xaml @@ -1,18 +1,17 @@  - - + - - + + - - + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Resources/icons/link_external.xaml b/src/GitHub.VisualStudio/Resources/icons/link_external.xaml index 8b07baa31a..d013a78fca 100644 --- a/src/GitHub.VisualStudio/Resources/icons/link_external.xaml +++ b/src/GitHub.VisualStudio/Resources/icons/link_external.xaml @@ -1,18 +1,17 @@  - - + - - + + - - + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Resources/icons/mark_github.xaml b/src/GitHub.VisualStudio/Resources/icons/mark_github.xaml index ab7fc054dd..c364f48ea7 100644 --- a/src/GitHub.VisualStudio/Resources/icons/mark_github.xaml +++ b/src/GitHub.VisualStudio/Resources/icons/mark_github.xaml @@ -1,18 +1,19 @@ - + + + + + #424242 + + + + - - - - - - - - - - + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Resources/icons/mark_github_toolbar.xaml b/src/GitHub.VisualStudio/Resources/icons/mark_github_toolbar.xaml index 0ed68d6041..a5e026a248 100644 --- a/src/GitHub.VisualStudio/Resources/icons/mark_github_toolbar.xaml +++ b/src/GitHub.VisualStudio/Resources/icons/mark_github_toolbar.xaml @@ -1,18 +1,17 @@  - - + - - + + - - + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Resources/icons/question.xaml b/src/GitHub.VisualStudio/Resources/icons/question.xaml new file mode 100644 index 0000000000..65ec1298d0 --- /dev/null +++ b/src/GitHub.VisualStudio/Resources/icons/question.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/Resources/logo_32x32@2x.png b/src/GitHub.VisualStudio/Resources/logo_32x32@2x.png deleted file mode 100644 index 1fd18c1c7a..0000000000 Binary files a/src/GitHub.VisualStudio/Resources/logo_32x32@2x.png and /dev/null differ diff --git a/src/GitHub.VisualStudio/Services/ConnectionManager.cs b/src/GitHub.VisualStudio/Services/ConnectionManager.cs index 3f734be929..cdb11865a4 100644 --- a/src/GitHub.VisualStudio/Services/ConnectionManager.cs +++ b/src/GitHub.VisualStudio/Services/ConnectionManager.cs @@ -2,229 +2,232 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel.Composition; -using System.Diagnostics; using System.Linq; -using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Extensions; using GitHub.Models; -using GitHub.Services; using GitHub.Primitives; -using System.Threading.Tasks; +using GitHub.Services; +using GitHubClient = Octokit.GitHubClient; +using IGitHubClient = Octokit.IGitHubClient; +using OauthClient = Octokit.OauthClient; +using User = Octokit.User; namespace GitHub.VisualStudio { - class CacheData - { - public IEnumerable connections; - } - - class ConnectionCacheItem - { - public Uri HostUrl { get; set; } - public string UserName { get; set; } - } - + /// + /// Manages the configured s to GitHub instances. + /// [Export(typeof(IConnectionManager))] - [PartCreationPolicy(CreationPolicy.Shared)] public class ConnectionManager : IConnectionManager { - readonly string cachePath; - readonly IVSGitServices vsGitServices; - const string cacheFile = "ghfvs.connections"; - - public event Func> DoLogin; - - Func fileExists; - Func readAllText; - Action writeAllText; - Action fileDelete; - Func dirExists; - Action dirCreate; + readonly IProgram program; + readonly IConnectionCache cache; + readonly IKeychain keychain; + readonly ILoginManager loginManager; + readonly TaskCompletionSource loaded; + readonly Lazy> connections; + readonly IUsageTracker usageTracker; + readonly IVisualStudioBrowser browser; [ImportingConstructor] - public ConnectionManager(IProgram program, IVSGitServices vsGitServices) + public ConnectionManager( + IProgram program, + IConnectionCache cache, + IKeychain keychain, + ILoginManager loginManager, + IUsageTracker usageTracker, + IVisualStudioBrowser browser) { - this.vsGitServices = vsGitServices; - fileExists = (path) => System.IO.File.Exists(path); - readAllText = (path, encoding) => System.IO.File.ReadAllText(path, encoding); - writeAllText = (path, content) => System.IO.File.WriteAllText(path, content); - fileDelete = (path) => System.IO.File.Delete(path); - dirExists = (path) => System.IO.Directory.Exists(path); - dirCreate = (path) => System.IO.Directory.CreateDirectory(path); - - Connections = new ObservableCollection(); - cachePath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - program.ApplicationName, - cacheFile); - - LoadConnectionsFromCache(); - - Connections.CollectionChanged += RefreshConnections; + this.program = program; + this.cache = cache; + this.keychain = keychain; + this.loginManager = loginManager; + this.usageTracker = usageTracker; + this.browser = browser; + loaded = new TaskCompletionSource(); + connections = new Lazy>( + this.CreateConnections, + LazyThreadSafetyMode.ExecutionAndPublication); } - public ConnectionManager(IProgram program, Rothko.IOperatingSystem os, IVSGitServices vsGitServices) - { - this.vsGitServices = vsGitServices; - fileExists = (path) => os.File.Exists(path); - readAllText = (path, encoding) => os.File.ReadAllText(path, encoding); - writeAllText = (path, content) => os.File.WriteAllText(path, content); - fileDelete = (path) => os.File.Delete(path); - dirExists = (path) => os.Directory.Exists(path); - dirCreate = (path) => os.Directory.CreateDirectory(path); - - Connections = new ObservableCollection(); - cachePath = System.IO.Path.Combine( - os.Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - program.ApplicationName, - cacheFile); - - LoadConnectionsFromCache(); - - Connections.CollectionChanged += RefreshConnections; - } + /// + public IReadOnlyObservableCollection Connections => connections.Value; - public IConnection CreateConnection(HostAddress address, string username) + /// + public async Task GetConnection(HostAddress address) { - return SetupConnection(address, username); + return (await GetLoadedConnections()).FirstOrDefault(x => x.HostAddress == address); } - public bool AddConnection(HostAddress address, string username) + /// + public async Task> GetLoadedConnections() { - if (Connections.FirstOrDefault(x => x.HostAddress.Equals(address)) != null) - return false; - Connections.Add(SetupConnection(address, username)); - return true; + return await GetLoadedConnectionsInternal(); } - void AddConnection(Uri hostUrl, string username) + /// + public async Task LogIn(HostAddress address, string userName, string password) { - var address = HostAddress.Create(hostUrl); - if (Connections.FirstOrDefault(x => x.HostAddress.Equals(address)) != null) - return; - var conn = SetupConnection(address, username); - Connections.Add(conn); - } + var conns = await GetLoadedConnectionsInternal(); - public bool RemoveConnection(HostAddress address) - { - var c = Connections.FirstOrDefault(x => x.HostAddress.Equals(address)); - if (c == null) - return false; - RequestLogout(c); - return true; - } + if (conns.Any(x => x.HostAddress == address)) + { + throw new InvalidOperationException($"A connection to {address.Title} already exists."); + } - public IObservable RequestLogin(IConnection connection) - { - var handler = DoLogin; - if (handler == null) - return null; - return handler(connection); - } + var client = CreateClient(address); + var login = await loginManager.Login(address, client, userName, password); + var connection = new Connection(address, login.User, login.Scopes); - public void RequestLogout(IConnection connection) - { - Connections.Remove(connection); + conns.Add(connection); + await SaveConnections(); + await usageTracker.IncrementCounter(x => x.NumberOfLogins); + return connection; } - public async Task RefreshRepositories() + /// + public async Task LogInViaOAuth(HostAddress address, CancellationToken cancel) { - var list = await Task.Run(() => vsGitServices.GetKnownRepositories()); - list.GroupBy(r => Connections.FirstOrDefault(c => r.CloneUrl != null && c.HostAddress.Equals(HostAddress.Create(r.CloneUrl)))) - .Where(g => g.Key != null) - .ForEach(g => + var conns = await GetLoadedConnectionsInternal(); + + if (conns.Any(x => x.HostAddress == address)) { - var repos = g.Key.Repositories; - repos.Except(g).ToList().ForEach(c => repos.Remove(c)); - g.Except(repos).ToList().ForEach(c => repos.Add(c)); - }); - } + throw new InvalidOperationException($"A connection to {address} already exists."); + } - IConnection SetupConnection(HostAddress address, string username) - { - var conn = new Connection(this, address, username); - return conn; + var client = CreateClient(address); + var oauthClient = new OauthClient(client.Connection); + var login = await loginManager.LoginViaOAuth(address, client, oauthClient, OpenBrowser, cancel); + var connection = new Connection(address, login.User, login.Scopes); + + conns.Add(connection); + await SaveConnections(); + await usageTracker.IncrementCounter(x => x.NumberOfLogins); + await usageTracker.IncrementCounter(x => x.NumberOfOAuthLogins); + return connection; } - void RefreshConnections(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + /// + public async Task LogInWithToken(HostAddress address, string token) { - if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Remove) + var conns = await GetLoadedConnectionsInternal(); + + if (conns.Any(x => x.HostAddress == address)) { - foreach (IConnection c in e.OldItems) - { - // RepositoryHosts hasn't been loaded so it can't handle logging out, we have to do it ourselves - if (DoLogin == null) - Api.SimpleCredentialStore.RemoveCredentials(c.HostAddress.CredentialCacheKeyHost); - c.Dispose(); - } + throw new InvalidOperationException($"A connection to {address.Title} already exists."); } - SaveConnectionsToCache(); + + var client = CreateClient(address); + var login = await loginManager.LoginWithToken(address, client, token); + var connection = new Connection(address, login.User, login.Scopes); + + conns.Add(connection); + await SaveConnections(); + await usageTracker.IncrementCounter(x => x.NumberOfLogins); + await usageTracker.IncrementCounter(x => x.NumberOfTokenLogins); + return connection; } - void LoadConnectionsFromCache() + /// + public async Task LogOut(HostAddress address) { - EnsureCachePath(); + var connection = await GetConnection(address); - if (!fileExists(cachePath)) - return; + if (connection == null) + { + throw new KeyNotFoundException($"Could not find a connection to {address.Title}."); + } + + var client = CreateClient(address); + await loginManager.Logout(address, client); + connections.Value.Remove(connection); + await SaveConnections(); + } - string data = readAllText(cachePath, Encoding.UTF8); + /// + public async Task Retry(IConnection connection) + { + var c = (Connection)connection; + c.SetLoggingIn(); - CacheData cacheData; try { - cacheData = SimpleJson.DeserializeObject(data); + var client = CreateClient(c.HostAddress); + var login = await loginManager.LoginFromCache(connection.HostAddress, client); + c.SetSuccess(login.User, login.Scopes); + await usageTracker.IncrementCounter(x => x.NumberOfLogins); } - catch + catch (Exception e) { - cacheData = null; + c.SetError(e); } + } - if (cacheData == null || cacheData.connections == null) - { - // cache is corrupt, remove - fileDelete(cachePath); - return; - } + ObservableCollectionEx CreateConnections() + { + var result = new ObservableCollectionEx(); + LoadConnections(result).Forget(); + return result; + } - cacheData.connections.ForEach(c => - { - if (c.HostUrl != null) - AddConnection(c.HostUrl, c.UserName); - }); + IGitHubClient CreateClient(HostAddress address) + { + return new GitHubClient( + program.ProductHeader, + new KeychainCredentialStore(keychain, address), + address.ApiUri); } - void SaveConnectionsToCache() + async Task> GetLoadedConnectionsInternal() { - EnsureCachePath(); + var result = Connections; + await loaded.Task; + return connections.Value; + } - var cache = new CacheData(); - cache.connections = Connections.Select(conn => - new ConnectionCacheItem - { - HostUrl = conn.HostAddress.WebUri, - UserName = conn.Username, - }); + async Task LoadConnections(ObservableCollection result) + { try { - string data = SimpleJson.SerializeObject(cache); - writeAllText(cachePath, data); + foreach (var c in await cache.Load()) + { + var connection = new Connection(c.HostAddress, c.UserName); + result.Add(connection); + } + + foreach (Connection connection in result) + { + var client = CreateClient(connection.HostAddress); + LoginResult login = null; + + try + { + login = await loginManager.LoginFromCache(connection.HostAddress, client); + connection.SetSuccess(login.User, login.Scopes); + await usageTracker.IncrementCounter(x => x.NumberOfLogins); + } + catch (Exception e) + { + connection.SetError(e); + } + } } - catch (Exception ex) + finally { - Debug.Fail(ex.ToString()); + loaded.SetResult(null); } } - void EnsureCachePath() + async Task SaveConnections() { - if (fileExists(cachePath)) - return; - var di = System.IO.Path.GetDirectoryName(cachePath); - if (!dirExists(di)) - dirCreate(di); + var conns = await GetLoadedConnectionsInternal(); + var details = conns.Select(x => new ConnectionDetails(x.HostAddress, x.Username)); + await cache.Save(details); } - public ObservableCollection Connections { get; private set; } + void OpenBrowser(Uri uri) => browser.OpenUrl(uri); } } diff --git a/src/GitHub.VisualStudio/Services/GitHubServiceProvider.cs b/src/GitHub.VisualStudio/Services/GitHubServiceProvider.cs index 5276fd5d5d..947b5f645b 100644 --- a/src/GitHub.VisualStudio/Services/GitHubServiceProvider.cs +++ b/src/GitHub.VisualStudio/Services/GitHubServiceProvider.cs @@ -7,95 +7,38 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using GitHub.Infrastructure; +using GitHub.Logging; using GitHub.Models; +using GitHub.Exports; using GitHub.Services; -using GitHub.UI; using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.Shell; -using NLog; -using NullGuard; using Task = System.Threading.Tasks.Task; using System.Threading.Tasks; +using GitHub.Extensions; +using Serilog; +using Log = GitHub.Logging.Log; namespace GitHub.VisualStudio { - /// - /// This is a thin MEF wrapper around the GitHubServiceProvider - /// which is registered as a global VS service. This class just - /// redirects every request to the actual service, and can be - /// thrown away as soon as the caller is done (no state is kept) - /// - [Export(typeof(IGitHubServiceProvider))] - [PartCreationPolicy(CreationPolicy.NonShared)] - [NullGuard(ValidationFlags.None)] - public class GitHubProviderDispatcher : IGitHubServiceProvider - { - readonly IGitHubServiceProvider theRealProvider; - - [ImportingConstructor] - public GitHubProviderDispatcher([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider) - { - theRealProvider = serviceProvider.GetService(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; - } - - public ExportProvider ExportProvider => theRealProvider.ExportProvider; - - public IServiceProvider GitServiceProvider - { - get - { - return theRealProvider.GitServiceProvider; - } - - set - { - theRealProvider.GitServiceProvider = value; - } - } - - public void AddService(Type t, object owner, object instance) => theRealProvider.AddService(t, owner, instance); - - public void AddService(object owner, T instance) where T : class => theRealProvider.AddService(owner, instance); - - public T GetService() where T : class => theRealProvider.GetService(); - - public object GetService(Type serviceType) => theRealProvider.GetService(serviceType); - - public Ret GetService() where T : class where Ret : class => theRealProvider.GetService(); - - public void RemoveService(Type t, object owner) => theRealProvider.RemoveService(t, owner); - - public object TryGetService(string typename) => theRealProvider.TryGetService(typename); - - public object TryGetService(Type t) => theRealProvider.TryGetService(t); - - public T TryGetService() where T : class => theRealProvider.TryGetService(); - } - /// /// This is a globally registered service (see `GitHubPackage`). /// If you need to access this service via MEF, use the `IGitHubServiceProvider` type /// public class GitHubServiceProvider : IGitHubServiceProvider, IDisposable { - public static IGitHubServiceProvider Instance => Package.GetGlobalService(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; - class OwnedComposablePart { public object Owner { get; set; } public ComposablePart Part { get; set; } } - static readonly Logger log = LogManager.GetCurrentClassLogger(); - CompositeDisposable disposables = new CompositeDisposable(); + static readonly ILogger log = LogManager.ForContext(); readonly IServiceProviderPackage asyncServiceProvider; readonly IServiceProvider syncServiceProvider; readonly Dictionary tempParts; readonly Version currentVersion; - bool initializingLogging = false; + List disposables = new List(); bool initialized = false; public ExportProvider ExportProvider { get; private set; } @@ -116,11 +59,13 @@ CompositionContainer TempContainer } } - [AllowNull] public IServiceProvider GitServiceProvider { get; set; } public GitHubServiceProvider(IServiceProviderPackage asyncServiceProvider, IServiceProvider syncServiceProvider) { + Guard.ArgumentNotNull(asyncServiceProvider, nameof(asyncServiceProvider)); + Guard.ArgumentNotNull(syncServiceProvider, nameof(syncServiceProvider)); + this.currentVersion = this.GetType().Assembly.GetName().Version; this.asyncServiceProvider = asyncServiceProvider; this.syncServiceProvider = syncServiceProvider; @@ -132,7 +77,7 @@ public async Task Initialize() { IComponentModel componentModel = await asyncServiceProvider.GetServiceAsync(typeof(SComponentModel)) as IComponentModel; - Debug.Assert(componentModel != null, "Service of type SComponentModel not found"); + Log.Assert(componentModel != null, "Service of type SComponentModel not found"); if (componentModel == null) { log.Error("Service of type SComponentModel not found"); @@ -142,41 +87,31 @@ public async Task Initialize() ExportProvider = componentModel.DefaultExportProvider; if (ExportProvider == null) { - log.Error("DefaultExportProvider could not be obtained."); + log.Error("DefaultExportProvider could not be obtained"); } initialized = true; } - [return: AllowNull] public object TryGetService(Type serviceType) { - if (!initializingLogging && log.Factory.Configuration == null) - { - initializingLogging = true; - try - { - var logging = TryGetService(typeof(ILoggingConfiguration)) as ILoggingConfiguration; - logging.Configure(); - } - catch - { -#if DEBUG - throw; -#endif - } - } - - string contract = AttributedModelServices.GetContractName(serviceType); + var contract = AttributedModelServices.GetContractName(serviceType); var instance = AddToDisposables(TempContainer.GetExportedValueOrDefault(contract)); if (instance != null) return instance; var sp = initialized ? syncServiceProvider : asyncServiceProvider; - instance = sp.GetService(serviceType); - if (instance != null) - return instance; + try + { + instance = sp.GetService(serviceType); + if (instance != null) + return instance; + } + catch (Exception ex) + { + log.Error(ex, "Error loading {ServiceType}", serviceType); + } instance = AddToDisposables(ExportProvider.GetExportedValues(contract).FirstOrDefault(x => contract.StartsWith("github.", StringComparison.OrdinalIgnoreCase) ? x.GetType().Assembly.GetName().Version == currentVersion : true)); @@ -190,15 +125,18 @@ public object TryGetService(Type serviceType) return null; } - [return: AllowNull] public object TryGetService(string typename) { + Guard.ArgumentNotEmptyString(typename, nameof(typename)); + var type = Type.GetType(typename, false, true); return TryGetService(type); } public object GetService(Type serviceType) { + Guard.ArgumentNotNull(serviceType, nameof(serviceType)); + var instance = TryGetService(serviceType); if (instance != null) return instance; @@ -213,14 +151,12 @@ public T GetService() where T : class return (T)GetService(typeof(T)); } - [return: AllowNull] public T TryGetService() where T : class { return TryGetService(typeof(T)) as T; } [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter")] - [return: AllowNull] public Ret GetService() where T : class where Ret : class { @@ -229,20 +165,36 @@ public Ret GetService() where T : class public void AddService(object owner, T instance) where T : class { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(instance, nameof(instance)); + AddService(typeof(T), owner, instance); } public void AddService(Type t, object owner, object instance) { + Guard.ArgumentNotNull(t, nameof(t)); + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(instance, nameof(instance)); + string contract = AttributedModelServices.GetContractName(t); - Debug.Assert(!string.IsNullOrEmpty(contract), "Every type must have a contract name"); + + if (string.IsNullOrEmpty(contract)) + { + throw new GitHubLogicException("Every type must have a contract name"); + } // we want to remove stale instances of a service, if they exist, regardless of who put them there RemoveService(t, null); var batch = new CompositionBatch(); var part = batch.AddExportedValue(contract, instance); - Debug.Assert(part != null, "Adding an exported value must return a non-null part"); + + if (part == null) + { + throw new GitHubLogicException("Adding an exported value must return a non-null part"); + } + tempParts.Add(contract, new OwnedComposablePart { Owner = owner, Part = part }); TempContainer.Compose(batch); } @@ -253,10 +205,12 @@ public void AddService(Type t, object owner, object instance) /// The type we want to remove /// The owner, which either has to match what was passed to AddService, /// or if it's null, the service will be removed without checking for ownership - public void RemoveService(Type t, [AllowNull] object owner) + public void RemoveService(Type t, object owner) { + Guard.ArgumentNotNull(t, nameof(t)); + string contract = AttributedModelServices.GetContractName(t); - Debug.Assert(!string.IsNullOrEmpty(contract), "Every type must have a contract name"); + Log.Assert(!string.IsNullOrEmpty(contract), "Every type must have a contract name"); OwnedComposablePart part; if (tempParts.TryGetValue(contract, out part)) @@ -288,7 +242,13 @@ protected void Dispose(bool disposing) if (disposed) return; if (disposables != null) - disposables.Dispose(); + { + foreach (var disposable in disposables) + { + disposable.Dispose(); + } + } + disposables = null; if (tempContainer != null) tempContainer.Dispose(); diff --git a/src/GitHub.VisualStudio/Services/JsonConnectionCache.cs b/src/GitHub.VisualStudio/Services/JsonConnectionCache.cs new file mode 100644 index 0000000000..2e0f900223 --- /dev/null +++ b/src/GitHub.VisualStudio/Services/JsonConnectionCache.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using Rothko; +using Serilog; + +namespace GitHub.VisualStudio +{ + /// + /// Loads and saves the configured connections from/to a .json file. + /// + [Export(typeof(IConnectionCache))] + public class JsonConnectionCache : IConnectionCache + { + static readonly ILogger log = LogManager.ForContext(); + const string DefaultCacheFile = "ghfvs.connections"; + readonly string cachePath; + readonly IOperatingSystem os; + + [ImportingConstructor] + public JsonConnectionCache(IProgram program, IOperatingSystem os) + : this(program, os, DefaultCacheFile) + { + } + + public JsonConnectionCache(IProgram program, IOperatingSystem os, string cacheFile) + { + this.os = os; + cachePath = Path.Combine( + os.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData), + program.ApplicationName, + cacheFile); + } + + public Task> Load() + { + if (os.File.Exists(cachePath)) + { + try + { + // TODO: Need a ReadAllTextAsync method here. + var data = os.File.ReadAllText(cachePath, Encoding.UTF8); + var result = SimpleJson.DeserializeObject(data); + return Task.FromResult(result.connections.Select(FromCache)); + } + catch (Exception e) + { + try + { + os.File.Delete(cachePath); + } + catch { } + + log.Error(e, "Failed to read connection cache from {Path}", cachePath); + } + } + + return Task.FromResult(Enumerable.Empty()); + } + + public Task Save(IEnumerable connections) + { + var data = SimpleJson.SerializeObject(new CacheData + { + connections = connections.Select(ToCache).ToList(), + }); + + try + { + os.File.WriteAllText(cachePath, data); + } + catch (Exception e) + { + log.Error(e, "Failed to write connection cache to {Path}", cachePath); + } + + return Task.CompletedTask; + } + + static ConnectionDetails FromCache(ConnectionCacheItem c) + { + return new ConnectionDetails(HostAddress.Create(c.HostUrl), c.UserName); + } + + static ConnectionCacheItem ToCache(ConnectionDetails c) + { + return new ConnectionCacheItem + { + HostUrl = c.HostAddress.WebUri, + UserName = c.UserName, + }; + } + + class CacheData + { + public IEnumerable connections { get; set; } + } + + class ConnectionCacheItem + { + public Uri HostUrl { get; set; } + public string UserName { get; set; } + } + } +} diff --git a/src/GitHub.VisualStudio/Services/LocalRepositories.cs b/src/GitHub.VisualStudio/Services/LocalRepositories.cs new file mode 100644 index 0000000000..8d0a50db80 --- /dev/null +++ b/src/GitHub.VisualStudio/Services/LocalRepositories.cs @@ -0,0 +1,51 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.Services +{ + /// + /// Stores a collection of known local repositories. + /// + /// + /// The list of repositories exposed here is the list of local repositories known to Team + /// Explorer. + /// + [Export(typeof(ILocalRepositories))] + public class LocalRepositories : ILocalRepositories + { + readonly IVSGitServices vsGitServices; + + [ImportingConstructor] + public LocalRepositories(IVSGitServices vsGitServices, [Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) + { + this.vsGitServices = vsGitServices; + JoinableTaskContext = joinableTaskContext ?? ThreadHelper.JoinableTaskContext; + } + + /// + public async Task Refresh() + { + await TaskScheduler.Default; + var list = vsGitServices.GetKnownRepositories(); + await JoinableTaskContext.Factory.SwitchToMainThreadAsync(); + + repositories.Except(list).ToList().ForEach(x => repositories.Remove(x)); + list.Except(repositories).ToList().ForEach(x => repositories.Add(x)); + } + + readonly ObservableCollectionEx repositories + = new ObservableCollectionEx(); + + /// + public IReadOnlyObservableCollection Repositories => repositories; + + JoinableTaskContext JoinableTaskContext { get; } + } +} diff --git a/src/GitHub.VisualStudio/Services/ShowDialogService.cs b/src/GitHub.VisualStudio/Services/ShowDialogService.cs new file mode 100644 index 0000000000..12f6725467 --- /dev/null +++ b/src/GitHub.VisualStudio/Services/ShowDialogService.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using GitHub.VisualStudio.Views.Dialog; + +namespace GitHub.VisualStudio.UI.Services +{ + [Export(typeof(IShowDialogService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class ShowDialogService : IShowDialogService + { + readonly IGitHubServiceProvider serviceProvider; + + [ImportingConstructor] + public ShowDialogService(IGitHubServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public Task Show(IDialogContentViewModel viewModel) + { + var result = default(object); + + using (var dialogViewModel = CreateViewModel()) + using (dialogViewModel.Done.Take(1).Subscribe(x => result = x)) + { + dialogViewModel.Start(viewModel); + + var window = new GitHubDialogWindow(dialogViewModel); + window.ShowModal(); + } + + return Task.FromResult(result); + } + + public async Task Show( + TViewModel viewModel, + IConnection connection, + IEnumerable scopes) + where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel + { + var result = default(object); + + using (var dialogViewModel = CreateViewModel()) + using (dialogViewModel.Done.Take(1).Subscribe(x => result = x)) + { + if (!connection.Scopes.Matches(scopes)) + { + await dialogViewModel.StartWithLogout(viewModel, connection); + } + else + { + await viewModel.InitializeAsync(connection); + dialogViewModel.Start(viewModel); + } + + var window = new GitHubDialogWindow(dialogViewModel); + window.ShowModal(); + } + + return result; + } + + public async Task ShowWithFirstConnection(TViewModel viewModel) + where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel + { + var result = default(object); + + using (var dialogViewModel = CreateViewModel()) + using (dialogViewModel.Done.Take(1).Subscribe(x => result = x)) + { + var task = dialogViewModel.StartWithConnection(viewModel); + var window = new GitHubDialogWindow(dialogViewModel); + window.ShowModal(); + await task; + } + + return result; + } + + IGitHubDialogWindowViewModel CreateViewModel() + { + return serviceProvider.GetService(); + } + } +} diff --git a/src/GitHub.VisualStudio/Services/UIProvider.cs b/src/GitHub.VisualStudio/Services/UIProvider.cs deleted file mode 100644 index 1ff9cb7f26..0000000000 --- a/src/GitHub.VisualStudio/Services/UIProvider.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading.Tasks; -using System.Windows; -using GitHub.Extensions; -using GitHub.Helpers; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using NLog; -using NullGuard; -using ReactiveUI; -using GitHub.App.Factories; -using GitHub.Exports; -using GitHub.Controllers; - -namespace GitHub.VisualStudio.UI -{ - [Export(typeof(IUIProvider))] - [PartCreationPolicy(CreationPolicy.NonShared)] - [NullGuard(ValidationFlags.None)] - public class UIProviderDispatcher : IUIProvider - { - readonly IUIProvider theRealProvider; - - [ImportingConstructor] - public UIProviderDispatcher([Import(typeof(Microsoft.VisualStudio.Shell.SVsServiceProvider))] IServiceProvider serviceProvider) - { - theRealProvider = serviceProvider.GetServiceSafe(); - } - - public IUIController Configure(UIControllerFlow flow, IConnection connection = null, ViewWithData data = null) => theRealProvider.Configure(flow, connection, data); - - public IView GetView(UIViewType which, ViewWithData data = null) => theRealProvider.GetView(which, data); - - public void Run(IUIController controller) => theRealProvider.Run(controller); - - public IUIController Run(UIControllerFlow flow) => theRealProvider.Run(flow); - - public void RunInDialog(IUIController controller) => theRealProvider.RunInDialog(controller); - - public void RunInDialog(UIControllerFlow flow, IConnection connection = null) => theRealProvider.RunInDialog(flow, connection); - - public void StopUI(IUIController controller) => theRealProvider.StopUI(controller); - } - - [NullGuard(ValidationFlags.None)] - public class UIProvider : IUIProvider, IDisposable - { - static readonly Logger log = LogManager.GetCurrentClassLogger(); - - WindowController windowController; - - readonly CompositeDisposable disposables = new CompositeDisposable(); - - readonly IGitHubServiceProvider serviceProvider; - - public UIProvider(IGitHubServiceProvider serviceProvider) - { - this.serviceProvider = serviceProvider; - } - - public IView GetView(UIViewType which, ViewWithData data = null) - { - var uiFactory = serviceProvider.GetService(); - var pair = uiFactory.CreateViewAndViewModel(which); - pair.ViewModel.Initialize(data); - pair.View.DataContext = pair.ViewModel; - return pair.View; - } - - public IUIController Configure(UIControllerFlow flow, IConnection connection = null, ViewWithData data = null) - { - var controller = new UIController(serviceProvider); - disposables.Add(controller); - var listener = controller.Configure(flow, connection, data).Publish().RefCount(); - - listener.Subscribe(_ => { }, () => - { - StopUI(controller); - }); - - // if the flow is authentication, we need to show the login dialog. and we can't - // block the main thread on the subscriber, it'll block other handlers, so we're doing - // this on a separate thread and posting the dialog to the main thread - listener - .Where(c => c.Flow == UIControllerFlow.Authentication) - .ObserveOn(RxApp.TaskpoolScheduler) - .Subscribe(c => - { - // nothing to do, we already have a dialog - if (windowController != null) - return; - RunModalDialogForAuthentication(c.Flow, listener, c).Forget(); - }); - - return controller; - } - - public IUIController Run(UIControllerFlow flow) - { - var controller = Configure(flow); - controller.Start(); - return controller; - } - - public void Run(IUIController controller) - { - controller.Start(); - } - - public void RunInDialog(UIControllerFlow flow, IConnection connection = null) - { - var controller = Configure(flow, connection); - RunInDialog(controller); - } - - public void RunInDialog(IUIController controller) - { - var listener = controller.TransitionSignal; - - windowController = new UI.WindowController(listener); - windowController.WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner; - EventHandler stopUIAction = (s, e) => - { - StopUI(controller); - }; - windowController.Closed += stopUIAction; - listener.Subscribe(_ => { }, () => - { - windowController.Closed -= stopUIAction; - windowController.Close(); - StopUI(controller); - }); - - controller.Start(); - windowController.ShowModal(); - windowController = null; - } - - public void StopUI(IUIController controller) - { - try - { - if (!controller.IsStopped) - controller.Stop(); - disposables.Remove(controller); - } - catch (Exception ex) - { - log.Error("Failed to dispose UI. {0}", ex); - } - } - - async Task RunModalDialogForAuthentication(UIControllerFlow flow, IObservable listener, LoadData initiaLoadData) - { - await ThreadingHelper.SwitchToMainThreadAsync(); - windowController = new WindowController(listener, - (v, f) => f == flow, - (v, f) => f != flow); - windowController.WindowStartupLocation = WindowStartupLocation.CenterOwner; - windowController.Load(initiaLoadData.View); - windowController.ShowModal(); - windowController = null; - } - - bool disposed; - protected void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - - if (disposables != null) - disposables.Dispose(); - disposed = true; - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.VisualStudio/Services/UsageService.cs b/src/GitHub.VisualStudio/Services/UsageService.cs new file mode 100644 index 0000000000..55356b9642 --- /dev/null +++ b/src/GitHub.VisualStudio/Services/UsageService.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Logging; +using GitHub.Models; +using Serilog; +using Rothko; +using Microsoft.VisualStudio.Threading; +using Microsoft.VisualStudio.Shell; +using Environment = System.Environment; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.Services +{ + [Export(typeof(IUsageService))] + public sealed class UsageService : IUsageService, IDisposable + { + const string StoreFileName = "metrics.json"; + const string UserStoreFileName = "user.json"; + static readonly ILogger log = LogManager.ForContext(); + + readonly IGitHubServiceProvider serviceProvider; + readonly IEnvironment environment; + readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1); + + string storePath; + string userStorePath; + Guid? userGuid; + + [ImportingConstructor] + public UsageService(IGitHubServiceProvider serviceProvider, IEnvironment environment, + [Import(AllowDefault = true)] JoinableTaskContext joinableTaskContext) + { + this.serviceProvider = serviceProvider; + this.environment = environment; + JoinableTaskContext = joinableTaskContext ?? ThreadHelper.JoinableTaskContext; + } + + public void Dispose() + { + semaphoreSlim.Dispose(); + } + + public async Task GetUserGuid() + { + await Initialize(); + + if (!userGuid.HasValue) + { + try + { + if (File.Exists(userStorePath)) + { + var json = await ReadAllTextAsync(userStorePath); + var data = SimpleJson.DeserializeObject(json); + userGuid = data.UserGuid; + } + } + catch (Exception ex) + { + log.Error(ex, "Failed reading user metrics GUID"); + } + } + + if (!userGuid.HasValue || userGuid.Value == Guid.Empty) + { + userGuid = Guid.NewGuid(); + + try + { + var data = new UserData { UserGuid = userGuid.Value }; + var json = SimpleJson.SerializeObject(data); + await WriteAllTextAsync(userStorePath, json); + } + catch (Exception ex) + { + log.Error(ex, "Failed writing user metrics GUID"); + } + } + + return userGuid.Value; + } + + public IDisposable StartTimer(Func callback, TimeSpan dueTime, TimeSpan period) + { + return new Timer( +#pragma warning disable VSTHRD101 // Avoid unsupported async delegates + async _ => + { + try { await callback(); } + catch (Exception ex) { log.Warning(ex, "Failed submitting usage data"); } + }, +#pragma warning restore VSTHRD101 // Avoid unsupported async delegates + null, + dueTime, + period); + } + + public async Task ReadLocalData() + { + await Initialize(); + + var json = File.Exists(storePath) ? await ReadAllTextAsync(storePath) : null; + + try + { + return json != null ? + SimpleJson.DeserializeObject(json) : + new UsageData { Reports = new List() }; + } + catch (Exception ex) + { + log.Error(ex, "Error deserializing usage"); + return new UsageData { Reports = new List() }; + } + } + + public async Task WriteLocalData(UsageData data) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(storePath)); + var json = SimpleJson.SerializeObject(data); + + await WriteAllTextAsync(storePath, json); + } + catch (Exception ex) + { + log.Error(ex, "Failed to write usage data"); + } + } + + async Task Initialize() + { + if (storePath == null) + { + await JoinableTaskContext.Factory.SwitchToMainThreadAsync(); + + var program = serviceProvider.GetService(); + + var localApplicationDataPath = environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + storePath = Path.Combine(localApplicationDataPath, program.ApplicationName, StoreFileName); + userStorePath = Path.Combine(localApplicationDataPath, program.ApplicationName, UserStoreFileName); + } + } + + async Task ReadAllTextAsync(string path) + { + // Avoid IOException when metrics updated multiple times in quick succession + await semaphoreSlim.WaitAsync(); + try + { + using (var s = File.OpenRead(path)) + using (var r = new StreamReader(s, Encoding.UTF8)) + { + return await r.ReadToEndAsync(); + } + } + finally + { + semaphoreSlim.Release(); + } + } + + async Task WriteAllTextAsync(string path, string text) + { + // Avoid IOException when metrics updated multiple times in quick succession + await semaphoreSlim.WaitAsync(); + try + { + using (var s = new FileStream(path, FileMode.Create)) + using (var w = new StreamWriter(s, Encoding.UTF8)) + { + await w.WriteAsync(text); + } + } + finally + { + semaphoreSlim.Release(); + } + } + + class UserData + { + public Guid UserGuid { get; set; } + } + + JoinableTaskContext JoinableTaskContext { get; } + } +} diff --git a/src/GitHub.VisualStudio/Services/UsageTracker.cs b/src/GitHub.VisualStudio/Services/UsageTracker.cs index a9567a24f0..5295d2aa65 100644 --- a/src/GitHub.VisualStudio/Services/UsageTracker.cs +++ b/src/GitHub.VisualStudio/Services/UsageTracker.cs @@ -1,346 +1,184 @@ using System; -using System.ComponentModel.Composition; -using System.Diagnostics; using System.Globalization; using System.Linq; -using System.Text; -using System.Windows.Threading; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; +using GitHub.Logging; using GitHub.Models; using GitHub.Settings; +using Microsoft.VisualStudio.Threading; +using Serilog; using Task = System.Threading.Tasks.Task; -using GitHub.Extensions; -using System.Threading.Tasks; -using GitHub.Helpers; namespace GitHub.Services { - public class UsageTracker : IUsageTracker + public sealed class UsageTracker : IUsageTracker, IDisposable { - const string StoreFileName = "ghfvs.usage"; - static readonly Calendar cal = CultureInfo.InvariantCulture.Calendar; - + static readonly ILogger log = LogManager.ForContext(); readonly IGitHubServiceProvider gitHubServiceProvider; - readonly DispatcherTimer timer; + bool initialized; IMetricsService client; - IConnectionManager connectionManager; - IPackageSettings userSettings; + readonly IUsageService service; + Lazy connectionManager; + readonly IPackageSettings userSettings; + readonly bool vsTelemetry; IVSServices vsservices; - string storePath; - bool firstRun = true; + IUsageTracker visualStudioUsageTracker; + IDisposable timer; + bool firstTick = true; - Func fileExists; - Func readAllText; - Action writeAllText; - Action dirCreate; - - [ImportingConstructor] - public UsageTracker(IGitHubServiceProvider gitHubServiceProvider) + public UsageTracker( + IGitHubServiceProvider gitHubServiceProvider, + IUsageService service, + IPackageSettings settings, + JoinableTaskContext joinableTaskContext, + bool vsTelemetry) { this.gitHubServiceProvider = gitHubServiceProvider; + this.service = service; + this.userSettings = settings; + JoinableTaskContext = joinableTaskContext; + this.vsTelemetry = vsTelemetry; - fileExists = (path) => System.IO.File.Exists(path); - readAllText = (path, encoding) => - { - try - { - return System.IO.File.ReadAllText(path, encoding); - } - catch - { - return null; - } - }; - writeAllText = (path, content, encoding) => - { - try - { - System.IO.File.WriteAllText(path, content, encoding); - } - catch {} - }; - dirCreate = (path) => System.IO.Directory.CreateDirectory(path); - - this.timer = new DispatcherTimer( - TimeSpan.FromMinutes(3), - DispatcherPriority.Background, - TimerTick, - ThreadingHelper.MainThreadDispatcher); - - RunTimer(); - } - - public async Task IncrementLaunchCount() - { - var usage = await LoadUsage(); - ++usage.Model.NumberOfStartups; - ++usage.Model.NumberOfStartupsWeek; - ++usage.Model.NumberOfStartupsMonth; - SaveUsage(usage); - } - - public async Task IncrementCloneCount() - { - var usage = await LoadUsage(); - ++usage.Model.NumberOfClones; - SaveUsage(usage); - } - - public async Task IncrementCreateCount() - { - var usage = await LoadUsage(); - ++usage.Model.NumberOfReposCreated; - SaveUsage(usage); + timer = StartTimer(); } - public async Task IncrementPublishCount() + public void Dispose() { - var usage = await LoadUsage(); - ++usage.Model.NumberOfReposPublished; - SaveUsage(usage); + timer?.Dispose(); } - public async Task IncrementOpenInGitHubCount() + public async Task IncrementCounter(Expression> counter) { - var usage = await LoadUsage(); - ++usage.Model.NumberOfOpenInGitHub; - SaveUsage(usage); - } + await Initialize(); - public async Task IncrementLinkToGitHubCount() - { - var usage = await LoadUsage(); - ++usage.Model.NumberOfLinkToGitHub; - SaveUsage(usage); - } + var property = (MemberExpression)counter.Body; + var propertyInfo = (PropertyInfo)property.Member; + var counterName = propertyInfo.Name; + log.Verbose("Increment counter {Name}", counterName); - public async Task IncrementCreateGistCount() - { - var usage = await LoadUsage(); - ++usage.Model.NumberOfGists; - SaveUsage(usage); - } + var updateTask = UpdateUsageMetrics(propertyInfo); - public async Task IncrementUpstreamPullRequestCount() - { - var usage = await LoadUsage(); - ++usage.Model.NumberOfUpstreamPullRequests; - SaveUsage(usage); - } + if (visualStudioUsageTracker != null) + { + // Not available on Visual Studio 2015 + await visualStudioUsageTracker.IncrementCounter(counter); + } - public async Task IncrementLoginCount() - { - var usage = await LoadUsage(); - ++usage.Model.NumberOfLogins; - SaveUsage(usage); + await updateTask; } - public async Task IncrementPullRequestCheckOutCount(bool fork) - { - var usage = await LoadUsage(); - - if (fork) - ++usage.Model.NumberOfForkPullRequestsCheckedOut; - else - ++usage.Model.NumberOfLocalPullRequestsCheckedOut; + bool IsEnterpriseUser => + connectionManager.Value?.Connections.Any(x => !x.HostAddress.IsGitHubDotCom()) ?? false; - SaveUsage(usage); - } + bool IsGitHubUser => + connectionManager.Value?.Connections.Any(x => x.HostAddress.IsGitHubDotCom()) ?? false; - public async Task IncrementPullRequestPushCount(bool fork) + async Task UpdateUsageMetrics(PropertyInfo propertyInfo) { - var usage = await LoadUsage(); + var data = await service.ReadLocalData(); + var usage = await GetCurrentReport(data); - if (fork) - ++usage.Model.NumberOfForkPullRequestPushes; - else - ++usage.Model.NumberOfLocalPullRequestPushes; + var value = (int)propertyInfo.GetValue(usage.Measures); + propertyInfo.SetValue(usage.Measures, value + 1); - SaveUsage(usage); + await service.WriteLocalData(data); } - public async Task IncrementPullRequestPullCount(bool fork) + IDisposable StartTimer() { - var usage = await LoadUsage(); - - if (fork) - ++usage.Model.NumberOfForkPullRequestPulls; - else - ++usage.Model.NumberOfLocalPullRequestPulls; - - SaveUsage(usage); + return service.StartTimer(TimerTick, TimeSpan.FromMinutes(3), TimeSpan.FromDays(1)); } async Task Initialize() { + if (initialized) + return; + // The services needed by the usage tracker are loaded when they are first needed to // improve the startup time of the extension. - if (userSettings == null) - { - await ThreadingHelper.SwitchToMainThreadAsync(); - - client = gitHubServiceProvider.GetService(); - connectionManager = gitHubServiceProvider.GetService(); - userSettings = gitHubServiceProvider.GetService(); - vsservices = gitHubServiceProvider.GetService(); - - var program = gitHubServiceProvider.GetService(); - storePath = System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - program.ApplicationName, - StoreFileName); - } - } + await JoinableTaskContext.Factory.SwitchToMainThreadAsync(); - async Task LoadUsage() - { - await Initialize(); + client = gitHubServiceProvider.TryGetService(); + connectionManager = new Lazy(() => gitHubServiceProvider.GetService()); + vsservices = gitHubServiceProvider.GetService(); - var json = fileExists(storePath) ? readAllText(storePath, Encoding.UTF8) : null; - UsageStore result = null; - try - { - result = json != null ? - SimpleJson.DeserializeObject(json) : - new UsageStore { Model = new UsageModel() }; - } - catch + if (vsTelemetry) { - result = new UsageStore { Model = new UsageModel() }; + log.Verbose("Creating VisualStudioUsageTracker"); + visualStudioUsageTracker = new VisualStudioUsageTracker(connectionManager); } - result.Model.Lang = CultureInfo.InstalledUICulture.IetfLanguageTag; - result.Model.AppVersion = AssemblyVersionInformation.Version; - result.Model.VSVersion = vsservices.VSVersion; - - return result; - } - - void SaveUsage(UsageStore store) - { - dirCreate(System.IO.Path.GetDirectoryName(storePath)); - var json = SimpleJson.SerializeObject(store); - writeAllText(storePath, json, Encoding.UTF8); - } - - void RunTimer() - { - // The timer first ticks after 3 minutes to allow things to settle down after startup. - // This will be changed to 8 hours after the first tick by the TimerTick method. - timer.Start(); - } - - void TimerTick(object sender, EventArgs e) - { - TimerTick() - .Catch(ex => - { - //log.Warn("Failed submitting usage data", ex); - }) - .Forget(); + initialized = true; } async Task TimerTick() { await Initialize(); - if (firstRun) + if (firstTick) { - await IncrementLaunchCount(); - timer.Interval = TimeSpan.FromHours(8); - firstRun = false; + await IncrementCounter(x => x.NumberOfStartups); + firstTick = false; } if (client == null || !userSettings.CollectMetrics) { - timer.Stop(); + timer.Dispose(); + timer = null; return; } - // Every time we increment the launch count we increment both daily and weekly - // launch count but we only submit (and clear) the weekly launch count when we've - // transitioned into a new week. We've defined a week by the ISO8601 definition, - // i.e. week starting on Monday and ending on Sunday. - var usage = await LoadUsage(); - var lastDate = usage.LastUpdated; - var currentDate = DateTimeOffset.Now; - var includeWeekly = GetIso8601WeekOfYear(lastDate) != GetIso8601WeekOfYear(currentDate); - var includeMonthly = lastDate.Month != currentDate.Month; - - // Only send stats once a day. - if (lastDate.Date != currentDate.Date) - { - await SendUsage(usage.Model, includeWeekly, includeMonthly); - ClearCounters(usage.Model, includeWeekly, includeMonthly); - usage.LastUpdated = DateTimeOffset.Now.UtcDateTime; - SaveUsage(usage); - } - } - - async Task SendUsage(UsageModel usage, bool weekly, bool monthly) - { - Debug.Assert(client != null, "SendUsage should not be called when there is no IMetricsService"); + var data = await service.ReadLocalData(); - if (connectionManager.Connections.Any(x => x.HostAddress.IsGitHubDotCom())) + var changed = false; + for (var i = data.Reports.Count - 1; i >= 0; --i) { - usage.IsGitHubUser = true; + if (data.Reports[i].Dimensions.Date.Date != DateTimeOffset.Now.Date) + { + try + { + await client.PostUsage(data.Reports[i]); + data.Reports.RemoveAt(i); + changed = true; + } + catch (Exception ex) + { + log.Error(ex, "Failed to send metrics"); + } + } } - if (connectionManager.Connections.Any(x => !x.HostAddress.IsGitHubDotCom())) + if (changed) { - usage.IsEnterpriseUser = true; + await service.WriteLocalData(data); } - - var model = usage.Clone(weekly, monthly); - await client.PostUsage(model); } - // http://blogs.msdn.com/b/shawnste/archive/2006/01/24/iso-8601-week-of-year-format-in-microsoft-net.aspx - static int GetIso8601WeekOfYear(DateTimeOffset time) + async Task GetCurrentReport(UsageData data) { - // Seriously cheat. If its Monday, Tuesday or Wednesday, then it'll - // be the same week# as whatever Thursday, Friday or Saturday are, - // and we always get those right - DayOfWeek day = cal.GetDayOfWeek(time.UtcDateTime); - if (day >= DayOfWeek.Monday && day <= DayOfWeek.Wednesday) + var current = data.Reports.FirstOrDefault(x => x.Dimensions.Date.Date == DateTimeOffset.Now.Date); + + if (current == null) { - time = time.AddDays(3); + var guid = await service.GetUserGuid(); + current = UsageModel.Create(guid); + data.Reports.Add(current); } - // Return the week of our adjusted day - return cal.GetWeekOfYear(time.UtcDateTime, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); - } + current.Dimensions.Lang = CultureInfo.InstalledUICulture.IetfLanguageTag; + current.Dimensions.CurrentLang = CultureInfo.CurrentCulture.IetfLanguageTag; + current.Dimensions.CurrentUILang = CultureInfo.CurrentUICulture.IetfLanguageTag; + current.Dimensions.AppVersion = ExtensionInformation.Version; + current.Dimensions.VSVersion = vsservices.VSVersion; - static void ClearCounters(UsageModel usage, bool weekly, bool monthly) - { - usage.NumberOfStartups = 0; - usage.NumberOfClones = 0; - usage.NumberOfReposCreated = 0; - usage.NumberOfReposPublished = 0; - usage.NumberOfGists = 0; - usage.NumberOfOpenInGitHub = 0; - usage.NumberOfLinkToGitHub = 0; - usage.NumberOfLogins = 0; - usage.NumberOfUpstreamPullRequests = 0; - usage.NumberOfPullRequestsOpened = 0; - usage.NumberOfLocalPullRequestsCheckedOut = 0; - usage.NumberOfLocalPullRequestPulls = 0; - usage.NumberOfLocalPullRequestPushes = 0; - usage.NumberOfForkPullRequestsCheckedOut = 0; - usage.NumberOfForkPullRequestPulls = 0; - usage.NumberOfForkPullRequestPushes = 0; - - if (weekly) - usage.NumberOfStartupsWeek = 0; - - if (monthly) - usage.NumberOfStartupsMonth = 0; + current.Dimensions.IsGitHubUser = IsGitHubUser; + current.Dimensions.IsEnterpriseUser = IsEnterpriseUser; + return current; } - class UsageStore - { - public DateTimeOffset LastUpdated { get; set; } - public UsageModel Model { get; set; } - } + JoinableTaskContext JoinableTaskContext { get; } } } diff --git a/src/GitHub.VisualStudio/Services/UsageTrackerDispatcher.cs b/src/GitHub.VisualStudio/Services/UsageTrackerDispatcher.cs deleted file mode 100644 index 2b597dff72..0000000000 --- a/src/GitHub.VisualStudio/Services/UsageTrackerDispatcher.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.VisualStudio.Shell; -using System; -using System.ComponentModel.Composition; -using Task = System.Threading.Tasks.Task; - -namespace GitHub.Services -{ - [Export(typeof(IUsageTracker))] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class UsageTrackerDispatcher : IUsageTracker - { - readonly IUsageTracker inner; - - [ImportingConstructor] - public UsageTrackerDispatcher([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider) - { - inner = serviceProvider.GetService(typeof(IUsageTracker)) as IUsageTracker; - } - - public Task IncrementCloneCount() => inner.IncrementCloneCount(); - public Task IncrementCreateCount() => inner.IncrementCreateCount(); - public Task IncrementCreateGistCount() => inner.IncrementCreateGistCount(); - public Task IncrementLaunchCount() => inner.IncrementLaunchCount(); - public Task IncrementLinkToGitHubCount() => inner.IncrementLinkToGitHubCount(); - public Task IncrementLoginCount() => inner.IncrementLoginCount(); - public Task IncrementOpenInGitHubCount() => inner.IncrementOpenInGitHubCount(); - public Task IncrementPublishCount() => inner.IncrementPublishCount(); - public Task IncrementUpstreamPullRequestCount() => inner.IncrementUpstreamPullRequestCount(); - public Task IncrementPullRequestCheckOutCount(bool fork) => inner.IncrementPullRequestCheckOutCount(fork); - public Task IncrementPullRequestPullCount(bool fork) => inner.IncrementPullRequestPullCount(fork); - public Task IncrementPullRequestPushCount(bool fork) => inner.IncrementPullRequestPushCount(fork); - } -} diff --git a/src/GitHub.VisualStudio/Services/VSGitExtFactory.cs b/src/GitHub.VisualStudio/Services/VSGitExtFactory.cs new file mode 100644 index 0000000000..c968093f55 --- /dev/null +++ b/src/GitHub.VisualStudio/Services/VSGitExtFactory.cs @@ -0,0 +1,39 @@ +using System; +using GitHub.Logging; +using Microsoft.VisualStudio.Threading; +using Serilog; + +namespace GitHub.Services +{ + public class VSGitExtFactory + { + static readonly ILogger log = LogManager.ForContext(); + + readonly int vsVersion; + readonly IServiceProvider serviceProvider; + readonly IGitService gitService; + readonly JoinableTaskContext joinableTaskContect; + + public VSGitExtFactory(int vsVersion, IServiceProvider serviceProvider, IGitService gitService, JoinableTaskContext joinableTaskContect) + { + this.vsVersion = vsVersion; + this.serviceProvider = serviceProvider; + this.gitService = gitService; + this.joinableTaskContect = joinableTaskContect; + } + + // The GitHub.TeamFoundation.* assemblies target different .NET and Visual Studio versions. + // We can't reference all of their projects directly, so instead we use reflection to retrieve + // and instantiate the correct implementation. + public IVSGitExt Create() + { + if(Type.GetType($"GitHub.VisualStudio.Base.VSGitExt, GitHub.TeamFoundation.{vsVersion}", false) is Type type) + { + return (IVSGitExt)Activator.CreateInstance(type, serviceProvider, gitService, joinableTaskContect); + } + + log.Error("There is no IVSGitExt implementation for DTE version {Version}", vsVersion); + return null; + } + } +} diff --git a/src/GitHub.VisualStudio/Services/VisualStudioUsageTracker.cs b/src/GitHub.VisualStudio/Services/VisualStudioUsageTracker.cs new file mode 100644 index 0000000000..f4709ad4d6 --- /dev/null +++ b/src/GitHub.VisualStudio/Services/VisualStudioUsageTracker.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using GitHub.Models; +using Microsoft.VisualStudio.Telemetry; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.Services +{ + /// + /// Implementation of that uses the built in Visual Studio telemetry. + /// + /// + /// This should only be created on Visual Studio 2017 and above. + /// + public sealed class VisualStudioUsageTracker : IUsageTracker + { + const int TelemetryVersion = 1; // Please update the version every time you want to indicate a change in telemetry logic when the extension itself is updated + + const string EventNamePrefix = "vs/github/usagetracker/"; + const string PropertyPrefix = "vs.github."; + + readonly Lazy connectionManager; + + public VisualStudioUsageTracker(Lazy connectionManager) + { + this.connectionManager = connectionManager; + } + + public Task IncrementCounter(Expression> counter) + { + var property = (MemberExpression)counter.Body; + var propertyInfo = (PropertyInfo)property.Member; + var counterName = propertyInfo.Name; + LogTelemetryEvent(counterName); + + return Task.CompletedTask; + } + + void LogTelemetryEvent(string counterName) + { + const string numberOfPrefix = "numberof"; + if (counterName.StartsWith(numberOfPrefix, StringComparison.OrdinalIgnoreCase)) + { + counterName = counterName.Substring(numberOfPrefix.Length); + } + + var operation = new TelemetryEvent(Event.UsageTracker); + operation.Properties[Property.TelemetryVersion] = TelemetryVersion; + operation.Properties[Property.CounterName] = counterName; + operation.Properties[Property.ExtensionVersion] = ExtensionInformation.Version; + operation.Properties[Property.IsGitHubUser] = IsGitHubUser; + operation.Properties[Property.IsEnterpriseUser] = IsEnterpriseUser; + + TelemetryService.DefaultSession.PostEvent(operation); + } + + bool IsEnterpriseUser => + connectionManager.Value?.Connections.Any(x => !x.HostAddress.IsGitHubDotCom()) ?? false; + + bool IsGitHubUser => + connectionManager.Value?.Connections.Any(x => x.HostAddress.IsGitHubDotCom()) ?? false; + + static class Event + { + public const string UsageTracker = EventNamePrefix + "increment-counter"; + } + + static class Property + { + public const string TelemetryVersion = PropertyPrefix + nameof(TelemetryVersion); + public const string CounterName = PropertyPrefix + nameof(CounterName); + public const string ExtensionVersion = PropertyPrefix + nameof(ExtensionVersion); + public const string IsGitHubUser = PropertyPrefix + nameof(IsGitHubUser); + public const string IsEnterpriseUser = PropertyPrefix + nameof(IsEnterpriseUser); + } + } +} diff --git a/src/GitHub.VisualStudio/Settings/Guids.cs b/src/GitHub.VisualStudio/Settings/Guids.cs deleted file mode 100644 index 015be600bb..0000000000 --- a/src/GitHub.VisualStudio/Settings/Guids.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; - -namespace GitHub.VisualStudio -{ - static class GuidList - { - public const string guidGitHubPkgString = "c3d3dc68-c977-411f-b3e8-03b0dccf7dfc"; - public const string guidAssemblyResolverPkgString = "a6424dba-34cb-360d-a4de-1b0b0411e57d"; - public const string guidGitHubCmdSetString = "c4c91892-8881-4588-a5d9-b41e8f540f5a"; - public const string guidGitHubToolbarCmdSetString = "C5F1193E-F300-41B3-B4C4-5A703DD3C1C6"; - public const string guidContextMenuSetString = "31057D08-8C3C-4C5B-9F91-8682EA08EC27"; - public const string guidImageMonikerString = "27841f47-070a-46d6-90be-a5cbbfc724ac"; - - public static readonly Guid guidGitHubCmdSet = new Guid(guidGitHubCmdSetString); - public static readonly Guid guidGitHubToolbarCmdSet = new Guid(guidGitHubToolbarCmdSetString); - public static readonly Guid guidContextMenuSet = new Guid(guidContextMenuSetString); - public static readonly Guid guidImageMoniker = new Guid(guidImageMonikerString); - } -} diff --git a/src/GitHub.VisualStudio/Settings/OptionsPage.cs b/src/GitHub.VisualStudio/Settings/OptionsPage.cs index cee9a6c3b3..f86e3431af 100644 --- a/src/GitHub.VisualStudio/Settings/OptionsPage.cs +++ b/src/GitHub.VisualStudio/Settings/OptionsPage.cs @@ -1,10 +1,14 @@ -using GitHub.Settings; -using GitHub.VisualStudio.UI; -using Microsoft.VisualStudio.Shell; -using System; +using System; +using System.Windows; +using System.Windows.Controls; using System.ComponentModel; using System.Runtime.InteropServices; -using System.Windows; +using GitHub.Exports; +using GitHub.Logging; +using GitHub.Settings; +using GitHub.VisualStudio.UI; +using Microsoft.VisualStudio.Shell; +using Serilog; namespace GitHub.VisualStudio { @@ -13,16 +17,32 @@ namespace GitHub.VisualStudio [Guid("68C87C7B-0212-4256-BB6D-6A6BB847A3A7")] public class OptionsPage : UIElementDialogPage { + static readonly ILogger log = LogManager.ForContext(); + OptionsControl child; IPackageSettings packageSettings; protected override UIElement Child { - get { return child ?? (child = new OptionsControl()); } + get + { + if (!ExportForVisualStudioProcessAttribute.IsVisualStudioProcess()) + { + return new Grid(); // Show blank page + } + + return child ?? (child = new OptionsControl()); + } } protected override void OnActivate(CancelEventArgs e) { + if (!ExportForVisualStudioProcessAttribute.IsVisualStudioProcess()) + { + log.Warning("Don't activate options for non-Visual Studio process"); + return; + } + base.OnActivate(e); packageSettings = Services.DefaultExportProvider.GetExportedValue(); LoadSettings(); @@ -31,16 +51,24 @@ protected override void OnActivate(CancelEventArgs e) void LoadSettings() { child.CollectMetrics = packageSettings.CollectMetrics; + child.EnableTraceLogging = packageSettings.EnableTraceLogging; } void SaveSettings() { packageSettings.CollectMetrics = child.CollectMetrics; + packageSettings.EnableTraceLogging = child.EnableTraceLogging; packageSettings.Save(); } protected override void OnApply(PageApplyEventArgs args) { + if (!ExportForVisualStudioProcessAttribute.IsVisualStudioProcess()) + { + log.Warning("Don't apply options for non-Visual Studio process"); + return; + } + if (args.ApplyBehavior == ApplyKind.Apply) { SaveSettings(); diff --git a/src/GitHub.VisualStudio/Settings/PackageSettings.cs b/src/GitHub.VisualStudio/Settings/PackageSettings.cs index 1bdb6824cd..9c7b456a08 100644 --- a/src/GitHub.VisualStudio/Settings/PackageSettings.cs +++ b/src/GitHub.VisualStudio/Settings/PackageSettings.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.ComponentModel.Composition; using Microsoft.VisualStudio.Settings; using Microsoft.VisualStudio.Shell; @@ -9,14 +10,11 @@ namespace GitHub.VisualStudio.Settings { - [Export(typeof(IPackageSettings))] - [PartCreationPolicy(CreationPolicy.Shared)] public partial class PackageSettings : NotificationAwareObject, IPackageSettings { readonly SettingsStore settingsStore; - [ImportingConstructor] - public PackageSettings([Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider) + public PackageSettings(IServiceProvider serviceProvider) { var sm = new ShellSettingsManager(serviceProvider); settingsStore = new SettingsStore(sm.GetWritableSettingsStore(SettingsScope.UserSettings), Info.ApplicationInfo.ApplicationSafeName); diff --git a/src/GitHub.VisualStudio/Settings/generated/PackageSettingsGen.cs b/src/GitHub.VisualStudio/Settings/generated/PackageSettingsGen.cs index cb6aedb8f7..e9ccc3dd62 100644 --- a/src/GitHub.VisualStudio/Settings/generated/PackageSettingsGen.cs +++ b/src/GitHub.VisualStudio/Settings/generated/PackageSettingsGen.cs @@ -1,19 +1,29 @@ // This is an automatically generated file, based on settings.json and PackageSettingsGen.tt /* settings.json content: { - "settings": [ + "settings": [ { - "name": "CollectMetrics", - "type": "bool", - "default": 'true' + "name": "CollectMetrics", + "type": "bool", + "default": 'true' }, { - "name": "UIState", - "type": "object", - "typename": "UIState", - "default": 'null' + "name": "UIState", + "type": "object", + "typename": "UIState", + "default": "null" + }, + { + "name": "HideTeamExplorerWelcomeMessage", + "type": "bool", + "default": "false" + }, + { + "name": "EnableTraceLogging", + "type": "bool", + "default": "false" } - ] + ] } */ @@ -33,6 +43,13 @@ public bool CollectMetrics set { collectMetrics = value; this.RaisePropertyChange(); } } + bool forkButton; + public bool ForkButton + { + get { return forkButton; } + set { forkButton = value; this.RaisePropertyChange(); } + } + UIState uIState; public UIState UIState { @@ -40,17 +57,35 @@ public UIState UIState set { uIState = value; this.RaisePropertyChange(); } } + bool hideTeamExplorerWelcomeMessage; + public bool HideTeamExplorerWelcomeMessage + { + get { return hideTeamExplorerWelcomeMessage; } + set { hideTeamExplorerWelcomeMessage = value; this.RaisePropertyChange(); } + } + + bool enableTraceLogging; + public bool EnableTraceLogging + { + get { return enableTraceLogging; } + set { enableTraceLogging = value; this.RaisePropertyChange(); } + } + void LoadSettings() { CollectMetrics = (bool)settingsStore.Read("CollectMetrics", true); UIState = SimpleJson.DeserializeObject((string)settingsStore.Read("UIState", "{}")); + HideTeamExplorerWelcomeMessage = (bool)settingsStore.Read("HideTeamExplorerWelcomeMessage", false); + EnableTraceLogging = (bool)settingsStore.Read("EnableTraceLogging", false); } void SaveSettings() { settingsStore.Write("CollectMetrics", CollectMetrics); settingsStore.Write("UIState", SimpleJson.SerializeObject(UIState)); + settingsStore.Write("HideTeamExplorerWelcomeMessage", HideTeamExplorerWelcomeMessage); + settingsStore.Write("EnableTraceLogging", EnableTraceLogging); } } diff --git a/src/GitHub.VisualStudio/UI/AsyncPaneBase.cs b/src/GitHub.VisualStudio/UI/AsyncPaneBase.cs new file mode 100644 index 0000000000..41f4382fef --- /dev/null +++ b/src/GitHub.VisualStudio/UI/AsyncPaneBase.cs @@ -0,0 +1,98 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using GitHub.Factories; +using GitHub.Services; +using GitHub.ViewModels; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using ReactiveUI; + +namespace GitHub.VisualStudio.UI +{ + public class AsyncPaneBase : ToolWindowPane + { + /// + /// Gets or sets an ID string that identifies the content of the pane. + /// + public string Id { get; set; } + } + + public class AsyncPaneBase : AsyncPaneBase + where TViewModel : IPaneViewModel + { + readonly ContentPresenter contentPresenter; + IDisposable subscription; + JoinableTask viewModelTask; + + public AsyncPaneBase() + { + Content = contentPresenter = new ContentPresenter(); + } + + public virtual FrameworkElement View + { + get => (FrameworkElement)contentPresenter.Content; + set => contentPresenter.Content = value; + } + + protected override void Initialize() + { + // Using JoinableTaskFactory from parent AsyncPackage. That way if VS shuts down before this + // work is done, we won't risk crashing due to arbitrary work going on in background threads. + var asyncPackage = (AsyncPackage)Package; + viewModelTask = asyncPackage.JoinableTaskFactory.RunAsync(() => InitializeAsync(asyncPackage)); + } + + public Task GetViewModelAsync() => viewModelTask.JoinAsync(); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + subscription?.Dispose(); + subscription = null; + } + } + + async Task InitializeAsync(AsyncPackage asyncPackage) + { + try + { + // Allow MEF to initialize its cache asynchronously + var provider = (IGitHubServiceProvider)await asyncPackage.GetServiceAsync(typeof(IGitHubServiceProvider)); + var teServiceHolder = provider.GetService(); + teServiceHolder.ServiceProvider = this; + var factory = provider.GetService(); + var viewModel = provider.ExportProvider.GetExportedValue(); + await viewModel.InitializeAsync(this); + View = factory.CreateView(); + if (View == null) + { + throw new CompositionException("Could not find view for " + typeof(TViewModel).FullName); + } + View.DataContext = viewModel; + subscription = viewModel.WhenAnyValue(x => x.PaneCaption).Subscribe(x => Caption = x); + return viewModel; + } + catch (Exception e) + { + ShowError(e); + throw; + } + } + + void ShowError(Exception e) + { + View = new TextBox + { + Text = e.ToString(), + IsReadOnly = true, + }; + } + } +} \ No newline at end of file diff --git a/src/GitHub.VisualStudio/UI/GitHubPane.cs b/src/GitHub.VisualStudio/UI/GitHubPane.cs index 69ecc2b951..d0f04d4f42 100644 --- a/src/GitHub.VisualStudio/UI/GitHubPane.cs +++ b/src/GitHub.VisualStudio/UI/GitHubPane.cs @@ -1,20 +1,22 @@ using System; -using System.Runtime.InteropServices; -using Microsoft.VisualStudio.Shell; -using Microsoft.VisualStudio.Shell.Interop; +using System.Threading.Tasks; using System.ComponentModel.Design; +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Linq; +using System.Runtime.InteropServices; +using System.Windows; using System.Windows.Controls; +using GitHub.Factories; using GitHub.Services; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; -using System.Diagnostics; -using GitHub.VisualStudio.UI.Views; +using GitHub.ViewModels.GitHubPane; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Threading; +using ReactiveUI; +using Microsoft; namespace GitHub.VisualStudio.UI { - /// /// This class implements the tool window exposed by this package and hosts a user control. /// @@ -27,56 +29,205 @@ namespace GitHub.VisualStudio.UI /// /// [Guid(GitHubPaneGuid)] - [NullGuard.NullGuard(NullGuard.ValidationFlags.None)] - public class GitHubPane : ToolWindowPane, IServiceProviderAware, IViewHost + public class GitHubPane : ToolWindowPane { public const string GitHubPaneGuid = "6b0fdc0a-f28e-47a0-8eed-cc296beff6d2"; - bool initialized = false; - IView View + JoinableTask viewModelTask; + + IDisposable viewSubscription; + ContentPresenter contentPresenter; + + public FrameworkElement View { - get { return Content as IView; } - set { Content = value; } + get { return contentPresenter.Content as FrameworkElement; } + set + { + viewSubscription?.Dispose(); + viewSubscription = null; + + contentPresenter.Content = value; + + viewSubscription = value.WhenAnyValue(x => x.DataContext) + .SelectMany(x => + { + var pane = x as IGitHubPaneViewModel; + return pane?.WhenAnyValue(p => p.IsSearchEnabled, p => p.SearchQuery) + ?? Observable.Return(Tuple.Create(false, null)); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => UpdateSearchHost(x.Item1, x.Item2)); + } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2214:DoNotCallOverridableMethodsInConstructors")] + [SuppressMessage("Microsoft.Usage", "CA2214:DoNotCallOverridableMethodsInConstructors")] public GitHubPane() : base(null) { Caption = "GitHub"; - + Content = contentPresenter = new ContentPresenter(); + BitmapImageMoniker = new Microsoft.VisualStudio.Imaging.Interop.ImageMoniker() { - Guid = GuidList.guidImageMoniker, + Guid = Guids.guidImageMoniker, Id = 1 }; - ToolBar = new CommandID(GuidList.guidGitHubToolbarCmdSet, PkgCmdIDList.idGitHubToolbar); + ToolBar = new CommandID(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.idGitHubToolbar); ToolBarLocation = (int)VSTWT_LOCATION.VSTWT_TOP; - var provider = Services.GitHubServiceProvider; - var uiProvider = provider.GetServiceSafe(); - View = uiProvider.GetView(Exports.UIViewType.GitHubPane); } + public override bool SearchEnabled => true; + protected override void Initialize() { - base.Initialize(); - Initialize(this); + // Using JoinableTaskFactory from parent AsyncPackage. That way if VS shuts down before this + // work is done, we won't risk crashing due to arbitrary work going on in background threads. + var asyncPackage = (AsyncPackage)Package; + JoinableTaskFactory = asyncPackage.JoinableTaskFactory; + + viewModelTask = JoinableTaskFactory.RunAsync(() => InitializeAsync(asyncPackage)); } - public void Initialize(IServiceProvider serviceProvider) + public Task GetViewModelAsync() => viewModelTask.JoinAsync(); + + async Task InitializeAsync(AsyncPackage asyncPackage) { - if (!initialized) + try { - initialized = true; + ShowInitializing(); + + // Allow MEF to initialize its cache asynchronously + var provider = (IGitHubServiceProvider)await asyncPackage.GetServiceAsync(typeof(IGitHubServiceProvider)); + Assumes.Present(provider); + + var teServiceHolder = provider.GetService(); + teServiceHolder.ServiceProvider = this; - var vm = View.ViewModel as IServiceProviderAware; - Debug.Assert(vm != null); - vm?.Initialize(serviceProvider); + var factory = provider.GetService(); + var viewModel = provider.ExportProvider.GetExportedValue(); + await viewModel.InitializeAsync(this); + + View = factory.CreateView(); + View.DataContext = viewModel; + + return viewModel; + } + catch (Exception e) + { + ShowError(e); + throw; + } + } + + [SuppressMessage("Microsoft.Design", "CA1061:DoNotHideBaseClassMethods", Justification = "WTF CA, I'm overriding!")] + public override IVsSearchTask CreateSearch(uint dwCookie, IVsSearchQuery pSearchQuery, IVsSearchCallback pSearchCallback) + { + var pane = View?.DataContext as IGitHubPaneViewModel; + + if (pane != null) + { + return new SearchTask(JoinableTaskFactory, pane, dwCookie, pSearchQuery, pSearchCallback); } + + return null; } - public void ShowView(ViewWithData data) + public override void ClearSearch() { - View.ViewModel?.Initialize(data); + var pane = View?.DataContext as IGitHubPaneViewModel; + + if (pane != null) + { + pane.SearchQuery = null; + } } + + public override void OnToolWindowCreated() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + base.OnToolWindowCreated(); + + Marshal.ThrowExceptionForHR(((IVsWindowFrame)Frame)?.SetProperty( + (int)__VSFPROPID5.VSFPROPID_SearchPlacement, + __VSSEARCHPLACEMENT.SP_STRETCH) ?? 0); + + var pane = View?.DataContext as IGitHubPaneViewModel; + UpdateSearchHost(pane?.IsSearchEnabled ?? false, pane?.SearchQuery); + } + + void ShowInitializing() + { + // This page is intentionally left blank. + } + + void ShowError(Exception e) + { + View = new TextBox + { + Text = e.ToString(), + IsReadOnly = true, + }; + } + + void UpdateSearchHost(bool enabled, string query) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + if (SearchHost != null) + { + SearchHost.IsEnabled = enabled; + + if (SearchHost.SearchQuery?.SearchString != query) + { + SearchHost.SearchAsync(query != null ? new SearchQuery(query) : null); + } + } + } + + class SearchTask : VsSearchTask + { + readonly JoinableTaskFactory joinableTaskFactory; + readonly IGitHubPaneViewModel viewModel; + + public SearchTask( + JoinableTaskFactory joinableTaskFactory, + IGitHubPaneViewModel viewModel, + uint dwCookie, + IVsSearchQuery pSearchQuery, + IVsSearchCallback pSearchCallback) + : base(dwCookie, pSearchQuery, pSearchCallback) + { + this.joinableTaskFactory = joinableTaskFactory; + this.viewModel = viewModel; + } + + protected override void OnStartSearch() + { + joinableTaskFactory.RunAsync(async () => + { + await joinableTaskFactory.SwitchToMainThreadAsync(); + viewModel.SearchQuery = SearchQuery.SearchString; + }); + + base.OnStartSearch(); + } + + protected override void OnStopSearch() => viewModel.SearchQuery = null; + } + + class SearchQuery : IVsSearchQuery + { + public SearchQuery(string query) + { + SearchString = query; + } + + public uint ParseError => 0; + public string SearchString { get; } + + public uint GetTokens(uint dwMaxTokens, IVsSearchToken[] rgpSearchTokens) => 0; + } + + public JoinableTaskFactory JoinableTaskFactory { get; private set; } } } diff --git a/src/GitHub.VisualStudio/UI/IssueishDocumentPane.cs b/src/GitHub.VisualStudio/UI/IssueishDocumentPane.cs new file mode 100644 index 0000000000..3b39d41c73 --- /dev/null +++ b/src/GitHub.VisualStudio/UI/IssueishDocumentPane.cs @@ -0,0 +1,19 @@ +using System; +using System.Runtime.InteropServices; +using GitHub.ViewModels.Documents; +using Microsoft.VisualStudio.Shell; + +namespace GitHub.VisualStudio.UI +{ + /// + /// A which displays an issue or pull request in a document window. + /// + [Guid(IssueishDocumentPaneGuid)] + public class IssueishDocumentPane : AsyncPaneBase + { + /// + /// The guid of the document pane. + /// + public const string IssueishDocumentPaneGuid = "9506846C-4CEC-4DDA-87E7-A99CDCD4E35B"; + } +} diff --git a/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml b/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml index d468a23b23..901305575e 100644 --- a/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml +++ b/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml @@ -2,9 +2,9 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:ghfvs="https://github.com/github/VisualStudio" xmlns:local="clr-namespace:GitHub.VisualStudio.UI" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:prop="clr-namespace:GitHub.VisualStudio.UI;assembly=GitHub.VisualStudio.UI" d:DesignHeight="200" mc:Ignorable="d"> @@ -61,24 +61,39 @@ - + - - - - - - - - - - + + + + + + + + + - - + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs b/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs index e60653c029..4dadaaa391 100644 --- a/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs +++ b/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs @@ -19,9 +19,15 @@ public bool CollectMetrics set { chkMetrics.IsChecked = value; } } + public bool EnableTraceLogging + { + get { return chkEnableTraceLogging.IsChecked ?? false; } + set { chkEnableTraceLogging.IsChecked = value; } + } + private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) { - var browser = Services.DefaultExportProvider.GetExportedValue(); + var browser = VisualStudio.Services.DefaultExportProvider.GetExportedValue(); browser?.OpenUrl(e.Uri); } } diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml b/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml deleted file mode 100644 index 0c572d5185..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml.cs b/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml.cs deleted file mode 100644 index 0e703598f4..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Reactive.Linq; -using System.Windows; -using System.Windows.Threading; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Services; -using GitHub.UI; -using GitHub.ViewModels; -using Microsoft.VisualStudio.Shell; -using ReactiveUI; - -namespace GitHub.VisualStudio.UI.Views.Controls -{ - public class GenericGistCreationControl : SimpleViewUserControl - { } - - [ExportView(ViewType=UIViewType.Gist)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class GistCreationControl - { - [ImportingConstructor] - public GistCreationControl( - ITeamExplorerServices teServices, - INotificationDispatcher notifications, - IGitHubServiceProvider serviceProvider) - { - InitializeComponent(); - - this.WhenActivated(d => - { - errorMessage.Visibility = Visibility.Collapsed; - - d(this.Bind(ViewModel, vm => vm.Description, v => v.descriptionTextBox.Text)); - d(this.Bind(ViewModel, vm => vm.FileName, v => v.fileNameTextBox.Text)); - d(this.Bind(ViewModel, vm => vm.IsPrivate, v => v.makePrivate.IsChecked)); - d(this.BindCommand(ViewModel, vm => vm.CreateGist, v => v.createGistButton)); - - d(this.Bind(ViewModel, vm => vm.Account, v => v.accountStackPanel.DataContext)); - - ViewModel.CreateGist - .Where(x => x != null) - .Subscribe(gist => - { - var browser = serviceProvider.TryGetService(); - browser?.OpenUrl(new Uri(gist.HtmlUrl)); - - var ns = serviceProvider.TryGetService(); - ns?.ShowMessage(UI.Resources.gistCreatedMessage); - - NotifyDone(); - }); - - d(notifications.Listen() - .Where(n => n.Type == Notification.NotificationType.Error) - .ObserveOnDispatcher(DispatcherPriority.Normal) - .Subscribe(n => - { - errorMessage.Visibility = Visibility.Visible; - errorMessageText.Text = n.Message; - })); - }); - } - } -} diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml b/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml deleted file mode 100644 index 85e3739ba9..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml +++ /dev/null @@ -1,166 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml.cs b/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml.cs deleted file mode 100644 index 26685c3abb..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Windows; -using System.Windows.Input; -using GitHub.Controls; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.UI; -using GitHub.ViewModels; -using ReactiveUI; -using System.ComponentModel.Composition; -using GitHub.UserErrors; -using System.Reactive.Disposables; - -namespace GitHub.VisualStudio.UI.Views.Controls -{ - public class GenericLoginControl : SimpleViewUserControl - { } - - /// - /// Interaction logic for LoginControl.xaml - /// - [ExportView(ViewType=UIViewType.Login)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class LoginControl : GenericLoginControl - { - IDisposable errorHandler; - - public LoginControl() - { - InitializeComponent(); - - this.WhenActivated(d => - { - SetupDotComBindings(d); - SetupEnterpriseBindings(d); - SetupSelectedAndVisibleTabBindings(d); - ViewModel.AuthenticationResults - .Subscribe(ret => - { - if (ret == Authentication.AuthenticationResult.Success) - { - NotifyDone(); - } - }); - - d(Disposable.Create(() => errorHandler.Dispose())); - }); - - IsVisibleChanged += (s, e) => - { - if (IsVisible) - dotComUserNameOrEmail.TryMoveFocus(FocusNavigationDirection.First).Subscribe(); - }; - } - - void SetupDotComBindings(Action d) - { - d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.IsLoggingIn, x => x.dotComloginControlsPanel.IsEnabled, x => x == false)); - - d(this.Bind(ViewModel, vm => vm.GitHubLogin.UsernameOrEmail, x => x.dotComUserNameOrEmail.Text)); - d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.UsernameOrEmailValidator, v => v.dotComUserNameOrEmailValidationMessage.ReactiveValidator)); - - d(this.BindPassword(ViewModel, vm => vm.GitHubLogin.Password, v => v.dotComPassword.Text, dotComPassword)); - d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.PasswordValidator, v => v.dotComPasswordValidationMessage.ReactiveValidator)); - - d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.Login, v => v.dotComLogInButton.Command)); - d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.IsLoggingIn, v => v.dotComLogInButton.ShowSpinner)); - d(this.OneWayBind(ViewModel, vm => vm.GitHubLogin.NavigatePricing, v => v.pricingLink.Command)); - } - - void SetupEnterpriseBindings(Action d) - { - d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.IsLoggingIn, x => x.enterpriseloginControlsPanel.IsEnabled, x => x == false)); - - d(this.Bind(ViewModel, vm => vm.EnterpriseLogin.UsernameOrEmail, x => x.enterpriseUserNameOrEmail.Text)); - d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.UsernameOrEmailValidator, v => v.enterpriseUserNameOrEmailValidationMessage.ReactiveValidator)); - - d(this.BindPassword(ViewModel, vm => vm.EnterpriseLogin.Password, v => v.enterprisePassword.Text, enterprisePassword)); - d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.PasswordValidator, v => v.enterprisePasswordValidationMessage.ReactiveValidator)); - - d(this.Bind(ViewModel, vm => vm.EnterpriseLogin.EnterpriseUrl, v => v.enterpriseUrl.Text)); - d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.EnterpriseUrlValidator, v => v.enterpriseUrlValidationMessage.ReactiveValidator)); - - d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.Login, v => v.enterpriseLogInButton.Command)); - d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.IsLoggingIn, v => v.enterpriseLogInButton.ShowSpinner)); - d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.NavigateLearnMore, v => v.learnMoreLink.Command)); - } - - void SetupSelectedAndVisibleTabBindings(Action d) - { - d(this.WhenAny(x => x.ViewModel.LoginMode, x => x.Value) - .Select(x => x == LoginMode.DotComOrEnterprise || x == LoginMode.DotComOnly) - .BindTo(this, v => v.dotComTab.IsEnabled)); - - d(this.WhenAny(x => x.ViewModel.LoginMode, x => x.Value) - .Select(x => x == LoginMode.DotComOrEnterprise || x == LoginMode.EnterpriseOnly) - .BindTo(this, v => v.enterpriseTab.IsEnabled)); - - d(this.WhenAny(x => x.ViewModel.LoginMode, x => x.Value) - .Select(x => x == LoginMode.DotComOrEnterprise || x == LoginMode.DotComOnly) - .Where(x => x == true) - .BindTo(this, v => v.dotComTab.IsSelected)); - - d(this.WhenAny(x => x.ViewModel.LoginMode, x => x.Value) - .Select(x => x == LoginMode.EnterpriseOnly) - .Where(x => x == true) - .BindTo(this, v => v.enterpriseTab.IsSelected)); - } - - void hostTabControl_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) - { - // This is a bit ugly but it's the simplest way I could think of dealing with it: there can only - // be one UserErrorMessages control active at any time and we need one for each tab. Register/unregister - // them here when the tab is changed. - var clearErrorWhen = Observable.Return(false); - - errorHandler?.Dispose(); - - switch (hostTabControl.SelectedIndex) - { - case 0: - errorHandler = dotComErrorMessage.RegisterHandler(clearErrorWhen); - break; - case 1: - errorHandler = enterpriseErrorMessage.RegisterHandler(clearErrorWhen); - break; - } - } - } -} diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/LogoutRequiredControl.xaml b/src/GitHub.VisualStudio/UI/Views/Controls/LogoutRequiredControl.xaml deleted file mode 100644 index 3ca5b5048d..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/LogoutRequiredControl.xaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/LogoutRequiredControl.xaml.cs b/src/GitHub.VisualStudio/UI/Views/Controls/LogoutRequiredControl.xaml.cs deleted file mode 100644 index 3d7e691e39..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/LogoutRequiredControl.xaml.cs +++ /dev/null @@ -1,37 +0,0 @@ -using GitHub.Exports; -using GitHub.UI; -using GitHub.ViewModels; -using ReactiveUI; -using System; -using System.ComponentModel.Composition; -using System.Reactive.Linq; - -namespace GitHub.VisualStudio.UI.Views.Controls -{ - public class GenericLogoutRequiredControl : SimpleViewUserControl - { } - - /// - /// Interaction logic for LogoutRequiredControl.xaml - /// - [ExportView(ViewType = UIViewType.LogoutRequired)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class LogoutRequiredControl : GenericLogoutRequiredControl - { - public LogoutRequiredControl() - { - InitializeComponent(); - - this.WhenActivated(d => - { - d(this.BindCommand(ViewModel, vm => vm.Logout, v => v.logoutButton)); - d(this.BindCommand(ViewModel, vm => vm.Cancel, v => v.cancelButton)); - - d(ViewModel.Logout - .Where(x => x == ProgressState.Success) - .Subscribe(_ => NotifyDone())); - d(ViewModel.CancelCommand.Subscribe(_ => NotifyCancel())); - }); - } - } -} diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/PullRequestListItem.xaml b/src/GitHub.VisualStudio/UI/Views/Controls/PullRequestListItem.xaml deleted file mode 100644 index d27d4d21cb..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/PullRequestListItem.xaml +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryPublishControl.xaml.cs b/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryPublishControl.xaml.cs deleted file mode 100644 index aabd1b1cec..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryPublishControl.xaml.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Reactive.Linq; -using System.Windows; -using System.Windows.Input; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.UI; -using GitHub.ViewModels; -using NullGuard; -using ReactiveUI; -using System.ComponentModel.Composition; -using GitHub.Extensions.Reactive; -using GitHub.Services; - -namespace GitHub.VisualStudio.UI.Views.Controls -{ - public class GenericRepositoryPublishControl : SimpleViewUserControl - { } - - [ExportView(ViewType=UIViewType.Publish)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class RepositoryPublishControl : GenericRepositoryPublishControl - { - [ImportingConstructor] - public RepositoryPublishControl(ITeamExplorerServices teServices, INotificationDispatcher notifications) - { - InitializeComponent(); - - this.WhenActivated(d => - { - d(this.BindCommand(ViewModel, vm => vm.PublishRepository, v => v.publishRepositoryButton)); - - ViewModel.PublishRepository.Subscribe(state => - { - if (state == ProgressState.Success) - { - teServices.ShowMessage(UI.Resources.RepositoryPublishedMessage); - NotifyDone(); - } - }); - - d(this.WhenAny(x => x.ViewModel.IsPublishing, x => x.Value) - .Subscribe(x => NotifyIsBusy(x))); - - - d(notifications.Listen() - .Where(n => n.Type == Notification.NotificationType.Error) - .Subscribe(n => teServices.ShowError(n.Message))); - - d(this.WhenAny(x => x.ViewModel.SafeRepositoryNameWarningValidator.ValidationResult, x => x.Value) - .WhereNotNull() - .Select(result => result?.Message) - .Subscribe(message => - { - if (!String.IsNullOrEmpty(message)) - teServices.ShowWarning(message); - else - teServices.ClearNotifications(); - })); - }); - IsVisibleChanged += (s, e) => - { - if (IsVisible) - this.TryMoveFocus(FocusNavigationDirection.First).Subscribe(); - }; - } - } -} diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml b/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml deleted file mode 100644 index 0ceef151b7..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - https://github.com/github/VisualStudio - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -