diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..56725e7b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,306 @@ +# editorconfig.org + +# top-most EditorConfig file +root = true + +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation +[*] +insert_final_newline = true +indent_style = space +indent_size = 4 +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +end_of_line = crlf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_style_allow_multiple_blank_lines_experimental = true:silent + +# C# files +[*.cs] +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# trim_trailing_whitespace = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# prefer var +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true:suggestion + +# use language keywords instead of BCL types +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# private static fields should have s_ prefix +dotnet_naming_rule.private_static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.private_static_fields_should_have_prefix.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_have_prefix.style = private_static_prefix_style + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.required_modifiers = static +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private + +dotnet_naming_style.private_static_prefix_style.required_prefix = s_ +dotnet_naming_style.private_static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style + +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal + +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# use accessibility modifiers +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Code style defaults +dotnet_sort_system_directives_first = true +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = 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 = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false +space_within_single_line_array_initializer_braces = true + +#Net Analyzer +dotnet_analyzer_diagnostic.category-Performance.severity = none #error - Uncomment when all violations are fixed. + +# CS0649: Field 'field' is never assigned to, and will always have its default value 'value' +dotnet_diagnostic.CS0649.severity = error + +# CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1591.severity = suggestion + +# CS0162: Remove unreachable code +dotnet_diagnostic.CS0162.severity = error +# CA1018: Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1018.severity = error +# CA1304: Specify CultureInfo +dotnet_diagnostic.CA1304.severity = warning +# CA1802: Use literals where appropriate +dotnet_diagnostic.CA1802.severity = warning +# CA1813: Avoid unsealed attributes +dotnet_diagnostic.CA1813.severity = error +# CA1815: Override equals and operator equals on value types +dotnet_diagnostic.CA1815.severity = warning +# CA1820: Test for empty strings using string length +dotnet_diagnostic.CA1820.severity = warning +# CA1821: Remove empty finalizers +dotnet_diagnostic.CA1821.severity = warning +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = suggestion +# CA1823: Avoid unused private fields +dotnet_diagnostic.CA1823.severity = warning +dotnet_code_quality.CA1822.api_surface = private, internal +# CA1825: Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = warning +# CA1826: Use property instead of Linq Enumerable method +dotnet_diagnostic.CA1826.severity = suggestion +# CA1827: Do not use Count/LongCount when Any can be used +dotnet_diagnostic.CA1827.severity = warning +# CA1828: Do not use CountAsync/LongCountAsync when AnyAsync can be used +dotnet_diagnostic.CA1828.severity = warning +# CA1829: Use Length/Count property instead of Enumerable.Count method +dotnet_diagnostic.CA1829.severity = warning +#CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters +dotnet_diagnostic.CA1847.severity = warning +#CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method +dotnet_diagnostic.CA1854.severity = warning +#CA2211:Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = error + +# IDE0005: remove used namespace using +dotnet_diagnostic.IDE0005.severity = error + +# Wrapping preferences +csharp_wrap_before_ternary_opsigns = false + +# Avalonia DevAnalyzer preferences +dotnet_diagnostic.AVADEV2001.severity = error + +# Avalonia PublicAnalyzer preferences +dotnet_diagnostic.AVP1000.severity = error +dotnet_diagnostic.AVP1001.severity = error +dotnet_diagnostic.AVP1002.severity = error +dotnet_diagnostic.AVP1010.severity = error +dotnet_diagnostic.AVP1011.severity = error +dotnet_diagnostic.AVP1012.severity = warning +dotnet_diagnostic.AVP1013.severity = error +dotnet_diagnostic.AVP1020.severity = error +dotnet_diagnostic.AVP1021.severity = error +dotnet_diagnostic.AVP1022.severity = error +dotnet_diagnostic.AVP1030.severity = error +dotnet_diagnostic.AVP1031.severity = error +dotnet_diagnostic.AVP1032.severity = error +dotnet_diagnostic.AVP1040.severity = error +dotnet_diagnostic.AVA2001.severity = error +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_prefer_readonly_struct = true:suggestion +csharp_prefer_static_local_function = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Xaml files +[*.{xaml,axaml}] +indent_size = 2 +# DuplicateSetterError +avalonia_xaml_diagnostic.AVLN2203.severity = error +# StyleInMergedDictionaries +avalonia_xaml_diagnostic.AVLN2204.severity = error +# RequiredTemplatePartMissing +avalonia_xaml_diagnostic.AVLN2205.severity = error +# OptionalTemplatePartMissing +avalonia_xaml_diagnostic.AVLN2206.severity = info +# TemplatePartWrongType +avalonia_xaml_diagnostic.AVLN2207.severity = error +# Obsolete +avalonia_xaml_diagnostic.AVLN5001.severity = error + +# Xml project files +[*.{csproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] +indent_size = 2 + +# Xml build files +[*.builds] +indent_size = 2 + +# Xml files +[*.{xml,stylecop,resx,ruleset}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# Shell scripts +[*.sh] +end_of_line = lf +[*.{cmd,bat}] +end_of_line = crlf + +# Package manifests +[{*.spec,control}] +end_of_line = lf + +# YAML files +[*.{yml,yaml}] +indent_size = 2 +end_of_line = lf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..bd1dfea9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto +*.md text +*.png binary +*.ico binary +*.sh text eol=lf +*.spec text eol=lf +control text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf +*.json text + +.gitattributes export-ignore +.gitignore export-ignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..12792cf6 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,79 @@ +name: Build +on: + workflow_call: +jobs: + build: + strategy: + matrix: + include: + - name : Windows x64 + os: windows-2019 + runtime: win-x64 + - name : Windows ARM64 + os: windows-2019 + runtime: win-arm64 + - name : macOS (Intel) + os: macos-13 + runtime: osx-x64 + - name : macOS (Apple Silicon) + os: macos-latest + runtime: osx-arm64 + - name : Linux + os: ubuntu-latest + runtime: linux-x64 + container: ubuntu:20.04 + - name : Linux (arm64) + os: ubuntu-latest + runtime: linux-arm64 + container: ubuntu:20.04 + name: Build ${{ matrix.name }} + runs-on: ${{ matrix.os }} + container: ${{ matrix.container || '' }} + steps: + - name: Install common CLI tools + if: ${{ startsWith(matrix.runtime, 'linux-') }} + run: | + export DEBIAN_FRONTEND=noninteractive + ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime + apt-get update + apt-get install -y sudo + sudo apt-get install -y curl wget git unzip zip libicu66 tzdata clang + - name: Checkout sources + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Configure arm64 packages + if: ${{ matrix.runtime == 'linux-arm64' }} + run: | + sudo dpkg --add-architecture arm64 + echo 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ focal main restricted + deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ focal-updates main restricted + deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ focal-backports main restricted' \ + | sudo tee /etc/apt/sources.list.d/arm64.list + sudo sed -i -e 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list + sudo sed -i -e 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list + - name: Install cross-compiling dependencies + if: ${{ matrix.runtime == 'linux-arm64' }} + run: | + sudo apt-get update + sudo apt-get install -y llvm gcc-aarch64-linux-gnu + - name: Build + run: dotnet build -c Release + - name: Publish + run: dotnet publish src/SourceGit.csproj -c Release -o publish -r ${{ matrix.runtime }} + - name: Rename executable file + if: ${{ startsWith(matrix.runtime, 'linux-') }} + run: mv publish/SourceGit publish/sourcegit + - name: Tar artifact + if: ${{ startsWith(matrix.runtime, 'linux-') || startsWith(matrix.runtime, 'osx-') }} + run: | + tar -cvf "sourcegit.${{ matrix.runtime }}.tar" -C publish . + rm -r publish/* + mv "sourcegit.${{ matrix.runtime }}.tar" publish + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: publish/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..50e02dc9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: Continuous Integration +on: + push: + branches: [develop] + pull_request: + branches: [develop] + workflow_dispatch: + workflow_call: +jobs: + build: + name: Build + uses: ./.github/workflows/build.yml + version: + name: Prepare version string + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Output version string + id: version + run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + package: + needs: [build, version] + name: Package + uses: ./.github/workflows/package.yml + with: + version: ${{ needs.version.outputs.version }} diff --git a/.github/workflows/localization-check.yml b/.github/workflows/localization-check.yml new file mode 100644 index 00000000..8dcd61c8 --- /dev/null +++ b/.github/workflows/localization-check.yml @@ -0,0 +1,41 @@ +name: Localization Check +on: + push: + branches: [ develop ] + paths: + - 'src/Resources/Locales/**' + workflow_dispatch: + workflow_call: + +jobs: + localization-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install fs-extra@11.2.0 path@0.12.7 xml2js@0.6.2 + + - name: Run localization check + run: node build/scripts/localization-check.js + + - name: Commit changes + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + if [ -n "$(git status --porcelain)" ]; then + git add TRANSLATION.md src/Resources/Locales/*.axaml + git commit -m 'doc: Update translation status and sort locale files' + git push + else + echo "No changes to commit" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 00000000..2dfc97fd --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,111 @@ +name: Package +on: + workflow_call: + inputs: + version: + description: SourceGit package version + required: true + type: string +jobs: + windows: + name: Package Windows + runs-on: windows-2019 + strategy: + matrix: + runtime: [ win-x64, win-arm64 ] + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Download build + uses: actions/download-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: build/SourceGit + - name: Package + shell: bash + env: + VERSION: ${{ inputs.version }} + RUNTIME: ${{ matrix.runtime }} + run: ./build/scripts/package.windows.sh + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: package.${{ matrix.runtime }} + path: build/sourcegit_*.zip + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: sourcegit.${{ matrix.runtime }} + osx-app: + name: Package macOS + runs-on: macos-latest + strategy: + matrix: + runtime: [osx-x64, osx-arm64] + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Download build + uses: actions/download-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: build + - name: Package + env: + VERSION: ${{ inputs.version }} + RUNTIME: ${{ matrix.runtime }} + run: | + mkdir build/SourceGit + tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit + ./build/scripts/package.osx-app.sh + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: package.${{ matrix.runtime }} + path: build/sourcegit_*.zip + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: sourcegit.${{ matrix.runtime }} + linux: + name: Package Linux + runs-on: ubuntu-latest + container: ubuntu:20.04 + strategy: + matrix: + runtime: [linux-x64, linux-arm64] + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Download package dependencies + run: | + export DEBIAN_FRONTEND=noninteractive + ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime + apt-get update + apt-get install -y curl wget git dpkg-dev fakeroot tzdata zip unzip desktop-file-utils rpm libfuse2 file build-essential binutils + - name: Download build + uses: actions/download-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: build + - name: Package + env: + VERSION: ${{ inputs.version }} + RUNTIME: ${{ matrix.runtime }} + APPIMAGE_EXTRACT_AND_RUN: 1 + run: | + mkdir build/SourceGit + tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit + ./build/scripts/package.linux.sh + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: package.${{ matrix.runtime }} + path: | + build/sourcegit-*.AppImage + build/sourcegit_*.deb + build/sourcegit-*.rpm + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: sourcegit.${{ matrix.runtime }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e61e608b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release +on: + push: + tags: + - v* +jobs: + build: + name: Build + uses: ./.github/workflows/build.yml + version: + name: Prepare version string + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Output version string + id: version + env: + TAG: ${{ github.ref_name }} + run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + package: + needs: [build, version] + name: Package + uses: ./.github/workflows/package.yml + with: + version: ${{ needs.version.outputs.version }} + release: + needs: [package, version] + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.version }} + run: gh release create "$TAG" -t "$VERSION" --notes-from-tag + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: package.* + path: packages + merge-multiple: true + - name: Upload assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: gh release upload "$TAG" packages/* diff --git a/.gitignore b/.gitignore index f348333a..e686a534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,41 @@ -.idea -.vs -.vscode -bin -obj -publish +.vs/ +.vscode/ +.idea/ + +*.sln.docstates *.user +*.suo +*.code-workspace + +.DS_Store +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +bin/ +obj/ +# ignore ci node files +node_modules/ +package.json +package-lock.json + +build/resources/ +build/SourceGit/ +build/SourceGit.app/ +build/*.zip +build/*.tar.gz +build/*.deb +build/*.rpm +build/*.AppImage +SourceGit.app/ +build.command +src/Properties/launchSettings.json diff --git a/LICENSE b/LICENSE index e7266704..442ce085 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2021 sourcegit +Copyright (c) 2025 sourcegit 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 @@ -17,4 +17,4 @@ 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. \ No newline at end of file +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index ca35f786..f9ba3072 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,207 @@ -# SourceGit - -Opensouce Git GUI client for Windows. - -## High-lights - -* Opensource/Free -* Light-weight -* Fast -* English/简体中文 -* Build-in light/dark themes -* Visual commit graph -* Supports SSH access with each remote -* GIT commands with GUI - * Clone/Fetch/Pull/Push... - * Branches - * Remotes - * Tags - * Stashes - * Submodules - * Subtrees - * Archive - * Patch/apply - * File histories - * Blame - * Revision Diffs - -## Download - -Pre-build Binaries:[Releases](https://github.com/sourcegit-scm/sourcegit/releases) - -> NOTE: You need install Git first. - -## Screen Shots - -* Drak Theme - -![Theme Dark](./screenshots/theme_dark.png) - -* Light Theme - -![Theme Light](./screenshots/theme_light.png) - -## Thanks - -* [XiaoLinger](https://gitee.com/LingerNN) Hotkey: `CTRL + Enter` to commit -* [carterl](https://gitee.com/carterl) Supports Windows Terminal; Rewrite way to find git executable -* [PUMA](https://gitee.com/whgfu) Configure for default user -* [Rwing](https://gitee.com/rwing) GitFlow: add an option to keep branch after finish -* [XiaoLinger](https://gitee.com/LingerNN) Fix localizations in popup panel +# SourceGit - Opensource Git GUI client. + +[![stars](https://img.shields.io/github/stars/sourcegit-scm/sourcegit.svg)](https://github.com/sourcegit-scm/sourcegit/stargazers) +[![forks](https://img.shields.io/github/forks/sourcegit-scm/sourcegit.svg)](https://github.com/sourcegit-scm/sourcegit/forks) +[![license](https://img.shields.io/github/license/sourcegit-scm/sourcegit.svg)](LICENSE) +[![latest](https://img.shields.io/github/v/release/sourcegit-scm/sourcegit.svg)](https://github.com/sourcegit-scm/sourcegit/releases/latest) +[![downloads](https://img.shields.io/github/downloads/sourcegit-scm/sourcegit/total)](https://github.com/sourcegit-scm/sourcegit/releases) + +## Highlights + +* Supports Windows/macOS/Linux +* Opensource/Free +* Fast +* Deutsch/English/Español/Français/Italiano/Português/Русский/Українська/简体中文/繁體中文/日本語/தமிழ் (Tamil) +* Built-in light/dark themes +* Customize theme +* Visual commit graph +* Supports SSH access with each remote +* GIT commands with GUI + * Clone/Fetch/Pull/Push... + * Merge/Rebase/Reset/Revert/Cherry-pick... + * Amend/Reword/Squash + * Interactive rebase + * Branches + * Remotes + * Tags + * Stashes + * Submodules + * Worktrees + * Archive + * Diff + * Save as patch/apply + * File histories + * Blame + * Revision Diffs + * Branch Diff + * Image Diff - Side-By-Side/Swipe/Blend +* Git command logs +* Search commits +* GitFlow +* Git LFS +* Bisect +* Issue Link +* Workspace +* Custom Action +* Using AI to generate commit message (C# port of [anjerodev/commitollama](https://github.com/anjerodev/commitollama)) + +> [!WARNING] +> **Linux** only tested on **Debian 12** on both **X11** & **Wayland**. + +## Translation Status + +You can find the current translation status in [TRANSLATION.md](https://github.com/sourcegit-scm/sourcegit/blob/develop/TRANSLATION.md) + +## How to Use + +**To use this tool, you need to install Git(>=2.25.1) first.** + +You can download the latest stable from [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) or download workflow artifacts from [GitHub Actions](https://github.com/sourcegit-scm/sourcegit/actions) to try this app based on latest commits. + +This software creates a folder `$"{System.Environment.SpecialFolder.ApplicationData}/SourceGit"`, which is platform-dependent, to store user settings, downloaded avatars and crash logs. + +| OS | PATH | +|---------|-----------------------------------------------------| +| Windows | `%APPDATA%\SourceGit` | +| Linux | `${HOME}/.config/SourceGit` or `${HOME}/.sourcegit` | +| macOS | `${HOME}/Library/Application Support/SourceGit` | + +> [!TIP] +> * You can open this data storage directory from the main menu `Open Data Storage Directory`. +> * You can create a `data` folder next to the `SourceGit` executable to force this app to store data (user settings, downloaded avatars and crash logs) into it (Portable-Mode). Only works on Windows. + +For **Windows** users: + +* **MSYS Git is NOT supported**. Please use official [Git for Windows](https://git-scm.com/download/win) instead. +* You can install the latest stable from `winget` with follow commands: + ```shell + winget install SourceGit + ``` +> [!NOTE] +> `winget` will install this software as a commandline tool. You need run `SourceGit` from console or `Win+R` at the first time. Then you can add it to the taskbar. +* You can install the latest stable by `scoop` with follow commands: + ```shell + scoop bucket add extras + scoop install sourcegit + ``` +* Pre-built binaries can be found in [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) + +For **macOS** users: + +* Thanks [@ybeapps](https://github.com/ybeapps) for making `SourceGit` available on `Homebrew`. You can simply install it with following command: + ```shell + brew tap ybeapps/homebrew-sourcegit + brew install --cask --no-quarantine sourcegit + ``` +* If you want to install `SourceGit.app` from GitHub Release manually, you need run following command to make sure it works: + ```shell + sudo xattr -cr /Applications/SourceGit.app + ``` +* Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your mac. +* You can run `echo $PATH > ~/Library/Application\ Support/SourceGit/PATH` to generate a custom PATH env file to introduce `PATH` env to SourceGit. + +For **Linux** users: + +* Thanks [@aikawayataro](https://github.com/aikawayataro) for providing `rpm` and `deb` repositories, hosted on [Codeberg](https://codeberg.org/yataro/-/packages). + + `deb` how to: + ```shell + curl https://codeberg.org/api/packages/yataro/debian/repository.key | sudo tee /etc/apt/keyrings/sourcegit.asc + echo "deb [signed-by=/etc/apt/keyrings/sourcegit.asc, arch=amd64,arm64] https://codeberg.org/api/packages/yataro/debian generic main" | sudo tee /etc/apt/sources.list.d/sourcegit.list + sudo apt update + sudo apt install sourcegit + ``` + + `rpm` how to: + ```shell + curl https://codeberg.org/api/packages/yataro/rpm.repo | sed -e 's/gpgcheck=1/gpgcheck=0/' > sourcegit.repo + + # Fedora 41 and newer + sudo dnf config-manager addrepo --from-repofile=./sourcegit.repo + # Fedora 40 and earlier + sudo dnf config-manager --add-repo ./sourcegit.repo + + sudo dnf install sourcegit + ``` + + If your distribution isn't using `dnf`, please refer to the documentation of your distribution on how to add an `rpm` repository. +* `AppImage` files can be found on [AppImage hub](https://appimage.github.io/SourceGit/), `xdg-open` (`xdg-utils`) must be installed to support open native file manager. +* Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your Linux. +* Maybe you need to set environment variable `AVALONIA_SCREEN_SCALE_FACTORS`. See https://github.com/AvaloniaUI/Avalonia/wiki/Configuring-X11-per-monitor-DPI. +* If you can NOT type accented characters, such as `ê`, `ó`, try to set the environment variable `AVALONIA_IM_MODULE` to `none`. + +## OpenAI + +This software supports using OpenAI or other AI service that has an OpenAI compatible HTTP API to generate commit message. You need configurate the service in `Preference` window. + +For `OpenAI`: + +* `Server` must be `https://api.openai.com/v1` + +For other AI service: + +* The `Server` should fill in a URL equivalent to OpenAI's `https://api.openai.com/v1`. For example, when using `Ollama`, it should be `http://localhost:11434/v1` instead of `http://localhost:11434/api/generate` +* The `API Key` is optional that depends on the service + +## External Tools + +This app supports open repository in external tools listed in the table below. + +| Tool | Windows | macOS | Linux | +|-------------------------------|---------|-------|-------| +| Visual Studio Code | YES | YES | YES | +| Visual Studio Code - Insiders | YES | YES | YES | +| VSCodium | YES | YES | YES | +| Fleet | YES | YES | YES | +| Sublime Text | YES | YES | YES | +| Zed | NO | YES | YES | +| Visual Studio | YES | NO | NO | + +> [!NOTE] +> This app will try to find those tools based on some pre-defined or expected locations automatically. If you are using one portable version of these tools, it will not be detected by this app. +> To solve this problem you can add a file named `external_editors.json` in app data storage directory and provide the path directly. For example: +```json +{ + "tools": { + "Visual Studio Code": "D:\\VSCode\\Code.exe" + } +} +``` + +> [!NOTE] +> This app also supports a lot of `JetBrains` IDEs, installing `JetBrains Toolbox` will help this app to find them. + +## Screenshots + +* Dark Theme + + ![Theme Dark](./screenshots/theme_dark.png) + +* Light Theme + + ![Theme Light](./screenshots/theme_light.png) + +* Custom + + You can find custom themes from [sourcegit-theme](https://github.com/sourcegit-scm/sourcegit-theme.git). And welcome to share your own themes. + +## Contributing + +Everyone is welcome to submit a PR. Please make sure your PR is based on the latest `develop` branch and the target branch of PR is `develop`. + +In short, here are the commands to get started once [.NET tools are installed](https://dotnet.microsoft.com/en-us/download): + +```sh +dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org +dotnet restore +dotnet build +dotnet run --project src/SourceGit.csproj +``` + +Thanks to all the people who contribute. + +[![Contributors](https://contrib.rocks/image?repo=sourcegit-scm/sourcegit&columns=20)](https://github.com/sourcegit-scm/sourcegit/graphs/contributors) + +## Third-Party Components + +For detailed license information, see [THIRD-PARTY-LICENSES.md](THIRD-PARTY-LICENSES.md). diff --git a/SourceGit.sln b/SourceGit.sln new file mode 100644 index 00000000..624322f8 --- /dev/null +++ b/SourceGit.sln @@ -0,0 +1,122 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34714.143 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGit", "src\SourceGit.csproj", "{2091C34D-4A17-4375-BEF3-4D60BE8113E4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{773082AC-D9C8-4186-8521-4B6A7BEE6158}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{FD384607-ED99-47B7-AF31-FB245841BC92}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{F45A9D95-AF25-42D8-BBAC-8259C9EEE820}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{67B6D05F-A000-40BA-ADB4-C9065F880D7B}" + ProjectSection(SolutionItems) = preProject + .github\workflows\build.yml = .github\workflows\build.yml + .github\workflows\ci.yml = .github\workflows\ci.yml + .github\workflows\package.yml = .github\workflows\package.yml + .github\workflows\release.yml = .github\workflows\release.yml + .github\workflows\localization-check.yml = .github\workflows\localization-check.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{49A7C2D6-558C-4FAA-8F5D-EEE81497AED7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "files", "files", "{3AB707DB-A02C-4AFC-BF12-D7DF2B333BAC}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore + global.json = global.json + LICENSE = LICENSE + README.md = README.md + VERSION = VERSION + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{ABC98884-F023-4EF4-A9C9-5DE9452BE955}" + ProjectSection(SolutionItems) = preProject + build\resources\app\App.icns = build\resources\app\App.icns + build\resources\app\App.plist = build\resources\app\App.plist + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_common", "_common", "{04FD74B1-FBDB-496E-A48F-3D59D71FF952}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "usr", "usr", "{76639799-54BC-45E8-BD90-F45F63ACD11D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "share", "share", "{A3ABAA7C-EE14-4448-B466-6E69C1347E7D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "applications", "applications", "{2AF28D3B-14A8-46A8-B828-157FAAB1B06F}" + ProjectSection(SolutionItems) = preProject + build\resources\_common\usr\share\applications\sourcegit.desktop = build\resources\_common\usr\share\applications\sourcegit.desktop + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "icons", "icons", "{7166EC6C-17F5-4B5E-B38E-1E53C81EACF6}" + ProjectSection(SolutionItems) = preProject + build\resources\_common\usr\share\icons\sourcegit.png = build\resources\_common\usr\share\icons\sourcegit.png + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deb", "deb", "{9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DEBIAN", "DEBIAN", "{F101849D-BDB7-40D4-A516-751150C3CCFC}" + ProjectSection(SolutionItems) = preProject + build\resources\deb\DEBIAN\control = build\resources\deb\DEBIAN\control + build\resources\deb\DEBIAN\preinst = build\resources\deb\DEBIAN\preinst + build\resources\deb\DEBIAN\prerm = build\resources\deb\DEBIAN\prerm + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rpm", "rpm", "{9BA0B044-0CC9-46F8-B551-204F149BF45D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SPECS", "SPECS", "{7802CD7A-591B-4EDD-96F8-9BF3F61692E4}" + ProjectSection(SolutionItems) = preProject + build\resources\rpm\SPECS\build.spec = build\resources\rpm\SPECS\build.spec + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "appimage", "appimage", "{5D125DD9-B48A-491F-B2FB-D7830D74C4DC}" + ProjectSection(SolutionItems) = preProject + build\resources\appimage\sourcegit.appdata.xml = build\resources\appimage\sourcegit.appdata.xml + build\resources\appimage\sourcegit.png = build\resources\appimage\sourcegit.png + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C54D4001-9940-477C-A0B6-E795ED0A3209}" + ProjectSection(SolutionItems) = preProject + build\scripts\localization-check.js = build\scripts\localization-check.js + build\scripts\package.linux.sh = build\scripts\package.linux.sh + build\scripts\package.osx-app.sh = build\scripts\package.osx-app.sh + build\scripts\package.windows.sh = build\scripts\package.windows.sh + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2091C34D-4A17-4375-BEF3-4D60BE8113E4} = {49A7C2D6-558C-4FAA-8F5D-EEE81497AED7} + {FD384607-ED99-47B7-AF31-FB245841BC92} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} + {67B6D05F-A000-40BA-ADB4-C9065F880D7B} = {F45A9D95-AF25-42D8-BBAC-8259C9EEE820} + {ABC98884-F023-4EF4-A9C9-5DE9452BE955} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {04FD74B1-FBDB-496E-A48F-3D59D71FF952} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {76639799-54BC-45E8-BD90-F45F63ACD11D} = {04FD74B1-FBDB-496E-A48F-3D59D71FF952} + {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} = {76639799-54BC-45E8-BD90-F45F63ACD11D} + {2AF28D3B-14A8-46A8-B828-157FAAB1B06F} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} + {7166EC6C-17F5-4B5E-B38E-1E53C81EACF6} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} + {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {F101849D-BDB7-40D4-A516-751150C3CCFC} = {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} + {9BA0B044-0CC9-46F8-B551-204F149BF45D} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {7802CD7A-591B-4EDD-96F8-9BF3F61692E4} = {9BA0B044-0CC9-46F8-B551-204F149BF45D} + {5D125DD9-B48A-491F-B2FB-D7830D74C4DC} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {C54D4001-9940-477C-A0B6-E795ED0A3209} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7FF1B9C6-B5BF-4A50-949F-4B407A0E31C9} + EndGlobalSection +EndGlobal diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md new file mode 100644 index 00000000..2338263c --- /dev/null +++ b/THIRD-PARTY-LICENSES.md @@ -0,0 +1,86 @@ +# Third-Party Licenses + +This project incorporates components from the following third parties: + +## Packages + +### AvaloniaUI + +- **Source**: https://github.com/AvaloniaUI/Avalonia +- **Version**: 11.2.5 +- **License**: MIT License +- **License Link**: https://github.com/AvaloniaUI/Avalonia/blob/master/licence.md + +### AvaloniaEdit + +- **Source**: https://github.com/AvaloniaUI/AvaloniaEdit +- **Version**: 11.2.0 +- **License**: MIT License +- **License Link**: https://github.com/AvaloniaUI/AvaloniaEdit/blob/master/LICENSE + +### LiveChartsCore.SkiaSharpView.Avalonia + +- **Source**: https://github.com/beto-rodriguez/LiveCharts2 +- **Version**: 2.0.0-rc5.4 +- **License**: MIT License +- **License Link**: https://github.com/beto-rodriguez/LiveCharts2/blob/master/LICENSE + +### TextMateSharp + +- **Source**: https://github.com/danipen/TextMateSharp +- **Version**: 1.0.66 +- **License**: MIT License +- **License Link**: https://github.com/danipen/TextMateSharp/blob/master/LICENSE.md + +### OpenAI .NET SDK + +- **Source**: https://github.com/openai/openai-dotnet +- **Version**: 2.2.0-beta2 +- **License**: MIT License +- **License Link**: https://github.com/openai/openai-dotnet/blob/main/LICENSE + +### Azure.AI.OpenAI + +- **Source**: https://github.com/Azure/azure-sdk-for-net +- **Version**: 2.2.0-beta2 +- **License**: MIT License +- **License Link**: https://github.com/Azure/azure-sdk-for-net/blob/main/LICENSE.txt + +## Fonts + +### JetBrainsMono + +- **Source**: https://github.com/JetBrains/JetBrainsMono +- **Commit**: v2.304 +- **License**: SIL Open Font License, Version 1.1 +- **License Link**: https://github.com/JetBrains/JetBrainsMono/blob/v2.304/OFL.txt + +## Grammar Files + +### haxe-TmLanguage + +- **Source**: https://github.com/vshaxe/haxe-TmLanguage +- **Commit**: ddad8b4c6d0781ac20be0481174ec1be772c5da5 +- **License**: MIT License +- **License Link**: https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/LICENSE.md + +### coc-toml + +- **Source**: https://github.com/kkiyama117/coc-toml +- **Commit**: aac3e0c65955c03314b2733041b19f903b7cc447 +- **License**: MIT License +- **License Link**: https://github.com/kkiyama117/coc-toml/blob/aac3e0c65955c03314b2733041b19f903b7cc447/LICENSE + +### eclipse-buildship + +- **Source**: https://github.com/eclipse/buildship +- **Commit**: 6bb773e7692f913dec27105129ebe388de34e68b +- **License**: Eclipse Public License 1.0 +- **License Link**: https://github.com/eclipse-buildship/buildship/blob/6bb773e7692f913dec27105129ebe388de34e68b/README.md + +### vscode-jsp-lang + +- **Source**: https://github.com/samuel-weinhardt/vscode-jsp-lang +- **Commit**: 0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355 +- **License**: MIT License +- **License Link**: https://github.com/samuel-weinhardt/vscode-jsp-lang/blob/0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355/LICENSE diff --git a/TRANSLATION.md b/TRANSLATION.md new file mode 100644 index 00000000..051440f0 --- /dev/null +++ b/TRANSLATION.md @@ -0,0 +1,511 @@ +# Translation Status + +This document shows the translation status of each locale file in the repository. + +## Details + +### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen) + +### ![de__DE](https://img.shields.io/badge/de__DE-96.14%25-yellow) + +
+Missing keys in de_DE.axaml + +- Text.Avatar.Load +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CommitDetail.Changes.Count +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Pull.RecurseSubmodules +- Text.Repository.ClearStashes +- Text.Repository.ShowSubmodulesAsTree +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.WorkingCopy.ResetAuthor + +
+ +### ![es__ES](https://img.shields.io/badge/es__ES-%E2%88%9A-brightgreen) + +### ![fr__FR](https://img.shields.io/badge/fr__FR-92.03%25-yellow) + +
+Missing keys in fr_FR.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.Git.PreferredMergeMode +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.ConfirmCommitWithFilter +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor + +
+ +### ![it__IT](https://img.shields.io/badge/it__IT-97.38%25-yellow) + +
+Missing keys in it_IT.axaml + +- Text.Avatar.Load +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CommitDetail.Changes.Count +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Pull.RecurseSubmodules +- Text.Repository.ClearStashes +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.WorkingCopy.ResetAuthor + +
+ +### ![ja__JP](https://img.shields.io/badge/ja__JP-91.78%25-yellow) + +
+Missing keys in ja_JP.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.CompareWithCurrent +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.Git.PreferredMergeMode +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.FilterCommits +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.ConfirmCommitWithFilter +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor + +
+ +### ![pt__BR](https://img.shields.io/badge/pt__BR-83.81%25-yellow) + +
+Missing keys in pt_BR.axaml + +- Text.AIAssistant.Regen +- Text.AIAssistant.Use +- Text.ApplyStash +- Text.ApplyStash.DropAfterApply +- Text.ApplyStash.RestoreIndex +- Text.ApplyStash.Stash +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.CustomAction +- Text.BranchCM.MergeMultiBranches +- Text.BranchCM.ResetToSelectedCommit +- Text.BranchUpstreamInvalid +- Text.Checkout.RecurseSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.Clone.RecurseSubmodules +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitCM.Merge +- Text.CommitCM.MergeMultiple +- Text.CommitDetail.Changes.Count +- Text.CommitDetail.Files.Search +- Text.CommitDetail.Info.Children +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.CustomAction.Scope.Branch +- Text.Configure.CustomAction.WaitForExit +- Text.Configure.Git.PreferredMergeMode +- Text.Configure.IssueTracker.AddSampleGiteeIssue +- Text.Configure.IssueTracker.AddSampleGiteePullRequest +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CopyFullPath +- Text.CreateBranch.Name.WarnSpace +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.DeleteRepositoryNode.Path +- Text.DeleteRepositoryNode.TipForGroup +- Text.DeleteRepositoryNode.TipForRepository +- Text.Diff.First +- Text.Diff.Last +- Text.Diff.Submodule.Deleted +- Text.Diff.UseBlockNavigation +- Text.Fetch.Force +- Text.FileCM.ResolveUsing +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.Clone +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.InProgress.CherryPick.Head +- Text.InProgress.Merge.Operating +- Text.InProgress.Rebase.StoppedAt +- Text.InProgress.Revert.Head +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Merge.Source +- Text.MergeMultiple +- Text.MergeMultiple.CommitChanges +- Text.MergeMultiple.Strategy +- Text.MergeMultiple.Targets +- Text.Preferences.AI.Streaming +- Text.Preferences.Appearance.EditorTabWidth +- Text.Preferences.General.DateFormat +- Text.Preferences.General.ShowChildren +- Text.Preferences.General.ShowTagsInGraph +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Preferences.Git.SSLVerify +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.FilterCommits +- Text.Repository.HistoriesLayout +- Text.Repository.HistoriesLayout.Horizontal +- Text.Repository.HistoriesLayout.Vertical +- Text.Repository.HistoriesOrder +- Text.Repository.Notifications.Clear +- Text.Repository.OnlyHighlightCurrentBranchInHistories +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.Skip +- Text.Repository.Tags.OrderByCreatorDate +- Text.Repository.Tags.OrderByName +- Text.Repository.Tags.Sort +- Text.Repository.UseRelativeTimeInHistories +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.SetUpstream +- Text.SetUpstream.Local +- Text.SetUpstream.Unset +- Text.SetUpstream.Upstream +- Text.SHALinkCM.NavigateTo +- Text.Stash.AutoRestore +- Text.Stash.AutoRestore.Tip +- Text.StashCM.SaveAsPatch +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.CommitToEdit +- Text.WorkingCopy.ConfirmCommitWithFilter +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor +- Text.WorkingCopy.SignOff + +
+ +### ![ru__RU](https://img.shields.io/badge/ru__RU-99.75%25-yellow) + +
+Missing keys in ru_RU.axaml + +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream + +
+ +### ![ta__IN](https://img.shields.io/badge/ta__IN-91.91%25-yellow) + +
+Missing keys in ta_IN.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.CompareWithCurrent +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.Git.PreferredMergeMode +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.UpdateSubmodules.Target +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor + +
+ +### ![uk__UA](https://img.shields.io/badge/uk__UA-93.15%25-yellow) + +
+Missing keys in uk_UA.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.ConfigureWorkspace.Name +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.ResetAuthor + +
+ +### ![zh__CN](https://img.shields.io/badge/zh__CN-%E2%88%9A-brightgreen) + +### ![zh__TW](https://img.shields.io/badge/zh__TW-%E2%88%9A-brightgreen) \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..d3e094ba --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2025.22 \ No newline at end of file diff --git a/build/README.md b/build/README.md new file mode 100644 index 00000000..17305edf --- /dev/null +++ b/build/README.md @@ -0,0 +1,15 @@ +# build + +> [!WARNING] +> The files under the `build` folder is used for `Github Action` only, **NOT** for end users. + +## How to build this project manually + +1. Make sure [.NET SDK 9](https://dotnet.microsoft.com/en-us/download) is installed on your machine. +2. Clone this project +3. Run the follow command under the project root dir +```sh +dotnet publish -c Release -r $RUNTIME_IDENTIFIER -o $DESTINATION_FOLDER src/SourceGit.csproj +``` +> [!NOTE] +> Please replace the `$RUNTIME_IDENTIFIER` with one of `win-x64`,`win-arm64`,`linux-x64`,`linux-arm64`,`osx-x64`,`osx-arm64`, and replace the `$DESTINATION_FOLDER` with the real path that will store the output executable files. diff --git a/build/resources/_common/applications/sourcegit.desktop b/build/resources/_common/applications/sourcegit.desktop new file mode 100644 index 00000000..bcf9c813 --- /dev/null +++ b/build/resources/_common/applications/sourcegit.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=SourceGit +Comment=Open-source & Free Git GUI Client +Exec=/opt/sourcegit/sourcegit +Icon=/usr/share/icons/sourcegit.png +Terminal=false +Type=Application +Categories=Development +MimeType=inode/directory; diff --git a/build/resources/_common/icons/sourcegit.png b/build/resources/_common/icons/sourcegit.png new file mode 100644 index 00000000..8cdcd3a8 Binary files /dev/null and b/build/resources/_common/icons/sourcegit.png differ diff --git a/build/resources/app/App.icns b/build/resources/app/App.icns new file mode 100644 index 00000000..4dc51b20 Binary files /dev/null and b/build/resources/app/App.icns differ diff --git a/build/resources/app/App.plist b/build/resources/app/App.plist new file mode 100644 index 00000000..ba6f40a2 --- /dev/null +++ b/build/resources/app/App.plist @@ -0,0 +1,26 @@ + + + + + CFBundleIconFile + App.icns + CFBundleIdentifier + com.sourcegit-scm.sourcegit + CFBundleName + SourceGit + CFBundleVersion + SOURCE_GIT_VERSION.0 + LSMinimumSystemVersion + 11.0 + CFBundleExecutable + SourceGit + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleShortVersionString + SOURCE_GIT_VERSION + NSHighResolutionCapable + + + diff --git a/build/resources/appimage/sourcegit.appdata.xml b/build/resources/appimage/sourcegit.appdata.xml new file mode 100644 index 00000000..012c82d3 --- /dev/null +++ b/build/resources/appimage/sourcegit.appdata.xml @@ -0,0 +1,16 @@ + + + com.sourcegit_scm.SourceGit + MIT + MIT + SourceGit + Open-source GUI client for git users + +

Open-source GUI client for git users

+
+ https://github.com/sourcegit-scm/sourcegit + com.sourcegit_scm.SourceGit.desktop + + com.sourcegit_scm.SourceGit.desktop + +
diff --git a/build/resources/appimage/sourcegit.png b/build/resources/appimage/sourcegit.png new file mode 100644 index 00000000..8cdcd3a8 Binary files /dev/null and b/build/resources/appimage/sourcegit.png differ diff --git a/build/resources/deb/DEBIAN/control b/build/resources/deb/DEBIAN/control new file mode 100755 index 00000000..71786b43 --- /dev/null +++ b/build/resources/deb/DEBIAN/control @@ -0,0 +1,8 @@ +Package: sourcegit +Version: 2025.10 +Priority: optional +Depends: libx11-6, libice6, libsm6, libicu | libicu76 | libicu74 | libicu72 | libicu71 | libicu70 | libicu69 | libicu68 | libicu67 | libicu66 | libicu65 | libicu63 | libicu60 | libicu57 | libicu55 | libicu52, xdg-utils +Architecture: amd64 +Installed-Size: 60440 +Maintainer: longshuang@msn.cn +Description: Open-source & Free Git GUI Client diff --git a/build/resources/deb/DEBIAN/preinst b/build/resources/deb/DEBIAN/preinst new file mode 100755 index 00000000..a93f8090 --- /dev/null +++ b/build/resources/deb/DEBIAN/preinst @@ -0,0 +1,32 @@ +#!/bin/sh + +set -e + +# summary of how this script can be called: +# * `install' +# * `install' +# * `upgrade' +# * `abort-upgrade' +# for details, see http://www.debian.org/doc/debian-policy/ + +case "$1" in + install|upgrade) + # Check if SourceGit is running and stop it + if pgrep -f '/opt/sourcegit/sourcegit' > /dev/null; then + echo "Stopping running SourceGit instance..." + pkill -f '/opt/sourcegit/sourcegit' || true + # Give the process a moment to terminate + sleep 1 + fi + ;; + + abort-upgrade) + ;; + + *) + echo "preinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/build/resources/deb/DEBIAN/prerm b/build/resources/deb/DEBIAN/prerm new file mode 100755 index 00000000..c2c9e4f0 --- /dev/null +++ b/build/resources/deb/DEBIAN/prerm @@ -0,0 +1,35 @@ +#!/bin/sh + +set -e + +# summary of how this script can be called: +# * `remove' +# * `upgrade' +# * `failed-upgrade' +# * `remove' `in-favour' +# * `deconfigure' `in-favour' +# `removing' +# +# for details, see http://www.debian.org/doc/debian-policy/ or +# the debian-policy package + +case "$1" in + remove|upgrade|deconfigure) + if pgrep -f '/opt/sourcegit/sourcegit' > /dev/null; then + echo "Stopping running SourceGit instance..." + pkill -f '/opt/sourcegit/sourcegit' || true + # Give the process a moment to terminate + sleep 1 + fi + ;; + + failed-upgrade) + ;; + + *) + echo "prerm called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/build/resources/rpm/SPECS/build.spec b/build/resources/rpm/SPECS/build.spec new file mode 100644 index 00000000..2a684837 --- /dev/null +++ b/build/resources/rpm/SPECS/build.spec @@ -0,0 +1,38 @@ +Name: sourcegit +Version: %_version +Release: 1 +Summary: Open-source & Free Git Gui Client +License: MIT +URL: https://sourcegit-scm.github.io/ +Source: https://github.com/sourcegit-scm/sourcegit/archive/refs/tags/v%_version.tar.gz +Requires: libX11.so.6()(%{__isa_bits}bit) +Requires: libSM.so.6()(%{__isa_bits}bit) +Requires: libicu +Requires: xdg-utils + +%define _build_id_links none + +%description +Open-source & Free Git Gui Client + +%install +mkdir -p %{buildroot}/opt/sourcegit +mkdir -p %{buildroot}/%{_bindir} +mkdir -p %{buildroot}/usr/share/applications +mkdir -p %{buildroot}/usr/share/icons +cp -f ../../../SourceGit/* %{buildroot}/opt/sourcegit/ +ln -rsf %{buildroot}/opt/sourcegit/sourcegit %{buildroot}/%{_bindir} +cp -r ../../_common/applications %{buildroot}/%{_datadir} +cp -r ../../_common/icons %{buildroot}/%{_datadir} +chmod 755 -R %{buildroot}/opt/sourcegit +chmod 755 %{buildroot}/%{_datadir}/applications/sourcegit.desktop + +%files +%dir /opt/sourcegit/ +/opt/sourcegit/* +/usr/share/applications/sourcegit.desktop +/usr/share/icons/* +%{_bindir}/sourcegit + +%changelog +# skip diff --git a/build/scripts/localization-check.js b/build/scripts/localization-check.js new file mode 100644 index 00000000..8d636b5b --- /dev/null +++ b/build/scripts/localization-check.js @@ -0,0 +1,83 @@ +const fs = require('fs-extra'); +const path = require('path'); +const xml2js = require('xml2js'); + +const repoRoot = path.join(__dirname, '../../'); +const localesDir = path.join(repoRoot, 'src/Resources/Locales'); +const enUSFile = path.join(localesDir, 'en_US.axaml'); +const outputFile = path.join(repoRoot, 'TRANSLATION.md'); + +const parser = new xml2js.Parser(); + +async function parseXml(filePath) { + const data = await fs.readFile(filePath); + return parser.parseStringPromise(data); +} + +async function filterAndSortTranslations(localeData, enUSKeys, enUSData) { + const strings = localeData.ResourceDictionary['x:String']; + // Remove keys that don't exist in English file + const filtered = strings.filter(item => enUSKeys.has(item.$['x:Key'])); + + // Sort based on the key order in English file + const enUSKeysArray = enUSData.ResourceDictionary['x:String'].map(item => item.$['x:Key']); + filtered.sort((a, b) => { + const aIndex = enUSKeysArray.indexOf(a.$['x:Key']); + const bIndex = enUSKeysArray.indexOf(b.$['x:Key']); + return aIndex - bIndex; + }); + + return filtered; +} + +async function calculateTranslationRate() { + const enUSData = await parseXml(enUSFile); + const enUSKeys = new Set(enUSData.ResourceDictionary['x:String'].map(item => item.$['x:Key'])); + const files = (await fs.readdir(localesDir)).filter(file => file !== 'en_US.axaml' && file.endsWith('.axaml')); + + const lines = []; + + lines.push('# Translation Status'); + lines.push('This document shows the translation status of each locale file in the repository.'); + lines.push(`## Details`); + lines.push(`### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen)`); + + for (const file of files) { + const locale = file.replace('.axaml', '').replace('_', '__'); + const filePath = path.join(localesDir, file); + const localeData = await parseXml(filePath); + const localeKeys = new Set(localeData.ResourceDictionary['x:String'].map(item => item.$['x:Key'])); + const missingKeys = [...enUSKeys].filter(key => !localeKeys.has(key)); + + // Sort and clean up extra translations + const sortedAndCleaned = await filterAndSortTranslations(localeData, enUSKeys, enUSData); + localeData.ResourceDictionary['x:String'] = sortedAndCleaned; + + // Save the updated file + const builder = new xml2js.Builder({ + headless: true, + renderOpts: { pretty: true, indent: ' ' } + }); + let xmlStr = builder.buildObject(localeData); + + // Add an empty line before the first x:String + xmlStr = xmlStr.replace(' 0) { + const progress = ((enUSKeys.size - missingKeys.length) / enUSKeys.size) * 100; + const badgeColor = progress >= 75 ? 'yellow' : 'red'; + + lines.push(`### ![${locale}](https://img.shields.io/badge/${locale}-${progress.toFixed(2)}%25-${badgeColor})`); + lines.push(`
\nMissing keys in ${file}\n\n${missingKeys.map(key => `- ${key}`).join('\n')}\n\n
`) + } else { + lines.push(`### ![${locale}](https://img.shields.io/badge/${locale}-%E2%88%9A-brightgreen)`); + } + } + + const content = lines.join('\n\n'); + console.log(content); + await fs.writeFile(outputFile, content, 'utf8'); +} + +calculateTranslationRate().catch(err => console.error(err)); diff --git a/build/scripts/package.linux.sh b/build/scripts/package.linux.sh new file mode 100755 index 00000000..1b4adbdc --- /dev/null +++ b/build/scripts/package.linux.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -e +set -o +set -u +set pipefail + +arch= +appimage_arch= +target= +case "$RUNTIME" in + linux-x64) + arch=amd64 + appimage_arch=x86_64 + target=x86_64;; + linux-arm64) + arch=arm64 + appimage_arch=arm_aarch64 + target=aarch64;; + *) + echo "Unknown runtime $RUNTIME" + exit 1;; +esac + +APPIMAGETOOL_URL=https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + +cd build + +if [[ ! -f "appimagetool" ]]; then + curl -o appimagetool -L "$APPIMAGETOOL_URL" + chmod +x appimagetool +fi + +rm -f SourceGit/*.dbg + +mkdir -p SourceGit.AppDir/opt +mkdir -p SourceGit.AppDir/usr/share/metainfo +mkdir -p SourceGit.AppDir/usr/share/applications + +cp -r SourceGit SourceGit.AppDir/opt/sourcegit +desktop-file-install resources/_common/applications/sourcegit.desktop --dir SourceGit.AppDir/usr/share/applications \ + --set-icon com.sourcegit_scm.SourceGit --set-key=Exec --set-value=AppRun +mv SourceGit.AppDir/usr/share/applications/{sourcegit,com.sourcegit_scm.SourceGit}.desktop +cp resources/appimage/sourcegit.png SourceGit.AppDir/com.sourcegit_scm.SourceGit.png +ln -rsf SourceGit.AppDir/opt/sourcegit/sourcegit SourceGit.AppDir/AppRun +ln -rsf SourceGit.AppDir/usr/share/applications/com.sourcegit_scm.SourceGit.desktop SourceGit.AppDir +cp resources/appimage/sourcegit.appdata.xml SourceGit.AppDir/usr/share/metainfo/com.sourcegit_scm.SourceGit.appdata.xml + +ARCH="$appimage_arch" ./appimagetool -v SourceGit.AppDir "sourcegit-$VERSION.linux.$arch.AppImage" + +mkdir -p resources/deb/opt/sourcegit/ +mkdir -p resources/deb/usr/bin +mkdir -p resources/deb/usr/share/applications +mkdir -p resources/deb/usr/share/icons +cp -f SourceGit/* resources/deb/opt/sourcegit +ln -rsf resources/deb/opt/sourcegit/sourcegit resources/deb/usr/bin +cp -r resources/_common/applications resources/deb/usr/share +cp -r resources/_common/icons resources/deb/usr/share +# Calculate installed size in KB +installed_size=$(du -sk resources/deb | cut -f1) +# Update the control file +sed -i -e "s/^Version:.*/Version: $VERSION/" \ + -e "s/^Architecture:.*/Architecture: $arch/" \ + -e "s/^Installed-Size:.*/Installed-Size: $installed_size/" \ + resources/deb/DEBIAN/control +# Build deb package with gzip compression +dpkg-deb -Zgzip --root-owner-group --build resources/deb "sourcegit_$VERSION-1_$arch.deb" + +rpmbuild -bb --target="$target" resources/rpm/SPECS/build.spec --define "_topdir $(pwd)/resources/rpm" --define "_version $VERSION" +mv "resources/rpm/RPMS/$target/sourcegit-$VERSION-1.$target.rpm" ./ diff --git a/build/scripts/package.osx-app.sh b/build/scripts/package.osx-app.sh new file mode 100755 index 00000000..2d43e24a --- /dev/null +++ b/build/scripts/package.osx-app.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e +set -o +set -u +set pipefail + +cd build + +mkdir -p SourceGit.app/Contents/Resources +mv SourceGit SourceGit.app/Contents/MacOS +cp resources/app/App.icns SourceGit.app/Contents/Resources/App.icns +sed "s/SOURCE_GIT_VERSION/$VERSION/g" resources/app/App.plist > SourceGit.app/Contents/Info.plist +rm -rf SourceGit.app/Contents/MacOS/SourceGit.dsym + +zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit.app diff --git a/build/scripts/package.windows.sh b/build/scripts/package.windows.sh new file mode 100755 index 00000000..c22a9d35 --- /dev/null +++ b/build/scripts/package.windows.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e +set -o +set -u +set pipefail + +cd build + +rm -rf SourceGit/*.pdb + +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then + powershell -Command "Compress-Archive -Path SourceGit -DestinationPath \"sourcegit_$VERSION.$RUNTIME.zip\" -Force" +else + zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit +fi diff --git a/global.json b/global.json new file mode 100644 index 00000000..a27a2b82 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/screenshots/theme_dark.png b/screenshots/theme_dark.png index 1a052161..85e18481 100644 Binary files a/screenshots/theme_dark.png and b/screenshots/theme_dark.png differ diff --git a/screenshots/theme_light.png b/screenshots/theme_light.png index 8076f778..2e8cf6fc 100644 Binary files a/screenshots/theme_light.png and b/screenshots/theme_light.png differ diff --git a/src/App.Commands.cs b/src/App.Commands.cs new file mode 100644 index 00000000..22e9fb51 --- /dev/null +++ b/src/App.Commands.cs @@ -0,0 +1,58 @@ +using System; +using System.Windows.Input; +using Avalonia.Controls; + +namespace SourceGit +{ + public partial class App + { + public class Command : ICommand + { + public event EventHandler CanExecuteChanged + { + add { } + remove { } + } + + public Command(Action action) + { + _action = action; + } + + public bool CanExecute(object parameter) => _action != null; + public void Execute(object parameter) => _action?.Invoke(parameter); + + private Action _action = null; + } + + public static bool IsCheckForUpdateCommandVisible + { + get + { +#if DISABLE_UPDATE_DETECTION + return false; +#else + return true; +#endif + } + } + + public static readonly Command OpenPreferencesCommand = new Command(_ => ShowWindow(new Views.Preferences(), false)); + public static readonly Command OpenHotkeysCommand = new Command(_ => ShowWindow(new Views.Hotkeys(), false)); + public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir)); + public static readonly Command OpenAboutCommand = new Command(_ => ShowWindow(new Views.About(), false)); + public static readonly Command CheckForUpdateCommand = new Command(_ => (Current as App)?.Check4Update(true)); + public static readonly Command QuitCommand = new Command(_ => Quit(0)); + public static readonly Command CopyTextBlockCommand = new Command(p => + { + var textBlock = p as TextBlock; + if (textBlock == null) + return; + + if (textBlock.Inlines is { Count: > 0 } inlines) + CopyText(inlines.Text); + else if (!string.IsNullOrEmpty(textBlock.Text)) + CopyText(textBlock.Text); + }); + } +} diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs new file mode 100644 index 00000000..9cad0792 --- /dev/null +++ b/src/App.JsonCodeGen.cs @@ -0,0 +1,54 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit +{ + public class ColorConverter : JsonConverter + { + public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Color.Parse(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } + + public class GridLengthConverter : JsonConverter + { + public override GridLength Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var size = reader.GetDouble(); + return new GridLength(size, GridUnitType.Pixel); + } + + public override void Write(Utf8JsonWriter writer, GridLength value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.Value); + } + } + + [JsonSourceGenerationOptions( + WriteIndented = true, + IgnoreReadOnlyFields = true, + IgnoreReadOnlyProperties = true, + Converters = [ + typeof(ColorConverter), + typeof(GridLengthConverter), + ] + )] + [JsonSerializable(typeof(Models.ExternalToolPaths))] + [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] + [JsonSerializable(typeof(Models.JetBrainsState))] + [JsonSerializable(typeof(Models.ThemeOverrides))] + [JsonSerializable(typeof(Models.Version))] + [JsonSerializable(typeof(Models.RepositorySettings))] + [JsonSerializable(typeof(ViewModels.Preferences))] + internal partial class JsonCodeGen : JsonSerializerContext { } +} diff --git a/src/App.axaml b/src/App.axaml new file mode 100644 index 00000000..186022d5 --- /dev/null +++ b/src/App.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.axaml.cs b/src/App.axaml.cs new file mode 100644 index 00000000..8e579373 --- /dev/null +++ b/src/App.axaml.cs @@ -0,0 +1,706 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Media.Fonts; +using Avalonia.Platform.Storage; +using Avalonia.Styling; +using Avalonia.Threading; + +namespace SourceGit +{ + public partial class App : Application + { + #region App Entry Point + [STAThread] + public static void Main(string[] args) + { + Native.OS.SetupDataDir(); + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + LogException(e.ExceptionObject as Exception); + }; + + TaskScheduler.UnobservedTaskException += (_, e) => + { + e.SetObserved(); + }; + + try + { + if (TryLaunchAsRebaseTodoEditor(args, out int exitTodo)) + Environment.Exit(exitTodo); + else if (TryLaunchAsRebaseMessageEditor(args, out int exitMessage)) + Environment.Exit(exitMessage); + else + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + LogException(ex); + } + } + + public static AppBuilder BuildAvaloniaApp() + { + var builder = AppBuilder.Configure(); + builder.UsePlatformDetect(); + builder.LogToTrace(); + builder.WithInterFont(); + builder.With(new FontManagerOptions() + { + DefaultFamilyName = "fonts:Inter#Inter" + }); + builder.ConfigureFonts(manager => + { + var monospace = new EmbeddedFontCollection( + new Uri("fonts:SourceGit", UriKind.Absolute), + new Uri("avares://SourceGit/Resources/Fonts", UriKind.Absolute)); + manager.AddFontCollection(monospace); + }); + + Native.OS.SetupApp(builder); + return builder; + } + + public static void LogException(Exception ex) + { + if (ex == null) + return; + + var builder = new StringBuilder(); + builder.Append($"Crash::: {ex.GetType().FullName}: {ex.Message}\n\n"); + builder.Append("----------------------------\n"); + builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}\n"); + builder.Append($"OS: {Environment.OSVersion}\n"); + builder.Append($"Framework: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\n"); + builder.Append($"Source: {ex.Source}\n"); + builder.Append($"Thread Name: {Thread.CurrentThread.Name ?? "Unnamed"}\n"); + builder.Append($"User: {Environment.UserName}\n"); + builder.Append($"App Start Time: {Process.GetCurrentProcess().StartTime}\n"); + builder.Append($"Exception Time: {DateTime.Now}\n"); + builder.Append($"Memory Usage: {Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024} MB\n"); + builder.Append($"---------------------------\n\n"); + builder.Append(ex); + + var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + var file = Path.Combine(Native.OS.DataDir, $"crash_{time}.log"); + File.WriteAllText(file, builder.ToString()); + } + #endregion + + #region Utility Functions + public static void ShowWindow(object data, bool showAsDialog) + { + var impl = (Views.ChromelessWindow target, bool isDialog) => + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + if (isDialog) + target.ShowDialog(owner); + else + target.Show(owner); + } + else + { + target.Show(); + } + }; + + if (data is Views.ChromelessWindow window) + { + impl(window, showAsDialog); + return; + } + + var dataTypeName = data.GetType().FullName; + if (string.IsNullOrEmpty(dataTypeName) || !dataTypeName.Contains(".ViewModels.", StringComparison.Ordinal)) + return; + + var viewTypeName = dataTypeName.Replace(".ViewModels.", ".Views."); + var viewType = Type.GetType(viewTypeName); + if (viewType == null || !viewType.IsSubclassOf(typeof(Views.ChromelessWindow))) + return; + + window = Activator.CreateInstance(viewType) as Views.ChromelessWindow; + if (window != null) + { + window.DataContext = data; + impl(window, showAsDialog); + } + } + + public static void RaiseException(string context, string message) + { + if (Current is App app && app._launcher != null) + app._launcher.DispatchNotification(context, message, true); + } + + public static void SendNotification(string context, string message) + { + if (Current is App app && app._launcher != null) + app._launcher.DispatchNotification(context, message, false); + } + + public static void SetLocale(string localeKey) + { + var app = Current as App; + if (app == null) + return; + + var targetLocale = app.Resources[localeKey] as ResourceDictionary; + if (targetLocale == null || targetLocale == app._activeLocale) + return; + + if (app._activeLocale != null) + app.Resources.MergedDictionaries.Remove(app._activeLocale); + + app.Resources.MergedDictionaries.Add(targetLocale); + app._activeLocale = targetLocale; + } + + public static void SetTheme(string theme, string themeOverridesFile) + { + var app = Current as App; + if (app == null) + return; + + if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) + app.RequestedThemeVariant = ThemeVariant.Light; + else if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) + app.RequestedThemeVariant = ThemeVariant.Dark; + else + app.RequestedThemeVariant = ThemeVariant.Default; + + if (app._themeOverrides != null) + { + app.Resources.MergedDictionaries.Remove(app._themeOverrides); + app._themeOverrides = null; + } + + if (!string.IsNullOrEmpty(themeOverridesFile) && File.Exists(themeOverridesFile)) + { + try + { + var resDic = new ResourceDictionary(); + var overrides = JsonSerializer.Deserialize(File.ReadAllText(themeOverridesFile), JsonCodeGen.Default.ThemeOverrides); + foreach (var kv in overrides.BasicColors) + { + if (kv.Key.Equals("SystemAccentColor", StringComparison.Ordinal)) + resDic["SystemAccentColor"] = kv.Value; + else + resDic[$"Color.{kv.Key}"] = kv.Value; + } + + if (overrides.GraphColors.Count > 0) + Models.CommitGraph.SetPens(overrides.GraphColors, overrides.GraphPenThickness); + else + Models.CommitGraph.SetDefaultPens(overrides.GraphPenThickness); + + Models.Commit.OpacityForNotMerged = overrides.OpacityForNotMergedCommits; + + app.Resources.MergedDictionaries.Add(resDic); + app._themeOverrides = resDic; + } + catch + { + // ignore + } + } + else + { + Models.CommitGraph.SetDefaultPens(); + } + } + + public static void SetFonts(string defaultFont, string monospaceFont, bool onlyUseMonospaceFontInEditor) + { + var app = Current as App; + if (app == null) + return; + + if (app._fontsOverrides != null) + { + app.Resources.MergedDictionaries.Remove(app._fontsOverrides); + app._fontsOverrides = null; + } + + defaultFont = app.FixFontFamilyName(defaultFont); + monospaceFont = app.FixFontFamilyName(monospaceFont); + + var resDic = new ResourceDictionary(); + if (!string.IsNullOrEmpty(defaultFont)) + resDic.Add("Fonts.Default", new FontFamily(defaultFont)); + + if (string.IsNullOrEmpty(monospaceFont)) + { + if (!string.IsNullOrEmpty(defaultFont)) + { + monospaceFont = $"fonts:SourceGit#JetBrains Mono,{defaultFont}"; + resDic.Add("Fonts.Monospace", new FontFamily(monospaceFont)); + } + } + else + { + if (!string.IsNullOrEmpty(defaultFont) && !monospaceFont.Contains(defaultFont, StringComparison.Ordinal)) + monospaceFont = $"{monospaceFont},{defaultFont}"; + + resDic.Add("Fonts.Monospace", new FontFamily(monospaceFont)); + } + + if (onlyUseMonospaceFontInEditor) + { + if (string.IsNullOrEmpty(defaultFont)) + resDic.Add("Fonts.Primary", new FontFamily("fonts:Inter#Inter")); + else + resDic.Add("Fonts.Primary", new FontFamily(defaultFont)); + } + else + { + if (!string.IsNullOrEmpty(monospaceFont)) + resDic.Add("Fonts.Primary", new FontFamily(monospaceFont)); + } + + if (resDic.Count > 0) + { + app.Resources.MergedDictionaries.Add(resDic); + app._fontsOverrides = resDic; + } + } + + public static async void CopyText(string data) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + if (desktop.MainWindow?.Clipboard is { } clipboard) + await clipboard.SetTextAsync(data ?? ""); + } + } + + public static async Task GetClipboardTextAsync() + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + if (desktop.MainWindow?.Clipboard is { } clipboard) + { + return await clipboard.GetTextAsync(); + } + } + return null; + } + + public static string Text(string key, params object[] args) + { + var fmt = Current?.FindResource($"Text.{key}") as string; + if (string.IsNullOrWhiteSpace(fmt)) + return $"Text.{key}"; + + if (args == null || args.Length == 0) + return fmt; + + return string.Format(fmt, args); + } + + public static Avalonia.Controls.Shapes.Path CreateMenuIcon(string key) + { + var icon = new Avalonia.Controls.Shapes.Path(); + icon.Width = 12; + icon.Height = 12; + icon.Stretch = Stretch.Uniform; + + if (Current?.FindResource(key) is StreamGeometry geo) + icon.Data = geo; + + return icon; + } + + public static IStorageProvider GetStorageProvider() + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + return desktop.MainWindow?.StorageProvider; + + return null; + } + + public static ViewModels.Launcher GetLauncher() + { + return Current is App app ? app._launcher : null; + } + + public static void Quit(int exitCode) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow?.Close(); + desktop.Shutdown(exitCode); + } + else + { + Environment.Exit(exitCode); + } + } + #endregion + + #region Overrides + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + + var pref = ViewModels.Preferences.Instance; + pref.PropertyChanged += (_, _) => pref.Save(); + + SetLocale(pref.Locale); + SetTheme(pref.Theme, pref.ThemeOverrides); + SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily, pref.OnlyUseMonoFontInEditor); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + BindingPlugins.DataValidators.RemoveAt(0); + + // Disable tooltip if window is not active. + ToolTip.ToolTipOpeningEvent.AddClassHandler((c, e) => + { + var topLevel = TopLevel.GetTopLevel(c); + if (topLevel is not Window { IsActive: true }) + e.Cancel = true; + }); + + if (TryLaunchAsCoreEditor(desktop)) + return; + + if (TryLaunchAsAskpass(desktop)) + return; + + _ipcChannel = new Models.IpcChannel(); + if (!_ipcChannel.IsFirstInstance) + { + var arg = desktop.Args is { Length: > 0 } ? desktop.Args[0].Trim() : string.Empty; + if (!string.IsNullOrEmpty(arg)) + { + if (arg.StartsWith('"') && arg.EndsWith('"')) + arg = arg.Substring(1, arg.Length - 2).Trim(); + + if (arg.Length > 0 && !Path.IsPathFullyQualified(arg)) + arg = Path.GetFullPath(arg); + } + + _ipcChannel.SendToFirstInstance(arg); + Environment.Exit(0); + } + else + { + _ipcChannel.MessageReceived += TryOpenRepository; + desktop.Exit += (_, _) => _ipcChannel.Dispose(); + TryLaunchAsNormal(desktop); + } + } + } + #endregion + + private static bool TryLaunchAsRebaseTodoEditor(string[] args, out int exitCode) + { + exitCode = -1; + + if (args.Length <= 1 || !args[0].Equals("--rebase-todo-editor", StringComparison.Ordinal)) + return false; + + var file = args[1]; + var filename = Path.GetFileName(file); + if (!filename.Equals("git-rebase-todo", StringComparison.OrdinalIgnoreCase)) + return true; + + var dirInfo = new DirectoryInfo(Path.GetDirectoryName(file)!); + if (!dirInfo.Exists || !dirInfo.Name.Equals("rebase-merge", StringComparison.Ordinal)) + return true; + + var jobsFile = Path.Combine(dirInfo.Parent!.FullName, "sourcegit_rebase_jobs.json"); + if (!File.Exists(jobsFile)) + return true; + + var collection = JsonSerializer.Deserialize(File.ReadAllText(jobsFile), JsonCodeGen.Default.InteractiveRebaseJobCollection); + var lines = new List(); + foreach (var job in collection.Jobs) + { + switch (job.Action) + { + case Models.InteractiveRebaseAction.Pick: + lines.Add($"p {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Edit: + lines.Add($"e {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Reword: + lines.Add($"r {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Squash: + lines.Add($"s {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Fixup: + lines.Add($"f {job.SHA}"); + break; + default: + lines.Add($"d {job.SHA}"); + break; + } + } + + File.WriteAllLines(file, lines); + + exitCode = 0; + return true; + } + + private static bool TryLaunchAsRebaseMessageEditor(string[] args, out int exitCode) + { + exitCode = -1; + + if (args.Length <= 1 || !args[0].Equals("--rebase-message-editor", StringComparison.Ordinal)) + return false; + + exitCode = 0; + + var file = args[1]; + var filename = Path.GetFileName(file); + if (!filename.Equals("COMMIT_EDITMSG", StringComparison.OrdinalIgnoreCase)) + return true; + + var gitDir = Path.GetDirectoryName(file)!; + var origHeadFile = Path.Combine(gitDir, "rebase-merge", "orig-head"); + var ontoFile = Path.Combine(gitDir, "rebase-merge", "onto"); + var doneFile = Path.Combine(gitDir, "rebase-merge", "done"); + var jobsFile = Path.Combine(gitDir, "sourcegit_rebase_jobs.json"); + if (!File.Exists(ontoFile) || !File.Exists(origHeadFile) || !File.Exists(doneFile) || !File.Exists(jobsFile)) + return true; + + var origHead = File.ReadAllText(origHeadFile).Trim(); + var onto = File.ReadAllText(ontoFile).Trim(); + var collection = JsonSerializer.Deserialize(File.ReadAllText(jobsFile), JsonCodeGen.Default.InteractiveRebaseJobCollection); + if (!collection.Onto.Equals(onto) || !collection.OrigHead.Equals(origHead)) + return true; + + var done = File.ReadAllText(doneFile).Trim().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (done.Length == 0) + return true; + + var current = done[^1].Trim(); + var match = REG_REBASE_TODO().Match(current); + if (!match.Success) + return true; + + var sha = match.Groups[1].Value; + foreach (var job in collection.Jobs) + { + if (job.SHA.StartsWith(sha)) + { + File.WriteAllText(file, job.Message); + break; + } + } + + return true; + } + + private bool TryLaunchAsCoreEditor(IClassicDesktopStyleApplicationLifetime desktop) + { + var args = desktop.Args; + if (args == null || args.Length <= 1 || !args[0].Equals("--core-editor", StringComparison.Ordinal)) + return false; + + var file = args[1]; + if (!File.Exists(file)) + { + desktop.Shutdown(-1); + return true; + } + + var editor = new Views.StandaloneCommitMessageEditor(); + editor.SetFile(file); + desktop.MainWindow = editor; + return true; + } + + private bool TryLaunchAsAskpass(IClassicDesktopStyleApplicationLifetime desktop) + { + var launchAsAskpass = Environment.GetEnvironmentVariable("SOURCEGIT_LAUNCH_AS_ASKPASS"); + if (launchAsAskpass is not "TRUE") + return false; + + var args = desktop.Args; + if (args?.Length > 0) + { + var askpass = new Views.Askpass(); + askpass.TxtDescription.Text = args[0]; + desktop.MainWindow = askpass; + return true; + } + + return false; + } + + private void TryLaunchAsNormal(IClassicDesktopStyleApplicationLifetime desktop) + { + Native.OS.SetupExternalTools(); + Models.AvatarManager.Instance.Start(); + + string startupRepo = null; + if (desktop.Args != null && desktop.Args.Length == 1 && Directory.Exists(desktop.Args[0])) + startupRepo = desktop.Args[0]; + + var pref = ViewModels.Preferences.Instance; + pref.SetCanModify(); + + _launcher = new ViewModels.Launcher(startupRepo); + desktop.MainWindow = new Views.Launcher() { DataContext = _launcher }; + desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; + +#if !DISABLE_UPDATE_DETECTION + if (pref.ShouldCheck4UpdateOnStartup()) + Check4Update(); +#endif + } + + private void TryOpenRepository(string repo) + { + if (!string.IsNullOrEmpty(repo) && Directory.Exists(repo)) + { + var test = new Commands.QueryRepositoryRootPath(repo).ReadToEnd(); + if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) + { + Dispatcher.UIThread.Invoke(() => + { + var node = ViewModels.Preferences.Instance.FindOrAddNodeByRepositoryPath(test.StdOut.Trim(), null, false); + ViewModels.Welcome.Instance.Refresh(); + _launcher?.OpenRepositoryInTab(node, null); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: Views.Launcher wnd }) + wnd.BringToTop(); + }); + + return; + } + } + + Dispatcher.UIThread.Invoke(() => + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: Views.Launcher launcher }) + launcher.BringToTop(); + }); + } + + private void Check4Update(bool manually = false) + { + Task.Run(async () => + { + try + { + // Fetch latest release information. + var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(5) }; + var data = await client.GetStringAsync("https://sourcegit-scm.github.io/data/version.json"); + + // Parse JSON into Models.Version. + var ver = JsonSerializer.Deserialize(data, JsonCodeGen.Default.Version); + if (ver == null) + return; + + // Check if already up-to-date. + if (!ver.IsNewVersion) + { + if (manually) + ShowSelfUpdateResult(new Models.AlreadyUpToDate()); + return; + } + + // Should not check ignored tag if this is called manually. + if (!manually) + { + var pref = ViewModels.Preferences.Instance; + if (ver.TagName == pref.IgnoreUpdateTag) + return; + } + + ShowSelfUpdateResult(ver); + } + catch (Exception e) + { + if (manually) + ShowSelfUpdateResult(new Models.SelfUpdateFailed(e)); + } + }); + } + + private void ShowSelfUpdateResult(object data) + { + Dispatcher.UIThread.Post(() => + { + ShowWindow(new ViewModels.SelfUpdate() { Data = data }, true); + }); + } + + private string FixFontFamilyName(string input) + { + if (string.IsNullOrEmpty(input)) + return string.Empty; + + var parts = input.Split(','); + var trimmed = new List(); + + foreach (var part in parts) + { + var t = part.Trim(); + if (string.IsNullOrEmpty(t)) + continue; + + // Collapse multiple spaces into single space + var prevChar = '\0'; + var sb = new StringBuilder(); + + foreach (var c in t) + { + if (c == ' ' && prevChar == ' ') + continue; + sb.Append(c); + prevChar = c; + } + + var name = sb.ToString(); + if (name.Contains('#', StringComparison.Ordinal)) + { + if (!name.Equals("fonts:Inter#Inter", StringComparison.Ordinal) && + !name.Equals("fonts:SourceGit#JetBrains Mono", StringComparison.Ordinal)) + continue; + } + + trimmed.Add(name); + } + + return trimmed.Count > 0 ? string.Join(',', trimmed) : string.Empty; + } + + [GeneratedRegex(@"^[a-z]+\s+([a-fA-F0-9]{4,40})(\s+.*)?$")] + private static partial Regex REG_REBASE_TODO(); + + private Models.IpcChannel _ipcChannel = null; + private ViewModels.Launcher _launcher = null; + private ResourceDictionary _activeLocale = null; + private ResourceDictionary _themeOverrides = null; + private ResourceDictionary _fontsOverrides = null; + } +} diff --git a/src/App.manifest b/src/App.manifest index 5a6db8f3..11a2ff11 100644 --- a/src/App.manifest +++ b/src/App.manifest @@ -1,14 +1,18 @@  - - - - - - - - PerMonitorV2 - true - + + + + + + + + + + diff --git a/src/App.xaml b/src/App.xaml deleted file mode 100644 index 1f48956b..00000000 --- a/src/App.xaml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/App.xaml.cs b/src/App.xaml.cs deleted file mode 100644 index 1528ae9b..00000000 --- a/src/App.xaml.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.IO; -using System.Windows; - -namespace SourceGit { - - /// - /// 程序入口. - /// - public partial class App : Application { - - /// - /// 读取本地化字串 - /// - /// 本地化字串的Key - /// 可选格式化参数 - /// 本地化字串 - public static string Text(string key, params object[] args) { - var data = Current.FindResource($"Text.{key}") as string; - if (string.IsNullOrEmpty(data)) return $"Text.{key}"; - return string.Format(data, args); - } - - /// - /// 启动. - /// - /// - /// - protected override void OnStartup(StartupEventArgs e) { - base.OnStartup(e); - - // 崩溃上报 - AppDomain.CurrentDomain.UnhandledException += (_, ev) => Models.Issue.Create(ev.ExceptionObject as Exception); - - // 创建必要目录 - if (!Directory.Exists(Views.Controls.Avatar.CACHE_PATH)) { - Directory.CreateDirectory(Views.Controls.Avatar.CACHE_PATH); - } - - Models.Theme.Change(); - Models.Locale.Change(); - - // 如果启动命令中指定了路径,打开指定目录的仓库 - var launcher = new Views.Launcher(); - if (Models.Preference.Instance.IsReady) { - if (e.Args.Length > 0) { - var repo = Models.Preference.Instance.FindRepository(e.Args[0]); - if (repo == null) { - var path = new Commands.GetRepositoryRootPath(e.Args[0]).Result(); - if (path != null) { - var gitDir = new Commands.QueryGitDir(path).Result(); - repo = Models.Preference.Instance.AddRepository(path, gitDir); - } - } - - if (repo != null) Models.Watcher.Open(repo); - } else if (Models.Preference.Instance.Restore.IsEnabled) { - var restore = Models.Preference.Instance.Restore; - var actived = null as Models.Repository; - if (restore.Opened.Count > 0) { - foreach (var path in restore.Opened) { - if (!Directory.Exists(path)) continue; - var repo = Models.Preference.Instance.FindRepository(path); - if (repo != null) Models.Watcher.Open(repo); - if (path == restore.Actived) actived = repo; - } - - if (actived != null) Models.Watcher.Open(actived); - } - } - } - - // 主界面显示 - MainWindow = launcher; - MainWindow.Show(); - } - - /// - /// 后台运行 - /// - /// - /// - protected override void OnDeactivated(EventArgs e) { - base.OnDeactivated(e); - GC.Collect(); - Models.Preference.Save(); - } - } -} diff --git a/src/Commands/Add.cs b/src/Commands/Add.cs index fb98cf87..210eb4b2 100644 --- a/src/Commands/Add.cs +++ b/src/Commands/Add.cs @@ -1,27 +1,26 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - /// - /// `git add`命令 - /// - public class Add : Command { - public Add(string repo) { - Cwd = repo; - Args = "add ."; +namespace SourceGit.Commands +{ + public class Add : Command + { + public Add(string repo, bool includeUntracked) + { + WorkingDirectory = repo; + Context = repo; + Args = includeUntracked ? "add ." : "add -u ."; } - public Add(string repo, List paths) { - StringBuilder builder = new StringBuilder(); - builder.Append("add --"); - foreach (var p in paths) { - builder.Append(" \""); - builder.Append(p); - builder.Append("\""); - } + public Add(string repo, Models.Change change) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add -- \"{change.Path}\""; + } - Cwd = repo; - Args = builder.ToString(); + public Add(string repo, string pathspecFromFile) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add --pathspec-from-file=\"{pathspecFromFile}\""; } } } diff --git a/src/Commands/Apply.cs b/src/Commands/Apply.cs index f62f113c..d1c9ffbc 100644 --- a/src/Commands/Apply.cs +++ b/src/Commands/Apply.cs @@ -1,14 +1,18 @@ -namespace SourceGit.Commands { - /// - /// 应用Patch - /// - public class Apply : Command { - - public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceMode) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Apply : Command + { + public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceMode, string extra) + { + WorkingDirectory = repo; + Context = repo; Args = "apply "; - if (ignoreWhitespace) Args += "--ignore-whitespace "; - else Args += $"--whitespace={whitespaceMode} "; + if (ignoreWhitespace) + Args += "--ignore-whitespace "; + else + Args += $"--whitespace={whitespaceMode} "; + if (!string.IsNullOrEmpty(extra)) + Args += $"{extra} "; Args += $"\"{file}\""; } } diff --git a/src/Commands/Archive.cs b/src/Commands/Archive.cs index b4153e34..5e0919f7 100644 --- a/src/Commands/Archive.cs +++ b/src/Commands/Archive.cs @@ -1,22 +1,12 @@ -using System; - -namespace SourceGit.Commands { - - /// - /// 存档命令 - /// - public class Archive : Command { - private Action handler; - - public Archive(string repo, string revision, string to, Action onProgress) { - Cwd = repo; - Args = $"archive --format=zip --verbose --output=\"{to}\" {revision}"; - TraitErrorAsOutput = true; - handler = onProgress; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); +namespace SourceGit.Commands +{ + public class Archive : Command + { + public Archive(string repo, string revision, string saveTo) + { + WorkingDirectory = repo; + Context = repo; + Args = $"archive --format=zip --verbose --output=\"{saveTo}\" {revision}"; } } } diff --git a/src/Commands/AssumeUnchanged.cs b/src/Commands/AssumeUnchanged.cs index 9a0af3d9..28f78280 100644 --- a/src/Commands/AssumeUnchanged.cs +++ b/src/Commands/AssumeUnchanged.cs @@ -1,60 +1,14 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; +namespace SourceGit.Commands +{ + public class AssumeUnchanged : Command + { + public AssumeUnchanged(string repo, string file, bool bAdd) + { + var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; -namespace SourceGit.Commands { - /// - /// 查看、添加或移除忽略变更文件 - /// - public class AssumeUnchanged { - private string repo; - - class ViewCommand : Command { - private static readonly Regex REG = new Regex(@"^(\w)\s+(.+)$"); - private List outs = new List(); - - public ViewCommand(string repo) { - Cwd = repo; - Args = "ls-files -v"; - } - - public List Result() { - Exec(); - return outs; - } - - public override void OnReadline(string line) { - var match = REG.Match(line); - if (!match.Success) return; - - if (match.Groups[1].Value == "h") { - outs.Add(match.Groups[2].Value); - } - } - } - - class ModCommand : Command { - public ModCommand(string repo, string file, bool bAdd) { - var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; - - Cwd = repo; - Args = $"update-index {mode} -- \"{file}\""; - } - } - - public AssumeUnchanged(string repo) { - this.repo = repo; - } - - public List View() { - return new ViewCommand(repo).Result(); - } - - public void Add(string file) { - new ModCommand(repo, file, true).Exec(); - } - - public void Remove(string file) { - new ModCommand(repo, file, false).Exec(); + WorkingDirectory = repo; + Context = repo; + Args = $"update-index {mode} -- \"{file}\""; } } } diff --git a/src/Commands/Bisect.cs b/src/Commands/Bisect.cs new file mode 100644 index 00000000..a3bf1a97 --- /dev/null +++ b/src/Commands/Bisect.cs @@ -0,0 +1,13 @@ +namespace SourceGit.Commands +{ + public class Bisect : Command + { + public Bisect(string repo, string subcmd) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + Args = $"bisect {subcmd}"; + } + } +} diff --git a/src/Commands/Blame.cs b/src/Commands/Blame.cs index 571ff17e..1fc51fa4 100644 --- a/src/Commands/Blame.cs +++ b/src/Commands/Blame.cs @@ -1,77 +1,97 @@ -using System; -using System.Collections.Generic; +using System; +using System.Text; using System.Text.RegularExpressions; -namespace SourceGit.Commands { - /// - /// 逐行追溯 - /// - public class Blame : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^\^?([0-9a-f]+)\s+.*\((.*)\s+(\d+)\s+[\-\+]?\d+\s+\d+\) (.*)"); - private static readonly DateTime UTC_START = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); +namespace SourceGit.Commands +{ + public partial class Blame : Command + { + [GeneratedRegex(@"^\^?([0-9a-f]+)\s+.*\((.*)\s+(\d+)\s+[\-\+]?\d+\s+\d+\) (.*)")] + private static partial Regex REG_FORMAT(); - private Data data = new Data(); - private bool needUnifyCommitSHA = false; - private int minSHALen = 0; - - public class Data { - public List Lines = new List(); - public bool IsBinary = false; - } - - public Blame(string repo, string file, string revision) { - Cwd = repo; + public Blame(string repo, string file, string revision) + { + WorkingDirectory = repo; + Context = repo; Args = $"blame -t {revision} -- \"{file}\""; + RaiseError = false; + + _result.File = file; } - public Data Result() { - Exec(); + public Models.BlameData Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _result; - if (needUnifyCommitSHA) { - foreach (var line in data.Lines) { - if (line.CommitSHA.Length > minSHALen) { - line.CommitSHA = line.CommitSHA.Substring(0, minSHALen); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + ParseLine(line); + + if (_result.IsBinary) + break; + } + + if (_needUnifyCommitSHA) + { + foreach (var line in _result.LineInfos) + { + if (line.CommitSHA.Length > _minSHALen) + { + line.CommitSHA = line.CommitSHA.Substring(0, _minSHALen); } } } - return data; + _result.Content = _content.ToString(); + return _result; } - public override void OnReadline(string line) { - if (data.IsBinary) return; - if (string.IsNullOrEmpty(line)) return; - - if (line.IndexOf('\0') >= 0) { - data.IsBinary = true; - data.Lines.Clear(); + private void ParseLine(string line) + { + if (line.Contains('\0', StringComparison.Ordinal)) + { + _result.IsBinary = true; + _result.LineInfos.Clear(); return; } - var match = REG_FORMAT.Match(line); - if (!match.Success) return; + var match = REG_FORMAT().Match(line); + if (!match.Success) + return; + + _content.AppendLine(match.Groups[4].Value); var commit = match.Groups[1].Value; var author = match.Groups[2].Value; var timestamp = int.Parse(match.Groups[3].Value); - var content = match.Groups[4].Value; - var when = UTC_START.AddSeconds(timestamp).ToString("yyyy/MM/dd"); + var when = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString(_dateFormat); - var blameLine = new Models.BlameLine() { - LineNumber = $"{data.Lines.Count + 1}", + var info = new Models.BlameLineInfo() + { + IsFirstInGroup = commit != _lastSHA, CommitSHA = commit, Author = author, Time = when, - Content = content, }; - if (line[0] == '^') { - needUnifyCommitSHA = true; - if (minSHALen == 0) minSHALen = commit.Length; - else if (commit.Length < minSHALen) minSHALen = commit.Length; - } + _result.LineInfos.Add(info); + _lastSHA = commit; - data.Lines.Add(blameLine); + if (line[0] == '^') + { + _needUnifyCommitSHA = true; + _minSHALen = Math.Min(_minSHALen, commit.Length); + } } + + private readonly Models.BlameData _result = new Models.BlameData(); + private readonly StringBuilder _content = new StringBuilder(); + private readonly string _dateFormat = Models.DateTimeFormat.Active.DateOnly; + private string _lastSHA = string.Empty; + private bool _needUnifyCommitSHA = false; + private int _minSHALen = 64; } } diff --git a/src/Commands/Branch.cs b/src/Commands/Branch.cs index 387892ba..0d1b1f8f 100644 --- a/src/Commands/Branch.cs +++ b/src/Commands/Branch.cs @@ -1,38 +1,83 @@ -namespace SourceGit.Commands { - /// - /// 分支相关操作 - /// - class Branch : Command { - private string target = null; +using System.Text; - public Branch(string repo, string branch) { - Cwd = repo; - target = branch; +namespace SourceGit.Commands +{ + public static class Branch + { + public static string ShowCurrent(string repo) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch --show-current"; + return cmd.ReadToEnd().StdOut.Trim(); } - public void Create(string basedOn) { - Args = $"branch {target} {basedOn}"; - Exec(); + public static bool Create(string repo, string name, string basedOn, bool force, Models.ICommandLog log) + { + var builder = new StringBuilder(); + builder.Append("branch "); + if (force) + builder.Append("-f "); + builder.Append(name); + builder.Append(" "); + builder.Append(basedOn); + + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = builder.ToString(); + cmd.Log = log; + return cmd.Exec(); } - public void Rename(string to) { - Args = $"branch -M {target} {to}"; - Exec(); + public static bool Rename(string repo, string name, string to, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -M {name} {to}"; + cmd.Log = log; + return cmd.Exec(); } - public void SetUpstream(string upstream) { - Args = $"branch {target} "; - if (string.IsNullOrEmpty(upstream)) { - Args += "--unset-upstream"; - } else { - Args += $"-u {upstream}"; - } - Exec(); + public static bool SetUpstream(string repo, string name, string upstream, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Log = log; + + if (string.IsNullOrEmpty(upstream)) + cmd.Args = $"branch {name} --unset-upstream"; + else + cmd.Args = $"branch {name} -u {upstream}"; + + return cmd.Exec(); } - public void Delete() { - Args = $"branch -D {target}"; - Exec(); + public static bool DeleteLocal(string repo, string name, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -D {name}"; + cmd.Log = log; + return cmd.Exec(); + } + + public static bool DeleteRemote(string repo, string remote, string name, Models.ICommandLog log) + { + bool exists = new Remote(repo).HasBranch(remote, name); + if (exists) + return new Push(repo, remote, $"refs/heads/{name}", true) { Log = log }.Exec(); + + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -D -r {remote}/{name}"; + cmd.Log = log; + return cmd.Exec(); } } } diff --git a/src/Commands/Branches.cs b/src/Commands/Branches.cs deleted file mode 100644 index 88e767b3..00000000 --- a/src/Commands/Branches.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 解析所有的分支 - /// - public class Branches : Command { - private static readonly string PREFIX_LOCAL = "refs/heads/"; - private static readonly string PREFIX_REMOTE = "refs/remotes/"; - private static readonly string CMD = "branch -l --all -v --format=\"$%(refname)$%(objectname)$%(HEAD)$%(upstream)$%(upstream:track)$%(contents:subject)\""; - private static readonly Regex REG_FORMAT = new Regex(@"\$(.*)\$(.*)\$([\* ])\$(.*)\$(.*?)\$(.*)"); - private static readonly Regex REG_AHEAD = new Regex(@"ahead (\d+)"); - private static readonly Regex REG_BEHIND = new Regex(@"behind (\d+)"); - - private List loaded = new List(); - - public Branches(string path) { - Cwd = path; - Args = CMD; - } - - public List Result() { - Exec(); - return loaded; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var branch = new Models.Branch(); - var refName = match.Groups[1].Value; - if (refName.EndsWith("/HEAD")) return; - - if (refName.StartsWith(PREFIX_LOCAL, StringComparison.Ordinal)) { - branch.Name = refName.Substring(PREFIX_LOCAL.Length); - branch.IsLocal = true; - } else if (refName.StartsWith(PREFIX_REMOTE, StringComparison.Ordinal)) { - var name = refName.Substring(PREFIX_REMOTE.Length); - var shortNameIdx = name.IndexOf('/'); - if (shortNameIdx < 0) return; - - branch.Remote = name.Substring(0, shortNameIdx); - branch.Name = name.Substring(branch.Remote.Length + 1); - branch.IsLocal = false; - } else { - branch.Name = refName; - branch.IsLocal = true; - } - - branch.FullName = refName; - branch.Head = match.Groups[2].Value; - branch.IsCurrent = match.Groups[3].Value == "*"; - branch.Upstream = match.Groups[4].Value; - branch.UpstreamTrackStatus = ParseTrackStatus(match.Groups[5].Value); - branch.HeadSubject = match.Groups[6].Value; - - loaded.Add(branch); - } - - private string ParseTrackStatus(string data) { - if (string.IsNullOrEmpty(data)) return string.Empty; - - string track = string.Empty; - - var ahead = REG_AHEAD.Match(data); - if (ahead.Success) { - track += ahead.Groups[1].Value + "↑ "; - } - - var behind = REG_BEHIND.Match(data); - if (behind.Success) { - track += behind.Groups[1].Value + "↓"; - } - - return track.Trim(); - } - } -} diff --git a/src/Commands/Checkout.cs b/src/Commands/Checkout.cs index 1f169992..d2876740 100644 --- a/src/Commands/Checkout.cs +++ b/src/Commands/Checkout.cs @@ -1,51 +1,56 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; -namespace SourceGit.Commands { - /// - /// 检出 - /// - public class Checkout : Command { - private Action handler = null; - - public Checkout(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Checkout : Command + { + public Checkout(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Branch(string branch, Action onProgress) { - Args = $"checkout --progress {branch}"; - TraitErrorAsOutput = true; - handler = onProgress; + public bool Branch(string branch, bool force) + { + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(branch); + + Args = builder.ToString(); return Exec(); } - public bool Branch(string branch, string basedOn, Action onProgress) { - Args = $"checkout --progress -b {branch} {basedOn}"; - TraitErrorAsOutput = true; - handler = onProgress; + public bool Branch(string branch, string basedOn, bool force, bool allowOverwrite) + { + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(allowOverwrite ? "-B " : "-b "); + builder.Append(branch); + builder.Append(" "); + builder.Append(basedOn); + + Args = builder.ToString(); return Exec(); } - public bool File(string file, bool useTheirs) { - if (useTheirs) { - Args = $"checkout --theirs -- \"{file}\""; - } else { - Args = $"checkout --ours -- \"{file}\""; - } - + public bool Commit(string commitId, bool force) + { + var option = force ? "--force" : string.Empty; + Args = $"checkout {option} --detach --progress {commitId}"; return Exec(); } - public bool FileWithRevision(string file, string revision) { - Args = $"checkout {revision} -- \"{file}\""; - return Exec(); - } - - public bool Files(List files) { - StringBuilder builder = new StringBuilder(); - builder.Append("checkout -f -q --"); - foreach (var f in files) { + public bool UseTheirs(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --theirs --"); + foreach (var f in files) + { builder.Append(" \""); builder.Append(f); builder.Append("\""); @@ -54,8 +59,24 @@ namespace SourceGit.Commands { return Exec(); } - public override void OnReadline(string line) { - handler?.Invoke(line); + public bool UseMine(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --ours --"); + foreach (var f in files) + { + builder.Append(" \""); + builder.Append(f); + builder.Append("\""); + } + Args = builder.ToString(); + return Exec(); + } + + public bool FileWithRevision(string file, string revision) + { + Args = $"checkout --no-overlay {revision} -- \"{file}\""; + return Exec(); } } } diff --git a/src/Commands/CherryPick.cs b/src/Commands/CherryPick.cs index ca939e76..0c82b9fd 100644 --- a/src/Commands/CherryPick.cs +++ b/src/Commands/CherryPick.cs @@ -1,13 +1,20 @@ -namespace SourceGit.Commands { - /// - /// 遴选命令 - /// - public class CherryPick : Command { +namespace SourceGit.Commands +{ + public class CherryPick : Command + { + public CherryPick(string repo, string commits, bool noCommit, bool appendSourceToMessage, string extraParams) + { + WorkingDirectory = repo; + Context = repo; - public CherryPick(string repo, string commit, bool noCommit) { - var mode = noCommit ? "-n" : "--ff"; - Cwd = repo; - Args = $"cherry-pick {mode} {commit}"; + Args = "cherry-pick "; + if (noCommit) + Args += "-n "; + if (appendSourceToMessage) + Args += "-x "; + if (!string.IsNullOrEmpty(extraParams)) + Args += $"{extraParams} "; + Args += commits; } } } diff --git a/src/Commands/Clean.cs b/src/Commands/Clean.cs index 38a9a477..6ed74999 100644 --- a/src/Commands/Clean.cs +++ b/src/Commands/Clean.cs @@ -1,28 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - /// - /// 清理指令 - /// - public class Clean : Command { - - public Clean(string repo) { - Cwd = repo; - Args = "clean -qfd"; - } - - public Clean(string repo, List files) { - StringBuilder builder = new StringBuilder(); - builder.Append("clean -qfd --"); - foreach (var f in files) { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } - - Cwd = repo; - Args = builder.ToString(); +namespace SourceGit.Commands +{ + public class Clean : Command + { + public Clean(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "clean -qfdx"; } } } diff --git a/src/Commands/Clone.cs b/src/Commands/Clone.cs index fefc0f3e..efec264b 100644 --- a/src/Commands/Clone.cs +++ b/src/Commands/Clone.cs @@ -1,39 +1,21 @@ -using System; +namespace SourceGit.Commands +{ + public class Clone : Command + { + public Clone(string ctx, string path, string url, string localName, string sshKey, string extraArgs) + { + Context = ctx; + WorkingDirectory = path; + SSHKey = sshKey; + Args = "clone --progress --verbose "; -namespace SourceGit.Commands { + if (!string.IsNullOrEmpty(extraArgs)) + Args += $"{extraArgs} "; - /// - /// 克隆 - /// - public class Clone : Command { - private Action handler = null; - private Action onError = null; - - public Clone(string path, string url, string localName, string sshKey, string extraArgs, Action outputHandler, Action errHandler) { - Cwd = path; - TraitErrorAsOutput = true; - handler = outputHandler; - onError = errHandler; - - if (string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "clone --progress --verbose --recurse-submodules "; - - if (!string.IsNullOrEmpty(extraArgs)) Args += $"{extraArgs} "; Args += $"{url} "; - if (!string.IsNullOrEmpty(localName)) Args += localName; - } - public override void OnReadline(string line) { - handler?.Invoke(line); - } - - public override void OnException(string message) { - onError?.Invoke(message); + if (!string.IsNullOrEmpty(localName)) + Args += localName; } } } diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index 3c22903b..975922fc 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -1,180 +1,220 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Text; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - - /// - /// 用于取消命令执行的上下文对象 - /// - public class Context { - public bool IsCancelRequested { get; set; } = false; - } - - /// - /// 命令接口 - /// - public class Command { - - /// - /// 读取全部输出时的结果 - /// - public class ReadToEndResult { - public bool IsSuccess { get; set; } - public string Output { get; set; } - public string Error { get; set; } - } - - /// - /// 上下文 - /// - public Context Ctx { get; set; } = null; - - /// - /// 运行路径 - /// - public string Cwd { get; set; } = ""; - - /// - /// 参数 - /// - public string Args { get; set; } = ""; - - /// - /// 是否忽略错误 - /// - public bool DontRaiseError { get; set; } = false; - - /// - /// 使用标准错误输出 - /// - public bool TraitErrorAsOutput { get; set; } = false; - - /// - /// 运行 - /// - public bool Exec() { - var start = new ProcessStartInfo(); - start.FileName = Models.Preference.Instance.Git.Path; - start.Arguments = "--no-pager -c core.quotepath=off " + Args; - start.UseShellExecute = false; - start.CreateNoWindow = true; - start.RedirectStandardOutput = true; - start.RedirectStandardError = true; - start.StandardOutputEncoding = Encoding.UTF8; - start.StandardErrorEncoding = Encoding.UTF8; - - if (!string.IsNullOrEmpty(Cwd)) start.WorkingDirectory = Cwd; - - var progressFilter = new Regex(@"\s\d+%\s"); - var errs = new List(); - var proc = new Process() { StartInfo = start }; - var isCancelled = false; - - proc.OutputDataReceived += (o, e) => { - if (Ctx != null && Ctx.IsCancelRequested) { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) proc.Kill(); - return; - } - - if (e.Data == null) return; - OnReadline(e.Data); - }; - proc.ErrorDataReceived += (o, e) => { - if (Ctx != null && Ctx.IsCancelRequested) { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) proc.Kill(); - return; - } - - if (string.IsNullOrEmpty(e.Data)) return; - if (TraitErrorAsOutput) OnReadline(e.Data); - - if (progressFilter.IsMatch(e.Data)) return; - if (e.Data.StartsWith("remote: Counting objects:", StringComparison.Ordinal)) return; - errs.Add(e.Data); - }; - - try { - proc.Start(); - } catch (Exception e) { - if (!DontRaiseError) OnException(e.Message); - return false; - } - - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - proc.WaitForExit(); - - int exitCode = proc.ExitCode; - proc.Close(); - - if (!isCancelled && exitCode != 0 && errs.Count > 0) { - if (!DontRaiseError) OnException(string.Join("\n", errs)); - return false; - } else { - return true; - } - } - - /// - /// 直接读取全部标准输出 - /// - public ReadToEndResult ReadToEnd() { - var start = new ProcessStartInfo(); - start.FileName = Models.Preference.Instance.Git.Path; - start.Arguments = "--no-pager -c core.quotepath=off " + Args; - start.UseShellExecute = false; - start.CreateNoWindow = true; - start.RedirectStandardOutput = true; - start.RedirectStandardError = true; - start.StandardOutputEncoding = Encoding.UTF8; - start.StandardErrorEncoding = Encoding.UTF8; - - if (!string.IsNullOrEmpty(Cwd)) start.WorkingDirectory = Cwd; - - var proc = new Process() { StartInfo = start }; - try { - proc.Start(); - } catch (Exception e) { - return new ReadToEndResult() { - Output = string.Empty, - Error = e.Message, - IsSuccess = false, - }; - } - - var rs = new ReadToEndResult(); - rs.Output = proc.StandardOutput.ReadToEnd(); - rs.Error = proc.StandardError.ReadToEnd(); - - proc.WaitForExit(); - rs.IsSuccess = proc.ExitCode == 0; - proc.Close(); - - return rs; - } - - /// - /// 调用Exec时的读取函数 - /// - /// - public virtual void OnReadline(string line) { - } - - /// - /// 默认异常处理函数 - /// - /// - public virtual void OnException(string message) { - Models.Exception.Raise(message); - } - } -} +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public partial class Command + { + public class ReadToEndResult + { + public bool IsSuccess { get; set; } = false; + public string StdOut { get; set; } = ""; + public string StdErr { get; set; } = ""; + } + + public enum EditorType + { + None, + CoreEditor, + RebaseEditor, + } + + public string Context { get; set; } = string.Empty; + public CancellationToken CancellationToken { get; set; } = CancellationToken.None; + public string WorkingDirectory { get; set; } = null; + public EditorType Editor { get; set; } = EditorType.CoreEditor; // Only used in Exec() mode + public string SSHKey { get; set; } = string.Empty; + public string Args { get; set; } = string.Empty; + public bool RaiseError { get; set; } = true; + public Models.ICommandLog Log { get; set; } = null; + + public bool Exec() + { + Log?.AppendLine($"$ git {Args}\n"); + + var start = CreateGitStartInfo(); + var errs = new List(); + var proc = new Process() { StartInfo = start }; + + proc.OutputDataReceived += (_, e) => HandleOutput(e.Data, errs); + proc.ErrorDataReceived += (_, e) => HandleOutput(e.Data, errs); + + var dummy = null as Process; + var dummyProcLock = new object(); + try + { + proc.Start(); + + // It not safe, please only use `CancellationToken` in readonly commands. + if (CancellationToken.CanBeCanceled) + { + dummy = proc; + CancellationToken.Register(() => + { + lock (dummyProcLock) + { + if (dummy is { HasExited: false }) + dummy.Kill(); + } + }); + } + } + catch (Exception e) + { + if (RaiseError) + Dispatcher.UIThread.Post(() => App.RaiseException(Context, e.Message)); + + Log?.AppendLine(string.Empty); + return false; + } + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + proc.WaitForExit(); + + if (dummy != null) + { + lock (dummyProcLock) + { + dummy = null; + } + } + + int exitCode = proc.ExitCode; + proc.Close(); + Log?.AppendLine(string.Empty); + + if (!CancellationToken.IsCancellationRequested && exitCode != 0) + { + if (RaiseError) + { + var errMsg = string.Join("\n", errs).Trim(); + if (!string.IsNullOrEmpty(errMsg)) + Dispatcher.UIThread.Post(() => App.RaiseException(Context, errMsg)); + } + + return false; + } + + return true; + } + + public ReadToEndResult ReadToEnd() + { + var start = CreateGitStartInfo(); + var proc = new Process() { StartInfo = start }; + + try + { + proc.Start(); + } + catch (Exception e) + { + return new ReadToEndResult() + { + IsSuccess = false, + StdOut = string.Empty, + StdErr = e.Message, + }; + } + + var rs = new ReadToEndResult() + { + StdOut = proc.StandardOutput.ReadToEnd(), + StdErr = proc.StandardError.ReadToEnd(), + }; + + proc.WaitForExit(); + rs.IsSuccess = proc.ExitCode == 0; + proc.Close(); + + return rs; + } + + private ProcessStartInfo CreateGitStartInfo() + { + var start = new ProcessStartInfo(); + start.FileName = Native.OS.GitExecutable; + start.Arguments = "--no-pager -c core.quotepath=off -c credential.helper=manager "; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + + // Force using this app as SSH askpass program + var selfExecFile = Process.GetCurrentProcess().MainModule!.FileName; + if (!OperatingSystem.IsLinux()) + start.Environment.Add("DISPLAY", "required"); + start.Environment.Add("SSH_ASKPASS", selfExecFile); // Can not use parameter here, because it invoked by SSH with `exec` + start.Environment.Add("SSH_ASKPASS_REQUIRE", "prefer"); + start.Environment.Add("SOURCEGIT_LAUNCH_AS_ASKPASS", "TRUE"); + + // If an SSH private key was provided, sets the environment. + if (!start.Environment.ContainsKey("GIT_SSH_COMMAND") && !string.IsNullOrEmpty(SSHKey)) + start.Environment.Add("GIT_SSH_COMMAND", $"ssh -i '{SSHKey}'"); + + // Force using en_US.UTF-8 locale + if (OperatingSystem.IsLinux()) + { + start.Environment.Add("LANG", "C"); + start.Environment.Add("LC_ALL", "C"); + } + + // Force using this app as git editor. + switch (Editor) + { + case EditorType.CoreEditor: + start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --core-editor\" "; + break; + case EditorType.RebaseEditor: + start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --rebase-message-editor\" -c sequence.editor=\"\\\"{selfExecFile}\\\" --rebase-todo-editor\" -c rebase.abbreviateCommands=true "; + break; + default: + start.Arguments += "-c core.editor=true "; + break; + } + + // Append command args + start.Arguments += Args; + + // Working directory + if (!string.IsNullOrEmpty(WorkingDirectory)) + start.WorkingDirectory = WorkingDirectory; + + return start; + } + + private void HandleOutput(string line, List errs) + { + line ??= string.Empty; + Log?.AppendLine(line); + + // Lines to hide in error message. + if (line.Length > 0) + { + if (line.StartsWith("remote: Enumerating objects:", StringComparison.Ordinal) || + line.StartsWith("remote: Counting objects:", StringComparison.Ordinal) || + line.StartsWith("remote: Compressing objects:", StringComparison.Ordinal) || + line.StartsWith("Filtering content:", StringComparison.Ordinal) || + line.StartsWith("hint:", StringComparison.Ordinal)) + return; + + if (REG_PROGRESS().IsMatch(line)) + return; + } + + errs.Add(line); + } + + [GeneratedRegex(@"\d+%")] + private static partial Regex REG_PROGRESS(); + } +} diff --git a/src/Commands/Commit.cs b/src/Commands/Commit.cs index a3dde9a9..1585e7e3 100644 --- a/src/Commands/Commit.cs +++ b/src/Commands/Commit.cs @@ -1,17 +1,39 @@ -using System.IO; +using System.IO; -namespace SourceGit.Commands { - /// - /// `git commit`命令 - /// - public class Commit : Command { - public Commit(string repo, string message, bool amend) { - var file = Path.GetTempFileName(); - File.WriteAllText(file, message); +namespace SourceGit.Commands +{ + public class Commit : Command + { + public Commit(string repo, string message, bool signOff, bool amend, bool resetAuthor) + { + _tmpFile = Path.GetTempFileName(); + File.WriteAllText(_tmpFile, message); - Cwd = repo; - Args = $"commit --file=\"{file}\""; - if (amend) Args += " --amend --no-edit"; + WorkingDirectory = repo; + Context = repo; + Args = $"commit --allow-empty --file=\"{_tmpFile}\""; + if (signOff) + Args += " --signoff"; + if (amend) + Args += resetAuthor ? " --amend --reset-author --no-edit" : " --amend --no-edit"; } + + public bool Run() + { + var succ = Exec(); + + try + { + File.Delete(_tmpFile); + } + catch + { + // Ignore + } + + return succ; + } + + private readonly string _tmpFile; } } diff --git a/src/Commands/CommitChanges.cs b/src/Commands/CommitChanges.cs deleted file mode 100644 index defcbff2..00000000 --- a/src/Commands/CommitChanges.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - - /// - /// 取得一个提交的变更列表 - /// - public class CommitChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private List changes = new List(); - - public CommitChanges(string cwd, string commit) { - Cwd = cwd; - Args = $"show --name-status {commit}"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status[0]) { - case 'M': change.Set(Models.Change.Status.Modified); changes.Add(change); break; - case 'A': change.Set(Models.Change.Status.Added); changes.Add(change); break; - case 'D': change.Set(Models.Change.Status.Deleted); changes.Add(change); break; - case 'R': change.Set(Models.Change.Status.Renamed); changes.Add(change); break; - case 'C': change.Set(Models.Change.Status.Copied); changes.Add(change); break; - } - } - } -} diff --git a/src/Commands/CommitRangeChanges.cs b/src/Commands/CommitRangeChanges.cs deleted file mode 100644 index 05cc778e..00000000 --- a/src/Commands/CommitRangeChanges.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - - /// - /// 对比两个提交间的变更 - /// - public class CommitRangeChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private List changes = new List(); - - public CommitRangeChanges(string cwd, string start, string end) { - Cwd = cwd; - Args = $"diff --name-status {start} {end}"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status[0]) { - case 'M': change.Set(Models.Change.Status.Modified); changes.Add(change); break; - case 'A': change.Set(Models.Change.Status.Added); changes.Add(change); break; - case 'D': change.Set(Models.Change.Status.Deleted); changes.Add(change); break; - case 'R': change.Set(Models.Change.Status.Renamed); changes.Add(change); break; - case 'C': change.Set(Models.Change.Status.Copied); changes.Add(change); break; - } - } - } -} diff --git a/src/Commands/Commits.cs b/src/Commands/Commits.cs deleted file mode 100644 index b1b4c28f..00000000 --- a/src/Commands/Commits.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SourceGit.Commands { - - /// - /// 取得提交列表 - /// - public class Commits : Command { - private static readonly string GPGSIG_START = "gpgsig -----BEGIN PGP SIGNATURE-----"; - private static readonly string GPGSIG_END = " -----END PGP SIGNATURE-----"; - - private List commits = new List(); - private Models.Commit current = null; - private bool isSkipingGpgsig = false; - private bool isHeadFounded = false; - private bool findFirstMerged = true; - - public Commits(string path, string limits, bool needFindHead = true) { - Cwd = path; - Args = "log --date-order --decorate=full --pretty=raw " + limits; - findFirstMerged = needFindHead; - } - - public List Result() { - Exec(); - - if (current != null) { - current.Message = current.Message.Trim(); - commits.Add(current); - } - - if (findFirstMerged && !isHeadFounded && commits.Count > 0) { - MarkFirstMerged(); - } - - return commits; - } - - public override void OnReadline(string line) { - if (isSkipingGpgsig) { - if (line.StartsWith(GPGSIG_END, StringComparison.Ordinal)) isSkipingGpgsig = false; - return; - } else if (line.StartsWith(GPGSIG_START, StringComparison.Ordinal)) { - isSkipingGpgsig = true; - return; - } - - if (line.StartsWith("commit ", StringComparison.Ordinal)) { - if (current != null) { - current.Message = current.Message.Trim(); - commits.Add(current); - } - - current = new Models.Commit(); - line = line.Substring(7); - - var decoratorStart = line.IndexOf('('); - if (decoratorStart < 0) { - current.SHA = line.Trim(); - } else { - current.SHA = line.Substring(0, decoratorStart).Trim(); - current.IsMerged = ParseDecorators(current.Decorators, line.Substring(decoratorStart + 1)); - if (!isHeadFounded) isHeadFounded = current.IsMerged; - } - - return; - } - - if (current == null) return; - - if (line.StartsWith("tree ", StringComparison.Ordinal)) { - return; - } else if (line.StartsWith("parent ", StringComparison.Ordinal)) { - current.Parents.Add(line.Substring("parent ".Length)); - } else if (line.StartsWith("author ", StringComparison.Ordinal)) { - Models.User user = Models.User.Invalid; - ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); - current.Author = user; - current.AuthorTime = time; - } else if (line.StartsWith("committer ", StringComparison.Ordinal)) { - Models.User user = Models.User.Invalid; - ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); - current.Committer = user; - current.CommitterTime = time; - } else if (string.IsNullOrEmpty(current.Subject)) { - current.Subject = line.Trim(); - } else { - current.Message += (line.Trim() + "\n"); - } - } - - private bool ParseDecorators(List decorators, string data) { - bool isHeadOfCurrent = false; - - var subs = data.Split(new char[] { ',', ')', '(' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var sub in subs) { - var d = sub.Trim(); - if (d.StartsWith("tag: refs/tags/", StringComparison.Ordinal)) { - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.Tag, - Name = d.Substring(15).Trim(), - }); - } else if (d.EndsWith("/HEAD", StringComparison.Ordinal)) { - continue; - } else if (d.StartsWith("HEAD -> refs/heads/", StringComparison.Ordinal)) { - isHeadOfCurrent = true; - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.CurrentBranchHead, - Name = d.Substring(19).Trim(), - }); - } else if (d.StartsWith("refs/heads/", StringComparison.Ordinal)) { - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.LocalBranchHead, - Name = d.Substring(11).Trim(), - }); - } else if (d.StartsWith("refs/remotes/", StringComparison.Ordinal)) { - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.RemoteBranchHead, - Name = d.Substring(13).Trim(), - }); - } - } - - decorators.Sort((l, r) => { - if (l.Type != r.Type) { - return (int)l.Type - (int)r.Type; - } else { - return l.Name.CompareTo(r.Name); - } - }); - - return isHeadOfCurrent; - } - - private void MarkFirstMerged() { - Args = $"log --since=\"{commits.Last().CommitterTimeStr}\" --format=\"%H\""; - - var rs = ReadToEnd(); - var shas = rs.Output.Split(new char[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); - if (shas.Length == 0) return; - - var set = new HashSet(); - foreach (var sha in shas) set.Add(sha); - - foreach (var c in commits) { - if (set.Contains(c.SHA)) { - c.IsMerged = true; - break; - } - } - } - } -} diff --git a/src/Commands/CompareRevisions.cs b/src/Commands/CompareRevisions.cs new file mode 100644 index 00000000..c88e087a --- /dev/null +++ b/src/Commands/CompareRevisions.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class CompareRevisions : Command + { + [GeneratedRegex(@"^([MADC])\s+(.+)$")] + private static partial Regex REG_FORMAT(); + [GeneratedRegex(@"^R[0-9]{0,4}\s+(.+)$")] + private static partial Regex REG_RENAME_FORMAT(); + + public CompareRevisions(string repo, string start, string end) + { + WorkingDirectory = repo; + Context = repo; + + var based = string.IsNullOrEmpty(start) ? "-R" : start; + Args = $"diff --name-status {based} {end}"; + } + + public CompareRevisions(string repo, string start, string end, string path) + { + WorkingDirectory = repo; + Context = repo; + + var based = string.IsNullOrEmpty(start) ? "-R" : start; + Args = $"diff --name-status {based} {end} -- \"{path}\""; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _changes; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + ParseLine(line); + + _changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + return _changes; + } + + private void ParseLine(string line) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + { + match = REG_RENAME_FORMAT().Match(line); + if (match.Success) + { + var renamed = new Models.Change() { Path = match.Groups[1].Value }; + renamed.Set(Models.ChangeState.Renamed); + _changes.Add(renamed); + } + + return; + } + + var change = new Models.Change() { Path = match.Groups[2].Value }; + var status = match.Groups[1].Value; + + switch (status[0]) + { + case 'M': + change.Set(Models.ChangeState.Modified); + _changes.Add(change); + break; + case 'A': + change.Set(Models.ChangeState.Added); + _changes.Add(change); + break; + case 'D': + change.Set(Models.ChangeState.Deleted); + _changes.Add(change); + break; + case 'C': + change.Set(Models.ChangeState.Copied); + _changes.Add(change); + break; + } + } + + private readonly List _changes = new List(); + } +} diff --git a/src/Commands/Config.cs b/src/Commands/Config.cs index f77216dd..49e8fcb7 100644 --- a/src/Commands/Config.cs +++ b/src/Commands/Config.cs @@ -1,36 +1,68 @@ -namespace SourceGit.Commands { - /// - /// config命令 - /// - public class Config : Command { +using System; +using System.Collections.Generic; - public Config() { } +namespace SourceGit.Commands +{ + public class Config : Command + { + public Config(string repository) + { + if (string.IsNullOrEmpty(repository)) + { + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + else + { + WorkingDirectory = repository; + Context = repository; + _isLocal = true; + } - public Config(string repo) { - Cwd = repo; + RaiseError = false; } - public string Get(string key) { - Args = $"config {key}"; - return ReadToEnd().Output.Trim(); - } + public Dictionary ListAll() + { + Args = "config -l"; - public bool Set(string key, string val, bool allowEmpty = false) { - if (!allowEmpty && string.IsNullOrEmpty(val)) { - if (string.IsNullOrEmpty(Cwd)) { - Args = $"config --global --unset {key}"; - } else { - Args = $"config --unset {key}"; - } - } else { - if (string.IsNullOrEmpty(Cwd)) { - Args = $"config --global {key} \"{val}\""; - } else { - Args = $"config {key} \"{val}\""; + var output = ReadToEnd(); + var rs = new Dictionary(); + if (output.IsSuccess) + { + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var idx = line.IndexOf('=', StringComparison.Ordinal); + if (idx != -1) + { + var key = line.Substring(0, idx).Trim(); + var val = line.Substring(idx + 1).Trim(); + rs[key] = val; + } } } + return rs; + } + + public string Get(string key) + { + Args = $"config {key}"; + return ReadToEnd().StdOut.Trim(); + } + + public bool Set(string key, string value, bool allowEmpty = false) + { + var scope = _isLocal ? "--local" : "--global"; + + if (!allowEmpty && string.IsNullOrWhiteSpace(value)) + Args = $"config {scope} --unset {key}"; + else + Args = $"config {scope} {key} \"{value}\""; + return Exec(); } + + private bool _isLocal = false; } } diff --git a/src/Commands/CountLocalChangesWithoutUntracked.cs b/src/Commands/CountLocalChangesWithoutUntracked.cs new file mode 100644 index 00000000..a704f313 --- /dev/null +++ b/src/Commands/CountLocalChangesWithoutUntracked.cs @@ -0,0 +1,26 @@ +using System; + +namespace SourceGit.Commands +{ + public class CountLocalChangesWithoutUntracked : Command + { + public CountLocalChangesWithoutUntracked(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "--no-optional-locks status -uno --ignore-submodules=all --porcelain"; + } + + public int Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + return lines.Length; + } + + return 0; + } + } +} diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs index 4e4e641a..6af0a3cc 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -1,111 +1,281 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text.RegularExpressions; -namespace SourceGit.Commands { - /// - /// Diff命令(用于文件文件比对) - /// - public class Diff : Command { - private static readonly Regex REG_INDICATOR = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@"); - private Models.TextChanges changes = new Models.TextChanges(); - private List deleted = new List(); - private List added = new List(); - private int oldLine = 0; - private int newLine = 0; - private int lineIndex = 0; +namespace SourceGit.Commands +{ + public partial class Diff : Command + { + [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] + private static partial Regex REG_INDICATOR(); - public Diff(string repo, string args) { - Cwd = repo; - Args = $"diff --ignore-cr-at-eol --unified=4 {args}"; + [GeneratedRegex(@"^index\s([0-9a-f]{6,40})\.\.([0-9a-f]{6,40})(\s[1-9]{6})?")] + private static partial Regex REG_HASH_CHANGE(); + + private const string PREFIX_LFS_NEW = "+version https://git-lfs.github.com/spec/"; + private const string PREFIX_LFS_DEL = "-version https://git-lfs.github.com/spec/"; + private const string PREFIX_LFS_MODIFY = " version https://git-lfs.github.com/spec/"; + + public Diff(string repo, Models.DiffOption opt, int unified, bool ignoreWhitespace) + { + _result.TextDiff = new Models.TextDiff() + { + Repo = repo, + Option = opt, + }; + + WorkingDirectory = repo; + Context = repo; + + if (ignoreWhitespace) + Args = $"diff --no-ext-diff --patch --ignore-all-space --unified={unified} {opt}"; + else if (Models.DiffOption.IgnoreCRAtEOL) + Args = $"diff --no-ext-diff --patch --ignore-cr-at-eol --unified={unified} {opt}"; + else + Args = $"diff --no-ext-diff --patch --unified={unified} {opt}"; } - public Models.TextChanges Result() { - Exec(); - ProcessChanges(); - if (changes.IsBinary) changes.Lines.Clear(); - lineIndex = 0; - return changes; + public Models.DiffResult Result() + { + var rs = ReadToEnd(); + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + ParseLine(line); + + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + ParseLine(rs.StdOut.Substring(start)); + + if (_result.IsBinary || _result.IsLFS || _result.TextDiff.Lines.Count == 0) + { + _result.TextDiff = null; + } + else + { + ProcessInlineHighlights(); + _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); + } + + return _result; } - public override void OnReadline(string line) { - if (changes.IsBinary) return; + private void ParseLine(string line) + { + if (_result.IsBinary) + return; - if (changes.Lines.Count == 0) { - var match = REG_INDICATOR.Match(line); - if (!match.Success) { - if (line.StartsWith("Binary", StringComparison.Ordinal)) changes.IsBinary = true; + if (line.StartsWith("old mode ", StringComparison.Ordinal)) + { + _result.OldMode = line.Substring(9); + return; + } + + if (line.StartsWith("new mode ", StringComparison.Ordinal)) + { + _result.NewMode = line.Substring(9); + return; + } + + if (line.StartsWith("deleted file mode ", StringComparison.Ordinal)) + { + _result.OldMode = line.Substring(18); + return; + } + + if (line.StartsWith("new file mode ", StringComparison.Ordinal)) + { + _result.NewMode = line.Substring(14); + return; + } + + if (_result.IsLFS) + { + var ch = line[0]; + if (ch == '-') + { + if (line.StartsWith("-oid sha256:", StringComparison.Ordinal)) + { + _result.LFSDiff.Old.Oid = line.Substring(12); + } + else if (line.StartsWith("-size ", StringComparison.Ordinal)) + { + _result.LFSDiff.Old.Size = long.Parse(line.AsSpan(6)); + } + } + else if (ch == '+') + { + if (line.StartsWith("+oid sha256:", StringComparison.Ordinal)) + { + _result.LFSDiff.New.Oid = line.Substring(12); + } + else if (line.StartsWith("+size ", StringComparison.Ordinal)) + { + _result.LFSDiff.New.Size = long.Parse(line.AsSpan(6)); + } + } + else if (line.StartsWith(" size ", StringComparison.Ordinal)) + { + _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(line.AsSpan(6)); + } + return; + } + + if (_result.TextDiff.Lines.Count == 0) + { + if (line.StartsWith("Binary", StringComparison.Ordinal)) + { + _result.IsBinary = true; return; } - oldLine = int.Parse(match.Groups[1].Value); - newLine = int.Parse(match.Groups[2].Value); - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Indicator, line, "", "")); - } else { - if (line.Length == 0) { - ProcessChanges(); - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Normal, "", $"{oldLine}", $"{newLine}")); - oldLine++; - newLine++; + if (string.IsNullOrEmpty(_result.OldHash)) + { + var match = REG_HASH_CHANGE().Match(line); + if (!match.Success) + return; + + _result.OldHash = match.Groups[1].Value; + _result.NewHash = match.Groups[2].Value; + } + else + { + var match = REG_INDICATOR().Match(line); + if (!match.Success) + return; + + _oldLine = int.Parse(match.Groups[1].Value); + _newLine = int.Parse(match.Groups[2].Value); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0); + _result.TextDiff.Lines.Add(_last); + } + } + else + { + if (line.Length == 0) + { + ProcessInlineHighlights(); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine); + _result.TextDiff.Lines.Add(_last); + _oldLine++; + _newLine++; return; } var ch = line[0]; - if (ch == '-') { - deleted.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Deleted, line.Substring(1), $"{oldLine}", "")); - oldLine++; - } else if (ch == '+') { - added.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Added, line.Substring(1), "", $"{newLine}")); - newLine++; - } else if (ch != '\\') { - ProcessChanges(); - var match = REG_INDICATOR.Match(line); - if (match.Success) { - oldLine = int.Parse(match.Groups[1].Value); - newLine = int.Parse(match.Groups[2].Value); - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Indicator, line, "", "")); - } else { - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Normal, line.Substring(1), $"{oldLine}", $"{newLine}")); - oldLine++; - newLine++; + if (ch == '-') + { + if (_oldLine == 1 && _newLine == 0 && line.StartsWith(PREFIX_LFS_DEL, StringComparison.Ordinal)) + { + _result.IsLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return; } + + _last = new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0); + _deleted.Add(_last); + _oldLine++; + } + else if (ch == '+') + { + if (_oldLine == 0 && _newLine == 1 && line.StartsWith(PREFIX_LFS_NEW, StringComparison.Ordinal)) + { + _result.IsLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return; + } + + _last = new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine); + _added.Add(_last); + _newLine++; + } + else if (ch != '\\') + { + ProcessInlineHighlights(); + var match = REG_INDICATOR().Match(line); + if (match.Success) + { + _oldLine = int.Parse(match.Groups[1].Value); + _newLine = int.Parse(match.Groups[2].Value); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0); + _result.TextDiff.Lines.Add(_last); + } + else + { + if (_oldLine == 1 && _newLine == 1 && line.StartsWith(PREFIX_LFS_MODIFY, StringComparison.Ordinal)) + { + _result.IsLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return; + } + + _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), _oldLine, _newLine); + _result.TextDiff.Lines.Add(_last); + _oldLine++; + _newLine++; + } + } + else if (line.Equals("\\ No newline at end of file", StringComparison.Ordinal)) + { + _last.NoNewLineEndOfFile = true; } } } - private void ProcessChanges() { - if (deleted.Any()) { - if (added.Count == deleted.Count) { - for (int i = added.Count - 1; i >= 0; i--) { - var left = deleted[i]; - var right = added[i]; + private void ProcessInlineHighlights() + { + if (_deleted.Count > 0) + { + if (_added.Count == _deleted.Count) + { + for (int i = _added.Count - 1; i >= 0; i--) + { + var left = _deleted[i]; + var right = _added[i]; - if (left.Content.Length > 1024 || right.Content.Length > 1024) continue; + if (left.Content.Length > 1024 || right.Content.Length > 1024) + continue; - var chunks = Models.TextCompare.Process(left.Content, right.Content); - if (chunks.Count > 4) continue; + var chunks = Models.TextInlineChange.Compare(left.Content, right.Content); + if (chunks.Count > 4) + continue; - foreach (var chunk in chunks) { - if (chunk.DeletedCount > 0) { - left.Highlights.Add(new Models.TextChanges.HighlightRange(chunk.DeletedStart, chunk.DeletedCount)); + foreach (var chunk in chunks) + { + if (chunk.DeletedCount > 0) + { + left.Highlights.Add(new Models.TextInlineRange(chunk.DeletedStart, chunk.DeletedCount)); } - if (chunk.AddedCount > 0) { - right.Highlights.Add(new Models.TextChanges.HighlightRange(chunk.AddedStart, chunk.AddedCount)); + if (chunk.AddedCount > 0) + { + right.Highlights.Add(new Models.TextInlineRange(chunk.AddedStart, chunk.AddedCount)); } } } } - changes.Lines.AddRange(deleted); - deleted.Clear(); + _result.TextDiff.Lines.AddRange(_deleted); + _deleted.Clear(); } - if (added.Any()) { - changes.Lines.AddRange(added); - added.Clear(); + if (_added.Count > 0) + { + _result.TextDiff.Lines.AddRange(_added); + _added.Clear(); } } + + private readonly Models.DiffResult _result = new Models.DiffResult(); + private readonly List _deleted = new List(); + private readonly List _added = new List(); + private Models.TextDiffLine _last = null; + private int _oldLine = 0; + private int _newLine = 0; } } diff --git a/src/Commands/Discard.cs b/src/Commands/Discard.cs index dd35d0ca..f36ca6c9 100644 --- a/src/Commands/Discard.cs +++ b/src/Commands/Discard.cs @@ -1,42 +1,95 @@ -using System; +using System; using System.Collections.Generic; +using System.IO; -namespace SourceGit.Commands { - /// - /// 忽略变更 - /// - public class Discard { - private string repo = null; +using Avalonia.Threading; - public Discard(string repo) { - this.repo = repo; - } - - public void Whole() { - new Reset(repo, "HEAD", "--hard").Exec(); - new Clean(repo).Exec(); - } - - public void Changes(List changes) { - var needClean = new List(); - var needCheckout = new List(); - - foreach (var c in changes) { - if (c.WorkTree == Models.Change.Status.Untracked || c.WorkTree == Models.Change.Status.Added) { - needClean.Add(c.Path); - } else { - needCheckout.Add(c.Path); +namespace SourceGit.Commands +{ + public static class Discard + { + /// + /// Discard all local changes (unstaged & staged) + /// + /// + /// + /// + public static void All(string repo, bool includeIgnored, Models.ICommandLog log) + { + var changes = new QueryLocalChanges(repo).Result(); + try + { + foreach (var c in changes) + { + if (c.WorkTree == Models.ChangeState.Untracked || + c.WorkTree == Models.ChangeState.Added || + c.Index == Models.ChangeState.Added || + c.Index == Models.ChangeState.Renamed) + { + var fullPath = Path.Combine(repo, c.Path); + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + else + File.Delete(fullPath); + } } } - - for (int i = 0; i < needClean.Count; i += 10) { - var count = Math.Min(10, needClean.Count - i); - new Clean(repo, needClean.GetRange(i, count)).Exec(); + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + }); } - for (int i = 0; i < needCheckout.Count; i += 10) { - var count = Math.Min(10, needCheckout.Count - i); - new Checkout(repo).Files(needCheckout.GetRange(i, count)); + new Reset(repo, "HEAD", "--hard") { Log = log }.Exec(); + + if (includeIgnored) + new Clean(repo) { Log = log }.Exec(); + } + + /// + /// Discard selected changes (only unstaged). + /// + /// + /// + /// + public static void Changes(string repo, List changes, Models.ICommandLog log) + { + var restores = new List(); + + try + { + foreach (var c in changes) + { + if (c.WorkTree == Models.ChangeState.Untracked || c.WorkTree == Models.ChangeState.Added) + { + var fullPath = Path.Combine(repo, c.Path); + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + else + File.Delete(fullPath); + } + else + { + restores.Add(c.Path); + } + } + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + }); + } + + if (restores.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + File.WriteAllLines(pathSpecFile, restores); + new Restore(repo, pathSpecFile, false) { Log = log }.Exec(); + File.Delete(pathSpecFile); } } } diff --git a/src/Commands/ExecuteCustomAction.cs b/src/Commands/ExecuteCustomAction.cs new file mode 100644 index 00000000..e59bc068 --- /dev/null +++ b/src/Commands/ExecuteCustomAction.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics; +using System.Text; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class ExecuteCustomAction + { + public static void Run(string repo, string file, string args) + { + var start = new ProcessStartInfo(); + start.FileName = file; + start.Arguments = args; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.WorkingDirectory = repo; + + try + { + Process.Start(start); + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, e.Message)); + } + } + + public static void RunAndWait(string repo, string file, string args, Models.ICommandLog log) + { + var start = new ProcessStartInfo(); + start.FileName = file; + start.Arguments = args; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + start.WorkingDirectory = repo; + + log?.AppendLine($"$ {file} {args}\n"); + + var proc = new Process() { StartInfo = start }; + var builder = new StringBuilder(); + + proc.OutputDataReceived += (_, e) => + { + if (e.Data != null) + log?.AppendLine(e.Data); + }; + + proc.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + { + log?.AppendLine(e.Data); + builder.AppendLine(e.Data); + } + }; + + try + { + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + proc.WaitForExit(); + + var exitCode = proc.ExitCode; + if (exitCode != 0) + { + var errMsg = builder.ToString().Trim(); + if (!string.IsNullOrEmpty(errMsg)) + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, errMsg)); + } + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, e.Message)); + } + + proc.Close(); + } + } +} diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs index ad3925cc..edf2a6dd 100644 --- a/src/Commands/Fetch.cs +++ b/src/Commands/Fetch.cs @@ -1,102 +1,31 @@ -using System; -using System.Collections.Generic; -using System.Threading; +namespace SourceGit.Commands +{ + public class Fetch : Command + { + public Fetch(string repo, string remote, bool noTags, bool force) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "fetch --progress --verbose "; -namespace SourceGit.Commands { + if (noTags) + Args += "--no-tags "; + else + Args += "--tags "; - /// - /// 拉取 - /// - public class Fetch : Command { - private Action handler = null; + if (force) + Args += "--force "; - public Fetch(string repo, string remote, bool prune, Action outputHandler) { - Cwd = repo; - TraitErrorAsOutput = true; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "fetch --progress --verbose "; - if (prune) Args += "--prune "; Args += remote; - handler = outputHandler; - AutoFetch.MarkFetched(repo); } - public Fetch(string repo, string remote, string localBranch, string remoteBranch, Action outputHandler) { - Cwd = repo; - TraitErrorAsOutput = true; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += $"fetch --progress --verbose {remote} {remoteBranch}:{localBranch}"; - handler = outputHandler; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); - } - } - - /// - /// 自动拉取(每隔10分钟) - /// - public class AutoFetch { - private static Dictionary jobs = new Dictionary(); - - private Fetch cmd = null; - private long nextFetchPoint = 0; - private Timer timer = null; - - public static void Start(string repo) { - if (!Models.Preference.Instance.Git.AutoFetchRemotes) return; - - // 只自动更新加入管理列表中的仓库(子模块等不自动更新) - var exists = Models.Preference.Instance.FindRepository(repo); - if (exists == null) return; - - var job = new AutoFetch(repo); - jobs.Add(repo, job); - } - - public static void MarkFetched(string repo) { - if (!jobs.ContainsKey(repo)) return; - jobs[repo].nextFetchPoint = DateTime.Now.AddMinutes(10).ToFileTime(); - } - - public static void Stop(string repo) { - if (!jobs.ContainsKey(repo)) return; - - jobs[repo].timer.Dispose(); - jobs.Remove(repo); - } - - public AutoFetch(string repo) { - cmd = new Fetch(repo, "--all", true, null); - cmd.DontRaiseError = true; - - nextFetchPoint = DateTime.Now.AddMinutes(10).ToFileTime(); - timer = new Timer(OnTick, null, 60000, 10000); - } - - private void OnTick(object o) { - var now = DateTime.Now.ToFileTime(); - if (nextFetchPoint > now) return; - - Models.Watcher.SetEnabled(cmd.Cwd, false); - cmd.Exec(); - nextFetchPoint = DateTime.Now.AddMinutes(10).ToFileTime(); - Models.Watcher.SetEnabled(cmd.Cwd, true); + public Fetch(string repo, Models.Branch local, Models.Branch remote) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote.Remote}.sshkey"); + Args = $"fetch --progress --verbose {remote.Remote} {remote.Name}:{local.Name}"; } } } diff --git a/src/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs index af5fb624..bf850d60 100644 --- a/src/Commands/FormatPatch.cs +++ b/src/Commands/FormatPatch.cs @@ -1,12 +1,13 @@ -namespace SourceGit.Commands { - /// - /// 将Commit另存为Patch文件 - /// - public class FormatPatch : Command { - - public FormatPatch(string repo, string commit, string path) { - Cwd = repo; - Args = $"format-patch {commit} -1 -o \"{path}\""; +namespace SourceGit.Commands +{ + public class FormatPatch : Command + { + public FormatPatch(string repo, string commit, string saveTo) + { + WorkingDirectory = repo; + Context = repo; + Editor = EditorType.None; + Args = $"format-patch {commit} -1 --output=\"{saveTo}\""; } } } diff --git a/src/Commands/GC.cs b/src/Commands/GC.cs index ceadb0b7..0b27f487 100644 --- a/src/Commands/GC.cs +++ b/src/Commands/GC.cs @@ -1,21 +1,12 @@ -using System; - -namespace SourceGit.Commands { - /// - /// GC - /// - public class GC : Command { - private Action handler; - - public GC(string repo, Action onProgress) { - Cwd = repo; - Args = "gc"; - TraitErrorAsOutput = true; - handler = onProgress; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); +namespace SourceGit.Commands +{ + public class GC : Command + { + public GC(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "gc --prune=now"; } } } diff --git a/src/Commands/GenerateCommitMessage.cs b/src/Commands/GenerateCommitMessage.cs new file mode 100644 index 00000000..df61fdd2 --- /dev/null +++ b/src/Commands/GenerateCommitMessage.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + /// + /// A C# version of https://github.com/anjerodev/commitollama + /// + public class GenerateCommitMessage + { + public class GetDiffContent : Command + { + public GetDiffContent(string repo, Models.DiffOption opt) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff --diff-algorithm=minimal {opt}"; + } + } + + public GenerateCommitMessage(Models.OpenAIService service, string repo, List changes, CancellationToken cancelToken, Action onResponse) + { + _service = service; + _repo = repo; + _changes = changes; + _cancelToken = cancelToken; + _onResponse = onResponse; + } + + public void Exec() + { + try + { + _onResponse?.Invoke("Waiting for pre-file analyzing to completed...\n\n"); + + var responseBuilder = new StringBuilder(); + var summaryBuilder = new StringBuilder(); + foreach (var change in _changes) + { + if (_cancelToken.IsCancellationRequested) + return; + + responseBuilder.Append("- "); + summaryBuilder.Append("- "); + + var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd(); + if (rs.IsSuccess) + { + _service.Chat( + _service.AnalyzeDiffPrompt, + $"Here is the `git diff` output: {rs.StdOut}", + _cancelToken, + update => + { + responseBuilder.Append(update); + summaryBuilder.Append(update); + + _onResponse?.Invoke($"Waiting for pre-file analyzing to completed...\n\n{responseBuilder}"); + }); + } + + responseBuilder.Append("\n"); + summaryBuilder.Append("(file: "); + summaryBuilder.Append(change.Path); + summaryBuilder.Append(")\n"); + } + + if (_cancelToken.IsCancellationRequested) + return; + + var responseBody = responseBuilder.ToString(); + var subjectBuilder = new StringBuilder(); + _service.Chat( + _service.GenerateSubjectPrompt, + $"Here are the summaries changes:\n{summaryBuilder}", + _cancelToken, + update => + { + subjectBuilder.Append(update); + _onResponse?.Invoke($"{subjectBuilder}\n\n{responseBody}"); + }); + } + catch (Exception e) + { + Dispatcher.UIThread.Post(() => App.RaiseException(_repo, $"Failed to generate commit message: {e}")); + } + } + + private Models.OpenAIService _service; + private string _repo; + private List _changes; + private CancellationToken _cancelToken; + private Action _onResponse; + } +} diff --git a/src/Commands/GetRepositoryRootPath.cs b/src/Commands/GetRepositoryRootPath.cs deleted file mode 100644 index c4dc6777..00000000 --- a/src/Commands/GetRepositoryRootPath.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace SourceGit.Commands { - /// - /// 取得一个库的根路径 - /// - public class GetRepositoryRootPath : Command { - public GetRepositoryRootPath(string path) { - Cwd = path; - Args = "rev-parse --show-toplevel"; - } - - public string Result() { - var rs = ReadToEnd().Output; - if (string.IsNullOrEmpty(rs)) return null; - return rs.Trim(); - } - } -} diff --git a/src/Commands/GitFlow.cs b/src/Commands/GitFlow.cs index b6707b06..1d33fa3a 100644 --- a/src/Commands/GitFlow.cs +++ b/src/Commands/GitFlow.cs @@ -1,72 +1,92 @@ -namespace SourceGit.Commands { - /// - /// Git-Flow命令 - /// - public class GitFlow : Command { +using System.Text; +using Avalonia.Threading; - public GitFlow(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public static class GitFlow + { + public static bool Init(string repo, string master, string develop, string feature, string release, string hotfix, string version, Models.ICommandLog log) + { + var config = new Config(repo); + config.Set("gitflow.branch.master", master); + config.Set("gitflow.branch.develop", develop); + config.Set("gitflow.prefix.feature", feature); + config.Set("gitflow.prefix.bugfix", "bugfix/"); + config.Set("gitflow.prefix.release", release); + config.Set("gitflow.prefix.hotfix", hotfix); + config.Set("gitflow.prefix.support", "support/"); + config.Set("gitflow.prefix.versiontag", version, true); + + var init = new Command(); + init.WorkingDirectory = repo; + init.Context = repo; + init.Args = "flow init -d"; + init.Log = log; + return init.Exec(); } - public bool Init(string master, string develop, string feature, string release, string hotfix, string version) { - var branches = new Branches(Cwd).Result(); - var current = branches.Find(x => x.IsCurrent); + public static bool Start(string repo, Models.GitFlowBranchType type, string name, Models.ICommandLog log) + { + var start = new Command(); + start.WorkingDirectory = repo; + start.Context = repo; - var masterBranch = branches.Find(x => x.Name == master); - if (masterBranch == null && current != null) new Branch(Cwd, develop).Create(current.Head); - - var devBranch = branches.Find(x => x.Name == develop); - if (devBranch == null && current != null) new Branch(Cwd, develop).Create(current.Head); - - var cmd = new Config(Cwd); - cmd.Set("gitflow.branch.master", master); - cmd.Set("gitflow.branch.develop", develop); - cmd.Set("gitflow.prefix.feature", feature); - cmd.Set("gitflow.prefix.bugfix", "bugfix/"); - cmd.Set("gitflow.prefix.release", release); - cmd.Set("gitflow.prefix.hotfix", hotfix); - cmd.Set("gitflow.prefix.support", "support/"); - cmd.Set("gitflow.prefix.versiontag", version, true); - - Args = "flow init -d"; - return Exec(); - } - - public void Start(Models.GitFlowBranchType type, string name) { - switch (type) { - case Models.GitFlowBranchType.Feature: - Args = $"flow feature start {name}"; - break; - case Models.GitFlowBranchType.Release: - Args = $"flow release start {name}"; - break; - case Models.GitFlowBranchType.Hotfix: - Args = $"flow hotfix start {name}"; - break; - default: - return; + switch (type) + { + case Models.GitFlowBranchType.Feature: + start.Args = $"flow feature start {name}"; + break; + case Models.GitFlowBranchType.Release: + start.Args = $"flow release start {name}"; + break; + case Models.GitFlowBranchType.Hotfix: + start.Args = $"flow hotfix start {name}"; + break; + default: + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, "Bad git-flow branch type!!!")); + return false; } - Exec(); + start.Log = log; + return start.Exec(); } - public void Finish(Models.GitFlowBranchType type, string name, bool keepBranch) { - var option = keepBranch ? "-k" : string.Empty; - switch (type) { - case Models.GitFlowBranchType.Feature: - Args = $"flow feature finish {option} {name}"; - break; - case Models.GitFlowBranchType.Release: - Args = $"flow release finish {option} {name} -m \"RELEASE_DONE\""; - break; - case Models.GitFlowBranchType.Hotfix: - Args = $"flow hotfix finish {option} {name} -m \"HOTFIX_DONE\""; - break; - default: - return; + public static bool Finish(string repo, Models.GitFlowBranchType type, string name, bool squash, bool push, bool keepBranch, Models.ICommandLog log) + { + var builder = new StringBuilder(); + builder.Append("flow "); + + switch (type) + { + case Models.GitFlowBranchType.Feature: + builder.Append("feature"); + break; + case Models.GitFlowBranchType.Release: + builder.Append("release"); + break; + case Models.GitFlowBranchType.Hotfix: + builder.Append("hotfix"); + break; + default: + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, "Bad git-flow branch type!!!")); + return false; } - Exec(); + builder.Append(" finish "); + if (squash) + builder.Append("--squash "); + if (push) + builder.Append("--push "); + if (keepBranch) + builder.Append("-k "); + builder.Append(name); + + var finish = new Command(); + finish.WorkingDirectory = repo; + finish.Context = repo; + finish.Args = builder.ToString(); + finish.Log = log; + return finish.Exec(); } } } diff --git a/src/Commands/GitIgnore.cs b/src/Commands/GitIgnore.cs new file mode 100644 index 00000000..8b351f5e --- /dev/null +++ b/src/Commands/GitIgnore.cs @@ -0,0 +1,23 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public static class GitIgnore + { + public static void Add(string repo, string pattern) + { + var file = Path.Combine(repo, ".gitignore"); + if (!File.Exists(file)) + { + File.WriteAllLines(file, [pattern]); + return; + } + + var org = File.ReadAllText(file); + if (!org.EndsWith('\n')) + File.AppendAllLines(file, ["", pattern]); + else + File.AppendAllLines(file, [pattern]); + } + } +} diff --git a/src/Commands/Init.cs b/src/Commands/Init.cs index 35dde5a2..c44486da 100644 --- a/src/Commands/Init.cs +++ b/src/Commands/Init.cs @@ -1,12 +1,11 @@ -namespace SourceGit.Commands { - - /// - /// 初始化Git仓库 - /// - public class Init : Command { - - public Init(string workDir) { - Cwd = workDir; +namespace SourceGit.Commands +{ + public class Init : Command + { + public Init(string ctx, string dir) + { + Context = ctx; + WorkingDirectory = dir; Args = "init -q"; } } diff --git a/src/Commands/IsBareRepository.cs b/src/Commands/IsBareRepository.cs new file mode 100644 index 00000000..f92d0888 --- /dev/null +++ b/src/Commands/IsBareRepository.cs @@ -0,0 +1,24 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public class IsBareRepository : Command + { + public IsBareRepository(string path) + { + WorkingDirectory = path; + Args = "rev-parse --is-bare-repository"; + } + + public bool Result() + { + if (!Directory.Exists(Path.Combine(WorkingDirectory, "refs")) || + !Directory.Exists(Path.Combine(WorkingDirectory, "objects")) || + !File.Exists(Path.Combine(WorkingDirectory, "HEAD"))) + return false; + + var rs = ReadToEnd(); + return rs.IsSuccess && rs.StdOut.Trim() == "true"; + } + } +} diff --git a/src/Commands/IsBinary.cs b/src/Commands/IsBinary.cs new file mode 100644 index 00000000..af8f54bb --- /dev/null +++ b/src/Commands/IsBinary.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class IsBinary : Command + { + [GeneratedRegex(@"^\-\s+\-\s+.*$")] + private static partial Regex REG_TEST(); + + public IsBinary(string repo, string commit, string path) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff {Models.Commit.EmptyTreeSHA1} {commit} --numstat -- \"{path}\""; + RaiseError = false; + } + + public bool Result() + { + return REG_TEST().IsMatch(ReadToEnd().StdOut); + } + } +} diff --git a/src/Commands/IsBinaryFile.cs b/src/Commands/IsBinaryFile.cs deleted file mode 100644 index 68cbff62..00000000 --- a/src/Commands/IsBinaryFile.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 查询指定版本下的某文件是否是二进制文件 - /// - public class IsBinaryFile : Command { - private static readonly Regex REG_TEST = new Regex(@"^\-\s+\-\s+.*$"); - public IsBinaryFile(string repo, string commit, string path) { - Cwd = repo; - Args = $"diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904 {commit} --numstat -- \"{path}\""; - } - - public bool Result() { - return REG_TEST.IsMatch(ReadToEnd().Output); - } - } -} diff --git a/src/Commands/IsCommitSHA.cs b/src/Commands/IsCommitSHA.cs new file mode 100644 index 00000000..1b0c50e3 --- /dev/null +++ b/src/Commands/IsCommitSHA.cs @@ -0,0 +1,17 @@ +namespace SourceGit.Commands +{ + public class IsCommitSHA : Command + { + public IsCommitSHA(string repo, string hash) + { + WorkingDirectory = repo; + Args = $"cat-file -t {hash}"; + } + + public bool Result() + { + var rs = ReadToEnd(); + return rs.IsSuccess && rs.StdOut.Trim().Equals("commit"); + } + } +} diff --git a/src/Commands/IsConflictResolved.cs b/src/Commands/IsConflictResolved.cs new file mode 100644 index 00000000..9b243451 --- /dev/null +++ b/src/Commands/IsConflictResolved.cs @@ -0,0 +1,19 @@ +namespace SourceGit.Commands +{ + public class IsConflictResolved : Command + { + public IsConflictResolved(string repo, Models.Change change) + { + var opt = new Models.DiffOption(change, true); + + WorkingDirectory = repo; + Context = repo; + Args = $"diff -a --ignore-cr-at-eol --check {opt}"; + } + + public bool Result() + { + return ReadToEnd().IsSuccess; + } + } +} diff --git a/src/Commands/IsLFSFiltered.cs b/src/Commands/IsLFSFiltered.cs new file mode 100644 index 00000000..2a7234bb --- /dev/null +++ b/src/Commands/IsLFSFiltered.cs @@ -0,0 +1,27 @@ +namespace SourceGit.Commands +{ + public class IsLFSFiltered : Command + { + public IsLFSFiltered(string repo, string path) + { + WorkingDirectory = repo; + Context = repo; + Args = $"check-attr -z filter \"{path}\""; + RaiseError = false; + } + + public IsLFSFiltered(string repo, string sha, string path) + { + WorkingDirectory = repo; + Context = repo; + Args = $"check-attr --source {sha} -z filter \"{path}\""; + RaiseError = false; + } + + public bool Result() + { + var rs = ReadToEnd(); + return rs.IsSuccess && rs.StdOut.Contains("filter\0lfs"); + } + } +} diff --git a/src/Commands/LFS.cs b/src/Commands/LFS.cs index 87d3e378..18d2ba93 100644 --- a/src/Commands/LFS.cs +++ b/src/Commands/LFS.cs @@ -1,51 +1,115 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Text.RegularExpressions; -namespace SourceGit.Commands { - /// - /// LFS相关 - /// - public class LFS { - private string repo; +namespace SourceGit.Commands +{ + public partial class LFS + { + [GeneratedRegex(@"^(.+)\s+([\w.]+)\s+\w+:(\d+)$")] + private static partial Regex REG_LOCK(); - private class PruneCmd : Command { - private Action handler; - - public PruneCmd(string repo, Action onProgress) { - Cwd = repo; - Args = "lfs prune"; - TraitErrorAsOutput = true; - handler = onProgress; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); + private class SubCmd : Command + { + public SubCmd(string repo, string args, Models.ICommandLog log) + { + WorkingDirectory = repo; + Context = repo; + Args = args; + Log = log; } } - public LFS(string repo) { - this.repo = repo; + public LFS(string repo) + { + _repo = repo; } - public bool IsEnabled() { - var path = Path.Combine(repo, ".git", "hooks", "pre-push"); - if (!File.Exists(path)) return false; + public bool IsEnabled() + { + var path = Path.Combine(_repo, ".git", "hooks", "pre-push"); + if (!File.Exists(path)) + return false; var content = File.ReadAllText(path); return content.Contains("git lfs pre-push"); } - public bool IsFiltered(string path) { - var cmd = new Command(); - cmd.Cwd = repo; - cmd.Args = $"check-attr -a -z \"{path}\""; + public bool Install(Models.ICommandLog log) + { + return new SubCmd(_repo, "lfs install --local", log).Exec(); + } + public bool Track(string pattern, bool isFilenameMode, Models.ICommandLog log) + { + var opt = isFilenameMode ? "--filename" : ""; + return new SubCmd(_repo, $"lfs track {opt} \"{pattern}\"", log).Exec(); + } + + public void Fetch(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs fetch {remote}", log).Exec(); + } + + public void Pull(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs pull {remote}", log).Exec(); + } + + public void Push(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs push {remote}", log).Exec(); + } + + public void Prune(Models.ICommandLog log) + { + new SubCmd(_repo, "lfs prune", log).Exec(); + } + + public List Locks(string remote) + { + var locks = new List(); + var cmd = new SubCmd(_repo, $"lfs locks --remote={remote}", null); var rs = cmd.ReadToEnd(); - return rs.Output.Contains("filter\0lfs"); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_LOCK().Match(line); + if (match.Success) + { + locks.Add(new Models.LFSLock() + { + File = match.Groups[1].Value, + User = match.Groups[2].Value, + ID = long.Parse(match.Groups[3].Value), + }); + } + } + } + + return locks; } - public void Prune(Action onProgress) { - new PruneCmd(repo, onProgress).Exec(); + public bool Lock(string remote, string file, Models.ICommandLog log) + { + return new SubCmd(_repo, $"lfs lock --remote={remote} \"{file}\"", log).Exec(); } + + public bool Unlock(string remote, string file, bool force, Models.ICommandLog log) + { + var opt = force ? "-f" : ""; + return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} \"{file}\"", log).Exec(); + } + + public bool Unlock(string remote, long id, bool force, Models.ICommandLog log) + { + var opt = force ? "-f" : ""; + return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} --id={id}", log).Exec(); + } + + private readonly string _repo; } } diff --git a/src/Commands/LocalChanges.cs b/src/Commands/LocalChanges.cs deleted file mode 100644 index edb156db..00000000 --- a/src/Commands/LocalChanges.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 取得本地工作副本变更 - /// - public class LocalChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private static readonly string[] UNTRACKED = new string[] { "no", "all" }; - private List changes = new List(); - - public LocalChanges(string path, bool includeUntracked = true) { - Cwd = path; - Args = $"status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - if (line.EndsWith("/", StringComparison.Ordinal)) return; // Ignore changes with git-worktree - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status) { - case " M": change.Set(Models.Change.Status.None, Models.Change.Status.Modified); break; - case " A": change.Set(Models.Change.Status.None, Models.Change.Status.Added); break; - case " D": change.Set(Models.Change.Status.None, Models.Change.Status.Deleted); break; - case " R": change.Set(Models.Change.Status.None, Models.Change.Status.Renamed); break; - case " C": change.Set(Models.Change.Status.None, Models.Change.Status.Copied); break; - case "M": change.Set(Models.Change.Status.Modified, Models.Change.Status.None); break; - case "MM": change.Set(Models.Change.Status.Modified, Models.Change.Status.Modified); break; - case "MD": change.Set(Models.Change.Status.Modified, Models.Change.Status.Deleted); break; - case "A": change.Set(Models.Change.Status.Added, Models.Change.Status.None); break; - case "AM": change.Set(Models.Change.Status.Added, Models.Change.Status.Modified); break; - case "AD": change.Set(Models.Change.Status.Added, Models.Change.Status.Deleted); break; - case "D": change.Set(Models.Change.Status.Deleted, Models.Change.Status.None); break; - case "R": change.Set(Models.Change.Status.Renamed, Models.Change.Status.None); break; - case "RM": change.Set(Models.Change.Status.Renamed, Models.Change.Status.Modified); break; - case "RD": change.Set(Models.Change.Status.Renamed, Models.Change.Status.Deleted); break; - case "C": change.Set(Models.Change.Status.Copied, Models.Change.Status.None); break; - case "CM": change.Set(Models.Change.Status.Copied, Models.Change.Status.Modified); break; - case "CD": change.Set(Models.Change.Status.Copied, Models.Change.Status.Deleted); break; - case "DR": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Renamed); break; - case "DC": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Copied); break; - case "DD": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Deleted); break; - case "AU": change.Set(Models.Change.Status.Added, Models.Change.Status.Unmerged); break; - case "UD": change.Set(Models.Change.Status.Unmerged, Models.Change.Status.Deleted); break; - case "UA": change.Set(Models.Change.Status.Unmerged, Models.Change.Status.Added); break; - case "DU": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Unmerged); break; - case "AA": change.Set(Models.Change.Status.Added, Models.Change.Status.Added); break; - case "UU": change.Set(Models.Change.Status.Unmerged, Models.Change.Status.Unmerged); break; - case "??": change.Set(Models.Change.Status.Untracked, Models.Change.Status.Untracked); break; - default: return; - } - - changes.Add(change); - } - } -} diff --git a/src/Commands/Merge.cs b/src/Commands/Merge.cs index a44c72a1..b08377b9 100644 --- a/src/Commands/Merge.cs +++ b/src/Commands/Merge.cs @@ -1,21 +1,36 @@ -using System; +using System.Collections.Generic; +using System.Text; -namespace SourceGit.Commands { - /// - /// 合并分支 - /// - public class Merge : Command { - private Action handler = null; - - public Merge(string repo, string source, string mode, Action onProgress) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Merge : Command + { + public Merge(string repo, string source, string mode) + { + WorkingDirectory = repo; + Context = repo; Args = $"merge --progress {source} {mode}"; - TraitErrorAsOutput = true; - handler = onProgress; } - public override void OnReadline(string line) { - handler?.Invoke(line); + public Merge(string repo, List targets, bool autoCommit, string strategy) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("merge --progress "); + if (!string.IsNullOrEmpty(strategy)) + builder.Append($"--strategy={strategy} "); + if (!autoCommit) + builder.Append("--no-commit "); + + foreach (var t in targets) + { + builder.Append(t); + builder.Append(' '); + } + + Args = builder.ToString(); } } } diff --git a/src/Commands/MergeTool.cs b/src/Commands/MergeTool.cs new file mode 100644 index 00000000..fc6d0d75 --- /dev/null +++ b/src/Commands/MergeTool.cs @@ -0,0 +1,72 @@ +using System.IO; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class MergeTool + { + public static bool OpenForMerge(string repo, int toolType, string toolPath, string file) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.RaiseError = true; + + // NOTE: If no names are specified, 'git mergetool' will run the merge tool program on every file with merge conflicts. + var fileArg = string.IsNullOrEmpty(file) ? "" : $"\"{file}\""; + + if (toolType == 0) + { + cmd.Args = $"mergetool {fileArg}"; + return cmd.Exec(); + } + + if (!File.Exists(toolPath)) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, $"Can NOT find external merge tool in '{toolPath}'!")); + return false; + } + + var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); + if (supported == null) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); + return false; + } + + cmd.Args = $"-c mergetool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.Cmd}\" -c mergetool.writeToTemp=true -c mergetool.keepBackup=false -c mergetool.trustExitCode=true mergetool --tool=sourcegit {fileArg}"; + return cmd.Exec(); + } + + public static bool OpenForDiff(string repo, int toolType, string toolPath, Models.DiffOption option) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.RaiseError = true; + + if (toolType == 0) + { + cmd.Args = $"difftool -g --no-prompt {option}"; + return cmd.Exec(); + } + + if (!File.Exists(toolPath)) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, $"Can NOT find external diff tool in '{toolPath}'!")); + return false; + } + + var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); + if (supported == null) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); + return false; + } + + cmd.Args = $"-c difftool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.DiffCmd}\" difftool --tool=sourcegit --no-prompt {option}"; + return cmd.Exec(); + } + } +} diff --git a/src/Commands/Pull.cs b/src/Commands/Pull.cs index e2ab522a..698fbfce 100644 --- a/src/Commands/Pull.cs +++ b/src/Commands/Pull.cs @@ -1,51 +1,18 @@ -using System; +namespace SourceGit.Commands +{ + public class Pull : Command + { + public Pull(string repo, string remote, string branch, bool useRebase) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "pull --verbose --progress "; -namespace SourceGit.Commands { + if (useRebase) + Args += "--rebase=true "; - /// - /// 拉回 - /// - public class Pull : Command { - private Action handler = null; - private bool needStash = false; - - public Pull(string repo, string remote, string branch, bool useRebase, bool autoStash, Action onProgress) { - Cwd = repo; - TraitErrorAsOutput = true; - handler = onProgress; - needStash = autoStash; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "pull --verbose --progress --tags "; - if (useRebase) Args += "--rebase "; Args += $"{remote} {branch}"; } - - public bool Run() { - if (needStash) { - var changes = new LocalChanges(Cwd).Result(); - if (changes.Count > 0) { - if (!new Stash(Cwd).Push(changes, "PULL_AUTO_STASH", true)) { - return false; - } - } else { - needStash = false; - } - } - - var succ = Exec(); - if (succ && needStash) new Stash(Cwd).Pop("stash@{0}"); - return succ; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); - } } } diff --git a/src/Commands/Push.cs b/src/Commands/Push.cs index 7784c581..8a5fe33c 100644 --- a/src/Commands/Push.cs +++ b/src/Commands/Push.cs @@ -1,63 +1,37 @@ -using System; +namespace SourceGit.Commands +{ + public class Push : Command + { + public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool checkSubmodules, bool track, bool force) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "push --progress --verbose "; -namespace SourceGit.Commands { - /// - /// 推送 - /// - public class Push : Command { - private Action handler = null; - - public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool force, bool track, Action onProgress) { - Cwd = repo; - TraitErrorAsOutput = true; - handler = onProgress; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "push --progress --verbose "; - - if (withTags) Args += "--tags "; - if (track) Args += "-u "; - if (force) Args += "--force-with-lease "; + if (withTags) + Args += "--tags "; + if (checkSubmodules) + Args += "--recurse-submodules=check "; + if (track) + Args += "-u "; + if (force) + Args += "--force-with-lease "; Args += $"{remote} {local}:{remoteBranch}"; } - public Push(string repo, string remote, string branch) { - Cwd = repo; + public Push(string repo, string remote, string refname, bool isDelete) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "push "; - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } + if (isDelete) + Args += "--delete "; - Args += $"push {remote} --delete {branch}"; - } - - public Push(string repo, string remote, string tag, bool isDelete) { - Cwd = repo; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "push "; - if (isDelete) Args += "--delete "; - Args += $"{remote} refs/tags/{tag}"; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); + Args += $"{remote} {refname}"; } } } diff --git a/src/Commands/QueryAssumeUnchangedFiles.cs b/src/Commands/QueryAssumeUnchangedFiles.cs new file mode 100644 index 00000000..b5c23b0b --- /dev/null +++ b/src/Commands/QueryAssumeUnchangedFiles.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryAssumeUnchangedFiles : Command + { + [GeneratedRegex(@"^(\w)\s+(.+)$")] + private static partial Regex REG_PARSE(); + + public QueryAssumeUnchangedFiles(string repo) + { + WorkingDirectory = repo; + Args = "ls-files -v"; + RaiseError = false; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_PARSE().Match(line); + if (!match.Success) + continue; + + if (match.Groups[1].Value == "h") + outs.Add(match.Groups[2].Value); + } + + return outs; + } + } +} diff --git a/src/Commands/QueryBranches.cs b/src/Commands/QueryBranches.cs new file mode 100644 index 00000000..d0ecd322 --- /dev/null +++ b/src/Commands/QueryBranches.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryBranches : Command + { + private const string PREFIX_LOCAL = "refs/heads/"; + private const string PREFIX_REMOTE = "refs/remotes/"; + private const string PREFIX_DETACHED_AT = "(HEAD detached at"; + private const string PREFIX_DETACHED_FROM = "(HEAD detached from"; + + public QueryBranches(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "branch -l --all -v --format=\"%(refname)%00%(committerdate:unix)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)\""; + } + + public List Result(out int localBranchesCount) + { + localBranchesCount = 0; + + var branches = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return branches; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var remoteHeads = new Dictionary(); + foreach (var line in lines) + { + var b = ParseLine(line); + if (b != null) + { + branches.Add(b); + if (!b.IsLocal) + remoteHeads.Add(b.FullName, b.Head); + else + localBranchesCount++; + } + } + + foreach (var b in branches) + { + if (b.IsLocal && !string.IsNullOrEmpty(b.Upstream)) + { + if (remoteHeads.TryGetValue(b.Upstream, out var upstreamHead)) + { + b.IsUpstreamGone = false; + + if (b.TrackStatus == null) + b.TrackStatus = new QueryTrackStatus(WorkingDirectory, b.Head, upstreamHead).Result(); + } + else + { + b.IsUpstreamGone = true; + + if (b.TrackStatus == null) + b.TrackStatus = new Models.BranchTrackStatus(); + } + } + } + + return branches; + } + + private Models.Branch ParseLine(string line) + { + var parts = line.Split('\0'); + if (parts.Length != 6) + return null; + + var branch = new Models.Branch(); + var refName = parts[0]; + if (refName.EndsWith("/HEAD", StringComparison.Ordinal)) + return null; + + branch.IsDetachedHead = refName.StartsWith(PREFIX_DETACHED_AT, StringComparison.Ordinal) || + refName.StartsWith(PREFIX_DETACHED_FROM, StringComparison.Ordinal); + + if (refName.StartsWith(PREFIX_LOCAL, StringComparison.Ordinal)) + { + branch.Name = refName.Substring(PREFIX_LOCAL.Length); + branch.IsLocal = true; + } + else if (refName.StartsWith(PREFIX_REMOTE, StringComparison.Ordinal)) + { + var name = refName.Substring(PREFIX_REMOTE.Length); + var shortNameIdx = name.IndexOf('/', StringComparison.Ordinal); + if (shortNameIdx < 0) + return null; + + branch.Remote = name.Substring(0, shortNameIdx); + branch.Name = name.Substring(branch.Remote.Length + 1); + branch.IsLocal = false; + } + else + { + branch.Name = refName; + branch.IsLocal = true; + } + + branch.FullName = refName; + branch.CommitterDate = ulong.Parse(parts[1]); + branch.Head = parts[2]; + branch.IsCurrent = parts[3] == "*"; + branch.Upstream = parts[4]; + branch.IsUpstreamGone = false; + + if (!branch.IsLocal || + string.IsNullOrEmpty(branch.Upstream) || + string.IsNullOrEmpty(parts[5]) || + parts[5].Equals("=", StringComparison.Ordinal)) + branch.TrackStatus = new Models.BranchTrackStatus(); + + return branch; + } + } +} diff --git a/src/Commands/QueryCommitChildren.cs b/src/Commands/QueryCommitChildren.cs new file mode 100644 index 00000000..4e99ce7a --- /dev/null +++ b/src/Commands/QueryCommitChildren.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryCommitChildren : Command + { + public QueryCommitChildren(string repo, string commit, int max) + { + WorkingDirectory = repo; + Context = repo; + _commit = commit; + Args = $"rev-list -{max} --parents --branches --remotes --ancestry-path ^{commit}"; + } + + public List Result() + { + var rs = ReadToEnd(); + var outs = new List(); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.Contains(_commit)) + outs.Add(line.Substring(0, 40)); + } + } + + return outs; + } + + private string _commit; + } +} diff --git a/src/Commands/QueryCommitFullMessage.cs b/src/Commands/QueryCommitFullMessage.cs new file mode 100644 index 00000000..36b6d1c7 --- /dev/null +++ b/src/Commands/QueryCommitFullMessage.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Commands +{ + public class QueryCommitFullMessage : Command + { + public QueryCommitFullMessage(string repo, string sha) + { + WorkingDirectory = repo; + Context = repo; + Args = $"show --no-show-signature --format=%B -s {sha}"; + } + + public string Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + return rs.StdOut.TrimEnd(); + return string.Empty; + } + } +} diff --git a/src/Commands/QueryCommitSignInfo.cs b/src/Commands/QueryCommitSignInfo.cs new file mode 100644 index 00000000..133949af --- /dev/null +++ b/src/Commands/QueryCommitSignInfo.cs @@ -0,0 +1,34 @@ +namespace SourceGit.Commands +{ + public class QueryCommitSignInfo : Command + { + public QueryCommitSignInfo(string repo, string sha, bool useFakeSignersFile) + { + WorkingDirectory = repo; + Context = repo; + + const string baseArgs = "show --no-show-signature --format=%G?%n%GS%n%GK -s"; + const string fakeSignersFileArg = "-c gpg.ssh.allowedSignersFile=/dev/null"; + Args = $"{(useFakeSignersFile ? fakeSignersFileArg : string.Empty)} {baseArgs} {sha}"; + } + + public Models.CommitSignInfo Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return null; + + var raw = rs.StdOut.Trim().ReplaceLineEndings("\n"); + if (raw.Length <= 1) + return null; + + var lines = raw.Split('\n'); + return new Models.CommitSignInfo() + { + VerifyResult = lines[0][0], + Signer = lines[1], + Key = lines[2] + }; + } + } +} diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs new file mode 100644 index 00000000..9e1d9918 --- /dev/null +++ b/src/Commands/QueryCommits.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class QueryCommits : Command + { + public QueryCommits(string repo, string limits, bool needFindHead = true) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {limits}"; + _findFirstMerged = needFindHead; + } + + public QueryCommits(string repo, string filter, Models.CommitSearchMethod method, bool onlyCurrentBranch) + { + string search = onlyCurrentBranch ? string.Empty : "--branches --remotes "; + + if (method == Models.CommitSearchMethod.ByAuthor) + { + search += $"-i --author=\"{filter}\""; + } + else if (method == Models.CommitSearchMethod.ByCommitter) + { + search += $"-i --committer=\"{filter}\""; + } + else if (method == Models.CommitSearchMethod.ByMessage) + { + var argsBuilder = new StringBuilder(); + argsBuilder.Append(search); + + var words = filter.Split([' ', '\t', '\r'], StringSplitOptions.RemoveEmptyEntries); + foreach (var word in words) + { + var escaped = word.Trim().Replace("\"", "\\\"", StringComparison.Ordinal); + argsBuilder.Append($"--grep=\"{escaped}\" "); + } + argsBuilder.Append("--all-match -i"); + + search = argsBuilder.ToString(); + } + else if (method == Models.CommitSearchMethod.ByFile) + { + search += $"-- \"{filter}\""; + } + else + { + search = $"-G\"{filter}\""; + } + + WorkingDirectory = repo; + Context = repo; + Args = $"log -1000 --date-order --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {search}"; + _findFirstMerged = false; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _commits; + + var nextPartIdx = 0; + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + switch (nextPartIdx) + { + case 0: + _current = new Models.Commit() { SHA = line }; + _commits.Add(_current); + break; + case 1: + ParseParent(line); + break; + case 2: + _current.ParseDecorators(line); + if (_current.IsMerged && !_isHeadFounded) + _isHeadFounded = true; + break; + case 3: + _current.Author = Models.User.FindOrAdd(line); + break; + case 4: + _current.AuthorTime = ulong.Parse(line); + break; + case 5: + _current.Committer = Models.User.FindOrAdd(line); + break; + case 6: + _current.CommitterTime = ulong.Parse(line); + break; + case 7: + _current.Subject = line; + nextPartIdx = -1; + break; + } + + nextPartIdx++; + + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + _current.Subject = rs.StdOut.Substring(start); + + if (_findFirstMerged && !_isHeadFounded && _commits.Count > 0) + MarkFirstMerged(); + + return _commits; + } + + private void ParseParent(string data) + { + if (data.Length < 8) + return; + + _current.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); + } + + private void MarkFirstMerged() + { + Args = $"log --since=\"{_commits[^1].CommitterTimeStr}\" --format=\"%H\""; + + var rs = ReadToEnd(); + var shas = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (shas.Length == 0) + return; + + var set = new HashSet(); + foreach (var sha in shas) + set.Add(sha); + + foreach (var c in _commits) + { + if (set.Contains(c.SHA)) + { + c.IsMerged = true; + break; + } + } + } + + private List _commits = new List(); + private Models.Commit _current = null; + private bool _findFirstMerged = false; + private bool _isHeadFounded = false; + } +} diff --git a/src/Commands/QueryCommitsForInteractiveRebase.cs b/src/Commands/QueryCommitsForInteractiveRebase.cs new file mode 100644 index 00000000..9f238319 --- /dev/null +++ b/src/Commands/QueryCommitsForInteractiveRebase.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryCommitsForInteractiveRebase : Command + { + public QueryCommitsForInteractiveRebase(string repo, string on) + { + _boundary = $"----- BOUNDARY OF COMMIT {Guid.NewGuid()} -----"; + + WorkingDirectory = repo; + Context = repo; + Args = $"log --date-order --no-show-signature --decorate=full --format=\"%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%B%n{_boundary}\" {on}..HEAD"; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _commits; + + var nextPartIdx = 0; + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + switch (nextPartIdx) + { + case 0: + _current = new Models.InteractiveCommit(); + _current.Commit.SHA = line; + _commits.Add(_current); + break; + case 1: + ParseParent(line); + break; + case 2: + _current.Commit.ParseDecorators(line); + break; + case 3: + _current.Commit.Author = Models.User.FindOrAdd(line); + break; + case 4: + _current.Commit.AuthorTime = ulong.Parse(line); + break; + case 5: + _current.Commit.Committer = Models.User.FindOrAdd(line); + break; + case 6: + _current.Commit.CommitterTime = ulong.Parse(line); + break; + default: + var boundary = rs.StdOut.IndexOf(_boundary, end + 1, StringComparison.Ordinal); + if (boundary > end) + { + _current.Message = rs.StdOut.Substring(start, boundary - start - 1); + end = boundary + _boundary.Length; + } + else + { + _current.Message = rs.StdOut.Substring(start); + end = rs.StdOut.Length - 2; + } + + nextPartIdx = -1; + break; + } + + nextPartIdx++; + + start = end + 1; + if (start >= rs.StdOut.Length - 1) + break; + + end = rs.StdOut.IndexOf('\n', start); + } + + return _commits; + } + + private void ParseParent(string data) + { + if (data.Length < 8) + return; + + _current.Commit.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); + } + + private List _commits = []; + private Models.InteractiveCommit _current = null; + private readonly string _boundary; + } +} diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs index 04eab006..83d0a575 100644 --- a/src/Commands/QueryFileContent.cs +++ b/src/Commands/QueryFileContent.cs @@ -1,26 +1,73 @@ -using System.Collections.Generic; +using System; +using System.Diagnostics; +using System.IO; -namespace SourceGit.Commands { - /// - /// 取得指定提交下的某文件内容 - /// - public class QueryFileContent : Command { - private List lines = new List(); - private int added = 0; +namespace SourceGit.Commands +{ + public static class QueryFileContent + { + public static Stream Run(string repo, string revision, string file) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"show {revision}:\"{file}\""; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardOutput = true; - public QueryFileContent(string repo, string commit, string path) { - Cwd = repo; - Args = $"show {commit}:\"{path}\""; + var stream = new MemoryStream(); + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardOutput.BaseStream.CopyTo(stream); + proc.WaitForExit(); + proc.Close(); + + stream.Position = 0; + } + catch (Exception e) + { + App.RaiseException(repo, $"Failed to query file content: {e}"); + } + + return stream; } - public List Result() { - Exec(); - return lines; - } + public static Stream FromLFS(string repo, string oid, long size) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"lfs smudge"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardInput = true; + starter.RedirectStandardOutput = true; - public override void OnReadline(string line) { - added++; - lines.Add(new Models.TextLine() { Number = added, Data = line }); + var stream = new MemoryStream(); + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardInput.WriteLine("version https://git-lfs.github.com/spec/v1"); + proc.StandardInput.WriteLine($"oid sha256:{oid}"); + proc.StandardInput.WriteLine($"size {size}"); + proc.StandardOutput.BaseStream.CopyTo(stream); + proc.WaitForExit(); + proc.Close(); + + stream.Position = 0; + } + catch (Exception e) + { + App.RaiseException(repo, $"Failed to query file content: {e}"); + } + + return stream; } } } diff --git a/src/Commands/QueryFileSize.cs b/src/Commands/QueryFileSize.cs new file mode 100644 index 00000000..30af7715 --- /dev/null +++ b/src/Commands/QueryFileSize.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryFileSize : Command + { + [GeneratedRegex(@"^\d+\s+\w+\s+[0-9a-f]+\s+(\d+)\s+.*$")] + private static partial Regex REG_FORMAT(); + + public QueryFileSize(string repo, string file, string revision) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree {revision} -l -- \"{file}\""; + } + + public long Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + { + var match = REG_FORMAT().Match(rs.StdOut); + if (match.Success) + return long.Parse(match.Groups[1].Value); + } + + return 0; + } + } +} diff --git a/src/Commands/QueryFileSizeChange.cs b/src/Commands/QueryFileSizeChange.cs deleted file mode 100644 index 70792e2a..00000000 --- a/src/Commands/QueryFileSizeChange.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.IO; - -namespace SourceGit.Commands { - /// - /// 查询文件大小变化 - /// - public class QueryFileSizeChange { - - class QuerySizeCmd : Command { - public QuerySizeCmd(string repo, string path, string revision) { - Cwd = repo; - Args = $"cat-file -s {revision}:\"{path}\""; - } - - public long Result() { - string data = ReadToEnd().Output; - long size; - if (!long.TryParse(data, out size)) size = 0; - return size; - } - } - - private Models.FileSizeChange change = new Models.FileSizeChange(); - - public QueryFileSizeChange(string repo, string[] revisions, string path, string orgPath) { - if (revisions.Length == 0) { - change.NewSize = new FileInfo(Path.Combine(repo, path)).Length; - change.OldSize = new QuerySizeCmd(repo, path, "HEAD").Result(); - } else if (revisions.Length == 1) { - change.NewSize = new QuerySizeCmd(repo, path, "HEAD").Result(); - if (string.IsNullOrEmpty(orgPath)) { - change.OldSize = new QuerySizeCmd(repo, path, revisions[0]).Result(); - } else { - change.OldSize = new QuerySizeCmd(repo, orgPath, revisions[0]).Result(); - } - } else { - change.NewSize = new QuerySizeCmd(repo, path, revisions[1]).Result(); - if (string.IsNullOrEmpty(orgPath)) { - change.OldSize = new QuerySizeCmd(repo, path, revisions[0]).Result(); - } else { - change.OldSize = new QuerySizeCmd(repo, orgPath, revisions[0]).Result(); - } - } - } - - public Models.FileSizeChange Result() { - return change; - } - } -} diff --git a/src/Commands/QueryGitCommonDir.cs b/src/Commands/QueryGitCommonDir.cs new file mode 100644 index 00000000..1076243e --- /dev/null +++ b/src/Commands/QueryGitCommonDir.cs @@ -0,0 +1,26 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public class QueryGitCommonDir : Command + { + public QueryGitCommonDir(string workDir) + { + WorkingDirectory = workDir; + Args = "rev-parse --git-common-dir"; + RaiseError = false; + } + + public string Result() + { + var rs = ReadToEnd().StdOut; + if (string.IsNullOrEmpty(rs)) + return null; + + rs = rs.Trim(); + if (Path.IsPathRooted(rs)) + return rs; + return Path.GetFullPath(Path.Combine(WorkingDirectory, rs)); + } + } +} diff --git a/src/Commands/QueryGitDir.cs b/src/Commands/QueryGitDir.cs index af45273d..e3a94baf 100644 --- a/src/Commands/QueryGitDir.cs +++ b/src/Commands/QueryGitDir.cs @@ -1,23 +1,26 @@ -using System.IO; +using System.IO; -namespace SourceGit.Commands { - - /// - /// 取得GitDir - /// - public class QueryGitDir : Command { - public QueryGitDir(string workDir) { - Cwd = workDir; +namespace SourceGit.Commands +{ + public class QueryGitDir : Command + { + public QueryGitDir(string workDir) + { + WorkingDirectory = workDir; Args = "rev-parse --git-dir"; + RaiseError = false; } - public string Result() { - var rs = ReadToEnd().Output; - if (string.IsNullOrEmpty(rs)) return null; + public string Result() + { + var rs = ReadToEnd().StdOut; + if (string.IsNullOrEmpty(rs)) + return null; rs = rs.Trim(); - if (Path.IsPathRooted(rs)) return rs; - return Path.GetFullPath(Path.Combine(Cwd, rs)); + if (Path.IsPathRooted(rs)) + return rs; + return Path.GetFullPath(Path.Combine(WorkingDirectory, rs)); } } } diff --git a/src/Commands/QueryLFSObject.cs b/src/Commands/QueryLFSObject.cs deleted file mode 100644 index 8db8bbe0..00000000 --- a/src/Commands/QueryLFSObject.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; - -namespace SourceGit.Commands { - /// - /// 取得一个LFS对象的信息 - /// - public class QueryLFSObject : Command { - private Models.LFSObject obj = new Models.LFSObject(); - - public QueryLFSObject(string repo, string commit, string path) { - Cwd = repo; - Args = $"show {commit}:\"{path}\""; - } - - public Models.LFSObject Result() { - Exec(); - return obj; - } - - public override void OnReadline(string line) { - if (line.StartsWith("oid sha256:", StringComparison.Ordinal)) { - obj.OID = line.Substring(11).Trim(); - } else if (line.StartsWith("size")) { - obj.Size = int.Parse(line.Substring(4).Trim()); - } - } - } -} diff --git a/src/Commands/QueryLFSObjectChange.cs b/src/Commands/QueryLFSObjectChange.cs deleted file mode 100644 index 5f9e1631..00000000 --- a/src/Commands/QueryLFSObjectChange.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace SourceGit.Commands { - /// - /// 查询LFS对象变更 - /// - public class QueryLFSObjectChange : Command { - private Models.LFSChange change = new Models.LFSChange(); - - public QueryLFSObjectChange(string repo, string args) { - Cwd = repo; - Args = $"diff --ignore-cr-at-eol {args}"; - } - - public Models.LFSChange Result() { - Exec(); - return change; - } - - public override void OnReadline(string line) { - var ch = line[0]; - if (ch == '-') { - if (change.Old == null) change.Old = new Models.LFSObject(); - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - change.Old.OID = line.Substring(11); - } else if (line.StartsWith("size ")) { - change.Old.Size = int.Parse(line.Substring(5)); - } - } else if (ch == '+') { - if (change.New == null) change.New = new Models.LFSObject(); - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - change.New.OID = line.Substring(11); - } else if (line.StartsWith("size ")) { - change.New.Size = int.Parse(line.Substring(5)); - } - } else if (line.StartsWith(" size ")) { - change.New.Size = change.Old.Size = int.Parse(line.Substring(6)); - } - } - } -} diff --git a/src/Commands/QueryLocalChanges.cs b/src/Commands/QueryLocalChanges.cs new file mode 100644 index 00000000..788ed617 --- /dev/null +++ b/src/Commands/QueryLocalChanges.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public partial class QueryLocalChanges : Command + { + [GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] + private static partial Regex REG_FORMAT(); + private static readonly string[] UNTRACKED = ["no", "all"]; + + public QueryLocalChanges(string repo, bool includeUntracked = true) + { + WorkingDirectory = repo; + Context = repo; + Args = $"--no-optional-locks status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain"; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + { + Dispatcher.UIThread.Post(() => App.RaiseException(Context, rs.StdErr)); + return outs; + } + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + continue; + + var change = new Models.Change() { Path = match.Groups[2].Value }; + var status = match.Groups[1].Value; + + switch (status) + { + case " M": + change.Set(Models.ChangeState.None, Models.ChangeState.Modified); + break; + case " T": + change.Set(Models.ChangeState.None, Models.ChangeState.TypeChanged); + break; + case " A": + change.Set(Models.ChangeState.None, Models.ChangeState.Added); + break; + case " D": + change.Set(Models.ChangeState.None, Models.ChangeState.Deleted); + break; + case " R": + change.Set(Models.ChangeState.None, Models.ChangeState.Renamed); + break; + case " C": + change.Set(Models.ChangeState.None, Models.ChangeState.Copied); + break; + case "M": + change.Set(Models.ChangeState.Modified); + break; + case "MM": + change.Set(Models.ChangeState.Modified, Models.ChangeState.Modified); + break; + case "MT": + change.Set(Models.ChangeState.Modified, Models.ChangeState.TypeChanged); + break; + case "MD": + change.Set(Models.ChangeState.Modified, Models.ChangeState.Deleted); + break; + case "T": + change.Set(Models.ChangeState.TypeChanged); + break; + case "TM": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.Modified); + break; + case "TT": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.TypeChanged); + break; + case "TD": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.Deleted); + break; + case "A": + change.Set(Models.ChangeState.Added); + break; + case "AM": + change.Set(Models.ChangeState.Added, Models.ChangeState.Modified); + break; + case "AT": + change.Set(Models.ChangeState.Added, Models.ChangeState.TypeChanged); + break; + case "AD": + change.Set(Models.ChangeState.Added, Models.ChangeState.Deleted); + break; + case "D": + change.Set(Models.ChangeState.Deleted); + break; + case "R": + change.Set(Models.ChangeState.Renamed); + break; + case "RM": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.Modified); + break; + case "RT": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.TypeChanged); + break; + case "RD": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.Deleted); + break; + case "C": + change.Set(Models.ChangeState.Copied); + break; + case "CM": + change.Set(Models.ChangeState.Copied, Models.ChangeState.Modified); + break; + case "CT": + change.Set(Models.ChangeState.Copied, Models.ChangeState.TypeChanged); + break; + case "CD": + change.Set(Models.ChangeState.Copied, Models.ChangeState.Deleted); + break; + case "DD": + change.ConflictReason = Models.ConflictReason.BothDeleted; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "AU": + change.ConflictReason = Models.ConflictReason.AddedByUs; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UD": + change.ConflictReason = Models.ConflictReason.DeletedByThem; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UA": + change.ConflictReason = Models.ConflictReason.AddedByThem; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "DU": + change.ConflictReason = Models.ConflictReason.DeletedByUs; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "AA": + change.ConflictReason = Models.ConflictReason.BothAdded; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UU": + change.ConflictReason = Models.ConflictReason.BothModified; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "??": + change.Set(Models.ChangeState.None, Models.ChangeState.Untracked); + break; + } + + if (change.Index != Models.ChangeState.None || change.WorkTree != Models.ChangeState.None) + outs.Add(change); + } + + return outs; + } + } +} diff --git a/src/Commands/QueryRefsContainsCommit.cs b/src/Commands/QueryRefsContainsCommit.cs new file mode 100644 index 00000000..cabe1b50 --- /dev/null +++ b/src/Commands/QueryRefsContainsCommit.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryRefsContainsCommit : Command + { + public QueryRefsContainsCommit(string repo, string commit) + { + WorkingDirectory = repo; + RaiseError = false; + Args = $"for-each-ref --format=\"%(refname)\" --contains {commit}"; + } + + public List Result() + { + var rs = new List(); + + var output = ReadToEnd(); + if (!output.IsSuccess) + return rs; + + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.EndsWith("/HEAD", StringComparison.Ordinal)) + continue; + + if (line.StartsWith("refs/heads/", StringComparison.Ordinal)) + rs.Add(new() { Name = line.Substring("refs/heads/".Length), Type = Models.DecoratorType.LocalBranchHead }); + else if (line.StartsWith("refs/remotes/", StringComparison.Ordinal)) + rs.Add(new() { Name = line.Substring("refs/remotes/".Length), Type = Models.DecoratorType.RemoteBranchHead }); + else if (line.StartsWith("refs/tags/", StringComparison.Ordinal)) + rs.Add(new() { Name = line.Substring("refs/tags/".Length), Type = Models.DecoratorType.Tag }); + } + + return rs; + } + } +} diff --git a/src/Commands/QueryRemotes.cs b/src/Commands/QueryRemotes.cs new file mode 100644 index 00000000..7afec74d --- /dev/null +++ b/src/Commands/QueryRemotes.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryRemotes : Command + { + [GeneratedRegex(@"^([\w\.\-]+)\s*(\S+).*$")] + private static partial Regex REG_REMOTE(); + + public QueryRemotes(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "remote -v"; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return outs; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_REMOTE().Match(line); + if (!match.Success) + continue; + + var remote = new Models.Remote() + { + Name = match.Groups[1].Value, + URL = match.Groups[2].Value, + }; + + if (outs.Find(x => x.Name == remote.Name) != null) + continue; + + outs.Add(remote); + } + + return outs; + } + } +} diff --git a/src/Commands/QueryRepositoryRootPath.cs b/src/Commands/QueryRepositoryRootPath.cs new file mode 100644 index 00000000..016621c8 --- /dev/null +++ b/src/Commands/QueryRepositoryRootPath.cs @@ -0,0 +1,11 @@ +namespace SourceGit.Commands +{ + public class QueryRepositoryRootPath : Command + { + public QueryRepositoryRootPath(string path) + { + WorkingDirectory = path; + Args = "rev-parse --show-toplevel"; + } + } +} diff --git a/src/Commands/QueryRevisionByRefName.cs b/src/Commands/QueryRevisionByRefName.cs new file mode 100644 index 00000000..7fb4ecfa --- /dev/null +++ b/src/Commands/QueryRevisionByRefName.cs @@ -0,0 +1,21 @@ +namespace SourceGit.Commands +{ + public class QueryRevisionByRefName : Command + { + public QueryRevisionByRefName(string repo, string refname) + { + WorkingDirectory = repo; + Context = repo; + Args = $"rev-parse {refname}"; + } + + public string Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess && !string.IsNullOrEmpty(rs.StdOut)) + return rs.StdOut.Trim(); + + return null; + } + } +} diff --git a/src/Commands/QueryRevisionFileNames.cs b/src/Commands/QueryRevisionFileNames.cs new file mode 100644 index 00000000..c6fd7373 --- /dev/null +++ b/src/Commands/QueryRevisionFileNames.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryRevisionFileNames : Command + { + public QueryRevisionFileNames(string repo, string revision) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -r -z --name-only {revision}"; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return []; + + var lines = rs.StdOut.Split('\0', System.StringSplitOptions.RemoveEmptyEntries); + var outs = new List(); + foreach (var line in lines) + outs.Add(line); + return outs; + } + } +} diff --git a/src/Commands/QueryRevisionObjects.cs b/src/Commands/QueryRevisionObjects.cs new file mode 100644 index 00000000..de3406e8 --- /dev/null +++ b/src/Commands/QueryRevisionObjects.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryRevisionObjects : Command + { + [GeneratedRegex(@"^\d+\s+(\w+)\s+([0-9a-f]+)\s+(.*)$")] + private static partial Regex REG_FORMAT(); + + public QueryRevisionObjects(string repo, string sha, string parentFolder) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -z {sha}"; + + if (!string.IsNullOrEmpty(parentFolder)) + Args += $" -- \"{parentFolder}\""; + } + + public List Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + { + var start = 0; + var end = rs.StdOut.IndexOf('\0', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + Parse(line); + start = end + 1; + end = rs.StdOut.IndexOf('\0', start); + } + + if (start < rs.StdOut.Length) + Parse(rs.StdOut.Substring(start)); + } + + return _objects; + } + + private void Parse(string line) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + return; + + var obj = new Models.Object(); + obj.SHA = match.Groups[2].Value; + obj.Type = Models.ObjectType.Blob; + obj.Path = match.Groups[3].Value; + + switch (match.Groups[1].Value) + { + case "blob": + obj.Type = Models.ObjectType.Blob; + break; + case "tree": + obj.Type = Models.ObjectType.Tree; + break; + case "tag": + obj.Type = Models.ObjectType.Tag; + break; + case "commit": + obj.Type = Models.ObjectType.Commit; + break; + } + + _objects.Add(obj); + } + + private List _objects = new List(); + } +} diff --git a/src/Commands/QuerySingleCommit.cs b/src/Commands/QuerySingleCommit.cs new file mode 100644 index 00000000..35289ec5 --- /dev/null +++ b/src/Commands/QuerySingleCommit.cs @@ -0,0 +1,41 @@ +using System; + +namespace SourceGit.Commands +{ + public class QuerySingleCommit : Command + { + public QuerySingleCommit(string repo, string sha) + { + WorkingDirectory = repo; + Context = repo; + Args = $"show --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s -s {sha}"; + } + + public Models.Commit Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess && !string.IsNullOrEmpty(rs.StdOut)) + { + var commit = new Models.Commit(); + var lines = rs.StdOut.Split('\n'); + if (lines.Length < 8) + return null; + + commit.SHA = lines[0]; + if (!string.IsNullOrEmpty(lines[1])) + commit.Parents.AddRange(lines[1].Split(' ', StringSplitOptions.RemoveEmptyEntries)); + if (!string.IsNullOrEmpty(lines[2])) + commit.ParseDecorators(lines[2]); + commit.Author = Models.User.FindOrAdd(lines[3]); + commit.AuthorTime = ulong.Parse(lines[4]); + commit.Committer = Models.User.FindOrAdd(lines[5]); + commit.CommitterTime = ulong.Parse(lines[6]); + commit.Subject = lines[7]; + + return commit; + } + + return null; + } + } +} diff --git a/src/Commands/QueryStagedChangesWithAmend.cs b/src/Commands/QueryStagedChangesWithAmend.cs new file mode 100644 index 00000000..78980401 --- /dev/null +++ b/src/Commands/QueryStagedChangesWithAmend.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryStagedChangesWithAmend : Command + { + [GeneratedRegex(@"^:[\d]{6} ([\d]{6}) ([0-9a-f]{40}) [0-9a-f]{40} ([ACDMT])\d{0,6}\t(.*)$")] + private static partial Regex REG_FORMAT1(); + [GeneratedRegex(@"^:[\d]{6} ([\d]{6}) ([0-9a-f]{40}) [0-9a-f]{40} R\d{0,6}\t(.*\t.*)$")] + private static partial Regex REG_FORMAT2(); + + public QueryStagedChangesWithAmend(string repo, string parent) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff-index --cached -M {parent}"; + _parent = parent; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return []; + + var changes = new List(); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT2().Match(line); + if (match.Success) + { + var change = new Models.Change() + { + Path = match.Groups[3].Value, + DataForAmend = new Models.ChangeDataForAmend() + { + FileMode = match.Groups[1].Value, + ObjectHash = match.Groups[2].Value, + ParentSHA = _parent, + }, + }; + change.Set(Models.ChangeState.Renamed); + changes.Add(change); + continue; + } + + match = REG_FORMAT1().Match(line); + if (match.Success) + { + var change = new Models.Change() + { + Path = match.Groups[4].Value, + DataForAmend = new Models.ChangeDataForAmend() + { + FileMode = match.Groups[1].Value, + ObjectHash = match.Groups[2].Value, + ParentSHA = _parent, + }, + }; + + var type = match.Groups[3].Value; + switch (type) + { + case "A": + change.Set(Models.ChangeState.Added); + break; + case "C": + change.Set(Models.ChangeState.Copied); + break; + case "D": + change.Set(Models.ChangeState.Deleted); + break; + case "M": + change.Set(Models.ChangeState.Modified); + break; + case "T": + change.Set(Models.ChangeState.TypeChanged); + break; + } + changes.Add(change); + } + } + + return changes; + } + + private readonly string _parent; + } +} diff --git a/src/Commands/QueryStagedFileBlobGuid.cs b/src/Commands/QueryStagedFileBlobGuid.cs new file mode 100644 index 00000000..3f52a5f2 --- /dev/null +++ b/src/Commands/QueryStagedFileBlobGuid.cs @@ -0,0 +1,29 @@ +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryStagedFileBlobGuid : Command + { + [GeneratedRegex(@"^\d+\s+([0-9a-f]+)\s+.*$")] + private static partial Regex REG_FORMAT(); + + public QueryStagedFileBlobGuid(string repo, string file) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-files -s -- \"{file}\""; + } + + public string Result() + { + var rs = ReadToEnd(); + var match = REG_FORMAT().Match(rs.StdOut.Trim()); + if (match.Success) + { + return match.Groups[1].Value; + } + + return string.Empty; + } + } +} diff --git a/src/Commands/QueryStashes.cs b/src/Commands/QueryStashes.cs new file mode 100644 index 00000000..b4067aaf --- /dev/null +++ b/src/Commands/QueryStashes.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryStashes : Command + { + public QueryStashes(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "stash list --format=%H%n%P%n%ct%n%gd%n%s"; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return outs; + + var nextPartIdx = 0; + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + + switch (nextPartIdx) + { + case 0: + _current = new Models.Stash() { SHA = line }; + outs.Add(_current); + break; + case 1: + ParseParent(line); + break; + case 2: + _current.Time = ulong.Parse(line); + break; + case 3: + _current.Name = line; + break; + case 4: + _current.Message = line; + break; + } + + nextPartIdx++; + if (nextPartIdx > 4) + nextPartIdx = 0; + + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + _current.Message = rs.StdOut.Substring(start); + + return outs; + } + + private void ParseParent(string data) + { + if (data.Length < 8) + return; + + _current.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); + } + + private Models.Stash _current = null; + } +} diff --git a/src/Commands/QuerySubmodules.cs b/src/Commands/QuerySubmodules.cs new file mode 100644 index 00000000..663c0ea0 --- /dev/null +++ b/src/Commands/QuerySubmodules.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QuerySubmodules : Command + { + [GeneratedRegex(@"^([U\-\+ ])([0-9a-f]+)\s(.*?)(\s\(.*\))?$")] + private static partial Regex REG_FORMAT_STATUS(); + [GeneratedRegex(@"^\s?[\w\?]{1,4}\s+(.+)$")] + private static partial Regex REG_FORMAT_DIRTY(); + [GeneratedRegex(@"^submodule\.(\S*)\.(\w+)=(.*)$")] + private static partial Regex REG_FORMAT_MODULE_INFO(); + + public QuerySubmodules(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "submodule status"; + } + + public List Result() + { + var submodules = new List(); + var rs = ReadToEnd(); + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var map = new Dictionary(); + var needCheckLocalChanges = false; + foreach (var line in lines) + { + var match = REG_FORMAT_STATUS().Match(line); + if (match.Success) + { + var stat = match.Groups[1].Value; + var sha = match.Groups[2].Value; + var path = match.Groups[3].Value; + + var module = new Models.Submodule() { Path = path, SHA = sha }; + switch (stat[0]) + { + case '-': + module.Status = Models.SubmoduleStatus.NotInited; + break; + case '+': + module.Status = Models.SubmoduleStatus.RevisionChanged; + break; + case 'U': + module.Status = Models.SubmoduleStatus.Unmerged; + break; + default: + module.Status = Models.SubmoduleStatus.Normal; + needCheckLocalChanges = true; + break; + } + + map.Add(path, module); + submodules.Add(module); + } + } + + if (submodules.Count > 0) + { + Args = "config --file .gitmodules --list"; + rs = ReadToEnd(); + if (rs.IsSuccess) + { + var modules = new Dictionary(); + lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT_MODULE_INFO().Match(line); + if (match.Success) + { + var name = match.Groups[1].Value; + var key = match.Groups[2].Value; + var val = match.Groups[3].Value; + + if (!modules.TryGetValue(name, out var m)) + { + m = new ModuleInfo(); + modules.Add(name, m); + } + + if (key.Equals("path", StringComparison.Ordinal)) + m.Path = val; + else if (key.Equals("url", StringComparison.Ordinal)) + m.URL = val; + } + } + + foreach (var kv in modules) + { + if (map.TryGetValue(kv.Value.Path, out var m)) + m.URL = kv.Value.URL; + } + } + } + + if (needCheckLocalChanges) + { + var builder = new StringBuilder(); + foreach (var kv in map) + { + if (kv.Value.Status == Models.SubmoduleStatus.Normal) + { + builder.Append('"'); + builder.Append(kv.Key); + builder.Append("\" "); + } + } + + Args = $"--no-optional-locks status --porcelain -- {builder}"; + rs = ReadToEnd(); + if (!rs.IsSuccess) + return submodules; + + lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT_DIRTY().Match(line); + if (match.Success) + { + var path = match.Groups[1].Value; + if (map.TryGetValue(path, out var m)) + m.Status = Models.SubmoduleStatus.Modified; + } + } + } + + return submodules; + } + + private class ModuleInfo + { + public string Path { get; set; } = string.Empty; + public string URL { get; set; } = string.Empty; + } + } +} diff --git a/src/Commands/QueryTags.cs b/src/Commands/QueryTags.cs new file mode 100644 index 00000000..4b706439 --- /dev/null +++ b/src/Commands/QueryTags.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryTags : Command + { + public QueryTags(string repo) + { + _boundary = $"----- BOUNDARY OF TAGS {Guid.NewGuid()} -----"; + + Context = repo; + WorkingDirectory = repo; + Args = $"tag -l --format=\"{_boundary}%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%0a%0a%(contents:body)\""; + } + + public List Result() + { + var tags = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return tags; + + var records = rs.StdOut.Split(_boundary, StringSplitOptions.RemoveEmptyEntries); + foreach (var record in records) + { + var subs = record.Split('\0', StringSplitOptions.None); + if (subs.Length != 6) + continue; + + var name = subs[0].Substring(10); + var message = subs[5].Trim(); + if (!string.IsNullOrEmpty(message) && message.Equals(name, StringComparison.Ordinal)) + message = null; + + tags.Add(new Models.Tag() + { + Name = name, + IsAnnotated = subs[1].Equals("tag", StringComparison.Ordinal), + SHA = string.IsNullOrEmpty(subs[3]) ? subs[2] : subs[3], + CreatorDate = ulong.Parse(subs[4]), + Message = message, + }); + } + + return tags; + } + + private string _boundary = string.Empty; + } +} diff --git a/src/Commands/QueryTrackStatus.cs b/src/Commands/QueryTrackStatus.cs new file mode 100644 index 00000000..e7e1f1c9 --- /dev/null +++ b/src/Commands/QueryTrackStatus.cs @@ -0,0 +1,34 @@ +using System; + +namespace SourceGit.Commands +{ + public class QueryTrackStatus : Command + { + public QueryTrackStatus(string repo, string local, string upstream) + { + WorkingDirectory = repo; + Context = repo; + Args = $"rev-list --left-right {local}...{upstream}"; + } + + public Models.BranchTrackStatus Result() + { + var status = new Models.BranchTrackStatus(); + + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return status; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line[0] == '>') + status.Behind.Add(line.Substring(1)); + else + status.Ahead.Add(line.Substring(1)); + } + + return status; + } + } +} diff --git a/src/Commands/QueryUpdatableSubmodules.cs b/src/Commands/QueryUpdatableSubmodules.cs new file mode 100644 index 00000000..03f4a24d --- /dev/null +++ b/src/Commands/QueryUpdatableSubmodules.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryUpdatableSubmodules : Command + { + [GeneratedRegex(@"^([U\-\+ ])([0-9a-f]+)\s(.*?)(\s\(.*\))?$")] + private static partial Regex REG_FORMAT_STATUS(); + + public QueryUpdatableSubmodules(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "submodule status"; + } + + public List Result() + { + var submodules = new List(); + var rs = ReadToEnd(); + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT_STATUS().Match(line); + if (match.Success) + { + var stat = match.Groups[1].Value; + var path = match.Groups[3].Value; + if (!stat.StartsWith(' ')) + submodules.Add(path); + } + } + + return submodules; + } + } +} diff --git a/src/Commands/Rebase.cs b/src/Commands/Rebase.cs index 791233af..2ec50f3c 100644 --- a/src/Commands/Rebase.cs +++ b/src/Commands/Rebase.cs @@ -1,14 +1,26 @@ -namespace SourceGit.Commands { - /// - /// 变基命令 - /// - public class Rebase : Command { - - public Rebase(string repo, string basedOn, bool autoStash) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Rebase : Command + { + public Rebase(string repo, string basedOn, bool autoStash) + { + WorkingDirectory = repo; + Context = repo; Args = "rebase "; - if (autoStash) Args += "--autostash "; + if (autoStash) + Args += "--autostash "; Args += basedOn; } } + + public class InteractiveRebase : Command + { + public InteractiveRebase(string repo, string basedOn) + { + WorkingDirectory = repo; + Context = repo; + Editor = EditorType.RebaseEditor; + Args = $"rebase -i --autosquash {basedOn}"; + } + } } diff --git a/src/Commands/Remote.cs b/src/Commands/Remote.cs index 09326afd..beaf412b 100644 --- a/src/Commands/Remote.cs +++ b/src/Commands/Remote.cs @@ -1,36 +1,58 @@ -namespace SourceGit.Commands { - /// - /// 远程操作 - /// - public class Remote : Command { - - public Remote(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Remote : Command + { + public Remote(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Add(string name, string url) { + public bool Add(string name, string url) + { Args = $"remote add {name} {url}"; return Exec(); } - public bool Delete(string name) { + public bool Delete(string name) + { Args = $"remote remove {name}"; return Exec(); } - public bool Rename(string name, string to) { + public bool Rename(string name, string to) + { Args = $"remote rename {name} {to}"; return Exec(); } - public bool Prune(string name) { + public bool Prune(string name) + { Args = $"remote prune {name}"; return Exec(); } - public bool SetURL(string name, string url) { - Args = $"remote set-url {name} {url}"; + public string GetURL(string name, bool isPush) + { + Args = "remote get-url" + (isPush ? " --push " : " ") + name; + + var rs = ReadToEnd(); + return rs.IsSuccess ? rs.StdOut.Trim() : string.Empty; + } + + public bool SetURL(string name, string url, bool isPush) + { + Args = "remote set-url" + (isPush ? " --push " : " ") + $"{name} {url}"; return Exec(); } + + public bool HasBranch(string remote, string branch) + { + SSHKey = new Config(WorkingDirectory).Get($"remote.{remote}.sshkey"); + Args = $"ls-remote {remote} {branch}"; + + var rs = ReadToEnd(); + return rs.IsSuccess && rs.StdOut.Trim().Length > 0; + } } } diff --git a/src/Commands/Remotes.cs b/src/Commands/Remotes.cs deleted file mode 100644 index 1866b7f2..00000000 --- a/src/Commands/Remotes.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 获取远程列表 - /// - public class Remotes : Command { - private static readonly Regex REG_REMOTE = new Regex(@"^([\w\.\-]+)\s*(\S+).*$"); - private List loaded = new List(); - - public Remotes(string repo) { - Cwd = repo; - Args = "remote -v"; - } - - public List Result() { - Exec(); - return loaded; - } - - public override void OnReadline(string line) { - var match = REG_REMOTE.Match(line); - if (!match.Success) return; - - var remote = new Models.Remote() { - Name = match.Groups[1].Value, - URL = match.Groups[2].Value, - }; - - if (loaded.Find(x => x.Name == remote.Name) != null) return; - loaded.Add(remote); - } - } -} diff --git a/src/Commands/Reset.cs b/src/Commands/Reset.cs index b60ca174..6a54533b 100644 --- a/src/Commands/Reset.cs +++ b/src/Commands/Reset.cs @@ -1,33 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - /// - /// 重置命令 - /// - public class Reset : Command { - - public Reset(string repo) { - Cwd = repo; - Args = "reset"; - } - - public Reset(string repo, string revision, string mode) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Reset : Command + { + public Reset(string repo, string revision, string mode) + { + WorkingDirectory = repo; + Context = repo; Args = $"reset {mode} {revision}"; } - - public Reset(string repo, List files) { - Cwd = repo; - - StringBuilder builder = new StringBuilder(); - builder.Append("reset --"); - foreach (var f in files) { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } - Args = builder.ToString(); - } } } diff --git a/src/Commands/Restore.cs b/src/Commands/Restore.cs new file mode 100644 index 00000000..663ea975 --- /dev/null +++ b/src/Commands/Restore.cs @@ -0,0 +1,53 @@ +using System.Text; + +namespace SourceGit.Commands +{ + public class Restore : Command + { + /// + /// Only used for single staged change. + /// + /// + /// + public Restore(string repo, Models.Change stagedChange) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("restore --staged -- \""); + builder.Append(stagedChange.Path); + builder.Append('"'); + + if (stagedChange.Index == Models.ChangeState.Renamed) + { + builder.Append(" \""); + builder.Append(stagedChange.OriginalPath); + builder.Append('"'); + } + + Args = builder.ToString(); + } + + /// + /// Restore changes given in a path-spec file. + /// + /// + /// + /// + public Restore(string repo, string pathspecFile, bool isStaged) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("restore "); + builder.Append(isStaged ? "--staged " : "--worktree --recurse-submodules "); + builder.Append("--pathspec-from-file=\""); + builder.Append(pathspecFile); + builder.Append('"'); + + Args = builder.ToString(); + } + } +} diff --git a/src/Commands/Revert.cs b/src/Commands/Revert.cs index 2a656fc8..2e7afd11 100644 --- a/src/Commands/Revert.cs +++ b/src/Commands/Revert.cs @@ -1,13 +1,14 @@ -namespace SourceGit.Commands { - /// - /// 撤销提交 - /// - public class Revert : Command { - - public Revert(string repo, string commit, bool autoCommit) { - Cwd = repo; - Args = $"revert {commit} --no-edit"; - if (!autoCommit) Args += " --no-commit"; +namespace SourceGit.Commands +{ + public class Revert : Command + { + public Revert(string repo, string commit, bool autoCommit) + { + WorkingDirectory = repo; + Context = repo; + Args = $"revert -m 1 {commit} --no-edit"; + if (!autoCommit) + Args += " --no-commit"; } } } diff --git a/src/Commands/RevisionObjects.cs b/src/Commands/RevisionObjects.cs deleted file mode 100644 index 25a190ec..00000000 --- a/src/Commands/RevisionObjects.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 取出指定Revision下的文件列表 - /// - public class RevisionObjects : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^\d+\s+(\w+)\s+([0-9a-f]+)\s+(.*)$"); - private List objects = new List(); - - public RevisionObjects(string cwd, string sha) { - Cwd = cwd; - Args = $"ls-tree -r {sha}"; - } - - public List Result() { - Exec(); - return objects; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var obj = new Models.Object(); - obj.SHA = match.Groups[2].Value; - obj.Type = Models.ObjectType.Blob; - obj.Path = match.Groups[3].Value; - - switch (match.Groups[1].Value) { - case "blob": obj.Type = Models.ObjectType.Blob; break; - case "tree": obj.Type = Models.ObjectType.Tree; break; - case "tag": obj.Type = Models.ObjectType.Tag; break; - case "commit": obj.Type = Models.ObjectType.Commit; break; - } - - objects.Add(obj); - } - } -} diff --git a/src/Commands/Reword.cs b/src/Commands/Reword.cs deleted file mode 100644 index 3296c37c..00000000 --- a/src/Commands/Reword.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.IO; - -namespace SourceGit.Commands { - /// - /// 编辑HEAD的提交信息 - /// - public class Reword : Command { - public Reword(string repo, string msg) { - var tmp = Path.GetTempFileName(); - File.WriteAllText(tmp, msg); - - Cwd = repo; - Args = $"commit --amend --allow-empty --file=\"{tmp}\""; - } - } -} diff --git a/src/Commands/SaveChangesAsPatch.cs b/src/Commands/SaveChangesAsPatch.cs new file mode 100644 index 00000000..b10037a1 --- /dev/null +++ b/src/Commands/SaveChangesAsPatch.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class SaveChangesAsPatch + { + public static bool ProcessLocalChanges(string repo, List changes, bool isUnstaged, string saveTo) + { + using (var sw = File.Create(saveTo)) + { + foreach (var change in changes) + { + if (!ProcessSingleChange(repo, new Models.DiffOption(change, isUnstaged), sw)) + return false; + } + } + + return true; + } + + public static bool ProcessRevisionCompareChanges(string repo, List changes, string baseRevision, string targetRevision, string saveTo) + { + using (var sw = File.Create(saveTo)) + { + foreach (var change in changes) + { + if (!ProcessSingleChange(repo, new Models.DiffOption(baseRevision, targetRevision, change), sw)) + return false; + } + } + + return true; + } + + public static bool ProcessStashChanges(string repo, List opts, string saveTo) + { + using (var sw = File.Create(saveTo)) + { + foreach (var opt in opts) + { + if (!ProcessSingleChange(repo, opt, sw)) + return false; + } + } + return true; + } + + private static bool ProcessSingleChange(string repo, Models.DiffOption opt, FileStream writer) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"diff --ignore-cr-at-eol --unified=4 {opt}"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardOutput = true; + + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardOutput.BaseStream.CopyTo(writer); + proc.WaitForExit(); + var rs = proc.ExitCode == 0; + proc.Close(); + + return rs; + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, "Save change to patch failed: " + e.Message); + }); + return false; + } + } + } +} diff --git a/src/Commands/SaveChangesToPatch.cs b/src/Commands/SaveChangesToPatch.cs deleted file mode 100644 index b40b9bee..00000000 --- a/src/Commands/SaveChangesToPatch.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.IO; - -namespace SourceGit.Commands { - /// - /// 将Changes保存到文件流中 - /// - public class SaveChangeToStream : Command { - private StreamWriter writer = null; - - public SaveChangeToStream(string repo, Models.Change change, StreamWriter to) { - Cwd = repo; - if (change.WorkTree == Models.Change.Status.Added || change.WorkTree == Models.Change.Status.Untracked) { - Args = $"diff --no-index --no-ext-diff --find-renames -- /dev/null \"{change.Path}\""; - } else { - var pathspec = $"\"{change.Path}\""; - if (!string.IsNullOrEmpty(change.OriginalPath)) pathspec = $"\"{change.OriginalPath}\" \"{change.Path}\""; - Args = $"diff --binary --no-ext-diff --find-renames --full-index -- {pathspec}"; - } - writer = to; - } - - public override void OnReadline(string line) { - writer.WriteLine(line); - } - } -} diff --git a/src/Commands/SaveRevisionFile.cs b/src/Commands/SaveRevisionFile.cs index a83fc618..550844ef 100644 --- a/src/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -1,44 +1,64 @@ +using System; using System.Diagnostics; using System.IO; -namespace SourceGit.Commands { - /// - /// 保存指定版本的文件 - /// - public class SaveRevisionFile { - private string cwd = ""; - private string bat = ""; +using Avalonia.Threading; - public SaveRevisionFile(string repo, string path, string sha, string saveTo) { - var tmp = Path.GetTempFileName(); - var cmd = $"\"{Models.Preference.Instance.Git.Path}\" --no-pager "; - - var isLFS = new LFS(repo).IsFiltered(path); - if (isLFS) { - cmd += $"show {sha}:\"{path}\" > {tmp}.lfs\n"; - cmd += $"\"{Models.Preference.Instance.Git.Path}\" --no-pager lfs smudge < {tmp}.lfs > \"{saveTo}\"\n"; - } else { - cmd += $"show {sha}:\"{path}\" > \"{saveTo}\"\n"; +namespace SourceGit.Commands +{ + public static class SaveRevisionFile + { + public static void Run(string repo, string revision, string file, string saveTo) + { + var isLFSFiltered = new IsLFSFiltered(repo, revision, file).Result(); + if (isLFSFiltered) + { + var pointerStream = QueryFileContent.Run(repo, revision, file); + ExecCmd(repo, $"lfs smudge", saveTo, pointerStream); + } + else + { + ExecCmd(repo, $"show {revision}:\"{file}\"", saveTo); } - - cwd = repo; - bat = tmp + ".bat"; - - File.WriteAllText(bat, cmd); } - public void Exec() { + private static bool ExecCmd(string repo, string args, string outputFile, Stream input = null) + { var starter = new ProcessStartInfo(); - starter.FileName = bat; - starter.WorkingDirectory = cwd; + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = args; + starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardInput = true; + starter.RedirectStandardOutput = true; + starter.RedirectStandardError = true; - var proc = Process.Start(starter); - proc.WaitForExit(); - proc.Close(); + using (var sw = File.OpenWrite(outputFile)) + { + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + if (input != null) + proc.StandardInput.Write(new StreamReader(input).ReadToEnd()); + proc.StandardOutput.BaseStream.CopyTo(sw); + proc.WaitForExit(); + var rs = proc.ExitCode == 0; + proc.Close(); - File.Delete(bat); + return rs; + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, "Save file failed: " + e.Message); + }); + return false; + } + } } } } diff --git a/src/Commands/Stash.cs b/src/Commands/Stash.cs index 0a923f2c..7d1a269b 100644 --- a/src/Commands/Stash.cs +++ b/src/Commands/Stash.cs @@ -1,82 +1,100 @@ -using System.Collections.Generic; -using System.IO; +using System.Collections.Generic; +using System.Text; -namespace SourceGit.Commands { - /// - /// 单个贮藏相关操作 - /// - public class Stash : Command { - - public Stash(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Stash : Command + { + public Stash(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Push(List changes, string message, bool bFull) { - if (bFull) { - var needAdd = new List(); - foreach (var c in changes) { - if (c.WorkTree == Models.Change.Status.Added || c.WorkTree == Models.Change.Status.Untracked) { - needAdd.Add(c.Path); - if (needAdd.Count > 10) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - } - } + public bool Push(string message, bool includeUntracked = true, bool keepIndex = false) + { + var builder = new StringBuilder(); + builder.Append("stash push "); + if (includeUntracked) + builder.Append("--include-untracked "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\""); - if (needAdd.Count > 0) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - - Args = $"stash push -m \"{message}\""; - return Exec(); - } else { - var temp = Path.GetTempFileName(); - var stream = new FileStream(temp, FileMode.Create); - var writer = new StreamWriter(stream); - - var needAdd = new List(); - foreach (var c in changes) { - writer.WriteLine(c.Path); - - if (c.WorkTree == Models.Change.Status.Added || c.WorkTree == Models.Change.Status.Untracked) { - needAdd.Add(c.Path); - if (needAdd.Count > 10) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - } - } - if (needAdd.Count > 0) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - - writer.Flush(); - stream.Flush(); - writer.Close(); - stream.Close(); - - Args = $"stash push -m \"{message}\" --pathspec-from-file=\"{temp}\""; - var succ = Exec(); - File.Delete(temp); - return succ; - } - } - - public bool Apply(string name) { - Args = $"stash apply -q {name}"; + Args = builder.ToString(); return Exec(); } - public bool Pop(string name) { - Args = $"stash pop -q {name}"; + public bool Push(string message, List changes, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --include-untracked "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\" -- "); + + foreach (var c in changes) + builder.Append($"\"{c.Path}\" "); + + Args = builder.ToString(); return Exec(); } - public bool Drop(string name) { - Args = $"stash drop -q {name}"; + public bool Push(string message, string pathspecFromFile, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --include-untracked --pathspec-from-file=\""); + builder.Append(pathspecFromFile); + builder.Append("\" "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\""); + + Args = builder.ToString(); + return Exec(); + } + + public bool PushOnlyStaged(string message, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --staged "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\""); + Args = builder.ToString(); + return Exec(); + } + + public bool Apply(string name, bool restoreIndex) + { + var opts = restoreIndex ? "--index" : string.Empty; + Args = $"stash apply -q {opts} \"{name}\""; + return Exec(); + } + + public bool Pop(string name) + { + Args = $"stash pop -q --index \"{name}\""; + return Exec(); + } + + public bool Drop(string name) + { + Args = $"stash drop -q \"{name}\""; + return Exec(); + } + + public bool Clear() + { + Args = "stash clear"; return Exec(); } } diff --git a/src/Commands/StashChanges.cs b/src/Commands/StashChanges.cs deleted file mode 100644 index 459a3776..00000000 --- a/src/Commands/StashChanges.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 查看Stash中的修改 - /// - public class StashChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private List changes = new List(); - - public StashChanges(string repo, string sha) { - Cwd = repo; - Args = $"diff --name-status --pretty=format: {sha}^ {sha}"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status[0]) { - case 'M': change.Set(Models.Change.Status.Modified); changes.Add(change); break; - case 'A': change.Set(Models.Change.Status.Added); changes.Add(change); break; - case 'D': change.Set(Models.Change.Status.Deleted); changes.Add(change); break; - case 'R': change.Set(Models.Change.Status.Renamed); changes.Add(change); break; - case 'C': change.Set(Models.Change.Status.Copied); changes.Add(change); break; - } - } - } -} diff --git a/src/Commands/Stashes.cs b/src/Commands/Stashes.cs deleted file mode 100644 index 3e7d9713..00000000 --- a/src/Commands/Stashes.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 解析当前仓库中的贮藏 - /// - public class Stashes : Command { - private static readonly Regex REG_STASH = new Regex(@"^Reflog: refs/(stash@\{\d+\}).*$"); - private List parsed = new List(); - private Models.Stash current = null; - - public Stashes(string path) { - Cwd = path; - Args = "stash list --pretty=raw"; - } - - public List Result() { - Exec(); - if (current != null) parsed.Add(current); - return parsed; - } - - public override void OnReadline(string line) { - if (line.StartsWith("commit ", StringComparison.Ordinal)) { - if (current != null && !string.IsNullOrEmpty(current.Name)) parsed.Add(current); - current = new Models.Stash() { SHA = line.Substring(7, 8) }; - return; - } - - if (current == null) return; - - if (line.StartsWith("Reflog: refs/stash@", StringComparison.Ordinal)) { - var match = REG_STASH.Match(line); - if (match.Success) current.Name = match.Groups[1].Value; - } else if (line.StartsWith("Reflog message: ", StringComparison.Ordinal)) { - current.Message = line.Substring(16); - } else if (line.StartsWith("author ", StringComparison.Ordinal)) { - Models.User user = Models.User.Invalid; - ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); - current.Author = user; - current.Time = time; - } - } - } -} diff --git a/src/Commands/Statistics.cs b/src/Commands/Statistics.cs new file mode 100644 index 00000000..e11c1740 --- /dev/null +++ b/src/Commands/Statistics.cs @@ -0,0 +1,48 @@ +using System; + +namespace SourceGit.Commands +{ + public class Statistics : Command + { + public Statistics(string repo, int max) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --date-order --branches --remotes -{max} --format=%ct$%aN±%aE"; + } + + public Models.Statistics Result() + { + var statistics = new Models.Statistics(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return statistics; + + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + ParseLine(statistics, rs.StdOut.Substring(start, end - start)); + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + ParseLine(statistics, rs.StdOut.Substring(start)); + + statistics.Complete(); + return statistics; + } + + private void ParseLine(Models.Statistics statistics, string line) + { + var dateEndIdx = line.IndexOf('$', StringComparison.Ordinal); + if (dateEndIdx == -1) + return; + + var dateStr = line.AsSpan(0, dateEndIdx); + if (double.TryParse(dateStr, out var date)) + statistics.AddCommit(line.Substring(dateEndIdx + 1), date); + } + } +} diff --git a/src/Commands/SubTree.cs b/src/Commands/SubTree.cs deleted file mode 100644 index 62f632fe..00000000 --- a/src/Commands/SubTree.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.IO; - -namespace SourceGit.Commands { - /// - /// 子树相关操作 - /// - public class SubTree : Command { - private Action handler = null; - - public SubTree(string repo) { - Cwd = repo; - TraitErrorAsOutput = true; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); - } - - public bool Add(string prefix, string source, string revision, bool squash, Action onProgress) { - var path = Path.Combine(Cwd, prefix); - if (Directory.Exists(path)) return true; - - handler = onProgress; - Args = $"subtree add --prefix=\"{prefix}\" {source} {revision}"; - if (squash) Args += " --squash"; - return Exec(); - } - - public void Pull(string prefix, string source, string branch, bool squash, Action onProgress) { - handler = onProgress; - Args = $"subtree pull --prefix=\"{prefix}\" {source} {branch}"; - if (squash) Args += " --squash"; - Exec(); - } - - public void Push(string prefix, string source, string branch, Action onProgress) { - handler = onProgress; - Args = $"subtree push --prefix=\"{prefix}\" {source} {branch}"; - Exec(); - } - } -} diff --git a/src/Commands/Submodule.cs b/src/Commands/Submodule.cs index bf3ba87d..025d035a 100644 --- a/src/Commands/Submodule.cs +++ b/src/Commands/Submodule.cs @@ -1,44 +1,66 @@ -using System; +using System.Collections.Generic; +using System.Text; -namespace SourceGit.Commands { - /// - /// 子模块 - /// - public class Submodule : Command { - private Action onProgress = null; - - public Submodule(string cwd) { - Cwd = cwd; +namespace SourceGit.Commands +{ + public class Submodule : Command + { + public Submodule(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Add(string url, string path, bool recursive, Action handler) { - Args = $"submodule add {url} {path}"; - onProgress = handler; - if (!Exec()) return false; + public bool Add(string url, string relativePath, bool recursive) + { + Args = $"-c protocol.file.allow=always submodule add \"{url}\" \"{relativePath}\""; + if (!Exec()) + return false; - if (recursive) { - Args = $"submodule update --init --recursive -- {path}"; + if (recursive) + { + Args = $"submodule update --init --recursive -- \"{relativePath}\""; return Exec(); - } else { + } + else + { + Args = $"submodule update --init -- \"{relativePath}\""; return true; } } - public bool Update() { - Args = $"submodule update --rebase --remote"; + public bool Update(List modules, bool init, bool recursive, bool useRemote = false) + { + var builder = new StringBuilder(); + builder.Append("submodule update"); + + if (init) + builder.Append(" --init"); + if (recursive) + builder.Append(" --recursive"); + if (useRemote) + builder.Append(" --remote"); + if (modules.Count > 0) + { + builder.Append(" --"); + foreach (var module in modules) + builder.Append($" \"{module}\""); + } + + Args = builder.ToString(); return Exec(); } - public bool Delete(string path) { - Args = $"submodule deinit -f {path}"; - if (!Exec()) return false; - - Args = $"rm -rf {path}"; + public bool Deinit(string module, bool force) + { + Args = force ? $"submodule deinit -f -- \"{module}\"" : $"submodule deinit -- \"{module}\""; return Exec(); } - public override void OnReadline(string line) { - onProgress?.Invoke(line); + public bool Delete(string module) + { + Args = $"rm -rf \"{module}\""; + return Exec(); } } } diff --git a/src/Commands/Submodules.cs b/src/Commands/Submodules.cs deleted file mode 100644 index 4daf69f6..00000000 --- a/src/Commands/Submodules.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 获取子模块列表 - /// - public class Submodules : Command { - private readonly Regex REG_FORMAT = new Regex(@"^[\-\+ ][0-9a-f]+\s(.*)\s\(.*\)$"); - private List modules = new List(); - - public Submodules(string repo) { - Cwd = repo; - Args = "submodule status"; - } - - public List Result() { - Exec(); - return modules; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - modules.Add(match.Groups[1].Value); - } - } -} diff --git a/src/Commands/Tag.cs b/src/Commands/Tag.cs index 88942878..017afea0 100644 --- a/src/Commands/Tag.cs +++ b/src/Commands/Tag.cs @@ -1,42 +1,51 @@ -using System.IO; +using System.IO; -namespace SourceGit.Commands { - - /// - /// 标签相关指令 - /// - public class Tag : Command { - - public Tag(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public static class Tag + { + public static bool Add(string repo, string name, string basedOn, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag --no-sign {name} {basedOn}"; + cmd.Log = log; + return cmd.Exec(); } - public bool Add(string name, string basedOn, string message) { - Args = $"tag -a {name} {basedOn} "; + public static bool Add(string repo, string name, string basedOn, string message, bool sign, Models.ICommandLog log) + { + var param = sign ? "--sign -a" : "--no-sign -a"; + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag {param} {name} {basedOn} "; + cmd.Log = log; - if (!string.IsNullOrEmpty(message)) { + if (!string.IsNullOrEmpty(message)) + { string tmp = Path.GetTempFileName(); File.WriteAllText(tmp, message); - Args += $"-F \"{tmp}\""; - } else { - Args += $"-m {name}"; + cmd.Args += $"-F \"{tmp}\""; + + var succ = cmd.Exec(); + File.Delete(tmp); + return succ; } - return Exec(); + cmd.Args += $"-m {name}"; + return cmd.Exec(); } - public bool Delete(string name, bool push) { - Args = $"tag --delete {name}"; - if (!Exec()) return false; - - if (push) { - var remotes = new Remotes(Cwd).Result(); - foreach (var r in remotes) { - new Push(Cwd, r.Name, name, true).Exec(); - } - } - - return true; + public static bool Delete(string repo, string name, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag --delete {name}"; + cmd.Log = log; + return cmd.Exec(); } } } diff --git a/src/Commands/Tags.cs b/src/Commands/Tags.cs deleted file mode 100644 index f534c7b2..00000000 --- a/src/Commands/Tags.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 解析所有的Tags - /// - public class Tags : Command { - public static readonly string CMD = "for-each-ref --sort=-creatordate --format=\"$%(refname:short)$%(objectname)$%(*objectname)\" refs/tags"; - public static readonly Regex REG_FORMAT = new Regex(@"\$(.*)\$(.*)\$(.*)"); - - private List loaded = new List(); - - public Tags(string path) { - Cwd = path; - Args = CMD; - } - - public List Result() { - Exec(); - return loaded; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var name = match.Groups[1].Value; - var commit = match.Groups[2].Value; - var dereference = match.Groups[3].Value; - - if (string.IsNullOrEmpty(dereference)) { - loaded.Add(new Models.Tag() { - Name = name, - SHA = commit, - }); - } else { - loaded.Add(new Models.Tag() { - Name = name, - SHA = dereference, - }); - } - } - } -} diff --git a/src/Commands/UnstageChangesForAmend.cs b/src/Commands/UnstageChangesForAmend.cs new file mode 100644 index 00000000..19def067 --- /dev/null +++ b/src/Commands/UnstageChangesForAmend.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public class UnstageChangesForAmend + { + public UnstageChangesForAmend(string repo, List changes) + { + _repo = repo; + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Renamed) + { + _patchBuilder.Append("0 0000000000000000000000000000000000000000\t"); + _patchBuilder.Append(c.Path); + _patchBuilder.Append("\0100644 "); + _patchBuilder.Append(c.DataForAmend.ObjectHash); + _patchBuilder.Append("\t"); + _patchBuilder.Append(c.OriginalPath); + } + else if (c.Index == Models.ChangeState.Added) + { + _patchBuilder.Append("0 0000000000000000000000000000000000000000\t"); + _patchBuilder.Append(c.Path); + } + else if (c.Index == Models.ChangeState.Deleted) + { + _patchBuilder.Append("100644 "); + _patchBuilder.Append(c.DataForAmend.ObjectHash); + _patchBuilder.Append("\t"); + _patchBuilder.Append(c.Path); + } + else + { + _patchBuilder.Append(c.DataForAmend.FileMode); + _patchBuilder.Append(" "); + _patchBuilder.Append(c.DataForAmend.ObjectHash); + _patchBuilder.Append("\t"); + _patchBuilder.Append(c.Path); + } + + _patchBuilder.Append("\n"); + } + } + + public bool Exec() + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = _repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = "-c core.editor=true update-index --index-info"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardInput = true; + starter.RedirectStandardOutput = false; + starter.RedirectStandardError = true; + + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardInput.Write(_patchBuilder.ToString()); + proc.StandardInput.Close(); + + var err = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + var rs = proc.ExitCode == 0; + proc.Close(); + + if (!rs) + Dispatcher.UIThread.Invoke(() => App.RaiseException(_repo, err)); + + return rs; + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(_repo, "Failed to unstage changes: " + e.Message); + }); + return false; + } + } + + private string _repo = ""; + private StringBuilder _patchBuilder = new StringBuilder(); + } +} diff --git a/src/Commands/Version.cs b/src/Commands/Version.cs deleted file mode 100644 index 1911f34a..00000000 --- a/src/Commands/Version.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace SourceGit.Commands { - /// - /// 检测git是否可用,并获取git版本信息 - /// - public class Version : Command { - const string GitVersionPrefix = "git version "; - public string Query() { - Args = $"--version"; - var result = ReadToEnd(); - if (!result.IsSuccess || string.IsNullOrEmpty(result.Output)) return null; - var version = result.Output.Trim(); - if (!version.StartsWith(GitVersionPrefix, StringComparison.Ordinal)) return null; - return version.Substring(GitVersionPrefix.Length); - } - } -} diff --git a/src/Commands/Worktree.cs b/src/Commands/Worktree.cs new file mode 100644 index 00000000..1198a443 --- /dev/null +++ b/src/Commands/Worktree.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace SourceGit.Commands +{ + public class Worktree : Command + { + public Worktree(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public List List() + { + Args = "worktree list --porcelain"; + + var rs = ReadToEnd(); + var worktrees = new List(); + var last = null as Models.Worktree; + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.StartsWith("worktree ", StringComparison.Ordinal)) + { + last = new Models.Worktree() { FullPath = line.Substring(9).Trim() }; + last.RelativePath = Path.GetRelativePath(WorkingDirectory, last.FullPath); + worktrees.Add(last); + } + else if (line.StartsWith("bare", StringComparison.Ordinal)) + { + last!.IsBare = true; + } + else if (line.StartsWith("HEAD ", StringComparison.Ordinal)) + { + last!.Head = line.Substring(5).Trim(); + } + else if (line.StartsWith("branch ", StringComparison.Ordinal)) + { + last!.Branch = line.Substring(7).Trim(); + } + else if (line.StartsWith("detached", StringComparison.Ordinal)) + { + last!.IsDetached = true; + } + else if (line.StartsWith("locked", StringComparison.Ordinal)) + { + last!.IsLocked = true; + } + } + } + + return worktrees; + } + + public bool Add(string fullpath, string name, bool createNew, string tracking) + { + Args = "worktree add "; + + if (!string.IsNullOrEmpty(tracking)) + Args += "--track "; + + if (!string.IsNullOrEmpty(name)) + { + if (createNew) + Args += $"-b {name} "; + else + Args += $"-B {name} "; + } + + Args += $"\"{fullpath}\" "; + + if (!string.IsNullOrEmpty(tracking)) + Args += tracking; + else if (!string.IsNullOrEmpty(name) && !createNew) + Args += name; + + return Exec(); + } + + public bool Prune() + { + Args = "worktree prune -v"; + return Exec(); + } + + public bool Lock(string fullpath) + { + Args = $"worktree lock \"{fullpath}\""; + return Exec(); + } + + public bool Unlock(string fullpath) + { + Args = $"worktree unlock \"{fullpath}\""; + return Exec(); + } + + public bool Remove(string fullpath, bool force) + { + if (force) + Args = $"worktree remove -f \"{fullpath}\""; + else + Args = $"worktree remove \"{fullpath}\""; + + return Exec(); + } + } +} diff --git a/src/Converters/BoolConverters.cs b/src/Converters/BoolConverters.cs new file mode 100644 index 00000000..3563fb37 --- /dev/null +++ b/src/Converters/BoolConverters.cs @@ -0,0 +1,14 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class BoolConverters + { + public static readonly FuncValueConverter ToPageTabWidth = + new FuncValueConverter(x => x ? 200 : double.NaN); + + public static readonly FuncValueConverter IsBoldToFontWeight = + new FuncValueConverter(x => x ? FontWeight.Bold : FontWeight.Normal); + } +} diff --git a/src/Converters/DoubleConverters.cs b/src/Converters/DoubleConverters.cs new file mode 100644 index 00000000..5b7c0a03 --- /dev/null +++ b/src/Converters/DoubleConverters.cs @@ -0,0 +1,19 @@ +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class DoubleConverters + { + public static readonly FuncValueConverter Increase = + new FuncValueConverter(v => v + 1.0); + + public static readonly FuncValueConverter Decrease = + new FuncValueConverter(v => v - 1.0); + + public static readonly FuncValueConverter ToPercentage = + new FuncValueConverter(v => (v * 100).ToString("F3") + "%"); + + public static readonly FuncValueConverter OneMinusToPercentage = + new FuncValueConverter(v => ((1.0 - v) * 100).ToString("F3") + "%"); + } +} diff --git a/src/Converters/FilterModeConverters.cs b/src/Converters/FilterModeConverters.cs new file mode 100644 index 00000000..c486af5e --- /dev/null +++ b/src/Converters/FilterModeConverters.cs @@ -0,0 +1,22 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class FilterModeConverters + { + public static readonly FuncValueConverter ToBorderBrush = + new FuncValueConverter(v => + { + switch (v) + { + case Models.FilterMode.Included: + return Brushes.Green; + case Models.FilterMode.Excluded: + return Brushes.Red; + default: + return Brushes.Transparent; + } + }); + } +} diff --git a/src/Converters/IntConverters.cs b/src/Converters/IntConverters.cs new file mode 100644 index 00000000..f21c5d24 --- /dev/null +++ b/src/Converters/IntConverters.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class IntConverters + { + public static readonly FuncValueConverter IsGreaterThanZero = + new FuncValueConverter(v => v > 0); + + public static readonly FuncValueConverter IsGreaterThanFour = + new FuncValueConverter(v => v > 4); + + public static readonly FuncValueConverter IsZero = + new FuncValueConverter(v => v == 0); + + public static readonly FuncValueConverter IsOne = + new FuncValueConverter(v => v == 1); + + public static readonly FuncValueConverter IsNotOne = + new FuncValueConverter(v => v != 1); + + public static readonly FuncValueConverter IsSubjectLengthBad = + new FuncValueConverter(v => v > ViewModels.Preferences.Instance.SubjectGuideLength); + + public static readonly FuncValueConverter IsSubjectLengthGood = + new FuncValueConverter(v => v <= ViewModels.Preferences.Instance.SubjectGuideLength); + + public static readonly FuncValueConverter ToTreeMargin = + new FuncValueConverter(v => new Thickness(v * 16, 0, 0, 0)); + + public static readonly FuncValueConverter ToBookmarkBrush = + new FuncValueConverter(bookmark => + { + if (bookmark == 0) + return Application.Current?.FindResource("Brush.FG1") as IBrush; + else + return Models.Bookmarks.Brushes[bookmark]; + }); + } +} diff --git a/src/Converters/InteractiveRebaseActionConverters.cs b/src/Converters/InteractiveRebaseActionConverters.cs new file mode 100644 index 00000000..dbd183bd --- /dev/null +++ b/src/Converters/InteractiveRebaseActionConverters.cs @@ -0,0 +1,51 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class InteractiveRebaseActionConverters + { + public static readonly FuncValueConverter ToIconBrush = + new FuncValueConverter(v => + { + switch (v) + { + case Models.InteractiveRebaseAction.Pick: + return Brushes.Green; + case Models.InteractiveRebaseAction.Edit: + return Brushes.Orange; + case Models.InteractiveRebaseAction.Reword: + return Brushes.Orange; + case Models.InteractiveRebaseAction.Squash: + return Brushes.LightGray; + case Models.InteractiveRebaseAction.Fixup: + return Brushes.LightGray; + default: + return Brushes.Red; + } + }); + + public static readonly FuncValueConverter ToName = + new FuncValueConverter(v => + { + switch (v) + { + case Models.InteractiveRebaseAction.Pick: + return "Pick"; + case Models.InteractiveRebaseAction.Edit: + return "Edit"; + case Models.InteractiveRebaseAction.Reword: + return "Reword"; + case Models.InteractiveRebaseAction.Squash: + return "Squash"; + case Models.InteractiveRebaseAction.Fixup: + return "Fixup"; + default: + return "Drop"; + } + }); + + public static readonly FuncValueConverter CanEditMessage = + new FuncValueConverter(v => v == Models.InteractiveRebaseAction.Reword || v == Models.InteractiveRebaseAction.Squash); + } +} diff --git a/src/Converters/ListConverters.cs b/src/Converters/ListConverters.cs new file mode 100644 index 00000000..6f3ae98b --- /dev/null +++ b/src/Converters/ListConverters.cs @@ -0,0 +1,28 @@ +using System.Collections; +using System.Collections.Generic; + +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class ListConverters + { + public static readonly FuncValueConverter Count = + new FuncValueConverter(v => v == null ? "0" : $"{v.Count}"); + + public static readonly FuncValueConverter ToCount = + new FuncValueConverter(v => v == null ? "(0)" : $"({v.Count})"); + + public static readonly FuncValueConverter IsNullOrEmpty = + new FuncValueConverter(v => v == null || v.Count == 0); + + public static readonly FuncValueConverter IsNotNullOrEmpty = + new FuncValueConverter(v => v != null && v.Count > 0); + + public static readonly FuncValueConverter, List> Top100Changes = + new FuncValueConverter, List>(v => (v == null || v.Count < 100) ? v : v.GetRange(0, 100)); + + public static readonly FuncValueConverter IsOnlyTop100Shows = + new FuncValueConverter(v => v != null && v.Count > 100); + } +} diff --git a/src/Converters/ObjectConverters.cs b/src/Converters/ObjectConverters.cs new file mode 100644 index 00000000..f7c57764 --- /dev/null +++ b/src/Converters/ObjectConverters.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class ObjectConverters + { + public class IsTypeOfConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null || parameter == null) + return false; + + return value.GetType().IsAssignableTo((Type)parameter); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return new NotImplementedException(); + } + } + + public static readonly IsTypeOfConverter IsTypeOf = new IsTypeOfConverter(); + } +} diff --git a/src/Converters/PathConverters.cs b/src/Converters/PathConverters.cs new file mode 100644 index 00000000..ac1e61e5 --- /dev/null +++ b/src/Converters/PathConverters.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; + +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class PathConverters + { + public static readonly FuncValueConverter PureFileName = + new(v => Path.GetFileName(v) ?? ""); + + public static readonly FuncValueConverter PureDirectoryName = + new(v => Path.GetDirectoryName(v) ?? ""); + + public static readonly FuncValueConverter RelativeToHome = + new(v => + { + if (OperatingSystem.IsWindows()) + return v; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; + if (v.StartsWith(home, StringComparison.Ordinal)) + return $"~{v.AsSpan(prefixLen)}"; + + return v; + }); + } +} diff --git a/src/Converters/StringConverters.cs b/src/Converters/StringConverters.cs new file mode 100644 index 00000000..bcadfae9 --- /dev/null +++ b/src/Converters/StringConverters.cs @@ -0,0 +1,88 @@ +using System; +using System.Globalization; + +using Avalonia.Data.Converters; +using Avalonia.Styling; + +namespace SourceGit.Converters +{ + public static class StringConverters + { + public class ToLocaleConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return Models.Locale.Supported.Find(x => x.Key == value as string); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value as Models.Locale)?.Key; + } + } + + public static readonly ToLocaleConverter ToLocale = new ToLocaleConverter(); + + public class ToThemeConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var theme = (string)value; + if (string.IsNullOrEmpty(theme)) + return ThemeVariant.Default; + + if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) + return ThemeVariant.Light; + + if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) + return ThemeVariant.Dark; + + return ThemeVariant.Default; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value as ThemeVariant)?.Key; + } + } + + public static readonly ToThemeConverter ToTheme = new ToThemeConverter(); + + public class FormatByResourceKeyConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var key = parameter as string; + return App.Text(key, value); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public static readonly FormatByResourceKeyConverter FormatByResourceKey = new FormatByResourceKeyConverter(); + + public static readonly FuncValueConverter ToShortSHA = + new FuncValueConverter(v => v == null ? string.Empty : (v.Length > 10 ? v.Substring(0, 10) : v)); + + public static readonly FuncValueConverter TrimRefsPrefix = + new FuncValueConverter(v => + { + if (v == null) + return string.Empty; + if (v.StartsWith("refs/heads/", StringComparison.Ordinal)) + return v.Substring(11); + if (v.StartsWith("refs/remotes/", StringComparison.Ordinal)) + return v.Substring(13); + return v; + }); + + public static readonly FuncValueConverter ContainsSpaces = + new FuncValueConverter(v => v != null && v.Contains(' ')); + + public static readonly FuncValueConverter IsNotNullOrWhitespace = + new FuncValueConverter(v => v != null && v.Trim().Length > 0); + } +} diff --git a/src/FodyWeavers.xml b/src/FodyWeavers.xml deleted file mode 100644 index 5029e706..00000000 --- a/src/FodyWeavers.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/FodyWeavers.xsd b/src/FodyWeavers.xsd deleted file mode 100644 index 05e92c11..00000000 --- a/src/FodyWeavers.xsd +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks - - - - - A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. - - - - - A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks - - - - - A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. - - - - - A list of unmanaged 32 bit assembly names to include, delimited with line breaks. - - - - - A list of unmanaged 64 bit assembly names to include, delimited with line breaks. - - - - - The order of preloaded assemblies, delimited with line breaks. - - - - - - This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file. - - - - - Controls if .pdbs for reference assemblies are also embedded. - - - - - Controls if runtime assemblies are also embedded. - - - - - Controls whether the runtime assemblies are embedded with their full path or only with their assembly name. - - - - - Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option. - - - - - As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off. - - - - - Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code. - - - - - Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior. - - - - - A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with | - - - - - A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |. - - - - - A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with | - - - - - A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |. - - - - - A list of unmanaged 32 bit assembly names to include, delimited with |. - - - - - A list of unmanaged 64 bit assembly names to include, delimited with |. - - - - - The order of preloaded assemblies, delimited with |. - - - - - - - - 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. - - - - - A comma-separated list of error codes that can be safely ignored in assembly verification. - - - - - 'false' to turn off automatic generation of the XML Schema file. - - - - - \ No newline at end of file diff --git a/src/Models/ApplyWhiteSpaceMode.cs b/src/Models/ApplyWhiteSpaceMode.cs new file mode 100644 index 00000000..aad45f57 --- /dev/null +++ b/src/Models/ApplyWhiteSpaceMode.cs @@ -0,0 +1,24 @@ +namespace SourceGit.Models +{ + public class ApplyWhiteSpaceMode + { + public static readonly ApplyWhiteSpaceMode[] Supported = + [ + new ApplyWhiteSpaceMode("No Warn", "Turns off the trailing whitespace warning", "nowarn"), + new ApplyWhiteSpaceMode("Warn", "Outputs warnings for a few such errors, but applies", "warn"), + new ApplyWhiteSpaceMode("Error", "Raise errors and refuses to apply the patch", "error"), + new ApplyWhiteSpaceMode("Error All", "Similar to 'error', but shows more", "error-all"), + ]; + + public string Name { get; set; } + public string Desc { get; set; } + public string Arg { get; set; } + + public ApplyWhiteSpaceMode(string n, string d, string a) + { + Name = n; + Desc = d; + Arg = a; + } + } +} diff --git a/src/Models/AvatarManager.cs b/src/Models/AvatarManager.cs new file mode 100644 index 00000000..fa07975d --- /dev/null +++ b/src/Models/AvatarManager.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace SourceGit.Models +{ + public interface IAvatarHost + { + void OnAvatarResourceChanged(string email, Bitmap image); + } + + public partial class AvatarManager + { + public static AvatarManager Instance + { + get + { + if (_instance == null) + _instance = new AvatarManager(); + + return _instance; + } + } + + private static AvatarManager _instance = null; + + [GeneratedRegex(@"^(?:(\d+)\+)?(.+?)@.+\.github\.com$")] + private static partial Regex REG_GITHUB_USER_EMAIL(); + + private readonly Lock _synclock = new(); + private string _storePath; + private List _avatars = new List(); + private Dictionary _resources = new Dictionary(); + private HashSet _requesting = new HashSet(); + private HashSet _defaultAvatars = new HashSet(); + + public void Start() + { + _storePath = Path.Combine(Native.OS.DataDir, "avatars"); + if (!Directory.Exists(_storePath)) + Directory.CreateDirectory(_storePath); + + LoadDefaultAvatar("noreply@github.com", "github.png"); + LoadDefaultAvatar("unrealbot@epicgames.com", "unreal.png"); + + Task.Run(() => + { + while (true) + { + var email = null as string; + + lock (_synclock) + { + foreach (var one in _requesting) + { + email = one; + break; + } + } + + if (email == null) + { + Thread.Sleep(100); + continue; + } + + var md5 = GetEmailHash(email); + var matchGithubUser = REG_GITHUB_USER_EMAIL().Match(email); + var url = matchGithubUser.Success ? + $"https://avatars.githubusercontent.com/{matchGithubUser.Groups[2].Value}" : + $"https://www.gravatar.com/avatar/{md5}?d=404"; + + var localFile = Path.Combine(_storePath, md5); + var img = null as Bitmap; + try + { + var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(2) }; + var task = client.GetAsync(url); + task.Wait(); + + var rsp = task.Result; + if (rsp.IsSuccessStatusCode) + { + using (var stream = rsp.Content.ReadAsStream()) + { + using (var writer = File.OpenWrite(localFile)) + { + stream.CopyTo(writer); + } + } + + using (var reader = File.OpenRead(localFile)) + { + img = Bitmap.DecodeToWidth(reader, 128); + } + } + } + catch + { + // ignored + } + + lock (_synclock) + { + _requesting.Remove(email); + } + + Dispatcher.UIThread.InvokeAsync(() => + { + _resources[email] = img; + NotifyResourceChanged(email, img); + }); + } + + // ReSharper disable once FunctionNeverReturns + }); + } + + public void Subscribe(IAvatarHost host) + { + _avatars.Add(host); + } + + public void Unsubscribe(IAvatarHost host) + { + _avatars.Remove(host); + } + + public Bitmap Request(string email, bool forceRefetch) + { + if (forceRefetch) + { + if (_defaultAvatars.Contains(email)) + return null; + + _resources.Remove(email); + + var localFile = Path.Combine(_storePath, GetEmailHash(email)); + if (File.Exists(localFile)) + File.Delete(localFile); + + NotifyResourceChanged(email, null); + } + else + { + if (_resources.TryGetValue(email, out var value)) + return value; + + var localFile = Path.Combine(_storePath, GetEmailHash(email)); + if (File.Exists(localFile)) + { + try + { + using (var stream = File.OpenRead(localFile)) + { + var img = Bitmap.DecodeToWidth(stream, 128); + _resources.Add(email, img); + return img; + } + } + catch + { + // ignore + } + } + } + + lock (_synclock) + { + _requesting.Add(email); + } + + return null; + } + + public void SetFromLocal(string email, string file) + { + try + { + Bitmap image = null; + + using (var stream = File.OpenRead(file)) + { + image = Bitmap.DecodeToWidth(stream, 128); + } + + if (image == null) + return; + + _resources[email] = image; + + _requesting.Remove(email); + + var store = Path.Combine(_storePath, GetEmailHash(email)); + File.Copy(file, store, true); + NotifyResourceChanged(email, image); + } + catch + { + // ignore + } + } + + private void LoadDefaultAvatar(string key, string img) + { + var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/{img}", UriKind.RelativeOrAbsolute)); + _resources.Add(key, new Bitmap(icon)); + _defaultAvatars.Add(key); + } + + private string GetEmailHash(string email) + { + var lowered = email.ToLower(CultureInfo.CurrentCulture).Trim(); + var hash = MD5.HashData(Encoding.Default.GetBytes(lowered)); + var builder = new StringBuilder(hash.Length * 2); + foreach (var c in hash) + builder.Append(c.ToString("x2")); + return builder.ToString(); + } + + private void NotifyResourceChanged(string email, Bitmap image) + { + foreach (var avatar in _avatars) + avatar.OnAvatarResourceChanged(email, image); + } + } +} diff --git a/src/Models/Bisect.cs b/src/Models/Bisect.cs new file mode 100644 index 00000000..2ed8beb2 --- /dev/null +++ b/src/Models/Bisect.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public enum BisectState + { + None = 0, + WaitingForRange, + Detecting, + } + + [Flags] + public enum BisectCommitFlag + { + None = 0, + Good = 1 << 0, + Bad = 1 << 1, + } + + public class Bisect + { + public HashSet Bads + { + get; + set; + } = []; + + public HashSet Goods + { + get; + set; + } = []; + } +} diff --git a/src/Models/Blame.cs b/src/Models/Blame.cs new file mode 100644 index 00000000..3eb8d8bf --- /dev/null +++ b/src/Models/Blame.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class BlameLineInfo + { + public bool IsFirstInGroup { get; set; } = false; + public string CommitSHA { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public string Time { get; set; } = string.Empty; + } + + public class BlameData + { + public string File { get; set; } = string.Empty; + public List LineInfos { get; set; } = new List(); + public string Content { get; set; } = string.Empty; + public bool IsBinary { get; set; } = false; + } +} diff --git a/src/Models/BlameLine.cs b/src/Models/BlameLine.cs deleted file mode 100644 index e97a4f87..00000000 --- a/src/Models/BlameLine.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SourceGit.Models { - /// - /// 追溯中的行信息 - /// - public class BlameLine { - public string LineNumber { get; set; } - public string CommitSHA { get; set; } - public string Author { get; set; } - public string Time { get; set; } - public string Content { get; set; } - } -} diff --git a/src/Models/Bookmarks.cs b/src/Models/Bookmarks.cs new file mode 100644 index 00000000..37cf689b --- /dev/null +++ b/src/Models/Bookmarks.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public static class Bookmarks + { + public static readonly Avalonia.Media.IBrush[] Brushes = [ + Avalonia.Media.Brushes.Transparent, + Avalonia.Media.Brushes.Red, + Avalonia.Media.Brushes.Orange, + Avalonia.Media.Brushes.Gold, + Avalonia.Media.Brushes.ForestGreen, + Avalonia.Media.Brushes.DarkCyan, + Avalonia.Media.Brushes.DeepSkyBlue, + Avalonia.Media.Brushes.Purple, + ]; + + public static readonly List Supported = new List(); + + static Bookmarks() + { + for (int i = 0; i < Brushes.Length; i++) + Supported.Add(i); + } + } +} diff --git a/src/Models/Branch.cs b/src/Models/Branch.cs index f6d63b50..7146da3f 100644 --- a/src/Models/Branch.cs +++ b/src/Models/Branch.cs @@ -1,16 +1,48 @@ -namespace SourceGit.Models { - /// - /// 分支数据 - /// - public class Branch { +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class BranchTrackStatus + { + public List Ahead { get; set; } = new List(); + public List Behind { get; set; } = new List(); + + public bool IsVisible => Ahead.Count > 0 || Behind.Count > 0; + + public override string ToString() + { + if (Ahead.Count == 0 && Behind.Count == 0) + return string.Empty; + + var track = ""; + if (Ahead.Count > 0) + track += $"{Ahead.Count}↑"; + if (Behind.Count > 0) + track += $" {Behind.Count}↓"; + return track.Trim(); + } + } + + public enum BranchSortMode + { + Name = 0, + CommitterDate, + } + + public class Branch + { public string Name { get; set; } public string FullName { get; set; } + public ulong CommitterDate { get; set; } public string Head { get; set; } - public string HeadSubject { get; set; } public bool IsLocal { get; set; } public bool IsCurrent { get; set; } + public bool IsDetachedHead { get; set; } public string Upstream { get; set; } - public string UpstreamTrackStatus { get; set; } + public BranchTrackStatus TrackStatus { get; set; } public string Remote { get; set; } + public bool IsUpstreamGone { get; set; } + + public string FriendlyName => IsLocal ? Name : $"{Remote}/{Name}"; } } diff --git a/src/Models/CRLFMode.cs b/src/Models/CRLFMode.cs new file mode 100644 index 00000000..3f510f00 --- /dev/null +++ b/src/Models/CRLFMode.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class CRLFMode + { + public string Name { get; set; } + public string Value { get; set; } + public string Desc { get; set; } + + public static readonly List Supported = new List() { + new CRLFMode("TRUE", "true", "Commit as LF, checkout as CRLF"), + new CRLFMode("INPUT", "input", "Only convert for commit"), + new CRLFMode("FALSE", "false", "Do NOT convert"), + }; + + public CRLFMode(string name, string value, string desc) + { + Name = name; + Value = value; + Desc = desc; + } + } +} diff --git a/src/Models/CRLFOption.cs b/src/Models/CRLFOption.cs deleted file mode 100644 index 059b5060..00000000 --- a/src/Models/CRLFOption.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; - -namespace SourceGit.Models { - - /// - /// 自动换行处理方式 - /// - public class CRLFOption { - public string Display { get; set; } - public string Value { get; set; } - public string Desc { get; set; } - - public static List Supported = new List() { - new CRLFOption("TRUE", "true", "Commit as LF, checkout as CRLF"), - new CRLFOption("INPUT", "input", "Only convert for commit"), - new CRLFOption("FALSE", "false", "Do NOT convert"), - }; - - public CRLFOption(string display, string value, string desc) { - Display = display; - Value = value; - Desc = desc; - } - } -} diff --git a/src/Models/Change.cs b/src/Models/Change.cs index 2bfc2c91..129678be 100644 --- a/src/Models/Change.cs +++ b/src/Models/Change.cs @@ -1,67 +1,126 @@ -namespace SourceGit.Models { +using System; - /// - /// Git变更 - /// - public class Change { +namespace SourceGit.Models +{ + public enum ChangeViewMode + { + List, + Grid, + Tree, + } - /// - /// 显示模式 - /// - public enum DisplayMode { - Tree, - List, - Grid, - } + public enum ChangeState + { + None, + Modified, + TypeChanged, + Added, + Deleted, + Renamed, + Copied, + Untracked, + Conflicted, + } - /// - /// 变更状态码 - /// - public enum Status { - None, - Modified, - Added, - Deleted, - Renamed, - Copied, - Unmerged, - Untracked, - } + public enum ConflictReason + { + None, + BothDeleted, + AddedByUs, + DeletedByThem, + AddedByThem, + DeletedByUs, + BothAdded, + BothModified, + } - public Status Index { get; set; } - public Status WorkTree { get; set; } = Status.None; + public class ChangeDataForAmend + { + public string FileMode { get; set; } = ""; + public string ObjectHash { get; set; } = ""; + public string ParentSHA { get; set; } = ""; + } + + public class Change + { + public ChangeState Index { get; set; } = ChangeState.None; + public ChangeState WorkTree { get; set; } = ChangeState.None; public string Path { get; set; } = ""; public string OriginalPath { get; set; } = ""; + public ChangeDataForAmend DataForAmend { get; set; } = null; + public ConflictReason ConflictReason { get; set; } = ConflictReason.None; - public bool IsConflit { - get { - if (Index == Status.Unmerged || WorkTree == Status.Unmerged) return true; - if (Index == Status.Added && WorkTree == Status.Added) return true; - if (Index == Status.Deleted && WorkTree == Status.Deleted) return true; - return false; - } - } + public bool IsConflicted => WorkTree == ChangeState.Conflicted; + public string ConflictMarker => CONFLICT_MARKERS[(int)ConflictReason]; + public string ConflictDesc => CONFLICT_DESCS[(int)ConflictReason]; - public void Set(Status index, Status workTree = Status.None) { + public string WorkTreeDesc => TYPE_DESCS[(int)WorkTree]; + public string IndexDesc => TYPE_DESCS[(int)Index]; + + public void Set(ChangeState index, ChangeState workTree = ChangeState.None) + { Index = index; WorkTree = workTree; - if (index == Status.Renamed || workTree == Status.Renamed) { - var idx = Path.IndexOf('\t'); - if (idx >= 0) { + if (index == ChangeState.Renamed || workTree == ChangeState.Renamed) + { + var idx = Path.IndexOf('\t', StringComparison.Ordinal); + if (idx >= 0) + { OriginalPath = Path.Substring(0, idx); Path = Path.Substring(idx + 1); - } else { - idx = Path.IndexOf(" -> "); - if (idx > 0) { + } + else + { + idx = Path.IndexOf(" -> ", StringComparison.Ordinal); + if (idx > 0) + { OriginalPath = Path.Substring(0, idx); Path = Path.Substring(idx + 4); } } } - if (Path[0] == '"') Path = Path.Substring(1, Path.Length - 2); - if (!string.IsNullOrEmpty(OriginalPath) && OriginalPath[0] == '"') OriginalPath = OriginalPath.Substring(1, OriginalPath.Length - 2); + if (Path[0] == '"') + Path = Path.Substring(1, Path.Length - 2); + + if (!string.IsNullOrEmpty(OriginalPath) && OriginalPath[0] == '"') + OriginalPath = OriginalPath.Substring(1, OriginalPath.Length - 2); } + + private static readonly string[] TYPE_DESCS = + [ + "Unknown", + "Modified", + "Type Changed", + "Added", + "Deleted", + "Renamed", + "Copied", + "Untracked", + "Conflict" + ]; + private static readonly string[] CONFLICT_MARKERS = + [ + string.Empty, + "DD", + "AU", + "UD", + "UA", + "DU", + "AA", + "UU" + ]; + private static readonly string[] CONFLICT_DESCS = + [ + string.Empty, + "Both deleted", + "Added by us", + "Deleted by them", + "Added by them", + "Deleted by us", + "Both added", + "Both modified" + ]; } } diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index 5467f3ec..f0f4b39b 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -1,39 +1,126 @@ -using System; +using System; using System.Collections.Generic; -using System.Text.RegularExpressions; -using System.Windows; -namespace SourceGit.Models { - /// - /// 提交记录 - /// - public class Commit { - private static readonly Regex REG_USER_FORMAT = new Regex(@"\w+ (.*) <(.*)> (\d{10}) [\+\-]\d+"); - private static readonly DateTime UTC_START = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); +using Avalonia; +using Avalonia.Media; + +namespace SourceGit.Models +{ + public enum CommitSearchMethod + { + BySHA = 0, + ByAuthor, + ByCommitter, + ByMessage, + ByFile, + ByContent, + } + + public class Commit + { + // As retrieved by: git mktree SHA.Substring(0, 8); public User Author { get; set; } = User.Invalid; public ulong AuthorTime { get; set; } = 0; public User Committer { get; set; } = User.Invalid; public ulong CommitterTime { get; set; } = 0; public string Subject { get; set; } = string.Empty; - public string Message { get; set; } = string.Empty; - public List Parents { get; set; } = new List(); - public List Decorators { get; set; } = new List(); + public List Parents { get; set; } = new(); + public List Decorators { get; set; } = new(); public bool HasDecorators => Decorators.Count > 0; + + public string AuthorTimeStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); + public string CommitterTimeStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); + public string AuthorTimeShortStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateOnly); + public string CommitterTimeShortStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Active.DateOnly); + public bool IsMerged { get; set; } = false; - public Thickness Margin { get; set; } = new Thickness(0); + public bool IsCommitterVisible => !Author.Equals(Committer) || AuthorTime != CommitterTime; + public bool IsCurrentHead => Decorators.Find(x => x.Type is DecoratorType.CurrentBranchHead or DecoratorType.CurrentCommitHead) != null; - public string AuthorTimeStr => UTC_START.AddSeconds(AuthorTime).ToString("yyyy-MM-dd HH:mm:ss"); - public string CommitterTimeStr => UTC_START.AddSeconds(CommitterTime).ToString("yyyy-MM-dd HH:mm:ss"); + public int Color { get; set; } = 0; + public double Opacity => IsMerged ? 1 : OpacityForNotMerged; + public FontWeight FontWeight => IsCurrentHead ? FontWeight.Bold : FontWeight.Regular; + public Thickness Margin { get; set; } = new(0); + public IBrush Brush => CommitGraph.Pens[Color].Brush; - public static void ParseUserAndTime(string data, ref User user, ref ulong time) { - var match = REG_USER_FORMAT.Match(data); - if (!match.Success) return; + public void ParseDecorators(string data) + { + if (data.Length < 3) + return; - user = User.FindOrAdd(match.Groups[1].Value, match.Groups[2].Value); - time = ulong.Parse(match.Groups[3].Value); + var subs = data.Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var sub in subs) + { + var d = sub.Trim(); + if (d.EndsWith("/HEAD", StringComparison.Ordinal)) + continue; + + if (d.StartsWith("tag: refs/tags/", StringComparison.Ordinal)) + { + Decorators.Add(new Decorator() + { + Type = DecoratorType.Tag, + Name = d.Substring(15), + }); + } + else if (d.StartsWith("HEAD -> refs/heads/", StringComparison.Ordinal)) + { + IsMerged = true; + Decorators.Add(new Decorator() + { + Type = DecoratorType.CurrentBranchHead, + Name = d.Substring(19), + }); + } + else if (d.Equals("HEAD")) + { + IsMerged = true; + Decorators.Add(new Decorator() + { + Type = DecoratorType.CurrentCommitHead, + Name = d, + }); + } + else if (d.StartsWith("refs/heads/", StringComparison.Ordinal)) + { + Decorators.Add(new Decorator() + { + Type = DecoratorType.LocalBranchHead, + Name = d.Substring(11), + }); + } + else if (d.StartsWith("refs/remotes/", StringComparison.Ordinal)) + { + Decorators.Add(new Decorator() + { + Type = DecoratorType.RemoteBranchHead, + Name = d.Substring(13), + }); + } + } + + Decorators.Sort((l, r) => + { + if (l.Type != r.Type) + return (int)l.Type - (int)r.Type; + else + return NumericSort.Compare(l.Name, r.Name); + }); } } + + public class CommitFullMessage + { + public string Message { get; set; } = string.Empty; + public InlineElementCollector Inlines { get; set; } = new(); + } } diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs new file mode 100644 index 00000000..01488656 --- /dev/null +++ b/src/Models/CommitGraph.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class CommitGraph + { + public static List Pens { get; } = []; + + public static void SetDefaultPens(double thickness = 2) + { + SetPens(s_defaultPenColors, thickness); + } + + public static void SetPens(List colors, double thickness) + { + Pens.Clear(); + + foreach (var c in colors) + Pens.Add(new Pen(c.ToUInt32(), thickness)); + + s_penCount = colors.Count; + } + + public class Path(int color, bool isMerged) + { + public List Points { get; } = []; + public int Color { get; } = color; + public bool IsMerged { get; } = isMerged; + } + + public class Link + { + public Point Start; + public Point Control; + public Point End; + public int Color; + public bool IsMerged; + } + + public enum DotType + { + Default, + Head, + Merge, + } + + public class Dot + { + public DotType Type; + public Point Center; + public int Color; + public bool IsMerged; + } + + public List Paths { get; } = []; + public List Links { get; } = []; + public List Dots { get; } = []; + + public static CommitGraph Parse(List commits, bool firstParentOnlyEnabled) + { + const double unitWidth = 12; + const double halfWidth = 6; + const double unitHeight = 1; + const double halfHeight = 0.5; + + var temp = new CommitGraph(); + var unsolved = new List(); + var ended = new List(); + var offsetY = -halfHeight; + var colorPicker = new ColorPicker(); + + foreach (var commit in commits) + { + var major = null as PathHelper; + var isMerged = commit.IsMerged; + + // Update current y offset + offsetY += unitHeight; + + // Find first curves that links to this commit and marks others that links to this commit ended. + var offsetX = 4 - halfWidth; + var maxOffsetOld = unsolved.Count > 0 ? unsolved[^1].LastX : offsetX + unitWidth; + foreach (var l in unsolved) + { + if (l.Next.Equals(commit.SHA, StringComparison.Ordinal)) + { + if (major == null) + { + offsetX += unitWidth; + major = l; + + if (commit.Parents.Count > 0) + { + major.Next = commit.Parents[0]; + major.Goto(offsetX, offsetY, halfHeight); + } + else + { + major.End(offsetX, offsetY, halfHeight); + ended.Add(l); + } + } + else + { + l.End(major.LastX, offsetY, halfHeight); + ended.Add(l); + } + + isMerged = isMerged || l.IsMerged; + } + else + { + offsetX += unitWidth; + l.Pass(offsetX, offsetY, halfHeight); + } + } + + // Remove ended curves from unsolved + foreach (var l in ended) + { + colorPicker.Recycle(l.Path.Color); + unsolved.Remove(l); + } + ended.Clear(); + + // If no path found, create new curve for branch head + // Otherwise, create new curve for new merged commit + if (major == null) + { + offsetX += unitWidth; + + if (commit.Parents.Count > 0) + { + major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(offsetX, offsetY)); + unsolved.Add(major); + temp.Paths.Add(major.Path); + } + } + else if (isMerged && !major.IsMerged && commit.Parents.Count > 0) + { + major.ReplaceMerged(); + temp.Paths.Add(major.Path); + } + + // Calculate link position of this commit. + var position = new Point(major?.LastX ?? offsetX, offsetY); + var dotColor = major?.Path.Color ?? 0; + var anchor = new Dot() { Center = position, Color = dotColor, IsMerged = isMerged }; + if (commit.IsCurrentHead) + anchor.Type = DotType.Head; + else if (commit.Parents.Count > 1) + anchor.Type = DotType.Merge; + else + anchor.Type = DotType.Default; + temp.Dots.Add(anchor); + + // Deal with other parents (the first parent has been processed) + if (!firstParentOnlyEnabled) + { + for (int j = 1; j < commit.Parents.Count; j++) + { + var parentHash = commit.Parents[j]; + var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal)); + if (parent != null) + { + if (isMerged && !parent.IsMerged) + { + parent.Goto(parent.LastX, offsetY + halfHeight, halfHeight); + parent.ReplaceMerged(); + temp.Paths.Add(parent.Path); + } + + temp.Links.Add(new Link + { + Start = position, + End = new Point(parent.LastX, offsetY + halfHeight), + Control = new Point(parent.LastX, position.Y), + Color = parent.Path.Color, + IsMerged = isMerged, + }); + } + else + { + offsetX += unitWidth; + + // Create new curve for parent commit that not includes before + var l = new PathHelper(parentHash, isMerged, colorPicker.Next(), position, new Point(offsetX, position.Y + halfHeight)); + unsolved.Add(l); + temp.Paths.Add(l.Path); + } + } + } + + // Margins & merge state (used by Views.Histories). + commit.IsMerged = isMerged; + commit.Margin = new Thickness(Math.Max(offsetX, maxOffsetOld) + halfWidth + 2, 0, 0, 0); + commit.Color = dotColor; + } + + // Deal with curves haven't ended yet. + for (var i = 0; i < unsolved.Count; i++) + { + var path = unsolved[i]; + var endY = (commits.Count - 0.5) * unitHeight; + + if (path.Path.Points.Count == 1 && Math.Abs(path.Path.Points[0].Y - endY) < 0.0001) + continue; + + path.End((i + 0.5) * unitWidth + 4, endY + halfHeight, halfHeight); + } + unsolved.Clear(); + + return temp; + } + + private class ColorPicker + { + public int Next() + { + if (_colorsQueue.Count == 0) + { + for (var i = 0; i < s_penCount; i++) + _colorsQueue.Enqueue(i); + } + + return _colorsQueue.Dequeue(); + } + + public void Recycle(int idx) + { + if (!_colorsQueue.Contains(idx)) + _colorsQueue.Enqueue(idx); + } + + private Queue _colorsQueue = new Queue(); + } + + private class PathHelper + { + public Path Path { get; private set; } + public string Next { get; set; } + public double LastX { get; private set; } + + public bool IsMerged => Path.IsMerged; + + public PathHelper(string next, bool isMerged, int color, Point start) + { + Next = next; + LastX = start.X; + _lastY = start.Y; + + Path = new Path(color, isMerged); + Path.Points.Add(start); + } + + public PathHelper(string next, bool isMerged, int color, Point start, Point to) + { + Next = next; + LastX = to.X; + _lastY = to.Y; + + Path = new Path(color, isMerged); + Path.Points.Add(start); + Path.Points.Add(to); + } + + /// + /// A path that just passed this row. + /// + /// + /// + /// + public void Pass(double x, double y, double halfHeight) + { + if (x > LastX) + { + Add(LastX, _lastY); + Add(x, y - halfHeight); + } + else if (x < LastX) + { + Add(LastX, y - halfHeight); + y += halfHeight; + Add(x, y); + } + + LastX = x; + _lastY = y; + } + + /// + /// A path that has commit in this row but not ended + /// + /// + /// + /// + public void Goto(double x, double y, double halfHeight) + { + if (x > LastX) + { + Add(LastX, _lastY); + Add(x, y - halfHeight); + } + else if (x < LastX) + { + var minY = y - halfHeight; + if (minY > _lastY) + minY -= halfHeight; + + Add(LastX, minY); + Add(x, y); + } + + LastX = x; + _lastY = y; + } + + /// + /// A path that has commit in this row and end. + /// + /// + /// + /// + public void End(double x, double y, double halfHeight) + { + if (x > LastX) + { + Add(LastX, _lastY); + Add(x, y - halfHeight); + } + else if (x < LastX) + { + Add(LastX, y - halfHeight); + } + + Add(x, y); + + LastX = x; + _lastY = y; + } + + /// + /// End the current path and create a new from the end. + /// + public void ReplaceMerged() + { + var color = Path.Color; + Add(LastX, _lastY); + + Path = new Path(color, true); + Path.Points.Add(new Point(LastX, _lastY)); + _endY = 0; + } + + private void Add(double x, double y) + { + if (_endY < y) + { + Path.Points.Add(new Point(x, y)); + _endY = y; + } + } + + private double _lastY = 0; + private double _endY = 0; + } + + private static int s_penCount = 0; + private static readonly List s_defaultPenColors = [ + Colors.Orange, + Colors.ForestGreen, + Colors.Turquoise, + Colors.Olive, + Colors.Magenta, + Colors.Red, + Colors.Khaki, + Colors.Lime, + Colors.RoyalBlue, + Colors.Teal, + ]; + } +} diff --git a/src/Models/CommitLink.cs b/src/Models/CommitLink.cs new file mode 100644 index 00000000..2891e5d6 --- /dev/null +++ b/src/Models/CommitLink.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class CommitLink + { + public string Name { get; set; } = null; + public string URLPrefix { get; set; } = null; + + public CommitLink(string name, string prefix) + { + Name = name; + URLPrefix = prefix; + } + + public static List Get(List remotes) + { + var outs = new List(); + + foreach (var remote in remotes) + { + if (remote.TryGetVisitURL(out var url)) + { + var trimmedUrl = url.AsSpan(); + if (url.EndsWith(".git")) + trimmedUrl = url.AsSpan(0, url.Length - 4); + + if (url.StartsWith("https://github.com/", StringComparison.Ordinal)) + outs.Add(new($"Github ({trimmedUrl.Slice(19)})", $"{url}/commit/")); + else if (url.StartsWith("https://gitlab.", StringComparison.Ordinal)) + outs.Add(new($"GitLab ({trimmedUrl.Slice(trimmedUrl.Slice(15).IndexOf('/') + 16)})", $"{url}/-/commit/")); + else if (url.StartsWith("https://gitee.com/", StringComparison.Ordinal)) + outs.Add(new($"Gitee ({trimmedUrl.Slice(18)})", $"{url}/commit/")); + else if (url.StartsWith("https://bitbucket.org/", StringComparison.Ordinal)) + outs.Add(new($"BitBucket ({trimmedUrl.Slice(22)})", $"{url}/commits/")); + else if (url.StartsWith("https://codeberg.org/", StringComparison.Ordinal)) + outs.Add(new($"Codeberg ({trimmedUrl.Slice(21)})", $"{url}/commit/")); + else if (url.StartsWith("https://gitea.org/", StringComparison.Ordinal)) + outs.Add(new($"Gitea ({trimmedUrl.Slice(18)})", $"{url}/commit/")); + else if (url.StartsWith("https://git.sr.ht/", StringComparison.Ordinal)) + outs.Add(new($"sourcehut ({trimmedUrl.Slice(18)})", $"{url}/commit/")); + } + } + + return outs; + } + } +} diff --git a/src/Models/CommitSignInfo.cs b/src/Models/CommitSignInfo.cs new file mode 100644 index 00000000..44b95e61 --- /dev/null +++ b/src/Models/CommitSignInfo.cs @@ -0,0 +1,60 @@ +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class CommitSignInfo + { + public char VerifyResult { get; init; } = 'N'; + public string Signer { get; init; } = string.Empty; + public string Key { get; init; } = string.Empty; + public bool HasSigner => !string.IsNullOrEmpty(Signer); + + public IBrush Brush + { + get + { + switch (VerifyResult) + { + case 'G': + case 'U': + return Brushes.Green; + case 'X': + case 'Y': + case 'R': + return Brushes.DarkOrange; + case 'B': + case 'E': + return Brushes.Red; + default: + return Brushes.Transparent; + } + } + } + + public string ToolTip + { + get + { + switch (VerifyResult) + { + case 'G': + return "Good signature."; + case 'U': + return "Good signature with unknown validity."; + case 'X': + return "Good signature but has expired."; + case 'Y': + return "Good signature made by expired key."; + case 'R': + return "Good signature made by a revoked key."; + case 'B': + return "Bad signature."; + case 'E': + return "Signature cannot be checked."; + default: + return "No signature."; + } + } + } + } +} diff --git a/src/Models/CommitTemplate.cs b/src/Models/CommitTemplate.cs new file mode 100644 index 00000000..3f331543 --- /dev/null +++ b/src/Models/CommitTemplate.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public class CommitTemplate : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string Content + { + get => _content; + set => SetProperty(ref _content, value); + } + + public string Apply(Branch branch, List changes) + { + var te = new TemplateEngine(); + return te.Eval(_content, branch, changes); + } + + private string _name = string.Empty; + private string _content = string.Empty; + } +} diff --git a/src/Models/ConventionalCommitType.cs b/src/Models/ConventionalCommitType.cs new file mode 100644 index 00000000..531a16c0 --- /dev/null +++ b/src/Models/ConventionalCommitType.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class ConventionalCommitType + { + public string Name { get; set; } + public string Type { get; set; } + public string Description { get; set; } + + public static readonly List Supported = [ + new("Features", "feat", "Adding a new feature"), + new("Bug Fixes", "fix", "Fixing a bug"), + new("Work In Progress", "wip", "Still being developed and not yet complete"), + new("Reverts", "revert", "Undoing a previous commit"), + new("Code Refactoring", "refactor", "Restructuring code without changing its external behavior"), + new("Performance Improvements", "perf", "Improves performance"), + new("Builds", "build", "Changes that affect the build system or external dependencies"), + new("Continuous Integrations", "ci", "Changes to CI configuration files and scripts"), + new("Documentations", "docs", "Updating documentation"), + new("Styles", "style", "Elements or code styles without changing the code logic"), + new("Tests", "test", "Adding or updating tests"), + new("Chores", "chore", "Other changes that don't modify src or test files"), + ]; + + public ConventionalCommitType(string name, string type, string description) + { + Name = name; + Type = type; + Description = description; + } + } +} diff --git a/src/Models/Count.cs b/src/Models/Count.cs new file mode 100644 index 00000000..d48b0c08 --- /dev/null +++ b/src/Models/Count.cs @@ -0,0 +1,19 @@ +using System; + +namespace SourceGit.Models +{ + public class Count : IDisposable + { + public int Value { get; set; } = 0; + + public Count(int value) + { + Value = value; + } + + public void Dispose() + { + // Ignore + } + } +} diff --git a/src/Models/CustomAction.cs b/src/Models/CustomAction.cs new file mode 100644 index 00000000..a614961a --- /dev/null +++ b/src/Models/CustomAction.cs @@ -0,0 +1,50 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public enum CustomActionScope + { + Repository, + Commit, + Branch, + } + + public class CustomAction : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public CustomActionScope Scope + { + get => _scope; + set => SetProperty(ref _scope, value); + } + + public string Executable + { + get => _executable; + set => SetProperty(ref _executable, value); + } + + public string Arguments + { + get => _arguments; + set => SetProperty(ref _arguments, value); + } + + public bool WaitForExit + { + get => _waitForExit; + set => SetProperty(ref _waitForExit, value); + } + + private string _name = string.Empty; + private CustomActionScope _scope = CustomActionScope.Repository; + private string _executable = string.Empty; + private string _arguments = string.Empty; + private bool _waitForExit = true; + } +} diff --git a/src/Models/DateTimeFormat.cs b/src/Models/DateTimeFormat.cs new file mode 100644 index 00000000..16276c40 --- /dev/null +++ b/src/Models/DateTimeFormat.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class DateTimeFormat + { + public string DateOnly { get; set; } + public string DateTime { get; set; } + + public string Example + { + get => _example.ToString(DateTime); + } + + public DateTimeFormat(string dateOnly, string dateTime) + { + DateOnly = dateOnly; + DateTime = dateTime; + } + + public static int ActiveIndex + { + get; + set; + } = 0; + + public static DateTimeFormat Active + { + get => Supported[ActiveIndex]; + } + + public static readonly List Supported = new List + { + new DateTimeFormat("yyyy/MM/dd", "yyyy/MM/dd, HH:mm:ss"), + new DateTimeFormat("yyyy.MM.dd", "yyyy.MM.dd, HH:mm:ss"), + new DateTimeFormat("yyyy-MM-dd", "yyyy-MM-dd, HH:mm:ss"), + new DateTimeFormat("MM/dd/yyyy", "MM/dd/yyyy, HH:mm:ss"), + new DateTimeFormat("MM.dd.yyyy", "MM.dd.yyyy, HH:mm:ss"), + new DateTimeFormat("MM-dd-yyyy", "MM-dd-yyyy, HH:mm:ss"), + new DateTimeFormat("dd/MM/yyyy", "dd/MM/yyyy, HH:mm:ss"), + new DateTimeFormat("dd.MM.yyyy", "dd.MM.yyyy, HH:mm:ss"), + new DateTimeFormat("dd-MM-yyyy", "dd-MM-yyyy, HH:mm:ss"), + new DateTimeFormat("MMM d yyyy", "MMM d yyyy, HH:mm:ss"), + new DateTimeFormat("d MMM yyyy", "d MMM yyyy, HH:mm:ss"), + }; + + private static readonly DateTime _example = new DateTime(2025, 1, 31, 8, 0, 0, DateTimeKind.Local); + } +} diff --git a/src/Models/Decorator.cs b/src/Models/Decorator.cs index 4197b6d0..7d985e31 100644 --- a/src/Models/Decorator.cs +++ b/src/Models/Decorator.cs @@ -1,21 +1,19 @@ -namespace SourceGit.Models { - - /// - /// 修饰类型 - /// - public enum DecoratorType { +namespace SourceGit.Models +{ + public enum DecoratorType + { None, CurrentBranchHead, LocalBranchHead, + CurrentCommitHead, RemoteBranchHead, Tag, } - /// - /// 提交的附加修饰 - /// - public class Decorator { + public class Decorator + { public DecoratorType Type { get; set; } = DecoratorType.None; public string Name { get; set; } = ""; + public bool IsTag => Type == DecoratorType.Tag; } } diff --git a/src/Models/DiffOption.cs b/src/Models/DiffOption.cs new file mode 100644 index 00000000..69f93980 --- /dev/null +++ b/src/Models/DiffOption.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Models +{ + public class DiffOption + { + /// + /// Enable `--ignore-cr-at-eol` by default? + /// + public static bool IgnoreCRAtEOL + { + get; + set; + } = true; + + public Change WorkingCopyChange => _workingCopyChange; + public bool IsUnstaged => _isUnstaged; + public List Revisions => _revisions; + public string Path => _path; + public string OrgPath => _orgPath; + + /// + /// Only used for working copy changes + /// + /// + /// + public DiffOption(Change change, bool isUnstaged) + { + _workingCopyChange = change; + _isUnstaged = isUnstaged; + + if (isUnstaged) + { + switch (change.WorkTree) + { + case ChangeState.Added: + case ChangeState.Untracked: + _extra = "--no-index"; + _path = change.Path; + _orgPath = "/dev/null"; + break; + default: + _path = change.Path; + _orgPath = change.OriginalPath; + break; + } + } + else + { + if (change.DataForAmend != null) + _extra = $"--cached {change.DataForAmend.ParentSHA}"; + else + _extra = "--cached"; + + _path = change.Path; + _orgPath = change.OriginalPath; + } + } + + /// + /// Only used for commit changes. + /// + /// + /// + public DiffOption(Commit commit, Change change) + { + var baseRevision = commit.Parents.Count == 0 ? Commit.EmptyTreeSHA1 : $"{commit.SHA}^"; + _revisions.Add(baseRevision); + _revisions.Add(commit.SHA); + _path = change.Path; + _orgPath = change.OriginalPath; + } + + /// + /// Diff with filepath. Used by FileHistories + /// + /// + /// + public DiffOption(Commit commit, string file) + { + var baseRevision = commit.Parents.Count == 0 ? Commit.EmptyTreeSHA1 : $"{commit.SHA}^"; + _revisions.Add(baseRevision); + _revisions.Add(commit.SHA); + _path = file; + } + + /// + /// Used to show differences between two revisions. + /// + /// + /// + /// + public DiffOption(string baseRevision, string targetRevision, Change change) + { + _revisions.Add(string.IsNullOrEmpty(baseRevision) ? "-R" : baseRevision); + _revisions.Add(targetRevision); + _path = change.Path; + _orgPath = change.OriginalPath; + } + + /// + /// Converts to diff command arguments. + /// + /// + public override string ToString() + { + var builder = new StringBuilder(); + if (!string.IsNullOrEmpty(_extra)) + builder.Append($"{_extra} "); + foreach (var r in _revisions) + builder.Append($"{r} "); + + builder.Append("-- "); + if (!string.IsNullOrEmpty(_orgPath)) + builder.Append($"\"{_orgPath}\" "); + builder.Append($"\"{_path}\""); + + return builder.ToString(); + } + + private readonly Change _workingCopyChange = null; + private readonly bool _isUnstaged = false; + private readonly string _path; + private readonly string _orgPath = string.Empty; + private readonly string _extra = string.Empty; + private readonly List _revisions = []; + } +} diff --git a/src/Models/DiffResult.cs b/src/Models/DiffResult.cs new file mode 100644 index 00000000..b2d91310 --- /dev/null +++ b/src/Models/DiffResult.cs @@ -0,0 +1,699 @@ +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +using Avalonia; +using Avalonia.Media.Imaging; + +namespace SourceGit.Models +{ + public enum TextDiffLineType + { + None, + Normal, + Indicator, + Added, + Deleted, + } + + public class TextInlineRange + { + public int Start { get; set; } + public int End { get; set; } + public TextInlineRange(int p, int n) { Start = p; End = p + n - 1; } + } + + public class TextDiffLine + { + public TextDiffLineType Type { get; set; } = TextDiffLineType.None; + public string Content { get; set; } = ""; + public int OldLineNumber { get; set; } = 0; + public int NewLineNumber { get; set; } = 0; + public List Highlights { get; set; } = new List(); + public bool NoNewLineEndOfFile { get; set; } = false; + + public string OldLine => OldLineNumber == 0 ? string.Empty : OldLineNumber.ToString(); + public string NewLine => NewLineNumber == 0 ? string.Empty : NewLineNumber.ToString(); + + public TextDiffLine() { } + public TextDiffLine(TextDiffLineType type, string content, int oldLine, int newLine) + { + Type = type; + Content = content; + OldLineNumber = oldLine; + NewLineNumber = newLine; + } + } + + public class TextDiffSelection + { + public int StartLine { get; set; } = 0; + public int EndLine { get; set; } = 0; + public bool HasChanges { get; set; } = false; + public bool HasLeftChanges { get; set; } = false; + public int IgnoredAdds { get; set; } = 0; + public int IgnoredDeletes { get; set; } = 0; + + public bool IsInRange(int idx) + { + return idx >= StartLine - 1 && idx < EndLine; + } + } + + public partial class TextDiff + { + public string File { get; set; } = string.Empty; + public List Lines { get; set; } = new List(); + public Vector ScrollOffset { get; set; } = Vector.Zero; + public int MaxLineNumber = 0; + + public string Repo { get; set; } = null; + public DiffOption Option { get; set; } = null; + + public TextDiffSelection MakeSelection(int startLine, int endLine, bool isCombined, bool isOldSide) + { + var rs = new TextDiffSelection(); + rs.StartLine = startLine; + rs.EndLine = endLine; + + for (int i = 0; i < startLine - 1; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added) + { + rs.HasLeftChanges = true; + rs.IgnoredAdds++; + } + else if (line.Type == TextDiffLineType.Deleted) + { + rs.HasLeftChanges = true; + rs.IgnoredDeletes++; + } + } + + for (int i = startLine - 1; i < endLine; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added) + { + if (isCombined) + { + rs.HasChanges = true; + break; + } + else if (isOldSide) + { + rs.HasLeftChanges = true; + } + else + { + rs.HasChanges = true; + } + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (isCombined) + { + rs.HasChanges = true; + break; + } + else if (isOldSide) + { + rs.HasChanges = true; + } + else + { + rs.HasLeftChanges = true; + } + } + } + + if (!rs.HasLeftChanges) + { + for (int i = endLine; i < Lines.Count; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added || line.Type == TextDiffLineType.Deleted) + { + rs.HasLeftChanges = true; + break; + } + } + } + + return rs; + } + + public void GenerateNewPatchFromSelection(Change change, string fileBlobGuid, TextDiffSelection selection, bool revert, string output) + { + var isTracked = !string.IsNullOrEmpty(fileBlobGuid); + var fileGuid = isTracked ? fileBlobGuid : "00000000"; + + var builder = new StringBuilder(); + builder.Append("diff --git a/").Append(change.Path).Append(" b/").Append(change.Path).Append('\n'); + if (!revert && !isTracked) + builder.Append("new file mode 100644\n"); + builder.Append("index 00000000...").Append(fileGuid).Append('\n'); + builder.Append("--- ").Append((revert || isTracked) ? $"a/{change.Path}\n" : "/dev/null\n"); + builder.Append("+++ b/").Append(change.Path).Append('\n'); + + var additions = selection.EndLine - selection.StartLine; + if (selection.StartLine != 1) + additions++; + + if (revert) + { + var totalLines = Lines.Count - 1; + builder.Append($"@@ -0,").Append(totalLines - additions).Append(" +0,").Append(totalLines).Append(" @@"); + for (int i = 1; i <= totalLines; i++) + { + var line = Lines[i]; + if (line.Type != TextDiffLineType.Added) + continue; + builder.Append(selection.IsInRange(i) ? "\n+" : "\n ").Append(line.Content); + } + } + else + { + builder.Append("@@ -0,0 +0,").Append(additions).Append(" @@"); + for (int i = selection.StartLine - 1; i < selection.EndLine; i++) + { + var line = Lines[i]; + if (line.Type != TextDiffLineType.Added) + continue; + builder.Append("\n+").Append(line.Content); + } + } + + builder.Append("\n\\ No newline at end of file\n"); + System.IO.File.WriteAllText(output, builder.ToString()); + } + + public void GeneratePatchFromSelection(Change change, string fileTreeGuid, TextDiffSelection selection, bool revert, string output) + { + var orgFile = !string.IsNullOrEmpty(change.OriginalPath) ? change.OriginalPath : change.Path; + + var builder = new StringBuilder(); + builder.Append("diff --git a/").Append(change.Path).Append(" b/").Append(change.Path).Append('\n'); + builder.Append("index 00000000...").Append(fileTreeGuid).Append(" 100644\n"); + builder.Append("--- a/").Append(orgFile).Append('\n'); + builder.Append("+++ b/").Append(change.Path); + + // If last line of selection is a change. Find one more line. + var tail = null as string; + if (selection.EndLine < Lines.Count) + { + var lastLine = Lines[selection.EndLine - 1]; + if (lastLine.Type == TextDiffLineType.Added || lastLine.Type == TextDiffLineType.Deleted) + { + for (int i = selection.EndLine; i < Lines.Count; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + break; + if (revert) + { + if (line.Type == TextDiffLineType.Normal || line.Type == TextDiffLineType.Added) + { + tail = line.Content; + break; + } + } + else + { + if (line.Type == TextDiffLineType.Normal || line.Type == TextDiffLineType.Deleted) + { + tail = line.Content; + break; + } + } + } + } + } + + // If the first line is not indicator. + if (Lines[selection.StartLine - 1].Type != TextDiffLineType.Indicator) + { + var indicator = selection.StartLine - 1; + for (int i = selection.StartLine - 2; i >= 0; i--) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + indicator = i; + break; + } + } + + var ignoreAdds = 0; + var ignoreRemoves = 0; + for (int i = 0; i < indicator; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added) + { + ignoreAdds++; + } + else if (line.Type == TextDiffLineType.Deleted) + { + ignoreRemoves++; + } + } + + for (int i = indicator; i < selection.StartLine - 1; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + ProcessIndicatorForPatch(builder, line, i, selection.StartLine, selection.EndLine, ignoreRemoves, ignoreAdds, revert, tail != null); + } + else if (line.Type == TextDiffLineType.Added) + { + if (revert) + builder.Append("\n ").Append(line.Content); + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (!revert) + builder.Append("\n ").Append(line.Content); + } + else if (line.Type == TextDiffLineType.Normal) + { + builder.Append("\n ").Append(line.Content); + } + } + } + + // Outputs the selected lines. + for (int i = selection.StartLine - 1; i < selection.EndLine; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + if (!ProcessIndicatorForPatch(builder, line, i, selection.StartLine, selection.EndLine, selection.IgnoredDeletes, selection.IgnoredAdds, revert, tail != null)) + { + break; + } + } + else if (line.Type == TextDiffLineType.Normal) + { + builder.Append("\n ").Append(line.Content); + } + else if (line.Type == TextDiffLineType.Added) + { + builder.Append("\n+").Append(line.Content); + } + else if (line.Type == TextDiffLineType.Deleted) + { + builder.Append("\n-").Append(line.Content); + } + } + + builder.Append("\n ").Append(tail); + builder.Append("\n"); + System.IO.File.WriteAllText(output, builder.ToString()); + } + + public void GeneratePatchFromSelectionSingleSide(Change change, string fileTreeGuid, TextDiffSelection selection, bool revert, bool isOldSide, string output) + { + var orgFile = !string.IsNullOrEmpty(change.OriginalPath) ? change.OriginalPath : change.Path; + + var builder = new StringBuilder(); + builder.Append("diff --git a/").Append(change.Path).Append(" b/").Append(change.Path).Append('\n'); + builder.Append("index 00000000...").Append(fileTreeGuid).Append(" 100644\n"); + builder.Append("--- a/").Append(orgFile).Append('\n'); + builder.Append("+++ b/").Append(change.Path); + + // If last line of selection is a change. Find one more line. + var tail = null as string; + if (selection.EndLine < Lines.Count) + { + var lastLine = Lines[selection.EndLine - 1]; + if (lastLine.Type == TextDiffLineType.Added || lastLine.Type == TextDiffLineType.Deleted) + { + for (int i = selection.EndLine; i < Lines.Count; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + break; + if (revert) + { + if (line.Type == TextDiffLineType.Normal || line.Type == TextDiffLineType.Added) + { + tail = line.Content; + break; + } + } + else + { + if (line.Type == TextDiffLineType.Normal || line.Type == TextDiffLineType.Deleted) + { + tail = line.Content; + break; + } + } + } + } + } + + // If the first line is not indicator. + if (Lines[selection.StartLine - 1].Type != TextDiffLineType.Indicator) + { + var indicator = selection.StartLine - 1; + for (int i = selection.StartLine - 2; i >= 0; i--) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + indicator = i; + break; + } + } + + var ignoreAdds = 0; + var ignoreRemoves = 0; + for (int i = 0; i < indicator; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added) + { + ignoreAdds++; + } + else if (line.Type == TextDiffLineType.Deleted) + { + ignoreRemoves++; + } + } + + for (int i = indicator; i < selection.StartLine - 1; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + ProcessIndicatorForPatchSingleSide(builder, line, i, selection.StartLine, selection.EndLine, ignoreRemoves, ignoreAdds, revert, isOldSide, tail != null); + } + else if (line.Type == TextDiffLineType.Added) + { + if (revert) + builder.Append("\n ").Append(line.Content); + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (!revert) + builder.Append("\n ").Append(line.Content); + } + else if (line.Type == TextDiffLineType.Normal) + { + builder.Append("\n ").Append(line.Content); + } + } + } + + // Outputs the selected lines. + for (int i = selection.StartLine - 1; i < selection.EndLine; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + if (!ProcessIndicatorForPatchSingleSide(builder, line, i, selection.StartLine, selection.EndLine, selection.IgnoredDeletes, selection.IgnoredAdds, revert, isOldSide, tail != null)) + { + break; + } + } + else if (line.Type == TextDiffLineType.Normal) + { + builder.Append("\n ").Append(line.Content); + } + else if (line.Type == TextDiffLineType.Added) + { + if (isOldSide) + { + if (revert) + { + builder.Append("\n ").Append(line.Content); + } + else + { + selection.IgnoredAdds++; + } + } + else + { + builder.Append("\n+").Append(line.Content); + } + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (isOldSide) + { + builder.Append("\n-").Append(line.Content); + } + else + { + if (!revert) + { + builder.Append("\n ").Append(line.Content); + } + else + { + selection.IgnoredDeletes++; + } + } + } + } + + builder.Append("\n ").Append(tail); + builder.Append("\n"); + System.IO.File.WriteAllText(output, builder.ToString()); + } + + private bool ProcessIndicatorForPatch(StringBuilder builder, TextDiffLine indicator, int idx, int start, int end, int ignoreRemoves, int ignoreAdds, bool revert, bool tailed) + { + var match = REG_INDICATOR().Match(indicator.Content); + var oldStart = int.Parse(match.Groups[1].Value); + var newStart = int.Parse(match.Groups[2].Value) + ignoreRemoves - ignoreAdds; + var oldCount = 0; + var newCount = 0; + for (int i = idx + 1; i < end; i++) + { + var test = Lines[i]; + if (test.Type == TextDiffLineType.Indicator) + break; + + if (test.Type == TextDiffLineType.Normal) + { + oldCount++; + newCount++; + } + else if (test.Type == TextDiffLineType.Added) + { + if (i < start - 1) + { + if (revert) + { + newCount++; + oldCount++; + } + } + else + { + newCount++; + } + + if (i == end - 1 && tailed) + { + newCount++; + oldCount++; + } + } + else if (test.Type == TextDiffLineType.Deleted) + { + if (i < start - 1) + { + if (!revert) + { + newCount++; + oldCount++; + } + } + else + { + oldCount++; + } + + if (i == end - 1 && tailed) + { + newCount++; + oldCount++; + } + } + } + + if (oldCount == 0 && newCount == 0) + return false; + + builder.Append($"\n@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); + return true; + } + + private bool ProcessIndicatorForPatchSingleSide(StringBuilder builder, TextDiffLine indicator, int idx, int start, int end, int ignoreRemoves, int ignoreAdds, bool revert, bool isOldSide, bool tailed) + { + var match = REG_INDICATOR().Match(indicator.Content); + var oldStart = int.Parse(match.Groups[1].Value); + var newStart = int.Parse(match.Groups[2].Value) + ignoreRemoves - ignoreAdds; + var oldCount = 0; + var newCount = 0; + for (int i = idx + 1; i < end; i++) + { + var test = Lines[i]; + if (test.Type == TextDiffLineType.Indicator) + break; + + if (test.Type == TextDiffLineType.Normal) + { + oldCount++; + newCount++; + } + else if (test.Type == TextDiffLineType.Added) + { + if (i < start - 1) + { + if (revert) + { + newCount++; + oldCount++; + } + } + else + { + if (isOldSide) + { + if (revert) + { + newCount++; + oldCount++; + } + } + else + { + newCount++; + } + } + + if (i == end - 1 && tailed) + { + newCount++; + oldCount++; + } + } + else if (test.Type == TextDiffLineType.Deleted) + { + if (i < start - 1) + { + if (!revert) + { + newCount++; + oldCount++; + } + } + else + { + if (isOldSide) + { + oldCount++; + } + else + { + if (!revert) + { + newCount++; + oldCount++; + } + } + } + + if (i == end - 1 && tailed) + { + newCount++; + oldCount++; + } + } + } + + if (oldCount == 0 && newCount == 0) + return false; + + builder.Append($"\n@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); + return true; + } + + [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] + private static partial Regex REG_INDICATOR(); + } + + public class LFSDiff + { + public LFSObject Old { get; set; } = new LFSObject(); + public LFSObject New { get; set; } = new LFSObject(); + } + + public class BinaryDiff + { + public long OldSize { get; set; } = 0; + public long NewSize { get; set; } = 0; + } + + public class ImageDiff + { + public Bitmap Old { get; set; } = null; + public Bitmap New { get; set; } = null; + + public long OldFileSize { get; set; } = 0; + public long NewFileSize { get; set; } = 0; + + public string OldImageSize => Old != null ? $"{Old.PixelSize.Width} x {Old.PixelSize.Height}" : "0 x 0"; + public string NewImageSize => New != null ? $"{New.PixelSize.Width} x {New.PixelSize.Height}" : "0 x 0"; + } + + public class NoOrEOLChange + { + } + + public class FileModeDiff + { + public string Old { get; set; } = string.Empty; + public string New { get; set; } = string.Empty; + } + + public class SubmoduleDiff + { + public RevisionSubmodule Old { get; set; } = null; + public RevisionSubmodule New { get; set; } = null; + } + + public class DiffResult + { + public bool IsBinary { get; set; } = false; + public bool IsLFS { get; set; } = false; + public string OldHash { get; set; } = string.Empty; + public string NewHash { get; set; } = string.Empty; + public string OldMode { get; set; } = string.Empty; + public string NewMode { get; set; } = string.Empty; + public TextDiff TextDiff { get; set; } = null; + public LFSDiff LFSDiff { get; set; } = null; + + public string FileModeChange + { + get + { + if (string.IsNullOrEmpty(OldMode) && string.IsNullOrEmpty(NewMode)) + return string.Empty; + + var oldDisplay = string.IsNullOrEmpty(OldMode) ? "0" : OldMode; + var newDisplay = string.IsNullOrEmpty(NewMode) ? "0" : NewMode; + + return $"{oldDisplay} → {newDisplay}"; + } + } + } +} diff --git a/src/Models/DirtyState.cs b/src/Models/DirtyState.cs new file mode 100644 index 00000000..2b9d898d --- /dev/null +++ b/src/Models/DirtyState.cs @@ -0,0 +1,12 @@ +using System; + +namespace SourceGit.Models +{ + [Flags] + public enum DirtyState + { + None = 0, + HasLocalChanges = 1 << 0, + HasPendingPullOrPush = 1 << 1, + } +} diff --git a/src/Models/Exception.cs b/src/Models/Exception.cs deleted file mode 100644 index 99d5b92b..00000000 --- a/src/Models/Exception.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace SourceGit.Models { - - /// - /// 错误通知 - /// - public static class Exception { - public static Action Handler { get; set; } - - public static void Raise(string error) { - Handler?.Invoke(error); - } - } -} diff --git a/src/Models/ExecutableFinder.cs b/src/Models/ExecutableFinder.cs deleted file mode 100644 index 7f36522e..00000000 --- a/src/Models/ExecutableFinder.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text; - -namespace SourceGit.Models { - /// - /// 用于在PATH中检测可执行文件 - /// - public class ExecutableFinder { - - // https://docs.microsoft.com/en-us/windows/desktop/api/shlwapi/nf-shlwapi-pathfindonpathw - // https://www.pinvoke.net/default.aspx/shlwapi.PathFindOnPath - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, SetLastError = false)] - private static extern bool PathFindOnPath([In, Out] StringBuilder pszFile, [In] string[] ppszOtherDirs); - - /// - /// 从PATH中找到可执行文件路径 - /// - /// - /// - public static string Find(string exec) { - var builder = new StringBuilder(exec, 259); - var rs = PathFindOnPath(builder, null); - return rs ? builder.ToString() : null; - } - } -} diff --git a/src/Models/ExternalMerger.cs b/src/Models/ExternalMerger.cs new file mode 100644 index 00000000..fe67ad6a --- /dev/null +++ b/src/Models/ExternalMerger.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace SourceGit.Models +{ + public class ExternalMerger + { + public int Type { get; set; } + public string Icon { get; set; } + public string Name { get; set; } + public string Exec { get; set; } + public string Cmd { get; set; } + public string DiffCmd { get; set; } + + public Bitmap IconImage + { + get + { + var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/ExternalToolIcons/{Icon}.png", UriKind.RelativeOrAbsolute)); + return new Bitmap(icon); + } + } + + public static readonly List Supported; + + static ExternalMerger() + { + if (OperatingSystem.IsWindows()) + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + new ExternalMerger(1, "vscode", "Visual Studio Code", "Code.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(2, "vscode_insiders", "Visual Studio Code - Insiders", "Code - Insiders.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(3, "vs", "Visual Studio", "vsDiffMerge.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\" /m", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(4, "tortoise_merge", "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\""), + new ExternalMerger(5, "kdiff3", "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(6, "beyond_compare", "Beyond Compare", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(7, "win_merge", "WinMerge", "WinMergeU.exe", "\"$MERGED\"", "-u -e -sw \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(8, "codium", "VSCodium", "VSCodium.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(9, "p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(10, "plastic_merge", "Plastic SCM", "mergetool.exe", "-s=\"$REMOTE\" -b=\"$BASE\" -d=\"$LOCAL\" -r=\"$MERGED\" --automatic", "-s=\"$LOCAL\" -d=\"$REMOTE\""), + new ExternalMerger(11, "meld", "Meld", "Meld.exe", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + }; + } + else if (OperatingSystem.IsMacOS()) + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + new ExternalMerger(1, "xcode", "FileMerge", "/usr/bin/opendiff", "\"$BASE\" \"$LOCAL\" \"$REMOTE\" -ancestor \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(2, "vscode", "Visual Studio Code", "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(3, "vscode_insiders", "Visual Studio Code - Insiders", "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(4, "kdiff3", "KDiff3", "/Applications/kdiff3.app/Contents/MacOS/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(5, "beyond_compare", "Beyond Compare", "/Applications/Beyond Compare.app/Contents/MacOS/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(6, "codium", "VSCodium", "/Applications/VSCodium.app/Contents/Resources/app/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(7, "p4merge", "P4Merge", "/Applications/p4merge.app/Contents/Resources/launchp4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + }; + } + else if (OperatingSystem.IsLinux()) + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + new ExternalMerger(1, "vscode", "Visual Studio Code", "/usr/share/code/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(2, "vscode_insiders", "Visual Studio Code - Insiders", "/usr/share/code-insiders/code-insiders", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(3, "kdiff3", "KDiff3", "/usr/bin/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(4, "beyond_compare", "Beyond Compare", "/usr/bin/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(5, "meld", "Meld", "/usr/bin/meld", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(6, "codium", "VSCodium", "/usr/share/codium/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(7, "p4merge", "P4Merge", "/usr/local/bin/p4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + }; + } + else + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + }; + } + } + + public ExternalMerger(int type, string icon, string name, string exec, string cmd, string diffCmd) + { + Type = type; + Icon = icon; + Name = name; + Exec = exec; + Cmd = cmd; + DiffCmd = diffCmd; + } + + public string[] GetPatterns() + { + if (OperatingSystem.IsWindows()) + { + return Exec.Split(';'); + } + else + { + var patterns = new List(); + var choices = Exec.Split(';', StringSplitOptions.RemoveEmptyEntries); + foreach (var c in choices) + { + patterns.Add(Path.GetFileName(c)); + } + return patterns.ToArray(); + } + } + } +} diff --git a/src/Models/ExternalTool.cs b/src/Models/ExternalTool.cs new file mode 100644 index 00000000..103e91bc --- /dev/null +++ b/src/Models/ExternalTool.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace SourceGit.Models +{ + public class ExternalTool + { + public string Name { get; private set; } + public Bitmap IconImage { get; private set; } = null; + + public ExternalTool(string name, string icon, string execFile, Func execArgsGenerator = null) + { + Name = name; + _execFile = execFile; + _execArgsGenerator = execArgsGenerator ?? (repo => $"\"{repo}\""); + + try + { + var asset = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/ExternalToolIcons/{icon}.png", + UriKind.RelativeOrAbsolute)); + IconImage = new Bitmap(asset); + } + catch + { + // ignore + } + } + + public void Open(string repo) + { + Process.Start(new ProcessStartInfo() + { + WorkingDirectory = repo, + FileName = _execFile, + Arguments = _execArgsGenerator.Invoke(repo), + UseShellExecute = false, + }); + } + + private string _execFile = string.Empty; + private Func _execArgsGenerator = null; + } + + public class JetBrainsState + { + [JsonPropertyName("version")] + public int Version { get; set; } = 0; + [JsonPropertyName("appVersion")] + public string AppVersion { get; set; } = string.Empty; + [JsonPropertyName("tools")] + public List Tools { get; set; } = new List(); + } + + public class JetBrainsTool + { + [JsonPropertyName("channelId")] + public string ChannelId { get; set; } + [JsonPropertyName("toolId")] + public string ToolId { get; set; } + [JsonPropertyName("productCode")] + public string ProductCode { get; set; } + [JsonPropertyName("tag")] + public string Tag { get; set; } + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } + [JsonPropertyName("displayVersion")] + public string DisplayVersion { get; set; } + [JsonPropertyName("buildNumber")] + public string BuildNumber { get; set; } + [JsonPropertyName("installLocation")] + public string InstallLocation { get; set; } + [JsonPropertyName("launchCommand")] + public string LaunchCommand { get; set; } + } + + public class ExternalToolPaths + { + [JsonPropertyName("tools")] + public Dictionary Tools { get; set; } = new Dictionary(); + } + + public class ExternalToolsFinder + { + public List Founded + { + get; + private set; + } = new List(); + + public ExternalToolsFinder() + { + var customPathsConfig = Path.Combine(Native.OS.DataDir, "external_editors.json"); + try + { + if (File.Exists(customPathsConfig)) + _customPaths = JsonSerializer.Deserialize(File.ReadAllText(customPathsConfig), JsonCodeGen.Default.ExternalToolPaths); + } + catch + { + // Ignore + } + + if (_customPaths == null) + _customPaths = new ExternalToolPaths(); + } + + public void TryAdd(string name, string icon, Func finder, Func execArgsGenerator = null) + { + if (_customPaths.Tools.TryGetValue(name, out var customPath) && File.Exists(customPath)) + { + Founded.Add(new ExternalTool(name, icon, customPath, execArgsGenerator)); + } + else + { + var path = finder(); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + Founded.Add(new ExternalTool(name, icon, path, execArgsGenerator)); + } + } + + public void VSCode(Func platformFinder) + { + TryAdd("Visual Studio Code", "vscode", platformFinder); + } + + public void VSCodeInsiders(Func platformFinder) + { + TryAdd("Visual Studio Code - Insiders", "vscode_insiders", platformFinder); + } + + public void VSCodium(Func platformFinder) + { + TryAdd("VSCodium", "codium", platformFinder); + } + + public void Fleet(Func platformFinder) + { + TryAdd("Fleet", "fleet", platformFinder); + } + + public void SublimeText(Func platformFinder) + { + TryAdd("Sublime Text", "sublime_text", platformFinder); + } + + public void Zed(Func platformFinder) + { + TryAdd("Zed", "zed", platformFinder); + } + + public void FindJetBrainsFromToolbox(Func platformFinder) + { + var exclude = new List { "fleet", "dotmemory", "dottrace", "resharper-u", "androidstudio" }; + var supported_icons = new List { "CL", "DB", "DL", "DS", "GO", "JB", "PC", "PS", "PY", "QA", "QD", "RD", "RM", "RR", "WRS", "WS" }; + var state = Path.Combine(platformFinder(), "state.json"); + if (File.Exists(state)) + { + var stateData = JsonSerializer.Deserialize(File.ReadAllText(state), JsonCodeGen.Default.JetBrainsState); + foreach (var tool in stateData.Tools) + { + if (exclude.Contains(tool.ToolId.ToLowerInvariant())) + continue; + + Founded.Add(new ExternalTool( + $"{tool.DisplayName} {tool.DisplayVersion}", + supported_icons.Contains(tool.ProductCode) ? $"JetBrains/{tool.ProductCode}" : "JetBrains/JB", + Path.Combine(tool.InstallLocation, tool.LaunchCommand))); + } + } + } + + private ExternalToolPaths _customPaths = null; + } +} diff --git a/src/Models/FileSizeChange.cs b/src/Models/FileSizeChange.cs deleted file mode 100644 index cced7392..00000000 --- a/src/Models/FileSizeChange.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SourceGit.Models { - /// - /// 文件大小变化 - /// - public class FileSizeChange { - public long OldSize = 0; - public long NewSize = 0; - } -} diff --git a/src/Models/Filter.cs b/src/Models/Filter.cs new file mode 100644 index 00000000..af4569fa --- /dev/null +++ b/src/Models/Filter.cs @@ -0,0 +1,60 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public enum FilterType + { + LocalBranch = 0, + LocalBranchFolder, + RemoteBranch, + RemoteBranchFolder, + Tag, + } + + public enum FilterMode + { + None = 0, + Included, + Excluded, + } + + public class Filter : ObservableObject + { + public string Pattern + { + get => _pattern; + set => SetProperty(ref _pattern, value); + } + + public FilterType Type + { + get; + set; + } = FilterType.LocalBranch; + + public FilterMode Mode + { + get => _mode; + set => SetProperty(ref _mode, value); + } + + public bool IsBranch + { + get => Type != FilterType.Tag; + } + + public Filter() + { + } + + public Filter(string pattern, FilterType type, FilterMode mode) + { + _pattern = pattern; + _mode = mode; + Type = type; + } + + private string _pattern = string.Empty; + private FilterMode _mode = FilterMode.None; + } +} diff --git a/src/Models/GPGFormat.cs b/src/Models/GPGFormat.cs new file mode 100644 index 00000000..0ba4e9e2 --- /dev/null +++ b/src/Models/GPGFormat.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class GPGFormat(string name, string value, string desc, string program, bool needFindProgram) + { + public string Name { get; set; } = name; + public string Value { get; set; } = value; + public string Desc { get; set; } = desc; + public string Program { get; set; } = program; + public bool NeedFindProgram { get; set; } = needFindProgram; + + public static readonly List Supported = [ + new GPGFormat("OPENPGP", "openpgp", "DEFAULT", "gpg", true), + new GPGFormat("X.509", "x509", "", "gpgsm", true), + new GPGFormat("SSH", "ssh", "Requires Git >= 2.34.0", "ssh-keygen", false), + ]; + } +} diff --git a/src/Models/GitFlow.cs b/src/Models/GitFlow.cs index 07c787df..5d26072b 100644 --- a/src/Models/GitFlow.cs +++ b/src/Models/GitFlow.cs @@ -1,36 +1,46 @@ -namespace SourceGit.Models { - /// - /// GitFlow的分支类型 - /// - public enum GitFlowBranchType { - None, +namespace SourceGit.Models +{ + public enum GitFlowBranchType + { + None = 0, Feature, Release, Hotfix, } - /// - /// GitFlow相关设置 - /// - public class GitFlow { - public string Feature { get; set; } - public string Release { get; set; } - public string Hotfix { get; set; } + public class GitFlow + { + public string Master { get; set; } = string.Empty; + public string Develop { get; set; } = string.Empty; + public string FeaturePrefix { get; set; } = string.Empty; + public string ReleasePrefix { get; set; } = string.Empty; + public string HotfixPrefix { get; set; } = string.Empty; - public bool IsEnabled { - get { - return !string.IsNullOrEmpty(Feature) - && !string.IsNullOrEmpty(Release) - && !string.IsNullOrEmpty(Hotfix); + public bool IsValid + { + get + { + return !string.IsNullOrEmpty(Master) && + !string.IsNullOrEmpty(Develop) && + !string.IsNullOrEmpty(FeaturePrefix) && + !string.IsNullOrEmpty(ReleasePrefix) && + !string.IsNullOrEmpty(HotfixPrefix); } } - public GitFlowBranchType GetBranchType(string name) { - if (!IsEnabled) return GitFlowBranchType.None; - if (name.StartsWith(Feature)) return GitFlowBranchType.Feature; - if (name.StartsWith(Release)) return GitFlowBranchType.Release; - if (name.StartsWith(Hotfix)) return GitFlowBranchType.Hotfix; - return GitFlowBranchType.None; + public string GetPrefix(GitFlowBranchType type) + { + switch (type) + { + case GitFlowBranchType.Feature: + return FeaturePrefix; + case GitFlowBranchType.Release: + return ReleasePrefix; + case GitFlowBranchType.Hotfix: + return HotfixPrefix; + default: + return string.Empty; + } } } } diff --git a/src/Models/GitVersions.cs b/src/Models/GitVersions.cs new file mode 100644 index 00000000..8aae63a3 --- /dev/null +++ b/src/Models/GitVersions.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Models +{ + public static class GitVersions + { + /// + /// The minimal version of Git that required by this app. + /// + public static readonly System.Version MINIMAL = new(2, 25, 1); + + /// + /// The minimal version of Git that supports the `stash push` command with the `--pathspec-from-file` option. + /// + public static readonly System.Version STASH_PUSH_WITH_PATHSPECFILE = new(2, 26, 0); + + /// + /// The minimal version of Git that supports the `stash push` command with the `--staged` option. + /// + public static readonly System.Version STASH_PUSH_ONLY_STAGED = new(2, 35, 0); + } +} diff --git a/src/Models/ICommandLog.cs b/src/Models/ICommandLog.cs new file mode 100644 index 00000000..34ec7031 --- /dev/null +++ b/src/Models/ICommandLog.cs @@ -0,0 +1,7 @@ +namespace SourceGit.Models +{ + public interface ICommandLog + { + void AppendLine(string line); + } +} diff --git a/src/Models/IRepository.cs b/src/Models/IRepository.cs new file mode 100644 index 00000000..2fc7c612 --- /dev/null +++ b/src/Models/IRepository.cs @@ -0,0 +1,15 @@ +namespace SourceGit.Models +{ + public interface IRepository + { + bool MayHaveSubmodules(); + + void RefreshBranches(); + void RefreshWorktrees(); + void RefreshTags(); + void RefreshCommits(); + void RefreshSubmodules(); + void RefreshWorkingCopyChanges(); + void RefreshStashes(); + } +} diff --git a/src/Models/ImageDecoder.cs b/src/Models/ImageDecoder.cs new file mode 100644 index 00000000..6fe0f428 --- /dev/null +++ b/src/Models/ImageDecoder.cs @@ -0,0 +1,10 @@ +namespace SourceGit.Models +{ + public enum ImageDecoder + { + None = 0, + Builtin, + Pfim, + Tiff, + } +} diff --git a/src/Models/InlineElement.cs b/src/Models/InlineElement.cs new file mode 100644 index 00000000..ea7bcee8 --- /dev/null +++ b/src/Models/InlineElement.cs @@ -0,0 +1,37 @@ +namespace SourceGit.Models +{ + public enum InlineElementType + { + Keyword = 0, + Link, + CommitSHA, + Code, + } + + public class InlineElement + { + public InlineElementType Type { get; } + public int Start { get; } + public int Length { get; } + public string Link { get; } + + public InlineElement(InlineElementType type, int start, int length, string link) + { + Type = type; + Start = start; + Length = length; + Link = link; + } + + public bool IsIntersecting(int start, int length) + { + if (start == Start) + return true; + + if (start < Start) + return start + length > Start; + + return start < Start + Length; + } + } +} diff --git a/src/Models/InlineElementCollector.cs b/src/Models/InlineElementCollector.cs new file mode 100644 index 00000000..d81aaf8d --- /dev/null +++ b/src/Models/InlineElementCollector.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class InlineElementCollector + { + public int Count => _implementation.Count; + public InlineElement this[int index] => _implementation[index]; + + public InlineElement Intersect(int start, int length) + { + foreach (var elem in _implementation) + { + if (elem.IsIntersecting(start, length)) + return elem; + } + + return null; + } + + public void Add(InlineElement element) + { + _implementation.Add(element); + } + + public void Sort() + { + _implementation.Sort((l, r) => l.Start.CompareTo(r.Start)); + } + + public void Clear() + { + _implementation.Clear(); + } + + private readonly List _implementation = []; + } +} diff --git a/src/Models/InstalledFonts.cs b/src/Models/InstalledFonts.cs deleted file mode 100644 index 7855b493..00000000 --- a/src/Models/InstalledFonts.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Windows.Markup; -using System.Windows.Media; - -namespace SourceGit.Models { - public class InstalledFont { - public string Name { get; set; } - public int FamilyIndex { get; set; } - - public static List GetFonts { - get { - var fontList = new List(); - - var fontCollection = Fonts.SystemFontFamilies; - var familyCount = fontCollection.Count; - - for (int i = 0; i < familyCount; i++) { - var fontFamily = fontCollection.ElementAt(i); - var familyNames = fontFamily.FamilyNames; - - if (!familyNames.TryGetValue(XmlLanguage.GetLanguage(CultureInfo.CurrentCulture.Name), out var name)) { - if (!familyNames.TryGetValue(XmlLanguage.GetLanguage("en-us"), out name)) { - name = familyNames.FirstOrDefault().Value; - } - } - - fontList.Add(new InstalledFont() { - Name = name, - FamilyIndex = i - }); - } - - fontList.Sort((p, n) => string.Compare(p.Name, n.Name, StringComparison.Ordinal)); - - return fontList; - } - } - } -} diff --git a/src/Models/InteractiveRebase.cs b/src/Models/InteractiveRebase.cs new file mode 100644 index 00000000..d1710d4a --- /dev/null +++ b/src/Models/InteractiveRebase.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public enum InteractiveRebaseAction + { + Pick, + Edit, + Reword, + Squash, + Fixup, + Drop, + } + + public class InteractiveCommit + { + public Commit Commit { get; set; } = new Commit(); + public string Message { get; set; } = string.Empty; + } + + public class InteractiveRebaseJob + { + public string SHA { get; set; } = string.Empty; + public InteractiveRebaseAction Action { get; set; } = InteractiveRebaseAction.Pick; + public string Message { get; set; } = string.Empty; + } + + public class InteractiveRebaseJobCollection + { + public string OrigHead { get; set; } = string.Empty; + public string Onto { get; set; } = string.Empty; + public List Jobs { get; set; } = new List(); + } +} diff --git a/src/Models/IpcChannel.cs b/src/Models/IpcChannel.cs new file mode 100644 index 00000000..c2a6c6c7 --- /dev/null +++ b/src/Models/IpcChannel.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Models +{ + public class IpcChannel : IDisposable + { + public bool IsFirstInstance + { + get => _isFirstInstance; + } + + public event Action MessageReceived; + + public IpcChannel() + { + try + { + _singletonLock = File.Open(Path.Combine(Native.OS.DataDir, "process.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + _isFirstInstance = true; + _server = new NamedPipeServerStream( + "SourceGitIPCChannel" + Environment.UserName, + PipeDirection.In, + -1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + _cancellationTokenSource = new CancellationTokenSource(); + Task.Run(StartServer); + } + catch + { + _isFirstInstance = false; + } + } + + public void SendToFirstInstance(string cmd) + { + try + { + using (var client = new NamedPipeClientStream(".", "SourceGitIPCChannel" + Environment.UserName, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly)) + { + client.Connect(1000); + if (!client.IsConnected) + return; + + using (var writer = new StreamWriter(client)) + { + writer.WriteLine(cmd); + writer.Flush(); + } + + if (OperatingSystem.IsWindows()) + client.WaitForPipeDrain(); + else + Thread.Sleep(1000); + } + } + catch + { + // IGNORE + } + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _singletonLock?.Dispose(); + } + + private async void StartServer() + { + using var reader = new StreamReader(_server); + + while (!_cancellationTokenSource.IsCancellationRequested) + { + try + { + await _server.WaitForConnectionAsync(_cancellationTokenSource.Token); + + if (!_cancellationTokenSource.IsCancellationRequested) + { + var line = await reader.ReadToEndAsync(_cancellationTokenSource.Token); + MessageReceived?.Invoke(line?.Trim()); + } + + _server.Disconnect(); + } + catch + { + if (!_cancellationTokenSource.IsCancellationRequested && _server.IsConnected) + _server.Disconnect(); + } + } + } + + private FileStream _singletonLock = null; + private bool _isFirstInstance = false; + private NamedPipeServerStream _server = null; + private CancellationTokenSource _cancellationTokenSource = null; + } +} diff --git a/src/Models/Issue.cs b/src/Models/Issue.cs deleted file mode 100644 index 52e531d6..00000000 --- a/src/Models/Issue.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Reflection; -using System.IO; -using System.Text; -using System.Diagnostics; - -namespace SourceGit.Models { - /// - /// 崩溃日志生成 - /// - public class Issue { - public static void Create(System.Exception e) { - var builder = new StringBuilder(); - builder.Append("Crash: "); - builder.Append(e.Message); - builder.Append("\n\n"); - builder.Append("----------------------------\n"); - builder.Append($"Windows OS: {Environment.OSVersion}\n"); - builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}"); - builder.Append($"Platform: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"); - builder.Append($"Source: {e.Source}"); - builder.Append($"---------------------------\n\n"); - builder.Append(e.StackTrace); - - var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); - var file = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); - file = Path.Combine(file, $"sourcegit_crash_{time}.log"); - File.WriteAllText(file, builder.ToString()); - } - } -} diff --git a/src/Models/IssueTrackerRule.cs b/src/Models/IssueTrackerRule.cs new file mode 100644 index 00000000..40c84b9e --- /dev/null +++ b/src/Models/IssueTrackerRule.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public class IssueTrackerRule : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string RegexString + { + get => _regexString; + set + { + if (SetProperty(ref _regexString, value)) + { + try + { + _regex = null; + _regex = new Regex(_regexString, RegexOptions.Multiline); + } + catch + { + // Ignore errors. + } + } + + OnPropertyChanged(nameof(IsRegexValid)); + } + } + + public bool IsRegexValid + { + get => _regex != null; + } + + public string URLTemplate + { + get => _urlTemplate; + set => SetProperty(ref _urlTemplate, value); + } + + public void Matches(InlineElementCollector outs, string message) + { + if (_regex == null || string.IsNullOrEmpty(_urlTemplate)) + return; + + var matches = _regex.Matches(message); + for (var i = 0; i < matches.Count; i++) + { + var match = matches[i]; + if (!match.Success) + continue; + + var start = match.Index; + var len = match.Length; + if (outs.Intersect(start, len) != null) + continue; + + var link = _urlTemplate; + for (var j = 1; j < match.Groups.Count; j++) + { + var group = match.Groups[j]; + if (group.Success) + link = link.Replace($"${j}", group.Value); + } + + outs.Add(new InlineElement(InlineElementType.Link, start, len, link)); + } + } + + private string _name; + private string _regexString; + private string _urlTemplate; + private Regex _regex = null; + } +} diff --git a/src/Models/LFSChange.cs b/src/Models/LFSChange.cs deleted file mode 100644 index e77f78ac..00000000 --- a/src/Models/LFSChange.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SourceGit.Models { - /// - /// LFS对象变更 - /// - public class LFSChange { - public LFSObject Old; - public LFSObject New; - public bool IsValid => Old != null || New != null; - } -} diff --git a/src/Models/LFSLock.cs b/src/Models/LFSLock.cs new file mode 100644 index 00000000..0a328cfb --- /dev/null +++ b/src/Models/LFSLock.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public class LFSLock + { + public string File { get; set; } = string.Empty; + public string User { get; set; } = string.Empty; + public long ID { get; set; } = 0; + } +} diff --git a/src/Models/LFSObject.cs b/src/Models/LFSObject.cs index 4bbc08e6..8bc2dda2 100644 --- a/src/Models/LFSObject.cs +++ b/src/Models/LFSObject.cs @@ -1,9 +1,22 @@ -namespace SourceGit.Models { - /// - /// LFS对象 - /// - public class LFSObject { - public string OID { get; set; } - public long Size { get; set; } +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + public partial class LFSObject + { + [GeneratedRegex(@"^version https://git-lfs.github.com/spec/v\d+\r?\noid sha256:([0-9a-f]+)\r?\nsize (\d+)[\r\n]*$")] + private static partial Regex REG_FORMAT(); + + public string Oid { get; set; } = string.Empty; + public long Size { get; set; } = 0; + + public static LFSObject Parse(string content) + { + var match = REG_FORMAT().Match(content); + if (match.Success) + return new() { Oid = match.Groups[1].Value, Size = long.Parse(match.Groups[2].Value) }; + + return null; + } } } diff --git a/src/Models/Locale.cs b/src/Models/Locale.cs deleted file mode 100644 index d3abb25c..00000000 --- a/src/Models/Locale.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace SourceGit.Models { - - /// - /// 支持的语言 - /// - public class Locale { - public string Name { get; set; } - public string Resource { get; set; } - - public static List Supported = new List() { - new Locale("English", "en_US"), - new Locale("简体中文", "zh_CN"), - }; - - public Locale(string name, string res) { - Name = name; - Resource = res; - } - - public static void Change() { - var lang = Preference.Instance.General.Locale; - foreach (var rs in App.Current.Resources.MergedDictionaries) { - if (rs.Source != null && rs.Source.OriginalString.StartsWith("pack://application:,,,/Resources/Locales/", StringComparison.Ordinal)) { - rs.Source = new Uri($"pack://application:,,,/Resources/Locales/{lang}.xaml", UriKind.Absolute); - break; - } - } - } - } -} diff --git a/src/Models/Locales.cs b/src/Models/Locales.cs new file mode 100644 index 00000000..1788a9b2 --- /dev/null +++ b/src/Models/Locales.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class Locale + { + public string Name { get; set; } + public string Key { get; set; } + + public static readonly List Supported = new List() { + new Locale("Deutsch", "de_DE"), + new Locale("English", "en_US"), + new Locale("Español", "es_ES"), + new Locale("Français", "fr_FR"), + new Locale("Italiano", "it_IT"), + new Locale("Português (Brasil)", "pt_BR"), + new Locale("Українська", "uk_UA"), + new Locale("Русский", "ru_RU"), + new Locale("简体中文", "zh_CN"), + new Locale("繁體中文", "zh_TW"), + new Locale("日本語", "ja_JP"), + new Locale("தமிழ் (Tamil)", "ta_IN"), + }; + + public Locale(string name, string key) + { + Name = name; + Key = key; + } + } +} diff --git a/src/Models/MergeMode.cs b/src/Models/MergeMode.cs new file mode 100644 index 00000000..5dc70030 --- /dev/null +++ b/src/Models/MergeMode.cs @@ -0,0 +1,25 @@ +namespace SourceGit.Models +{ + public class MergeMode + { + public static readonly MergeMode[] Supported = + [ + new MergeMode("Default", "Fast-forward if possible", ""), + new MergeMode("Fast-forward", "Refuse to merge when fast-forward is not possible", "--ff-only"), + new MergeMode("No Fast-forward", "Always create a merge commit", "--no-ff"), + new MergeMode("Squash", "Squash merge", "--squash"), + new MergeMode("Don't commit", "Merge without commit", "--no-ff --no-commit"), + ]; + + public string Name { get; set; } + public string Desc { get; set; } + public string Arg { get; set; } + + public MergeMode(string n, string d, string a) + { + Name = n; + Desc = d; + Arg = a; + } + } +} diff --git a/src/Models/MergeOption.cs b/src/Models/MergeOption.cs deleted file mode 100644 index 778c1a12..00000000 --- a/src/Models/MergeOption.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; - -namespace SourceGit.Models { - /// - /// 合并方式 - /// - public class MergeOption { - public string Name { get; set; } - public string Desc { get; set; } - public string Arg { get; set; } - - public static List Supported = new List() { - new MergeOption("Default", "Fast-forward if possible", ""), - new MergeOption("No Fast-forward", "Always create a merge commit", "--no-ff"), - new MergeOption("Squash", "Use '--squash'", "--squash"), - new MergeOption("Don't commit", "Merge without commit", "--no-commit"), - }; - - public MergeOption(string n, string d, string a) { - Name = n; - Desc = d; - Arg = a; - } - } -} diff --git a/src/Models/MergeStrategy.cs b/src/Models/MergeStrategy.cs new file mode 100644 index 00000000..ab1d446b --- /dev/null +++ b/src/Models/MergeStrategy.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class MergeStrategy + { + public string Name { get; internal set; } + public string Desc { get; internal set; } + public string Arg { get; internal set; } + + public static List ForMultiple { get; private set; } = [ + new MergeStrategy("Default", "Let Git automatically select a strategy", string.Empty), + new MergeStrategy("Octopus", "Attempt merging multiple heads", "octopus"), + new MergeStrategy("Ours", "Record the merge without modifying the tree", "ours"), + ]; + + public MergeStrategy(string n, string d, string a) + { + Name = n; + Desc = d; + Arg = a; + } + } +} diff --git a/src/Models/MergeTool.cs b/src/Models/MergeTool.cs deleted file mode 100644 index 1f991023..00000000 --- a/src/Models/MergeTool.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Microsoft.Win32; -using System; -using System.Collections.Generic; -using System.IO; - -namespace SourceGit.Models { - - /// - /// 外部合并工具 - /// - public class MergeTool { - public int Type { get; set; } - public string Name { get; set; } - public string Exec { get; set; } - public string Cmd { get; set; } - public string DiffCmd { get; set; } - public Func Finder { get; set; } - - public static List Supported = new List() { - new MergeTool(0, "--", "", "", "", () => ""), - new MergeTool(1, "Visual Studio Code", "Code.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\"", FindVSCode), - new MergeTool(2, "Visual Studio 2017/2019", "vsDiffMerge.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\" /m", "\"$LOCAL\" \"$REMOTE\"", FindVSMerge), - new MergeTool(3, "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\"", FindTortoiseMerge), - new MergeTool(4, "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\"", FindKDiff3), - new MergeTool(5, "Beyond Compare 4", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\"", FindBCompare), - new MergeTool(6, "WinMerge", "WinMergeU.exe", "-u -e \"$REMOTE\" \"$LOCAL\" \"$MERGED\"", "-u -e \"$LOCAL\" \"$REMOTE\"", FindWinMerge), - }; - - public MergeTool(int type, string name, string exec, string cmd, string diffCmd, Func finder) { - Type = type; - Name = name; - Exec = exec; - Cmd = cmd; - DiffCmd = diffCmd; - Finder = finder; - } - - private static string FindVSCode() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{C26E74D1-022E-4238-8B9D-1E7564A36CC9}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{1287CAD5-7C8D-410D-88B9-0D1EE4A83FF2}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{F8A2A208-72B3-4D61-95FC-8A65D340689B}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - return ""; - } - - private static string FindVSMerge() { - var dir = @"C:\Program Files (x86)\Microsoft Visual Studio"; - if (Directory.Exists($"{dir}\\2019")) { - dir += "\\2019"; - } else if (Directory.Exists($"{dir}\\2017")) { - dir += "\\2017"; - } else { - return ""; - } - - if (Directory.Exists($"{dir}\\Community")) { - dir += "\\Community"; - } else if (Directory.Exists($"{dir}\\Enterprise")) { - dir += "\\Enterprise"; - } else if (Directory.Exists($"{dir}\\Professional")) { - dir += "\\Professional"; - } else { - return ""; - } - - return $"{dir}\\Common7\\IDE\\CommonExtensions\\Microsoft\\TeamFoundation\\Team Explorer\\vsDiffMerge.exe"; - } - - private static string FindTortoiseMerge() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var tortoise = root.OpenSubKey("SOFTWARE\\TortoiseGit") ?? root.OpenSubKey("SOFTWARE\\TortoiseSVN"); - if (tortoise == null) return ""; - return tortoise.GetValue("TMergePath") as string; - } - - private static string FindKDiff3() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var kdiff = root.OpenSubKey(@"SOFTWARE\KDiff3\diff-ext"); - if (kdiff == null) return ""; - return kdiff.GetValue("diffcommand") as string; - } - - private static string FindBCompare() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var bc = root.OpenSubKey(@"SOFTWARE\Scooter Software\Beyond Compare"); - if (bc == null) return ""; - - var exec = bc.GetValue("ExePath") as string; - var dir = Path.GetDirectoryName(exec); - return $"{dir}\\BComp.exe"; - } - - private static string FindWinMerge() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.CurrentUser, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var merge = root.OpenSubKey(@"SOFTWARE\Thingamahoochie\WinMerge"); - if (merge == null) return ""; - - var exec = merge.GetValue("Executable") as string; - return exec; - } - } -} diff --git a/src/Models/Notification.cs b/src/Models/Notification.cs new file mode 100644 index 00000000..473947b0 --- /dev/null +++ b/src/Models/Notification.cs @@ -0,0 +1,8 @@ +namespace SourceGit.Models +{ + public class Notification + { + public bool IsError { get; set; } = false; + public string Message { get; set; } = string.Empty; + } +} diff --git a/src/Models/Null.cs b/src/Models/Null.cs new file mode 100644 index 00000000..e22ef8b3 --- /dev/null +++ b/src/Models/Null.cs @@ -0,0 +1,6 @@ +namespace SourceGit.Models +{ + public class Null + { + } +} diff --git a/src/Models/NumericSort.cs b/src/Models/NumericSort.cs new file mode 100644 index 00000000..baaf3da4 --- /dev/null +++ b/src/Models/NumericSort.cs @@ -0,0 +1,52 @@ +using System; + +namespace SourceGit.Models +{ + public static class NumericSort + { + public static int Compare(string s1, string s2) + { + int len1 = s1.Length; + int len2 = s2.Length; + + int marker1 = 0; + int marker2 = 0; + + while (marker1 < len1 && marker2 < len2) + { + char c1 = s1[marker1]; + char c2 = s2[marker2]; + + bool isDigit1 = char.IsDigit(c1); + bool isDigit2 = char.IsDigit(c2); + if (isDigit1 != isDigit2) + return c1.CompareTo(c2); + + int subLen1 = 1; + while (marker1 + subLen1 < len1 && char.IsDigit(s1[marker1 + subLen1]) == isDigit1) + subLen1++; + + int subLen2 = 1; + while (marker2 + subLen2 < len2 && char.IsDigit(s2[marker2 + subLen2]) == isDigit2) + subLen2++; + + string sub1 = s1.Substring(marker1, subLen1); + string sub2 = s2.Substring(marker2, subLen2); + + marker1 += subLen1; + marker2 += subLen2; + + int result; + if (isDigit1) + result = (subLen1 == subLen2) ? string.CompareOrdinal(sub1, sub2) : (subLen1 - subLen2); + else + result = string.Compare(sub1, sub2, StringComparison.OrdinalIgnoreCase); + + if (result != 0) + return result; + } + + return len1 - len2; + } + } +} diff --git a/src/Models/Object.cs b/src/Models/Object.cs index 01552f8c..119177ee 100644 --- a/src/Models/Object.cs +++ b/src/Models/Object.cs @@ -1,8 +1,7 @@ -namespace SourceGit.Models { - /// - /// 提交中元素类型 - /// - public enum ObjectType { +namespace SourceGit.Models +{ + public enum ObjectType + { None, Blob, Tree, @@ -10,10 +9,8 @@ namespace SourceGit.Models { Commit, } - /// - /// Git提交中的元素 - /// - public class Object { + public class Object + { public string SHA { get; set; } public ObjectType Type { get; set; } public string Path { get; set; } diff --git a/src/Models/OpenAI.cs b/src/Models/OpenAI.cs new file mode 100644 index 00000000..22fbcd51 --- /dev/null +++ b/src/Models/OpenAI.cs @@ -0,0 +1,235 @@ +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using Azure.AI.OpenAI; +using CommunityToolkit.Mvvm.ComponentModel; +using OpenAI; +using OpenAI.Chat; + +namespace SourceGit.Models +{ + public partial class OpenAIResponse + { + public OpenAIResponse(Action onUpdate) + { + _onUpdate = onUpdate; + } + + public void Append(string text) + { + var buffer = text; + + if (_thinkTail.Length > 0) + { + _thinkTail.Append(buffer); + buffer = _thinkTail.ToString(); + _thinkTail.Clear(); + } + + buffer = REG_COT().Replace(buffer, ""); + + var startIdx = buffer.IndexOf('<', StringComparison.Ordinal); + if (startIdx >= 0) + { + if (startIdx > 0) + OnReceive(buffer.Substring(0, startIdx)); + + var endIdx = buffer.IndexOf(">", startIdx + 1, StringComparison.Ordinal); + if (endIdx <= startIdx) + { + if (buffer.Length - startIdx <= 15) + _thinkTail.Append(buffer.Substring(startIdx)); + else + OnReceive(buffer.Substring(startIdx)); + } + else if (endIdx < startIdx + 15) + { + var tag = buffer.Substring(startIdx + 1, endIdx - startIdx - 1); + if (_thinkTags.Contains(tag)) + _thinkTail.Append(buffer.Substring(startIdx)); + else + OnReceive(buffer.Substring(startIdx)); + } + else + { + OnReceive(buffer.Substring(startIdx)); + } + } + else + { + OnReceive(buffer); + } + } + + public void End() + { + if (_thinkTail.Length > 0) + { + OnReceive(_thinkTail.ToString()); + _thinkTail.Clear(); + } + } + + private void OnReceive(string text) + { + if (!_hasTrimmedStart) + { + text = text.TrimStart(); + if (string.IsNullOrEmpty(text)) + return; + + _hasTrimmedStart = true; + } + + _onUpdate.Invoke(text); + } + + [GeneratedRegex(@"<(think|thought|thinking|thought_chain)>.*?", RegexOptions.Singleline)] + private static partial Regex REG_COT(); + + private Action _onUpdate = null; + private StringBuilder _thinkTail = new StringBuilder(); + private HashSet _thinkTags = ["think", "thought", "thinking", "thought_chain"]; + private bool _hasTrimmedStart = false; + } + + public class OpenAIService : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string Server + { + get => _server; + set => SetProperty(ref _server, value); + } + + public string ApiKey + { + get => _apiKey; + set => SetProperty(ref _apiKey, value); + } + + public string Model + { + get => _model; + set => SetProperty(ref _model, value); + } + + public bool Streaming + { + get => _streaming; + set => SetProperty(ref _streaming, value); + } + + public string AnalyzeDiffPrompt + { + get => _analyzeDiffPrompt; + set => SetProperty(ref _analyzeDiffPrompt, value); + } + + public string GenerateSubjectPrompt + { + get => _generateSubjectPrompt; + set => SetProperty(ref _generateSubjectPrompt, value); + } + + public OpenAIService() + { + AnalyzeDiffPrompt = """ + You are an expert developer specialist in creating commits. + Provide a super concise one sentence overall changes summary of the user `git diff` output following strictly the next rules: + - Do not use any code snippets, imports, file routes or bullets points. + - Do not mention the route of file that has been change. + - Write clear, concise, and descriptive messages that explain the MAIN GOAL made of the changes. + - Use the present tense and active voice in the message, for example, "Fix bug" instead of "Fixed bug.". + - Use the imperative mood, which gives the message a sense of command, e.g. "Add feature" instead of "Added feature". + - Avoid using general terms like "update" or "change", be specific about what was updated or changed. + - Avoid using terms like "The main goal of", just output directly the summary in plain text + """; + + GenerateSubjectPrompt = """ + You are an expert developer specialist in creating commits messages. + Your only goal is to retrieve a single commit message. + Based on the provided user changes, combine them in ONE SINGLE commit message retrieving the global idea, following strictly the next rules: + - Assign the commit {type} according to the next conditions: + feat: Only when adding a new feature. + fix: When fixing a bug. + docs: When updating documentation. + style: When changing elements styles or design and/or making changes to the code style (formatting, missing semicolons, etc.) without changing the code logic. + test: When adding or updating tests. + chore: When making changes to the build process or auxiliary tools and libraries. + revert: When undoing a previous commit. + refactor: When restructuring code without changing its external behavior, or is any of the other refactor types. + - Do not add any issues numeration, explain your output nor introduce your answer. + - Output directly only one commit message in plain text with the next format: {type}: {commit_message}. + - Be as concise as possible, keep the message under 50 characters. + """; + } + + public void Chat(string prompt, string question, CancellationToken cancellation, Action onUpdate) + { + var server = new Uri(_server); + var key = new ApiKeyCredential(_apiKey); + var client = null as ChatClient; + if (_server.Contains("openai.azure.com/", StringComparison.Ordinal)) + { + var azure = new AzureOpenAIClient(server, key); + client = azure.GetChatClient(_model); + } + else + { + var openai = new OpenAIClient(key, new() { Endpoint = server }); + client = openai.GetChatClient(_model); + } + + var messages = new List(); + messages.Add(_model.Equals("o1-mini", StringComparison.Ordinal) ? new UserChatMessage(prompt) : new SystemChatMessage(prompt)); + messages.Add(new UserChatMessage(question)); + + try + { + var rsp = new OpenAIResponse(onUpdate); + + if (_streaming) + { + var updates = client.CompleteChatStreaming(messages, null, cancellation); + + foreach (var update in updates) + { + if (update.ContentUpdate.Count > 0) + rsp.Append(update.ContentUpdate[0].Text); + } + } + else + { + var completion = client.CompleteChat(messages, null, cancellation); + + if (completion.Value.Content.Count > 0) + rsp.Append(completion.Value.Content[0].Text); + } + + rsp.End(); + } + catch + { + if (!cancellation.IsCancellationRequested) + throw; + } + } + + private string _name; + private string _server; + private string _apiKey; + private string _model; + private bool _streaming = true; + private string _analyzeDiffPrompt; + private string _generateSubjectPrompt; + } +} diff --git a/src/Models/Preference.cs b/src/Models/Preference.cs deleted file mode 100644 index 353a26f8..00000000 --- a/src/Models/Preference.cs +++ /dev/null @@ -1,297 +0,0 @@ -using Microsoft.Win32; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Windows; - -namespace SourceGit.Models { - - /// - /// 程序配置 - /// - public class Preference { - private static readonly string SAVE_PATH = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SourceGit", - "preference_v4.json"); - private static Preference instance = null; - - /// - /// 起始页仓库列表排序方式 - /// - public enum SortMethod { - ByName, - ByRecentlyOpened, - ByBookmark, - } - - /// - /// 通用配置 - /// - public class GeneralInfo { - - /// - /// 显示语言 - /// - public string Locale { get; set; } = "en_US"; - - /// - /// 系统字体 - /// - public string FontFamilyWindowSetting { get; set; } = "Microsoft YaHei UI"; - - [JsonIgnore] - public string FontFamilyWindow { - get => FontFamilyWindowSetting + ",Microsoft YaHei UI"; - set => FontFamilyWindowSetting = value; - } - - /// - /// 用户字体(提交列表、提交日志、差异比较等) - /// - public string FontFamilyContentSetting { get; set; } = "Consolas"; - - [JsonIgnore] - public string FontFamilyContent { - get => FontFamilyContentSetting + ",Microsoft YaHei UI"; - set => FontFamilyContentSetting = value; - } - - /// - /// 是否启用深色主题 - /// - public bool UseDarkTheme { get; set; } = false; - - /// - /// 历史提交记录最多显示的条目数 - /// - public uint MaxHistoryCommits { get; set; } = 20000; - - /// - /// 起始页仓库列表排序规则 - /// - public SortMethod SortBy { get; set; } = SortMethod.ByName; - } - - /// - /// Git配置 - /// - public class GitInfo { - - /// - /// git.exe所在路径 - /// - public string Path { get; set; } - - /// - /// 默认克隆路径 - /// - public string DefaultCloneDir { get; set; } - - /// - /// 启用自动拉取远程变更(每10分钟一次) - /// - public bool AutoFetchRemotes { get; set; } = true; - - /// - /// 在本地变更列表中显示未跟踪文件 - /// - public bool IncludeUntrackedInWC { get; set; } = true; - } - - /// - /// 外部合并工具配置 - /// - public class MergeToolInfo { - /// - /// 合并工具类型 - /// - public int Type { get; set; } = 0; - - /// - /// 合并工具可执行文件路径 - /// - public string Path { get; set; } = ""; - } - - /// - /// 使用设置 - /// - public class WindowInfo { - - /// - /// 最近一次设置的宽度 - /// - public double Width { get; set; } = 800; - - /// - /// 最近一次设置的高度 - /// - public double Height { get; set; } = 600; - - /// - /// 保存上次关闭时是否最大化中 - /// - public WindowState State { get; set; } = WindowState.Normal; - - /// - /// 将提交信息面板与提交记录左右排布 - /// - public bool MoveCommitInfoRight { get; set; } = false; - - /// - /// 使用合并Diff视图 - /// - public bool UseCombinedDiff { get; set; } = false; - - /// - /// Pull时是否使用Rebase替换Merge - /// - public bool UseRebaseOnPull { get; set; } = true; - - /// - /// Pull时是否使用自动暂存 - /// - public bool UseAutoStashOnPull { get; set; } = true; - - /// - /// 未暂存视图中变更显示方式 - /// - public Change.DisplayMode ChangeInUnstaged { get; set; } = Change.DisplayMode.Tree; - - /// - /// 暂存视图中变更显示方式 - /// - public Change.DisplayMode ChangeInStaged { get; set; } = Change.DisplayMode.Tree; - - /// - /// 提交信息视图中变更显示方式 - /// - public Change.DisplayMode ChangeInCommitInfo { get; set; } = Change.DisplayMode.Tree; - } - - /// - /// 恢复上次打开的窗口 - /// - public class RestoreTabs { - - /// - /// 是否开启该功能 - /// - public bool IsEnabled { get; set; } = false; - - /// - /// 上次打开的仓库 - /// - public List Opened { get; set; } = new List(); - - /// - /// 最后浏览的仓库 - /// - public string Actived { get; set; } = null; - } - - /// - /// 全局配置 - /// - [JsonIgnore] - public static Preference Instance { - get { - if (instance == null) return Load(); - return instance; - } - } - - /// - /// 检测配置是否正常 - /// - [JsonIgnore] - public bool IsReady { - get => File.Exists(Git.Path) && new Commands.Version().Query() != null; - } - - #region DATA - public GeneralInfo General { get; set; } = new GeneralInfo(); - public GitInfo Git { get; set; } = new GitInfo(); - public MergeToolInfo MergeTool { get; set; } = new MergeToolInfo(); - public WindowInfo Window { get; set; } = new WindowInfo(); - public List Repositories { get; set; } = new List(); - public RestoreTabs Restore { get; set; } = new RestoreTabs(); - #endregion - - #region LOAD_SAVE - public static Preference Load() { - if (!File.Exists(SAVE_PATH)) { - instance = new Preference(); - } else { - try { - instance = JsonSerializer.Deserialize(File.ReadAllText(SAVE_PATH)); - } catch { - instance = new Preference(); - } - } - - if (!instance.IsReady) { - var reg = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - var git = reg.OpenSubKey("SOFTWARE\\GitForWindows"); - if (git != null) { - instance.Git.Path = Path.Combine(git.GetValue("InstallPath") as string, "bin", "git.exe"); - } - } - - return instance; - } - - public static void Save() { - var dir = Path.GetDirectoryName(SAVE_PATH); - if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); - var data = JsonSerializer.Serialize(instance, new JsonSerializerOptions() { WriteIndented = true }); - File.WriteAllText(SAVE_PATH, data); - } - #endregion - - #region METHOD_ON_REPOSITORIES - public Repository AddRepository(string path, string gitDir) { - var repo = FindRepository(path); - if (repo != null) return repo; - - var dir = new DirectoryInfo(path); - repo = new Repository() { - Path = dir.FullName, - GitDir = gitDir, - Name = dir.Name - }; - - Repositories.Add(repo); - Repositories.Sort((l, r) => l.Name.CompareTo(r.Name)); - return repo; - } - - public Repository FindRepository(string path) { - var dir = new DirectoryInfo(path); - foreach (var repo in Repositories) { - if (repo.Path == dir.FullName) return repo; - } - return null; - } - - public void RemoveRepository(string path) { - var dir = new DirectoryInfo(path); - var removedIdx = -1; - - for (int i = 0; i < Repositories.Count; i++) { - if (Repositories[i].Path == dir.FullName) { - removedIdx = i; - break; - } - } - - if (removedIdx >= 0) Repositories.RemoveAt(removedIdx); - } - #endregion - } -} \ No newline at end of file diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs index a4455975..6e36cfb9 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -1,10 +1,85 @@ -namespace SourceGit.Models { +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + public partial class Remote + { + [GeneratedRegex(@"^https?://[^/]+/.+[^/\.]$")] + private static partial Regex REG_HTTPS(); + [GeneratedRegex(@"^git://[^/]+/.+[^/\.]$")] + private static partial Regex REG_GIT(); + [GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] + private static partial Regex REG_SSH1(); + [GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] + private static partial Regex REG_SSH2(); + + [GeneratedRegex(@"^git@([\w\.\-]+):([\w\-/~%]+/[\w\-\.%]+)\.git$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE(); + + private static readonly Regex[] URL_FORMATS = [ + REG_HTTPS(), + REG_GIT(), + REG_SSH1(), + REG_SSH2(), + ]; - /// - /// 远程 - /// - public class Remote { public string Name { get; set; } public string URL { get; set; } + + public static bool IsSSH(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + if (REG_SSH1().IsMatch(url)) + return true; + + return REG_SSH2().IsMatch(url); + } + + public static bool IsValidURL(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + foreach (var fmt in URL_FORMATS) + { + if (fmt.IsMatch(url)) + return true; + } + + return url.StartsWith("file://", StringComparison.Ordinal) || + url.StartsWith("./", StringComparison.Ordinal) || + url.StartsWith("../", StringComparison.Ordinal) || + Directory.Exists(url); + } + + public bool TryGetVisitURL(out string url) + { + url = null; + + if (URL.StartsWith("http", StringComparison.Ordinal)) + { + // Try to remove the user before host and `.git` extension. + var uri = new Uri(URL.EndsWith(".git", StringComparison.Ordinal) ? URL.Substring(0, URL.Length - 4) : URL); + if (uri.Port != 80 && uri.Port != 443) + url = $"{uri.Scheme}://{uri.Host}:{uri.Port}{uri.LocalPath}"; + else + url = $"{uri.Scheme}://{uri.Host}{uri.LocalPath}"; + + return true; + } + + var match = REG_TO_VISIT_URL_CAPTURE().Match(URL); + if (match.Success) + { + url = $"https://{match.Groups[1].Value}/{match.Groups[2].Value}"; + return true; + } + + return false; + } } } diff --git a/src/Models/Repository.cs b/src/Models/Repository.cs deleted file mode 100644 index ef1d9a9d..00000000 --- a/src/Models/Repository.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.Json.Serialization; - -namespace SourceGit.Models { - /// - /// 用于更新过滤器的参数 - /// - public class FilterUpdateParam { - /// - /// 是否是添加过滤的操作,false代表删除 - /// - public bool IsAdd = false; - - /// - /// 过滤内容 - /// - public string Name = ""; - } - - /// - /// 仓库 - /// - public class Repository { - - #region PROPERTIES_SAVED - public string Name { get; set; } = ""; - public string Path { get; set; } = ""; - public string GitDir { get; set; } = ""; - public long LastOpenTime { get; set; } = 0; - public List SubTrees { get; set; } = new List(); - public List Filters { get; set; } = new List(); - public List CommitMessages { get; set; } = new List(); - - public int Bookmark { - get { return bookmark; } - set { - if (value != bookmark) { - bookmark = value; - Watcher.NotifyBookmarkChanged(this); - } - } - } - #endregion - - #region PROPERTIES_RUNTIME - [JsonIgnore] public List Remotes = new List(); - [JsonIgnore] public List Branches = new List(); - [JsonIgnore] public GitFlow GitFlow = new GitFlow(); - #endregion - - /// - /// 记录历史输入的提交信息 - /// - /// - public void PushCommitMessage(string message) { - if (string.IsNullOrEmpty(message)) return; - - int exists = CommitMessages.Count; - if (exists > 0) { - var last = CommitMessages[0]; - if (last == message) return; - } - - if (exists >= 10) { - CommitMessages.RemoveRange(9, exists - 9); - } - - CommitMessages.Insert(0, message); - } - - /// - /// 判断一个文件是否在GitDir中 - /// - /// - /// - public bool ExistsInGitDir(string file) { - if (string.IsNullOrEmpty(file)) return false; - string fullpath = System.IO.Path.Combine(GitDir, file); - return Directory.Exists(fullpath) || File.Exists(fullpath); - } - - /// - /// 更新提交记录过滤器 - /// - /// 更新参数 - /// 是否发生了变化 - public bool UpdateFilters(FilterUpdateParam param = null) { - lock (updateFilterLock) { - bool changed = false; - - // 填写了参数就仅增删 - if (param != null) { - if (param.IsAdd) { - if (!Filters.Contains(param.Name)) { - Filters.Add(param.Name); - changed = true; - } - } else { - if (Filters.Contains(param.Name)) { - Filters.Remove(param.Name); - changed = true; - } - } - - return changed; - } - - // 未填写参数就检测,去掉无效的过滤 - if (Filters.Count > 0) { - var invalidFilters = new List(); - var branches = new Commands.Branches(Path).Result(); - var tags = new Commands.Tags(Path).Result(); - - foreach (var filter in Filters) { - if (filter.StartsWith("refs/")) { - if (branches.FindIndex(b => b.FullName == filter) < 0) invalidFilters.Add(filter); - } else { - if (tags.FindIndex(t => t.Name == filter) < 0) invalidFilters.Add(filter); - } - } - - if (invalidFilters.Count > 0) { - foreach (var filter in invalidFilters) Filters.Remove(filter); - return true; - } - } - - return false; - } - } - - private readonly object updateFilterLock = new object(); - private int bookmark = 0; - } -} diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs new file mode 100644 index 00000000..a54956d3 --- /dev/null +++ b/src/Models/RepositorySettings.cs @@ -0,0 +1,470 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using Avalonia.Collections; + +namespace SourceGit.Models +{ + public class RepositorySettings + { + public string DefaultRemote + { + get; + set; + } = string.Empty; + + public bool EnableReflog + { + get; + set; + } = false; + + public bool EnableFirstParentInHistories + { + get; + set; + } = false; + + public bool EnableTopoOrderInHistories + { + get; + set; + } = false; + + public bool OnlyHighlightCurrentBranchInHistories + { + get; + set; + } = false; + + public BranchSortMode LocalBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public BranchSortMode RemoteBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public TagSortMode TagSortMode + { + get; + set; + } = TagSortMode.CreatorDate; + + public bool IncludeUntrackedInLocalChanges + { + get; + set; + } = true; + + public bool EnableForceOnFetch + { + get; + set; + } = false; + + public bool FetchWithoutTags + { + get; + set; + } = false; + + public bool PreferRebaseInsteadOfMerge + { + get; + set; + } = true; + + public bool CheckSubmodulesOnPush + { + get; + set; + } = true; + + public bool PushAllTags + { + get; + set; + } = false; + + public bool PushToRemoteWhenCreateTag + { + get; + set; + } = true; + + public bool PushToRemoteWhenDeleteTag + { + get; + set; + } = false; + + public bool CheckoutBranchOnCreateBranch + { + get; + set; + } = true; + + public bool UpdateSubmodulesOnCheckoutBranch + { + get; + set; + } = true; + + public AvaloniaList HistoriesFilters + { + get; + set; + } = []; + + public AvaloniaList CommitTemplates + { + get; + set; + } = []; + + public AvaloniaList CommitMessages + { + get; + set; + } = []; + + public AvaloniaList IssueTrackerRules + { + get; + set; + } = []; + + public AvaloniaList CustomActions + { + get; + set; + } = []; + + public bool EnableAutoFetch + { + get; + set; + } = false; + + public int AutoFetchInterval + { + get; + set; + } = 10; + + public bool EnableSignOffForCommit + { + get; + set; + } = false; + + public bool IncludeUntrackedWhenStash + { + get; + set; + } = true; + + public bool OnlyStagedWhenStash + { + get; + set; + } = false; + + public bool KeepIndexWhenStash + { + get; + set; + } = false; + + public bool AutoRestoreAfterStash + { + get; + set; + } = false; + + public string PreferredOpenAIService + { + get; + set; + } = "---"; + + public bool IsLocalBranchesExpandedInSideBar + { + get; + set; + } = true; + + public bool IsRemotesExpandedInSideBar + { + get; + set; + } = false; + + public bool IsTagsExpandedInSideBar + { + get; + set; + } = false; + + public bool IsSubmodulesExpandedInSideBar + { + get; + set; + } = false; + + public bool IsWorktreeExpandedInSideBar + { + get; + set; + } = false; + + public List ExpandedBranchNodesInSideBar + { + get; + set; + } = []; + + public int PreferredMergeMode + { + get; + set; + } = 0; + + public string LastCommitMessage + { + get; + set; + } = string.Empty; + + public Dictionary CollectHistoriesFilters() + { + var map = new Dictionary(); + foreach (var filter in HistoriesFilters) + map.Add(filter.Pattern, filter.Mode); + return map; + } + + public bool UpdateHistoriesFilter(string pattern, FilterType type, FilterMode mode) + { + // Clear all filters when there's a filter that has different mode. + if (mode != FilterMode.None) + { + var clear = false; + foreach (var filter in HistoriesFilters) + { + if (filter.Mode != mode) + { + clear = true; + break; + } + } + + if (clear) + { + HistoriesFilters.Clear(); + HistoriesFilters.Add(new Filter(pattern, type, mode)); + return true; + } + } + else + { + for (int i = 0; i < HistoriesFilters.Count; i++) + { + var filter = HistoriesFilters[i]; + if (filter.Type == type && filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + { + HistoriesFilters.RemoveAt(i); + return true; + } + } + + return false; + } + + foreach (var filter in HistoriesFilters) + { + if (filter.Type != type) + continue; + + if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + return false; + } + + HistoriesFilters.Add(new Filter(pattern, type, mode)); + return true; + } + + public void RemoveChildrenBranchFilters(string pattern) + { + var dirty = new List(); + var prefix = $"{pattern}/"; + + foreach (var filter in HistoriesFilters) + { + if (filter.Type == FilterType.Tag) + continue; + + if (filter.Pattern.StartsWith(prefix, StringComparison.Ordinal)) + dirty.Add(filter); + } + + foreach (var filter in dirty) + HistoriesFilters.Remove(filter); + } + + public string BuildHistoriesFilter() + { + var includedRefs = new List(); + var excludedBranches = new List(); + var excludedRemotes = new List(); + var excludedTags = new List(); + foreach (var filter in HistoriesFilters) + { + if (filter.Type == FilterType.LocalBranch) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add(filter.Pattern); + else if (filter.Mode == FilterMode.Excluded) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}\" --decorate-refs-exclude=\"{filter.Pattern}\""); + } + else if (filter.Type == FilterType.LocalBranchFolder) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add($"--branches={filter.Pattern.AsSpan(11)}/*"); + else if (filter.Mode == FilterMode.Excluded) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\""); + } + else if (filter.Type == FilterType.RemoteBranch) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add(filter.Pattern); + else if (filter.Mode == FilterMode.Excluded) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}\" --decorate-refs-exclude=\"{filter.Pattern}\""); + } + else if (filter.Type == FilterType.RemoteBranchFolder) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add($"--remotes={filter.Pattern.AsSpan(13)}/*"); + else if (filter.Mode == FilterMode.Excluded) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\""); + } + else if (filter.Type == FilterType.Tag) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add($"refs/tags/{filter.Pattern}"); + else if (filter.Mode == FilterMode.Excluded) + excludedTags.Add($"--exclude=\"{filter.Pattern}\" --decorate-refs-exclude=\"refs/tags/{filter.Pattern}\""); + } + } + + var builder = new StringBuilder(); + if (includedRefs.Count > 0) + { + foreach (var r in includedRefs) + { + builder.Append(r); + builder.Append(' '); + } + } + else if (excludedBranches.Count + excludedRemotes.Count + excludedTags.Count > 0) + { + foreach (var b in excludedBranches) + { + builder.Append(b); + builder.Append(' '); + } + + builder.Append("--exclude=HEAD --branches "); + + foreach (var r in excludedRemotes) + { + builder.Append(r); + builder.Append(' '); + } + + builder.Append("--exclude=origin/HEAD --remotes "); + + foreach (var t in excludedTags) + { + builder.Append(t); + builder.Append(' '); + } + + builder.Append("--tags "); + } + + return builder.ToString(); + } + + public void PushCommitMessage(string message) + { + message = message.Trim().ReplaceLineEndings("\n"); + var existIdx = CommitMessages.IndexOf(message); + if (existIdx == 0) + return; + + if (existIdx > 0) + { + CommitMessages.Move(existIdx, 0); + return; + } + + if (CommitMessages.Count > 9) + CommitMessages.RemoveRange(9, CommitMessages.Count - 9); + + CommitMessages.Insert(0, message); + } + + public IssueTrackerRule AddIssueTracker(string name, string regex, string url) + { + var rule = new IssueTrackerRule() + { + Name = name, + RegexString = regex, + URLTemplate = url, + }; + + IssueTrackerRules.Add(rule); + return rule; + } + + public void RemoveIssueTracker(IssueTrackerRule rule) + { + if (rule != null) + IssueTrackerRules.Remove(rule); + } + + public CustomAction AddNewCustomAction() + { + var act = new CustomAction() { Name = "Unnamed Action" }; + CustomActions.Add(act); + return act; + } + + public void RemoveCustomAction(CustomAction act) + { + if (act != null) + CustomActions.Remove(act); + } + + public void MoveCustomActionUp(CustomAction act) + { + var idx = CustomActions.IndexOf(act); + if (idx > 0) + CustomActions.Move(idx - 1, idx); + } + + public void MoveCustomActionDown(CustomAction act) + { + var idx = CustomActions.IndexOf(act); + if (idx < CustomActions.Count - 1) + CustomActions.Move(idx + 1, idx); + } + } +} diff --git a/src/Models/ResetMode.cs b/src/Models/ResetMode.cs index 4532bcf1..827ccaa9 100644 --- a/src/Models/ResetMode.cs +++ b/src/Models/ResetMode.cs @@ -1,26 +1,30 @@ -using System.Collections.Generic; -using System.Windows.Media; +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class ResetMode + { + public static readonly ResetMode[] Supported = + [ + new ResetMode("Soft", "Keep all changes. Stage differences", "--soft", "S", Brushes.Green), + new ResetMode("Mixed", "Keep all changes. Unstage differences", "--mixed", "M", Brushes.Orange), + new ResetMode("Merge", "Reset while keeping unmerged changes", "--merge", "G", Brushes.Purple), + new ResetMode("Keep", "Reset while keeping local modifications", "--keep", "K", Brushes.Purple), + new ResetMode("Hard", "Discard all changes", "--hard", "H", Brushes.Red), + ]; -namespace SourceGit.Models { - /// - /// 重置方式 - /// - public class ResetMode { public string Name { get; set; } public string Desc { get; set; } public string Arg { get; set; } - public Brush Color { get; set; } + public string Key { get; set; } + public IBrush Color { get; set; } - public static List Supported = new List() { - new ResetMode("Soft", "Keep all changes. Stage differences", "--soft", Brushes.Green), - new ResetMode("Mixed", "Keep all changes. Unstage differences", "--mixed", Brushes.Orange), - new ResetMode("Hard", "Discard all changes", "--hard", Brushes.Red), - }; - - public ResetMode(string n, string d, string a, Brush b) { + public ResetMode(string n, string d, string a, string k, IBrush b) + { Name = n; Desc = d; Arg = a; + Key = k; Color = b; } } diff --git a/src/Models/RevisionFile.cs b/src/Models/RevisionFile.cs new file mode 100644 index 00000000..29a23efa --- /dev/null +++ b/src/Models/RevisionFile.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using System.IO; +using Avalonia.Media.Imaging; + +namespace SourceGit.Models +{ + public class RevisionBinaryFile + { + public long Size { get; set; } = 0; + } + + public class RevisionImageFile + { + public Bitmap Image { get; } + public long FileSize { get; } + public string ImageType { get; } + public string ImageSize => Image != null ? $"{Image.PixelSize.Width} x {Image.PixelSize.Height}" : "0 x 0"; + + public RevisionImageFile(string file, Bitmap img, long size) + { + Image = img; + FileSize = size; + ImageType = Path.GetExtension(file)!.Substring(1).ToUpper(CultureInfo.CurrentCulture); + } + } + + public class RevisionTextFile + { + public string FileName { get; set; } + public string Content { get; set; } + } + + public class RevisionLFSObject + { + public LFSObject Object { get; set; } + } + + public class RevisionSubmodule + { + public Commit Commit { get; set; } = null; + public CommitFullMessage FullMessage { get; set; } = null; + } +} diff --git a/src/Models/SelfUpdate.cs b/src/Models/SelfUpdate.cs new file mode 100644 index 00000000..e02f80d8 --- /dev/null +++ b/src/Models/SelfUpdate.cs @@ -0,0 +1,56 @@ +using System; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace SourceGit.Models +{ + public class Version + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("tag_name")] + public string TagName { get; set; } + + [JsonPropertyName("body")] + public string Body { get; set; } + + public bool IsNewVersion + { + get + { + try + { + System.Version version = new System.Version(TagName.Substring(1)); + System.Version current = Assembly.GetExecutingAssembly().GetName().Version!; + return current.CompareTo(version) < 0; + } + catch + { + return false; + } + } + } + } + + public class AlreadyUpToDate + { + } + + public class SelfUpdateFailed + { + public string Reason + { + get; + private set; + } + + public SelfUpdateFailed(Exception e) + { + if (e.InnerException is { } inner) + Reason = inner.Message; + else + Reason = e.Message; + } + } +} diff --git a/src/Models/ShellOrTerminal.cs b/src/Models/ShellOrTerminal.cs new file mode 100644 index 00000000..7dfb2237 --- /dev/null +++ b/src/Models/ShellOrTerminal.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace SourceGit.Models +{ + public class ShellOrTerminal + { + public string Type { get; set; } + public string Name { get; set; } + public string Exec { get; set; } + + public Bitmap Icon + { + get + { + var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/ShellIcons/{Type}.png", UriKind.RelativeOrAbsolute)); + return new Bitmap(icon); + } + } + + public static readonly List Supported; + + static ShellOrTerminal() + { + if (OperatingSystem.IsWindows()) + { + Supported = new List() + { + new ShellOrTerminal("git-bash", "Git Bash", "bash.exe"), + new ShellOrTerminal("pwsh", "PowerShell", "pwsh.exe|powershell.exe"), + new ShellOrTerminal("cmd", "Command Prompt", "cmd.exe"), + new ShellOrTerminal("wt", "Windows Terminal", "wt.exe") + }; + } + else if (OperatingSystem.IsMacOS()) + { + Supported = new List() + { + new ShellOrTerminal("mac-terminal", "Terminal", ""), + new ShellOrTerminal("iterm2", "iTerm", ""), + new ShellOrTerminal("warp", "Warp", ""), + new ShellOrTerminal("ghostty", "Ghostty", ""), + new ShellOrTerminal("kitty", "kitty", "") + }; + } + else + { + Supported = new List() + { + new ShellOrTerminal("gnome-terminal", "Gnome Terminal", "gnome-terminal"), + new ShellOrTerminal("konsole", "Konsole", "konsole"), + new ShellOrTerminal("xfce4-terminal", "Xfce4 Terminal", "xfce4-terminal"), + new ShellOrTerminal("lxterminal", "LXTerminal", "lxterminal"), + new ShellOrTerminal("deepin-terminal", "Deepin Terminal", "deepin-terminal"), + new ShellOrTerminal("mate-terminal", "MATE Terminal", "mate-terminal"), + new ShellOrTerminal("foot", "Foot", "foot"), + new ShellOrTerminal("wezterm", "WezTerm", "wezterm"), + new ShellOrTerminal("ptyxis", "Ptyxis", "ptyxis"), + new ShellOrTerminal("kitty", "kitty", "kitty"), + new ShellOrTerminal("custom", "Custom", ""), + }; + } + } + + public ShellOrTerminal(string type, string name, string exec) + { + Type = type; + Name = name; + Exec = exec; + } + } +} diff --git a/src/Models/Stash.cs b/src/Models/Stash.cs index f079277f..369ab145 100644 --- a/src/Models/Stash.cs +++ b/src/Models/Stash.cs @@ -1,18 +1,16 @@ -using System; - -namespace SourceGit.Models { - /// - /// 贮藏 - /// - public class Stash { - private static readonly DateTime UTC_START = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); +using System; +using System.Collections.Generic; +namespace SourceGit.Models +{ + public class Stash + { public string Name { get; set; } = ""; public string SHA { get; set; } = ""; - public User Author { get; set; } = User.Invalid; + public List Parents { get; set; } = []; public ulong Time { get; set; } = 0; public string Message { get; set; } = ""; - public string TimeStr => UTC_START.AddSeconds(Time).ToString("yyyy-MM-dd HH:mm:ss"); + public string TimeStr => DateTime.UnixEpoch.AddSeconds(Time).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); } } diff --git a/src/Models/StatisticSample.cs b/src/Models/StatisticSample.cs deleted file mode 100644 index 1fa7283b..00000000 --- a/src/Models/StatisticSample.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SourceGit.Models { - /// - /// 统计图表样品 - /// - public class StatisticSample { - /// - /// 在图表中的顺序 - /// - public int Index { get; set; } - /// - /// 样品名 - /// - public string Name { get; set; } - /// - /// 提交个数 - /// - public int Count { get; set; } - } -} diff --git a/src/Models/Statistics.cs b/src/Models/Statistics.cs new file mode 100644 index 00000000..a86380c3 --- /dev/null +++ b/src/Models/Statistics.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using LiveChartsCore; +using LiveChartsCore.Defaults; +using LiveChartsCore.SkiaSharpView; +using LiveChartsCore.SkiaSharpView.Painting; + +using SkiaSharp; + +namespace SourceGit.Models +{ + public enum StatisticsMode + { + All, + ThisMonth, + ThisWeek, + } + + public class StatisticsAuthor(User user, int count) + { + public User User { get; set; } = user; + public int Count { get; set; } = count; + } + + public class StatisticsReport + { + public int Total { get; set; } = 0; + public List Authors { get; set; } = new(); + public List Series { get; set; } = new(); + public List XAxes { get; set; } = new(); + public List YAxes { get; set; } = new(); + public StatisticsAuthor SelectedAuthor { get => _selectedAuthor; set => ChangeAuthor(value); } + + public StatisticsReport(StatisticsMode mode, DateTime start) + { + _mode = mode; + + YAxes.Add(new Axis() + { + TextSize = 10, + MinLimit = 0, + SeparatorsPaint = new SolidColorPaint(new SKColor(0x40808080)) { StrokeThickness = 1 } + }); + + if (mode == StatisticsMode.ThisWeek) + { + for (int i = 0; i < 7; i++) + _mapSamples.Add(start.AddDays(i), 0); + + XAxes.Add(new DateTimeAxis(TimeSpan.FromDays(1), v => WEEKDAYS[(int)v.DayOfWeek]) { TextSize = 10 }); + } + else if (mode == StatisticsMode.ThisMonth) + { + var now = DateTime.Now; + var maxDays = DateTime.DaysInMonth(now.Year, now.Month); + for (int i = 0; i < maxDays; i++) + _mapSamples.Add(start.AddDays(i), 0); + + XAxes.Add(new DateTimeAxis(TimeSpan.FromDays(1), v => $"{v:MM/dd}") { TextSize = 10 }); + } + else + { + XAxes.Add(new DateTimeAxis(TimeSpan.FromDays(30), v => $"{v:yyyy/MM}") { TextSize = 10 }); + } + } + + public void AddCommit(DateTime time, User author) + { + Total++; + + DateTime normalized; + if (_mode == StatisticsMode.ThisWeek || _mode == StatisticsMode.ThisMonth) + normalized = time.Date; + else + normalized = new DateTime(time.Year, time.Month, 1).ToLocalTime(); + + if (_mapSamples.TryGetValue(normalized, out var vs)) + _mapSamples[normalized] = vs + 1; + else + _mapSamples.Add(normalized, 1); + + if (_mapUsers.TryGetValue(author, out var vu)) + _mapUsers[author] = vu + 1; + else + _mapUsers.Add(author, 1); + + if (_mapUserSamples.TryGetValue(author, out var vus)) + { + if (vus.TryGetValue(normalized, out var n)) + vus[normalized] = n + 1; + else + vus.Add(normalized, 1); + } + else + { + _mapUserSamples.Add(author, new Dictionary + { + { normalized, 1 } + }); + } + } + + public void Complete() + { + foreach (var kv in _mapUsers) + Authors.Add(new StatisticsAuthor(kv.Key, kv.Value)); + + Authors.Sort((l, r) => r.Count - l.Count); + + var samples = new List(); + foreach (var kv in _mapSamples) + samples.Add(new DateTimePoint(kv.Key, kv.Value)); + + Series.Add( + new ColumnSeries() + { + Values = samples, + Stroke = null, + Fill = null, + Padding = 1, + } + ); + + _mapUsers.Clear(); + _mapSamples.Clear(); + } + + public void ChangeColor(uint color) + { + _fillColor = color; + + var fill = new SKColor(color); + + if (Series.Count > 0 && Series[0] is ColumnSeries total) + total.Fill = new SolidColorPaint(_selectedAuthor == null ? fill : fill.WithAlpha(51)); + + if (Series.Count > 1 && Series[1] is ColumnSeries user) + user.Fill = new SolidColorPaint(fill); + } + + public void ChangeAuthor(StatisticsAuthor author) + { + if (author == _selectedAuthor) + return; + + _selectedAuthor = author; + Series.RemoveRange(1, Series.Count - 1); + if (author == null || !_mapUserSamples.TryGetValue(author.User, out var userSamples)) + { + ChangeColor(_fillColor); + return; + } + + var samples = new List(); + foreach (var kv in userSamples) + samples.Add(new DateTimePoint(kv.Key, kv.Value)); + + Series.Add( + new ColumnSeries() + { + Values = samples, + Stroke = null, + Fill = null, + Padding = 1, + } + ); + + ChangeColor(_fillColor); + } + + private static readonly string[] WEEKDAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; + private StatisticsMode _mode; + private Dictionary _mapUsers = new(); + private Dictionary _mapSamples = new(); + private Dictionary> _mapUserSamples = new(); + private StatisticsAuthor _selectedAuthor = null; + private uint _fillColor = 255; + } + + public class Statistics + { + public StatisticsReport All { get; } + public StatisticsReport Month { get; } + public StatisticsReport Week { get; } + + public Statistics() + { + var today = DateTime.Now.ToLocalTime().Date; + var weekOffset = (7 + (int)today.DayOfWeek - (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek) % 7; + _thisWeekStart = today.AddDays(-weekOffset); + _thisMonthStart = today.AddDays(1 - today.Day); + + All = new StatisticsReport(StatisticsMode.All, DateTime.MinValue); + Month = new StatisticsReport(StatisticsMode.ThisMonth, _thisMonthStart); + Week = new StatisticsReport(StatisticsMode.ThisWeek, _thisWeekStart); + } + + public void AddCommit(string author, double timestamp) + { + var emailIdx = author.IndexOf('±', StringComparison.Ordinal); + var email = author.Substring(emailIdx + 1).ToLower(CultureInfo.CurrentCulture); + if (!_users.TryGetValue(email, out var user)) + { + user = User.FindOrAdd(author); + _users.Add(email, user); + } + + var time = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + if (time >= _thisWeekStart) + Week.AddCommit(time, user); + + if (time >= _thisMonthStart) + Month.AddCommit(time, user); + + All.AddCommit(time, user); + } + + public void Complete() + { + _users.Clear(); + + All.Complete(); + Month.Complete(); + Week.Complete(); + } + + private readonly DateTime _thisMonthStart; + private readonly DateTime _thisWeekStart; + private readonly Dictionary _users = new(); + } +} diff --git a/src/Models/SubTree.cs b/src/Models/SubTree.cs deleted file mode 100644 index fdbad2fe..00000000 --- a/src/Models/SubTree.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SourceGit.Models { - /// - /// 子树 - /// - public class SubTree { - public string Prefix { get; set; } - public string Remote { get; set; } - public string Branch { get; set; } = "master"; - } -} diff --git a/src/Models/Submodule.cs b/src/Models/Submodule.cs new file mode 100644 index 00000000..ca73a8de --- /dev/null +++ b/src/Models/Submodule.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Models +{ + public enum SubmoduleStatus + { + Normal = 0, + NotInited, + RevisionChanged, + Unmerged, + Modified, + } + + public class Submodule + { + public string Path { get; set; } = string.Empty; + public string SHA { get; set; } = string.Empty; + public string URL { get; set; } = string.Empty; + public SubmoduleStatus Status { get; set; } = SubmoduleStatus.Normal; + public bool IsDirty => Status > SubmoduleStatus.NotInited; + } +} diff --git a/src/Models/Tag.cs b/src/Models/Tag.cs index 20d6f09b..87944637 100644 --- a/src/Models/Tag.cs +++ b/src/Models/Tag.cs @@ -1,10 +1,27 @@ -namespace SourceGit.Models { - /// - /// 标签 - /// - public class Tag { - public string Name { get; set; } - public string SHA { get; set; } - public bool IsFiltered { get; set; } +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public enum TagSortMode + { + CreatorDate = 0, + Name, + } + + public class Tag : ObservableObject + { + public string Name { get; set; } = string.Empty; + public bool IsAnnotated { get; set; } = false; + public string SHA { get; set; } = string.Empty; + public ulong CreatorDate { get; set; } = 0; + public string Message { get; set; } = string.Empty; + + public FilterMode FilterMode + { + get => _filterMode; + set => SetProperty(ref _filterMode, value); + } + + private FilterMode _filterMode = FilterMode.None; } } diff --git a/src/Models/TemplateEngine.cs b/src/Models/TemplateEngine.cs new file mode 100644 index 00000000..c54f55fb --- /dev/null +++ b/src/Models/TemplateEngine.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + public class TemplateEngine + { + private class Context(Branch branch, IReadOnlyList changes) + { + public Branch branch = branch; + public IReadOnlyList changes = changes; + } + + private class Text(string text) + { + public string text = text; + } + + private class Variable(string name) + { + public string name = name; + } + + private class SlicedVariable(string name, int count) + { + public string name = name; + public int count = count; + } + + private class RegexVariable(string name, Regex regex, string replacement) + { + public string name = name; + public Regex regex = regex; + public string replacement = replacement; + } + + private const char ESCAPE = '\\'; + private const char VARIABLE_ANCHOR = '$'; + private const char VARIABLE_START = '{'; + private const char VARIABLE_END = '}'; + private const char VARIABLE_SLICE = ':'; + private const char VARIABLE_REGEX = '/'; + private const char NEWLINE = '\n'; + private const RegexOptions REGEX_OPTIONS = RegexOptions.Singleline | RegexOptions.IgnoreCase; + + public string Eval(string text, Branch branch, IReadOnlyList changes) + { + Reset(); + + _chars = text.ToCharArray(); + Parse(); + + var context = new Context(branch, changes); + var sb = new StringBuilder(); + sb.EnsureCapacity(text.Length); + foreach (var token in _tokens) + { + switch (token) + { + case Text text_token: + sb.Append(text_token.text); + break; + case Variable var_token: + sb.Append(EvalVariable(context, var_token)); + break; + case SlicedVariable sliced_var: + sb.Append(EvalVariable(context, sliced_var)); + break; + case RegexVariable regex_var: + sb.Append(EvalVariable(context, regex_var)); + break; + } + } + + return sb.ToString(); + } + + private void Reset() + { + _pos = 0; + _chars = []; + _tokens.Clear(); + } + + private char? Next() + { + var c = Peek(); + if (c is not null) + { + _pos++; + } + return c; + } + + private char? Peek() + { + return (_pos >= _chars.Length) ? null : _chars[_pos]; + } + + private int? Integer() + { + var start = _pos; + while (Peek() is char c && c >= '0' && c <= '9') + { + _pos++; + } + if (start >= _pos) + return null; + + var chars = new ReadOnlySpan(_chars, start, _pos - start); + return int.Parse(chars); + } + + private void Parse() + { + // text token start + var tok = _pos; + bool esc = false; + while (Next() is char c) + { + if (esc) + { + esc = false; + continue; + } + switch (c) + { + case ESCAPE: + // allow to escape only \ and $ + if (Peek() is char nc && (nc == ESCAPE || nc == VARIABLE_ANCHOR)) + { + esc = true; + FlushText(tok, _pos - 1); + tok = _pos; + } + break; + case VARIABLE_ANCHOR: + // backup the position + var bak = _pos; + var variable = TryParseVariable(); + if (variable is null) + { + // no variable found, rollback + _pos = bak; + } + else + { + // variable found, flush a text token + FlushText(tok, bak - 1); + _tokens.Add(variable); + tok = _pos; + } + break; + } + } + // flush text token + FlushText(tok, _pos); + } + + private void FlushText(int start, int end) + { + int len = end - start; + if (len <= 0) + return; + var text = new string(_chars, start, len); + _tokens.Add(new Text(text)); + } + + private object TryParseVariable() + { + if (Next() != VARIABLE_START) + return null; + int name_start = _pos; + while (Next() is char c) + { + // name character, continue advancing + if (IsNameChar(c)) + continue; + + var name_end = _pos - 1; + // not a name character but name is empty, cancel + if (name_start >= name_end) + return null; + var name = new string(_chars, name_start, name_end - name_start); + + return c switch + { + // variable + VARIABLE_END => new Variable(name), + // sliced variable + VARIABLE_SLICE => TryParseSlicedVariable(name), + // regex variable + VARIABLE_REGEX => TryParseRegexVariable(name), + _ => null, + }; + } + + return null; + } + + private object TryParseSlicedVariable(string name) + { + int? n = Integer(); + if (n is null) + return null; + if (Next() != VARIABLE_END) + return null; + + return new SlicedVariable(name, (int)n); + } + + private object TryParseRegexVariable(string name) + { + var regex = ParseRegex(); + if (regex == null) + return null; + var replacement = ParseReplacement(); + if (replacement == null) + return null; + + return new RegexVariable(name, regex, replacement); + } + + private Regex ParseRegex() + { + var sb = new StringBuilder(); + var tok = _pos; + var esc = false; + while (Next() is char c) + { + if (esc) + { + esc = false; + continue; + } + switch (c) + { + case ESCAPE: + // allow to escape only / as \ and { used frequently in regexes + if (Peek() == VARIABLE_REGEX) + { + esc = true; + sb.Append(_chars, tok, _pos - 1 - tok); + tok = _pos; + } + break; + case VARIABLE_REGEX: + // goto is fine + goto Loop_exit; + case NEWLINE: + // no newlines allowed + return null; + } + } + Loop_exit: + sb.Append(_chars, tok, _pos - 1 - tok); + + try + { + var pattern = sb.ToString(); + if (pattern.Length == 0) + return null; + var regex = new Regex(pattern, REGEX_OPTIONS); + + return regex; + } + catch (RegexParseException) + { + return null; + } + } + + private string ParseReplacement() + { + var sb = new StringBuilder(); + var tok = _pos; + var esc = false; + while (Next() is char c) + { + if (esc) + { + esc = false; + continue; + } + switch (c) + { + case ESCAPE: + // allow to escape only } + if (Peek() == VARIABLE_END) + { + esc = true; + sb.Append(_chars, tok, _pos - 1 - tok); + tok = _pos; + } + break; + case VARIABLE_END: + // goto is fine + goto Loop_exit; + case NEWLINE: + // no newlines allowed + return null; + } + } + Loop_exit: + sb.Append(_chars, tok, _pos - 1 - tok); + + var replacement = sb.ToString(); + + return replacement; + } + + private static bool IsNameChar(char c) + { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + } + + // (?) notice or log if variable is not found + private static string EvalVariable(Context context, string name) + { + if (!s_variables.TryGetValue(name, out var getter)) + { + return string.Empty; + } + return getter(context); + } + + private static string EvalVariable(Context context, Variable variable) + { + return EvalVariable(context, variable.name); + } + + private static string EvalVariable(Context context, SlicedVariable variable) + { + if (!s_slicedVariables.TryGetValue(variable.name, out var getter)) + { + return string.Empty; + } + return getter(context, variable.count); + } + + private static string EvalVariable(Context context, RegexVariable variable) + { + var str = EvalVariable(context, variable.name); + if (string.IsNullOrEmpty(str)) + return str; + return variable.regex.Replace(str, variable.replacement); + } + + private int _pos = 0; + private char[] _chars = []; + private readonly List _tokens = []; + + private delegate string VariableGetter(Context context); + + private static readonly IReadOnlyDictionary s_variables = new Dictionary() { + // legacy variables + {"branch_name", GetBranchName}, + {"files_num", GetFilesCount}, + {"files", GetFiles}, + // + {"BRANCH", GetBranchName}, + {"FILES_COUNT", GetFilesCount}, + {"FILES", GetFiles}, + }; + + private static string GetBranchName(Context context) + { + return context.branch.Name; + } + + private static string GetFilesCount(Context context) + { + return context.changes.Count.ToString(); + } + + private static string GetFiles(Context context) + { + var paths = new List(); + foreach (var c in context.changes) + paths.Add(c.Path); + return string.Join(", ", paths); + } + + private delegate string VariableSliceGetter(Context context, int count); + + private static readonly IReadOnlyDictionary s_slicedVariables = new Dictionary() { + // legacy variables + {"files", GetFilesSliced}, + // + {"FILES", GetFilesSliced}, + }; + + private static string GetFilesSliced(Context context, int count) + { + var sb = new StringBuilder(); + var paths = new List(); + var max = Math.Min(count, context.changes.Count); + for (int i = 0; i < max; i++) + paths.Add(context.changes[i].Path); + + sb.AppendJoin(", ", paths); + if (max < context.changes.Count) + sb.Append($" and {context.changes.Count - max} other files"); + + return sb.ToString(); + } + } +} diff --git a/src/Models/TextChanges.cs b/src/Models/TextChanges.cs deleted file mode 100644 index c563c5a7..00000000 --- a/src/Models/TextChanges.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Collections.Generic; - -namespace SourceGit.Models { - /// - /// Diff文本文件变化 - /// - public class TextChanges { - - public enum LineMode { - None, - Normal, - Indicator, - Added, - Deleted, - } - - public class HighlightRange { - public int Start { get; set; } - public int Count { get; set; } - public HighlightRange(int p, int n) { Start = p; Count = n; } - } - - public class Line { - public int Index { get; set; } = 0; - public LineMode Mode { get; set; } = LineMode.None; - public string Content { get; set; } = ""; - public string OldLine { get; set; } = ""; - public string NewLine { get; set; } = ""; - public List Highlights { get; set; } = new List(); - - public bool IsContent { - get { - return Mode == LineMode.Added - || Mode == LineMode.Deleted - || Mode == LineMode.Normal; - } - } - - public bool IsDifference { - get { - return Mode == LineMode.Added - || Mode == LineMode.Deleted - || Mode == LineMode.None; - } - } - - public Line() { } - - public Line(int index, LineMode mode, string content, string oldLine, string newLine) { - Index = index; - Mode = mode; - Content = content; - OldLine = oldLine; - NewLine = newLine; - } - } - - public bool IsBinary = false; - public List Lines = new List(); - } -} diff --git a/src/Models/TextCompare.cs b/src/Models/TextInlineChange.cs similarity index 62% rename from src/Models/TextCompare.cs rename to src/Models/TextInlineChange.cs index e59e3424..15901d03 100644 --- a/src/Models/TextCompare.cs +++ b/src/Models/TextInlineChange.cs @@ -1,40 +1,23 @@ -using System.Collections.Generic; +using System.Collections.Generic; -namespace SourceGit.Models { +namespace SourceGit.Models +{ + public class TextInlineChange + { + public int DeletedStart { get; set; } + public int DeletedCount { get; set; } + public int AddedStart { get; set; } + public int AddedCount { get; set; } - /// - /// 字串差异对比,改写自DiffPlex - /// - public class TextCompare { - private static readonly HashSet SEPS = new HashSet(" \t+-*/=!,:;.'\"/?|&#@%`<>()[]{}\\".ToCharArray()); - - /// - /// 差异信息 - /// - public class Different { - public int DeletedStart { get; set; } - public int DeletedCount { get; set; } - public int AddedStart { get; set; } - public int AddedCount { get; set; } - - public Different(int dp, int dc, int ap, int ac) { - DeletedStart = dp; - DeletedCount = dc; - AddedStart = ap; - AddedCount = ac; - } - } - - /// - /// 分片 - /// - public class Chunk { + private class Chunk + { public int Hash; public bool Modified; public int Start; public int Size; - public Chunk(int hash, int start, int size) { + public Chunk(int hash, int start, int size) + { Hash = hash; Modified = false; Start = start; @@ -42,10 +25,8 @@ namespace SourceGit.Models { } } - /// - /// 区间修改状态 - /// - public enum Edit { + private enum Edit + { None, DeletedRight, DeletedLeft, @@ -53,10 +34,8 @@ namespace SourceGit.Models { AddedLeft, } - /// - /// 当前区间检测结果 - /// - public class EditResult { + private class EditResult + { public Edit State; public int DeleteStart; public int DeleteEnd; @@ -64,13 +43,16 @@ namespace SourceGit.Models { public int AddEnd; } - /// - /// 对比字串 - /// - /// - /// - /// - public static List Process(string oldValue, string newValue) { + public TextInlineChange(int dp, int dc, int ap, int ac) + { + DeletedStart = dp; + DeletedCount = dc; + AddedStart = ap; + AddedCount = ac; + } + + public static List Compare(string oldValue, string newValue) + { var hashes = new Dictionary(); var chunksOld = MakeChunks(hashes, oldValue); var chunksNew = MakeChunks(hashes, newValue); @@ -81,12 +63,14 @@ namespace SourceGit.Models { var reverse = new int[max]; CheckModified(chunksOld, 0, sizeOld, chunksNew, 0, sizeNew, forward, reverse); - var ret = new List(); + var ret = new List(); var posOld = 0; var posNew = 0; - var last = null as Different; - do { - while (posOld < sizeOld && posNew < sizeNew && !chunksOld[posOld].Modified && !chunksNew[posNew].Modified) { + var last = null as TextInlineChange; + do + { + while (posOld < sizeOld && posNew < sizeNew && !chunksOld[posOld].Modified && !chunksNew[posNew].Modified) + { posOld++; posNew++; } @@ -95,20 +79,25 @@ namespace SourceGit.Models { var beginNew = posNew; var countOld = 0; var countNew = 0; - for (; posOld < sizeOld && chunksOld[posOld].Modified; posOld++) countOld += chunksOld[posOld].Size; - for (; posNew < sizeNew && chunksNew[posNew].Modified; posNew++) countNew += chunksNew[posNew].Size; + for (; posOld < sizeOld && chunksOld[posOld].Modified; posOld++) + countOld += chunksOld[posOld].Size; + for (; posNew < sizeNew && chunksNew[posNew].Modified; posNew++) + countNew += chunksNew[posNew].Size; - if (countOld + countNew == 0) continue; + if (countOld + countNew == 0) + continue; - var diff = new Different( + var diff = new TextInlineChange( countOld > 0 ? chunksOld[beginOld].Start : 0, countOld, countNew > 0 ? chunksNew[beginNew].Start : 0, countNew); - if (last != null) { + if (last != null) + { var midSizeOld = diff.DeletedStart - last.DeletedStart - last.DeletedCount; var midSizeNew = diff.AddedStart - last.AddedStart - last.AddedCount; - if (midSizeOld == 1 && midSizeNew == 1) { + if (midSizeOld == 1 && midSizeNew == 1) + { last.DeletedCount += (1 + countOld); last.AddedCount += (1 + countNew); continue; @@ -122,61 +111,86 @@ namespace SourceGit.Models { return ret; } - private static List MakeChunks(Dictionary hashes, string text) { + private static List MakeChunks(Dictionary hashes, string text) + { var start = 0; var size = text.Length; var chunks = new List(); + var delims = new HashSet(" \t+-*/=!,:;.'\"/?|&#@%`<>()[]{}\\".ToCharArray()); - for (int i = 0; i < size; i++) { + for (int i = 0; i < size; i++) + { var ch = text[i]; - if (SEPS.Contains(ch)) { - if (start != i) AddChunk(chunks, hashes, text.Substring(start, i - start), start); + if (delims.Contains(ch)) + { + if (start != i) + AddChunk(chunks, hashes, text.Substring(start, i - start), start); AddChunk(chunks, hashes, text.Substring(i, 1), i); start = i + 1; } } - if (start < size) AddChunk(chunks, hashes, text.Substring(start), start); + if (start < size) + AddChunk(chunks, hashes, text.Substring(start), start); return chunks; } - private static void CheckModified(List chunksOld, int startOld, int endOld, List chunksNew, int startNew, int endNew, int[] forward, int[] reverse) { - while (startOld < endOld && startNew < endNew && chunksOld[startOld].Hash == chunksNew[startNew].Hash) { + private static void CheckModified(List chunksOld, int startOld, int endOld, List chunksNew, int startNew, int endNew, int[] forward, int[] reverse) + { + while (startOld < endOld && startNew < endNew && chunksOld[startOld].Hash == chunksNew[startNew].Hash) + { startOld++; startNew++; } - while (startOld < endOld && startNew < endNew && chunksOld[endOld - 1].Hash == chunksNew[endNew - 1].Hash) { + while (startOld < endOld && startNew < endNew && chunksOld[endOld - 1].Hash == chunksNew[endNew - 1].Hash) + { endOld--; endNew--; } var lenOld = endOld - startOld; var lenNew = endNew - startNew; - if (lenOld > 0 && lenNew > 0) { + if (lenOld > 0 && lenNew > 0) + { var rs = CheckModifiedEdit(chunksOld, startOld, endOld, chunksNew, startNew, endNew, forward, reverse); - if (rs.State == Edit.None) return; + if (rs.State == Edit.None) + return; - if (rs.State == Edit.DeletedRight && rs.DeleteStart - 1 > startOld) { + if (rs.State == Edit.DeletedRight && rs.DeleteStart - 1 > startOld) + { chunksOld[--rs.DeleteStart].Modified = true; - } else if (rs.State == Edit.DeletedLeft && rs.DeleteEnd < endOld) { + } + else if (rs.State == Edit.DeletedLeft && rs.DeleteEnd < endOld) + { chunksOld[rs.DeleteEnd++].Modified = true; - } else if (rs.State == Edit.AddedRight && rs.AddStart - 1 > startNew) { + } + else if (rs.State == Edit.AddedRight && rs.AddStart - 1 > startNew) + { chunksNew[--rs.AddStart].Modified = true; - } else if (rs.State == Edit.AddedLeft && rs.AddEnd < endNew) { + } + else if (rs.State == Edit.AddedLeft && rs.AddEnd < endNew) + { chunksNew[rs.AddEnd++].Modified = true; } CheckModified(chunksOld, startOld, rs.DeleteStart, chunksNew, startNew, rs.AddStart, forward, reverse); CheckModified(chunksOld, rs.DeleteEnd, endOld, chunksNew, rs.AddEnd, endNew, forward, reverse); - } else if (lenOld > 0) { - for (int i = startOld; i < endOld; i++) chunksOld[i].Modified = true; - } else if (lenNew > 0) { - for (int i = startNew; i < endNew; i++) chunksNew[i].Modified = true; + } + else if (lenOld > 0) + { + for (int i = startOld; i < endOld; i++) + chunksOld[i].Modified = true; + } + else if (lenNew > 0) + { + for (int i = startNew; i < endNew; i++) + chunksNew[i].Modified = true; } } - private static EditResult CheckModifiedEdit(List chunksOld, int startOld, int endOld, List chunksNew, int startNew, int endNew, int[] forward, int[] reverse) { + private static EditResult CheckModifiedEdit(List chunksOld, int startOld, int endOld, List chunksNew, int startNew, int endNew, int[] forward, int[] reverse) + { var lenOld = endOld - startOld; var lenNew = endNew - startNew; var max = lenOld + lenNew + 1; @@ -188,39 +202,48 @@ namespace SourceGit.Models { forward[1 + half] = 0; reverse[1 + half] = lenOld + 1; - for (int i = 0; i <= half; i++) { - - // 正向 - for (int j = -i; j <= i; j += 2) { + for (int i = 0; i <= half; i++) + { + for (int j = -i; j <= i; j += 2) + { var idx = j + half; - int o, n; - if (j == -i || (j != i && forward[idx - 1] < forward[idx + 1])) { + int o; + if (j == -i || (j != i && forward[idx - 1] < forward[idx + 1])) + { o = forward[idx + 1]; rs.State = Edit.AddedRight; - } else { + } + else + { o = forward[idx - 1] + 1; rs.State = Edit.DeletedRight; } - n = o - j; + var n = o - j; var startX = o; var startY = n; - while (o < lenOld && n < lenNew && chunksOld[o + startOld].Hash == chunksNew[n + startNew].Hash) { + while (o < lenOld && n < lenNew && chunksOld[o + startOld].Hash == chunksNew[n + startNew].Hash) + { o++; n++; } forward[idx] = o; - if (!deltaEven && j - delta >= -i + 1 && j - delta <= i - 1) { + if (!deltaEven && j - delta >= -i + 1 && j - delta <= i - 1) + { var revIdx = (j - delta) + half; var revOld = reverse[revIdx]; int revNew = revOld - j; - if (revOld <= o && revNew <= n) { - if (i == 0) { + if (revOld <= o && revNew <= n) + { + if (i == 0) + { rs.State = Edit.None; - } else { + } + else + { rs.DeleteStart = startX + startOld; rs.DeleteEnd = o + startOld; rs.AddStart = startY + startNew; @@ -231,37 +254,46 @@ namespace SourceGit.Models { } } - // 反向 - for (int j = -i; j <= i; j += 2) { + for (int j = -i; j <= i; j += 2) + { var idx = j + half; - int o, n; - if (j == -i || (j != i && reverse[idx + 1] <= reverse[idx - 1])) { + int o; + if (j == -i || (j != i && reverse[idx + 1] <= reverse[idx - 1])) + { o = reverse[idx + 1] - 1; rs.State = Edit.DeletedLeft; - } else { + } + else + { o = reverse[idx - 1]; rs.State = Edit.AddedLeft; } - n = o - (j + delta); + var n = o - (j + delta); var endX = o; var endY = n; - while (o > 0 && n > 0 && chunksOld[startOld + o - 1].Hash == chunksNew[startNew + n - 1].Hash) { + while (o > 0 && n > 0 && chunksOld[startOld + o - 1].Hash == chunksNew[startNew + n - 1].Hash) + { o--; n--; } reverse[idx] = o; - if (deltaEven && j + delta >= -i && j + delta <= i) { + if (deltaEven && j + delta >= -i && j + delta <= i) + { var forIdx = (j + delta) + half; var forOld = forward[forIdx]; int forNew = forOld - (j + delta); - if (forOld >= o && forNew >= n) { - if (i == 0) { + if (forOld >= o && forNew >= n) + { + if (i == 0) + { rs.State = Edit.None; - } else { + } + else + { rs.DeleteStart = o + startOld; rs.DeleteEnd = endX + startOld; rs.AddStart = n + startNew; @@ -277,11 +309,14 @@ namespace SourceGit.Models { return rs; } - private static void AddChunk(List chunks, Dictionary hashes, string data, int start) { - int hash; - if (hashes.TryGetValue(data, out hash)) { + private static void AddChunk(List chunks, Dictionary hashes, string data, int start) + { + if (hashes.TryGetValue(data, out var hash)) + { chunks.Add(new Chunk(hash, start, data.Length)); - } else { + } + else + { hash = hashes.Count; hashes.Add(data, hash); chunks.Add(new Chunk(hash, start, data.Length)); diff --git a/src/Models/TextLine.cs b/src/Models/TextLine.cs deleted file mode 100644 index 72084cc0..00000000 --- a/src/Models/TextLine.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SourceGit.Models { - /// - /// 文件中的一行内容 - /// - public class TextLine { - public int Number { get; set; } - public string Data { get; set; } - } -} diff --git a/src/Models/TextMateHelper.cs b/src/Models/TextMateHelper.cs new file mode 100644 index 00000000..b7efae72 --- /dev/null +++ b/src/Models/TextMateHelper.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia; +using Avalonia.Platform; +using Avalonia.Styling; + +using AvaloniaEdit; +using AvaloniaEdit.TextMate; + +using TextMateSharp.Grammars; +using TextMateSharp.Internal.Grammars.Reader; +using TextMateSharp.Internal.Types; +using TextMateSharp.Registry; +using TextMateSharp.Themes; + +namespace SourceGit.Models +{ + public static class GrammarUtility + { + private static readonly ExtraGrammar[] s_extraGrammars = + [ + new ExtraGrammar("source.toml", [".toml"], "toml.json"), + new ExtraGrammar("source.kotlin", [".kotlin", ".kt", ".kts"], "kotlin.json"), + new ExtraGrammar("source.hx", [".hx"], "haxe.json"), + new ExtraGrammar("source.hxml", [".hxml"], "hxml.json"), + new ExtraGrammar("text.html.jsp", [".jsp", ".jspf", ".tag"], "jsp.json"), + ]; + + public static string GetScope(string file, RegistryOptions reg) + { + var extension = Path.GetExtension(file); + if (extension == ".h") + extension = ".cpp"; + else if (extension == ".resx" || extension == ".plist" || extension == ".manifest") + extension = ".xml"; + else if (extension == ".command") + extension = ".sh"; + + foreach (var grammar in s_extraGrammars) + { + foreach (var ext in grammar.Extensions) + { + if (ext.Equals(extension, StringComparison.OrdinalIgnoreCase)) + return grammar.Scope; + } + } + + return reg.GetScopeByExtension(extension); + } + + public static IRawGrammar GetGrammar(string scopeName, RegistryOptions reg) + { + foreach (var grammar in s_extraGrammars) + { + if (grammar.Scope.Equals(scopeName, StringComparison.OrdinalIgnoreCase)) + { + var asset = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Grammars/{grammar.File}", + UriKind.RelativeOrAbsolute)); + + try + { + return GrammarReader.ReadGrammarSync(new StreamReader(asset)); + } + catch + { + break; + } + } + } + + return reg.GetGrammar(scopeName); + } + + private record ExtraGrammar(string Scope, List Extensions, string File) + { + public readonly string Scope = Scope; + public readonly List Extensions = Extensions; + public readonly string File = File; + } + } + + public class RegistryOptionsWrapper(ThemeName defaultTheme) : IRegistryOptions + { + public string LastScope { get; set; } = string.Empty; + + public IRawTheme GetTheme(string scopeName) => _backend.GetTheme(scopeName); + public IRawTheme GetDefaultTheme() => _backend.GetDefaultTheme(); + public IRawTheme LoadTheme(ThemeName name) => _backend.LoadTheme(name); + public ICollection GetInjections(string scopeName) => _backend.GetInjections(scopeName); + public IRawGrammar GetGrammar(string scopeName) => GrammarUtility.GetGrammar(scopeName, _backend); + public string GetScope(string filename) => GrammarUtility.GetScope(filename, _backend); + + private readonly RegistryOptions _backend = new(defaultTheme); + } + + public static class TextMateHelper + { + public static TextMate.Installation CreateForEditor(TextEditor editor) + { + return editor.InstallTextMate(Application.Current?.ActualThemeVariant == ThemeVariant.Dark ? + new RegistryOptionsWrapper(ThemeName.DarkPlus) : + new RegistryOptionsWrapper(ThemeName.LightPlus)); + } + + public static void SetThemeByApp(TextMate.Installation installation) + { + if (installation is { RegistryOptions: RegistryOptionsWrapper reg }) + { + var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark; + installation.SetTheme(reg.LoadTheme(isDark ? ThemeName.DarkPlus : ThemeName.LightPlus)); + } + } + + public static void SetGrammarByFileName(TextMate.Installation installation, string filePath) + { + if (installation is { RegistryOptions: RegistryOptionsWrapper reg } && !string.IsNullOrEmpty(filePath)) + { + var scope = reg.GetScope(filePath); + if (reg.LastScope != scope) + { + reg.LastScope = scope; + installation.SetGrammar(reg.GetScope(filePath)); + GC.Collect(); + } + } + } + } +} diff --git a/src/Models/Theme.cs b/src/Models/Theme.cs deleted file mode 100644 index 7eafb22d..00000000 --- a/src/Models/Theme.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Windows; - -namespace SourceGit.Models { - /// - /// 主题 - /// - public static class Theme { - /// - /// 主题切换事件 - /// - public static event Action Changed; - - /// - /// 启用主题变化监听 - /// - /// - public static void AddListener(FrameworkElement elem, Action callback) { - elem.Loaded += (_, __) => Changed += callback; - elem.Unloaded += (_, __) => Changed -= callback; - } - - /// - /// 切换主题 - /// - public static void Change() { - var theme = Preference.Instance.General.UseDarkTheme ? "Dark" : "Light"; - foreach (var rs in App.Current.Resources.MergedDictionaries) { - if (rs.Source != null && rs.Source.OriginalString.StartsWith("pack://application:,,,/Resources/Themes/", StringComparison.Ordinal)) { - rs.Source = new Uri($"pack://application:,,,/Resources/Themes/{theme}.xaml", UriKind.Absolute); - break; - } - } - - Changed?.Invoke(); - } - } -} diff --git a/src/Models/ThemeOverrides.cs b/src/Models/ThemeOverrides.cs new file mode 100644 index 00000000..ccd9f57e --- /dev/null +++ b/src/Models/ThemeOverrides.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class ThemeOverrides + { + public Dictionary BasicColors { get; set; } = new Dictionary(); + public double GraphPenThickness { get; set; } = 2; + public double OpacityForNotMergedCommits { get; set; } = 0.5; + public List GraphColors { get; set; } = new List(); + } +} diff --git a/src/Models/User.cs b/src/Models/User.cs index bc3aeb3d..0b4816fe 100644 --- a/src/Models/User.cs +++ b/src/Models/User.cs @@ -1,36 +1,50 @@ -using System.Collections.Generic; +using System; +using System.Collections.Concurrent; -namespace SourceGit.Models { - /// - /// Git用户 - /// - public class User { - public static User Invalid = new User(); - public static Dictionary Caches = new Dictionary(); +namespace SourceGit.Models +{ + public class User + { + public static readonly User Invalid = new User(); public string Name { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; - public override bool Equals(object obj) { - if (obj == null || !(obj is User)) return false; - - var other = obj as User; - return Name == other.Name && Email == other.Email; + public User() + { + // Only used by User.Invalid } - public override int GetHashCode() { - return base.GetHashCode(); + public User(string data) + { + var nameEndIdx = data.IndexOf('±', StringComparison.Ordinal); + + Name = nameEndIdx > 0 ? data.Substring(0, nameEndIdx) : string.Empty; + Email = data.Substring(nameEndIdx + 1); + _hash = data.GetHashCode(); } - public static User FindOrAdd(string name, string email) { - string key = $"{name}#&#{email}"; - if (Caches.ContainsKey(key)) { - return Caches[key]; - } else { - User user = new User() { Name = name, Email = email }; - Caches.Add(key, user); - return user; - } + public override bool Equals(object obj) + { + return obj is User other && Name == other.Name && Email == other.Email; } + + public override int GetHashCode() + { + return _hash; + } + + public static User FindOrAdd(string data) + { + return _caches.GetOrAdd(data, key => new User(key)); + } + + public override string ToString() + { + return $"{Name} <{Email}>"; + } + + private static ConcurrentDictionary _caches = new ConcurrentDictionary(); + private readonly int _hash; } } diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index c05dadbf..a3cfc329 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -1,255 +1,252 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Threading; +using System.Threading.Tasks; -namespace SourceGit.Models { +namespace SourceGit.Models +{ + public class Watcher : IDisposable + { + public Watcher(IRepository repo, string fullpath, string gitDir) + { + _repo = repo; - /// - /// 文件系统更新监视 - /// - public class Watcher { - /// - /// 打开仓库事件 - /// - public static event Action Opened; + _wcWatcher = new FileSystemWatcher(); + _wcWatcher.Path = fullpath; + _wcWatcher.Filter = "*"; + _wcWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.CreationTime; + _wcWatcher.IncludeSubdirectories = true; + _wcWatcher.Created += OnWorkingCopyChanged; + _wcWatcher.Renamed += OnWorkingCopyChanged; + _wcWatcher.Changed += OnWorkingCopyChanged; + _wcWatcher.Deleted += OnWorkingCopyChanged; + _wcWatcher.EnableRaisingEvents = true; - /// - /// 仓库的书签变化了 - /// - public static event Action BookmarkChanged; + _repoWatcher = new FileSystemWatcher(); + _repoWatcher.Path = gitDir; + _repoWatcher.Filter = "*"; + _repoWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; + _repoWatcher.IncludeSubdirectories = true; + _repoWatcher.Created += OnRepositoryChanged; + _repoWatcher.Renamed += OnRepositoryChanged; + _repoWatcher.Changed += OnRepositoryChanged; + _repoWatcher.Deleted += OnRepositoryChanged; + _repoWatcher.EnableRaisingEvents = true; - /// - /// 跳转到指定提交的事件 - /// - public event Action Navigate; - /// - /// 工作副本变更 - /// - public event Action WorkingCopyChanged; - /// - /// 分支数据变更 - /// - public event Action BranchChanged; - /// - /// 标签变更 - /// - public event Action TagChanged; - /// - /// 贮藏变更 - /// - public event Action StashChanged; - /// - /// 子模块变更 - /// - public event Action SubmoduleChanged; - /// - /// 树更新 - /// - public event Action SubTreeChanged; + _timer = new Timer(Tick, null, 100, 100); + } - /// - /// 打开仓库事件 - /// - /// - public static void Open(Repository repo) { - if (all.ContainsKey(repo.Path)) { - Opened?.Invoke(repo); + public void SetEnabled(bool enabled) + { + if (enabled) + { + if (_lockCount > 0) + _lockCount--; + } + else + { + _lockCount++; + } + } + + public void SetSubmodules(List submodules) + { + lock (_lockSubmodule) + { + _submodules.Clear(); + foreach (var submodule in submodules) + _submodules.Add(submodule.Path); + } + } + + public void MarkBranchDirtyManually() + { + _updateBranch = DateTime.Now.ToFileTime() - 1; + } + + public void MarkTagDirtyManually() + { + _updateTags = DateTime.Now.ToFileTime() - 1; + } + + public void MarkWorkingCopyDirtyManually() + { + _updateWC = DateTime.Now.ToFileTime() - 1; + } + + public void Dispose() + { + _repoWatcher.EnableRaisingEvents = false; + _repoWatcher.Created -= OnRepositoryChanged; + _repoWatcher.Renamed -= OnRepositoryChanged; + _repoWatcher.Changed -= OnRepositoryChanged; + _repoWatcher.Deleted -= OnRepositoryChanged; + _repoWatcher.Dispose(); + _repoWatcher = null; + + _wcWatcher.EnableRaisingEvents = false; + _wcWatcher.Created -= OnWorkingCopyChanged; + _wcWatcher.Renamed -= OnWorkingCopyChanged; + _wcWatcher.Changed -= OnWorkingCopyChanged; + _wcWatcher.Deleted -= OnWorkingCopyChanged; + _wcWatcher.Dispose(); + _wcWatcher = null; + + _timer.Dispose(); + _timer = null; + } + + private void Tick(object sender) + { + if (_lockCount > 0) + return; + + var now = DateTime.Now.ToFileTime(); + if (_updateBranch > 0 && now > _updateBranch) + { + _updateBranch = 0; + _updateWC = 0; + + if (_updateTags > 0) + { + _updateTags = 0; + Task.Run(_repo.RefreshTags); + } + + if (_updateSubmodules > 0 || _repo.MayHaveSubmodules()) + { + _updateSubmodules = 0; + Task.Run(_repo.RefreshSubmodules); + } + + Task.Run(_repo.RefreshBranches); + Task.Run(_repo.RefreshCommits); + Task.Run(_repo.RefreshWorkingCopyChanges); + Task.Run(_repo.RefreshWorktrees); + } + + if (_updateWC > 0 && now > _updateWC) + { + _updateWC = 0; + Task.Run(_repo.RefreshWorkingCopyChanges); + } + + if (_updateSubmodules > 0 && now > _updateSubmodules) + { + _updateSubmodules = 0; + Task.Run(_repo.RefreshSubmodules); + } + + if (_updateStashes > 0 && now > _updateStashes) + { + _updateStashes = 0; + Task.Run(_repo.RefreshStashes); + } + + if (_updateTags > 0 && now > _updateTags) + { + _updateTags = 0; + Task.Run(_repo.RefreshTags); + Task.Run(_repo.RefreshCommits); + } + } + + private void OnRepositoryChanged(object o, FileSystemEventArgs e) + { + if (string.IsNullOrEmpty(e.Name)) + return; + + var name = e.Name.Replace('\\', '/').TrimEnd('/'); + if (name.Contains("fsmonitor--daemon/", StringComparison.Ordinal) || + name.EndsWith(".lock", StringComparison.Ordinal) || + name.StartsWith("lfs/", StringComparison.Ordinal)) + return; + + if (name.StartsWith("modules", StringComparison.Ordinal)) + { + if (name.EndsWith("/HEAD", StringComparison.Ordinal) || + name.EndsWith("/ORIG_HEAD", StringComparison.Ordinal)) + { + _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); + _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + } + } + else if (name.Equals("MERGE_HEAD", StringComparison.Ordinal) || + name.Equals("AUTO_MERGE", StringComparison.Ordinal)) + { + if (_repo.MayHaveSubmodules()) + _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); + } + else if (name.StartsWith("refs/tags", StringComparison.Ordinal)) + { + _updateTags = DateTime.Now.AddSeconds(.5).ToFileTime(); + } + else if (name.StartsWith("refs/stash", StringComparison.Ordinal)) + { + _updateStashes = DateTime.Now.AddSeconds(.5).ToFileTime(); + } + else if (name.Equals("HEAD", StringComparison.Ordinal) || + name.Equals("BISECT_START", StringComparison.Ordinal) || + name.StartsWith("refs/heads/", StringComparison.Ordinal) || + name.StartsWith("refs/remotes/", StringComparison.Ordinal) || + (name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal))) + { + _updateBranch = DateTime.Now.AddSeconds(.5).ToFileTime(); + } + else if (name.StartsWith("objects/", StringComparison.Ordinal) || name.Equals("index", StringComparison.Ordinal)) + { + _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + } + } + + private void OnWorkingCopyChanged(object o, FileSystemEventArgs e) + { + if (string.IsNullOrEmpty(e.Name)) + return; + + var name = e.Name.Replace('\\', '/').TrimEnd('/'); + if (name.Equals(".git", StringComparison.Ordinal) || + name.StartsWith(".git/", StringComparison.Ordinal) || + name.EndsWith("/.git", StringComparison.Ordinal)) + return; + + if (name.StartsWith(".vs/", StringComparison.Ordinal)) + return; + + if (name.Equals(".gitmodules", StringComparison.Ordinal)) + { + _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); return; } - var watcher = new Watcher(); - watcher.Start(repo.Path, repo.GitDir); - all.Add(repo.Path, watcher); - repo.LastOpenTime = DateTime.Now.ToFileTime(); - - Opened?.Invoke(repo); - } - - /// - /// 停止指定的监视器 - /// - /// - public static void Close(string repoPath) { - if (!all.ContainsKey(repoPath)) return; - all[repoPath].Stop(); - all.Remove(repoPath); - } - - /// - /// 取得一个仓库的监视器 - /// - /// - /// - public static Watcher Get(string repoPath) { - if (all.ContainsKey(repoPath)) return all[repoPath]; - return null; - } - - /// - /// 暂停或启用监听 - /// - /// - /// - public static void SetEnabled(string repoPath, bool enabled) { - if (all.ContainsKey(repoPath)) { - var watcher = all[repoPath]; - if (enabled) { - if (watcher.lockCount > 0) watcher.lockCount--; - } else { - watcher.lockCount++; + lock (_lockSubmodule) + { + foreach (var submodule in _submodules) + { + if (name.StartsWith(submodule, StringComparison.Ordinal)) + { + _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); + return; + } } } + + _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); } - /// - /// 通知仓库标签变化 - /// - /// - public static void NotifyBookmarkChanged(Repository repo) { - BookmarkChanged?.Invoke(repo.Path, repo.Bookmark); - } + private readonly IRepository _repo = null; + private FileSystemWatcher _repoWatcher = null; + private FileSystemWatcher _wcWatcher = null; + private Timer _timer = null; + private int _lockCount = 0; + private long _updateWC = 0; + private long _updateBranch = 0; + private long _updateSubmodules = 0; + private long _updateStashes = 0; + private long _updateTags = 0; - /// - /// 跳转到指定的提交 - /// - /// - public void NavigateTo(string commit) { - Navigate?.Invoke(commit); - } - - /// - /// 仅强制更新本地变化 - /// - public void RefreshWC() { - updateWC = 0; - WorkingCopyChanged?.Invoke(); - } - - /// - /// 通知更新子树列表 - /// - public void RefreshSubTrees() { - SubTreeChanged?.Invoke(); - } - - private void Start(string repo, string gitDir) { - wcWatcher = new FileSystemWatcher(); - wcWatcher.Path = repo; - wcWatcher.Filter = "*"; - wcWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.CreationTime; - wcWatcher.IncludeSubdirectories = true; - wcWatcher.Created += OnWorkingCopyChanged; - wcWatcher.Renamed += OnWorkingCopyChanged; - wcWatcher.Changed += OnWorkingCopyChanged; - wcWatcher.Deleted += OnWorkingCopyChanged; - wcWatcher.EnableRaisingEvents = true; - - repoWatcher = new FileSystemWatcher(); - repoWatcher.Path = gitDir; - repoWatcher.Filter = "*"; - repoWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; - repoWatcher.IncludeSubdirectories = true; - repoWatcher.Created += OnRepositoryChanged; - repoWatcher.Renamed += OnRepositoryChanged; - repoWatcher.Changed += OnRepositoryChanged; - repoWatcher.Deleted += OnRepositoryChanged; - repoWatcher.EnableRaisingEvents = true; - - timer = new Timer(Tick, null, 100, 100); - } - - private void Stop() { - repoWatcher.EnableRaisingEvents = false; - repoWatcher.Dispose(); - repoWatcher = null; - - wcWatcher.EnableRaisingEvents = false; - wcWatcher.Dispose(); - wcWatcher = null; - - timer.Dispose(); - timer = null; - - Navigate = null; - WorkingCopyChanged = null; - BranchChanged = null; - TagChanged = null; - StashChanged = null; - SubmoduleChanged = null; - SubTreeChanged = null; - } - - private void OnRepositoryChanged(object o, FileSystemEventArgs e) { - if (string.IsNullOrEmpty(e.Name)) return; - - if (e.Name.StartsWith("modules", StringComparison.Ordinal)) { - updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); - } else if (e.Name.StartsWith("refs\\tags", StringComparison.Ordinal)) { - updateTags = DateTime.Now.AddSeconds(.5).ToFileTime(); - } else if (e.Name.StartsWith("refs\\stash", StringComparison.Ordinal)) { - updateStashes = DateTime.Now.AddSeconds(.5).ToFileTime(); - } else if (e.Name.Equals("HEAD", StringComparison.Ordinal) || - e.Name.StartsWith("refs\\heads\\", StringComparison.Ordinal) || - e.Name.StartsWith("refs\\remotes\\", StringComparison.Ordinal) || - e.Name.StartsWith("worktrees\\")) { - updateBranch = DateTime.Now.AddSeconds(.5).ToFileTime(); - } else if (e.Name.StartsWith("objects\\", StringComparison.Ordinal) || e.Name.Equals("index", StringComparison.Ordinal)) { - updateWC = DateTime.Now.AddSeconds(.5).ToFileTime(); - } - } - - private void OnWorkingCopyChanged(object o, FileSystemEventArgs e) { - if (string.IsNullOrEmpty(e.Name)) return; - if (e.Name == ".git" || e.Name.StartsWith(".git\\", StringComparison.Ordinal)) return; - - updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); - } - - private void Tick(object sender) { - if (lockCount > 0) return; - - var now = DateTime.Now.ToFileTime(); - if (updateBranch > 0 && now > updateBranch) { - BranchChanged?.Invoke(); - WorkingCopyChanged?.Invoke(); - updateBranch = 0; - updateWC = 0; - } - - if (updateWC > 0 && now > updateWC) { - WorkingCopyChanged?.Invoke(); - updateWC = 0; - } - - if (updateSubmodules > 0 && now > updateSubmodules) { - SubmoduleChanged?.Invoke(); - updateSubmodules = 0; - } - - if (updateStashes > 0 && now > updateStashes) { - StashChanged?.Invoke(); - updateStashes = 0; - } - - if (updateTags > 0 && now > updateTags) { - TagChanged?.Invoke(); - updateTags = 0; - } - } - - #region PRIVATES - private static Dictionary all = new Dictionary(); - - private FileSystemWatcher repoWatcher = null; - private FileSystemWatcher wcWatcher = null; - private Timer timer = null; - private int lockCount = 0; - private long updateWC = 0; - private long updateBranch = 0; - private long updateSubmodules = 0; - private long updateStashes = 0; - private long updateTags = 0; - #endregion + private readonly Lock _lockSubmodule = new(); + private List _submodules = new List(); } } diff --git a/src/Models/WhitespaceOption.cs b/src/Models/WhitespaceOption.cs deleted file mode 100644 index 9e70437b..00000000 --- a/src/Models/WhitespaceOption.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; - -namespace SourceGit.Models { - /// - /// 应用补丁时空白字符的处理方式 - /// - public class WhitespaceOption { - public string Name { get; set; } - public string Desc { get; set; } - public string Arg { get; set; } - - public static List Supported = new List() { - new WhitespaceOption("Apply.NoWarn", "Apply.NoWarn.Desc", "nowarn"), - new WhitespaceOption("Apply.Warn", "Apply.Warn.Desc", "warn"), - new WhitespaceOption("Apply.Error", "Apply.Error.Desc", "error"), - new WhitespaceOption("Apply.ErrorAll", "Apply.ErrorAll.Desc", "error-all") - }; - - public WhitespaceOption(string n, string d, string a) { - Name = App.Text(n); - Desc = App.Text(d); - Arg = a; - } - } -} diff --git a/src/Models/Worktree.cs b/src/Models/Worktree.cs new file mode 100644 index 00000000..26f88a8a --- /dev/null +++ b/src/Models/Worktree.cs @@ -0,0 +1,40 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public class Worktree : ObservableObject + { + public string Branch { get; set; } = string.Empty; + public string FullPath { get; set; } = string.Empty; + public string RelativePath { get; set; } = string.Empty; + public string Head { get; set; } = string.Empty; + public bool IsBare { get; set; } = false; + public bool IsDetached { get; set; } = false; + + public bool IsLocked + { + get => _isLocked; + set => SetProperty(ref _isLocked, value); + } + + public string Name + { + get + { + if (IsDetached) + return $"detached HEAD at {Head.AsSpan(10)}"; + + if (Branch.StartsWith("refs/heads/", StringComparison.Ordinal)) + return Branch.Substring(11); + + if (Branch.StartsWith("refs/remotes/", StringComparison.Ordinal)) + return Branch.Substring(13); + + return Branch; + } + } + + private bool _isLocked = false; + } +} diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs new file mode 100644 index 00000000..3f6de903 --- /dev/null +++ b/src/Native/Linux.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.Versioning; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; + +namespace SourceGit.Native +{ + [SupportedOSPlatform("linux")] + internal class Linux : OS.IBackend + { + public void SetupApp(AppBuilder builder) + { + builder.With(new X11PlatformOptions() { EnableIme = true }); + } + + public void SetupWindow(Window window) + { + if (OS.UseSystemWindowFrame) + { + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.Default; + window.ExtendClientAreaToDecorationsHint = false; + } + else + { + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; + window.ExtendClientAreaToDecorationsHint = true; + window.Classes.Add("custom_window_frame"); + } + } + + public string FindGitExecutable() + { + return FindExecutable("git"); + } + + public string FindTerminal(Models.ShellOrTerminal shell) + { + if (shell.Type.Equals("custom", StringComparison.Ordinal)) + return string.Empty; + + return FindExecutable(shell.Exec); + } + + public List FindExternalTools() + { + var finder = new Models.ExternalToolsFinder(); + finder.VSCode(() => FindExecutable("code")); + finder.VSCodeInsiders(() => FindExecutable("code-insiders")); + finder.VSCodium(() => FindExecutable("codium")); + finder.Fleet(FindJetBrainsFleet); + finder.FindJetBrainsFromToolbox(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}/JetBrains/Toolbox"); + finder.SublimeText(() => FindExecutable("subl")); + finder.Zed(() => FindExecutable("zeditor")); + return finder.Founded; + } + + public void OpenBrowser(string url) + { + Process.Start("xdg-open", $"\"{url}\""); + } + + public void OpenInFileManager(string path, bool select) + { + if (Directory.Exists(path)) + { + Process.Start("xdg-open", $"\"{path}\""); + } + else + { + var dir = Path.GetDirectoryName(path); + if (Directory.Exists(dir)) + Process.Start("xdg-open", $"\"{dir}\""); + } + } + + public void OpenTerminal(string workdir) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var cwd = string.IsNullOrEmpty(workdir) ? home : workdir; + var terminal = OS.ShellOrTerminal; + + var startInfo = new ProcessStartInfo(); + startInfo.WorkingDirectory = cwd; + startInfo.FileName = terminal; + + if (terminal.EndsWith("wezterm", StringComparison.OrdinalIgnoreCase)) + startInfo.Arguments = $"start --cwd \"{cwd}\""; + else if (terminal.EndsWith("ptyxis", StringComparison.OrdinalIgnoreCase)) + startInfo.Arguments = $"--new-window --working-directory=\"{cwd}\""; + + try + { + Process.Start(startInfo); + } + catch (Exception e) + { + App.RaiseException(workdir, $"Failed to start '{OS.ShellOrTerminal}'. Reason: {e.Message}"); + } + } + + public void OpenWithDefaultEditor(string file) + { + var proc = Process.Start("xdg-open", $"\"{file}\""); + if (proc != null) + { + proc.WaitForExit(); + + if (proc.ExitCode != 0) + App.RaiseException("", $"Failed to open \"{file}\""); + + proc.Close(); + } + } + + private string FindExecutable(string filename) + { + var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var paths = pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in paths) + { + var test = Path.Combine(path, filename); + if (File.Exists(test)) + return test; + } + + return string.Empty; + } + + private string FindJetBrainsFleet() + { + var path = $"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}/JetBrains/Toolbox/apps/fleet/bin/Fleet"; + return File.Exists(path) ? path : FindExecutable("fleet"); + } + } +} diff --git a/src/Native/MacOS.cs b/src/Native/MacOS.cs new file mode 100644 index 00000000..b76d239a --- /dev/null +++ b/src/Native/MacOS.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.Versioning; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; + +namespace SourceGit.Native +{ + [SupportedOSPlatform("macOS")] + internal class MacOS : OS.IBackend + { + public void SetupApp(AppBuilder builder) + { + builder.With(new MacOSPlatformOptions() + { + DisableDefaultApplicationMenuItems = true, + }); + + // Fix `PATH` env on macOS. + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) + path = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; + else if (!path.Contains("/opt/homebrew/", StringComparison.Ordinal)) + path = "/opt/homebrew/bin:/opt/homebrew/sbin:" + path; + + var customPathFile = Path.Combine(OS.DataDir, "PATH"); + if (File.Exists(customPathFile)) + { + var env = File.ReadAllText(customPathFile).Trim(); + if (!string.IsNullOrEmpty(env)) + path = env; + } + + Environment.SetEnvironmentVariable("PATH", path); + } + + public void SetupWindow(Window window) + { + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome; + window.ExtendClientAreaToDecorationsHint = true; + } + + public string FindGitExecutable() + { + var gitPathVariants = new List() { + "/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git", "/opt/homebrew/opt/git/bin/git" + }; + foreach (var path in gitPathVariants) + if (File.Exists(path)) + return path; + return string.Empty; + } + + public string FindTerminal(Models.ShellOrTerminal shell) + { + switch (shell.Type) + { + case "mac-terminal": + return "Terminal"; + case "iterm2": + return "iTerm"; + case "warp": + return "Warp"; + case "ghostty": + return "Ghostty"; + case "kitty": + return "kitty"; + } + + return string.Empty; + } + + public List FindExternalTools() + { + var finder = new Models.ExternalToolsFinder(); + finder.VSCode(() => "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"); + finder.VSCodeInsiders(() => "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code"); + finder.VSCodium(() => "/Applications/VSCodium.app/Contents/Resources/app/bin/codium"); + finder.Fleet(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}/Applications/Fleet.app/Contents/MacOS/Fleet"); + finder.FindJetBrainsFromToolbox(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}/Library/Application Support/JetBrains/Toolbox"); + finder.SublimeText(() => "/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl"); + finder.Zed(() => File.Exists("/usr/local/bin/zed") ? "/usr/local/bin/zed" : "/Applications/Zed.app/Contents/MacOS/cli"); + return finder.Founded; + } + + public void OpenBrowser(string url) + { + Process.Start("open", url); + } + + public void OpenInFileManager(string path, bool select) + { + if (Directory.Exists(path)) + Process.Start("open", $"\"{path}\""); + else if (File.Exists(path)) + Process.Start("open", $"\"{path}\" -R"); + } + + public void OpenTerminal(string workdir) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var dir = string.IsNullOrEmpty(workdir) ? home : workdir; + Process.Start("open", $"-a {OS.ShellOrTerminal} \"{dir}\""); + } + + public void OpenWithDefaultEditor(string file) + { + Process.Start("open", $"\"{file}\""); + } + } +} diff --git a/src/Native/OS.cs b/src/Native/OS.cs new file mode 100644 index 00000000..ad6f8104 --- /dev/null +++ b/src/Native/OS.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +using Avalonia; +using Avalonia.Controls; + +namespace SourceGit.Native +{ + public static partial class OS + { + public interface IBackend + { + void SetupApp(AppBuilder builder); + void SetupWindow(Window window); + + string FindGitExecutable(); + string FindTerminal(Models.ShellOrTerminal shell); + List FindExternalTools(); + + void OpenTerminal(string workdir); + void OpenInFileManager(string path, bool select); + void OpenBrowser(string url); + void OpenWithDefaultEditor(string file); + } + + public static string DataDir + { + get; + private set; + } = string.Empty; + + public static string GitExecutable + { + get => _gitExecutable; + set + { + if (_gitExecutable != value) + { + _gitExecutable = value; + UpdateGitVersion(); + } + } + } + + public static string GitVersionString + { + get; + private set; + } = string.Empty; + + public static Version GitVersion + { + get; + private set; + } = new Version(0, 0, 0); + + public static string ShellOrTerminal + { + get; + set; + } = string.Empty; + + public static List ExternalTools + { + get; + set; + } = []; + + public static bool UseSystemWindowFrame + { + get => OperatingSystem.IsLinux() && _enableSystemWindowFrame; + set => _enableSystemWindowFrame = value; + } + + static OS() + { + if (OperatingSystem.IsWindows()) + { + _backend = new Windows(); + } + else if (OperatingSystem.IsMacOS()) + { + _backend = new MacOS(); + } + else if (OperatingSystem.IsLinux()) + { + _backend = new Linux(); + } + else + { + throw new Exception("Platform unsupported!!!"); + } + } + + public static void SetupApp(AppBuilder builder) + { + _backend.SetupApp(builder); + } + + public static void SetupDataDir() + { + if (OperatingSystem.IsWindows()) + { + var execFile = Process.GetCurrentProcess().MainModule!.FileName; + var portableDir = Path.Combine(Path.GetDirectoryName(execFile), "data"); + if (Directory.Exists(portableDir)) + { + DataDir = portableDir; + return; + } + } + + var osAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(osAppDataDir)) + DataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sourcegit"); + else + DataDir = Path.Combine(osAppDataDir, "SourceGit"); + + if (!Directory.Exists(DataDir)) + Directory.CreateDirectory(DataDir); + } + + public static void SetupExternalTools() + { + ExternalTools = _backend.FindExternalTools(); + } + + public static void SetupForWindow(Window window) + { + _backend.SetupWindow(window); + } + + public static string FindGitExecutable() + { + return _backend.FindGitExecutable(); + } + + public static bool TestShellOrTerminal(Models.ShellOrTerminal shell) + { + return !string.IsNullOrEmpty(_backend.FindTerminal(shell)); + } + + public static void SetShellOrTerminal(Models.ShellOrTerminal shell) + { + if (shell == null) + ShellOrTerminal = string.Empty; + else + ShellOrTerminal = _backend.FindTerminal(shell); + } + + public static void OpenInFileManager(string path, bool select = false) + { + _backend.OpenInFileManager(path, select); + } + + public static void OpenBrowser(string url) + { + _backend.OpenBrowser(url); + } + + public static void OpenTerminal(string workdir) + { + if (string.IsNullOrEmpty(ShellOrTerminal)) + App.RaiseException(workdir, $"Terminal is not specified! Please confirm that the correct shell/terminal has been configured."); + else + _backend.OpenTerminal(workdir); + } + + public static void OpenWithDefaultEditor(string file) + { + _backend.OpenWithDefaultEditor(file); + } + + public static string GetAbsPath(string root, string sub) + { + var fullpath = Path.Combine(root, sub); + if (OperatingSystem.IsWindows()) + return fullpath.Replace('/', '\\'); + + return fullpath; + } + + private static void UpdateGitVersion() + { + if (string.IsNullOrEmpty(_gitExecutable) || !File.Exists(_gitExecutable)) + { + GitVersionString = string.Empty; + GitVersion = new Version(0, 0, 0); + return; + } + + var start = new ProcessStartInfo(); + start.FileName = _gitExecutable; + start.Arguments = "--version"; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + + var proc = new Process() { StartInfo = start }; + try + { + proc.Start(); + + var rs = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + if (proc.ExitCode == 0 && !string.IsNullOrWhiteSpace(rs)) + { + GitVersionString = rs.Trim(); + + var match = REG_GIT_VERSION().Match(GitVersionString); + if (match.Success) + { + var major = int.Parse(match.Groups[1].Value); + var minor = int.Parse(match.Groups[2].Value); + var build = int.Parse(match.Groups[3].Value); + GitVersion = new Version(major, minor, build); + GitVersionString = GitVersionString.Substring(11).Trim(); + } + } + } + catch + { + // Ignore errors + } + + proc.Close(); + } + + [GeneratedRegex(@"^git version[\s\w]*(\d+)\.(\d+)[\.\-](\d+).*$")] + private static partial Regex REG_GIT_VERSION(); + + private static IBackend _backend = null; + private static string _gitExecutable = string.Empty; + private static bool _enableSystemWindowFrame = false; + } +} diff --git a/src/Native/Windows.cs b/src/Native/Windows.cs new file mode 100644 index 00000000..07cf51fb --- /dev/null +++ b/src/Native/Windows.cs @@ -0,0 +1,444 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace SourceGit.Native +{ + [SupportedOSPlatform("windows")] + internal class Windows : OS.IBackend + { + internal struct RECT + { + public int left; + public int top; + public int right; + public int bottom; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct MARGINS + { + public int cxLeftWidth; + public int cxRightWidth; + public int cyTopHeight; + public int cyBottomHeight; + } + + [DllImport("dwmapi.dll")] + private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins); + + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, SetLastError = false)] + private static extern bool PathFindOnPath([In, Out] StringBuilder pszFile, [In] string[] ppszOtherDirs); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = false)] + private static extern IntPtr ILCreateFromPathW(string pszPath); + + [DllImport("shell32.dll", SetLastError = false)] + private static extern void ILFree(IntPtr pidl); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = false)] + private static extern int SHOpenFolderAndSelectItems(IntPtr pidlFolder, int cild, IntPtr apidl, int dwFlags); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect); + + public void SetupApp(AppBuilder builder) + { + // Fix drop shadow issue on Windows 10 + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 22000, 0)) + { + Window.WindowStateProperty.Changed.AddClassHandler((w, _) => FixWindowFrameOnWin10(w)); + Control.LoadedEvent.AddClassHandler((w, _) => FixWindowFrameOnWin10(w)); + } + } + + public void SetupWindow(Window window) + { + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; + window.ExtendClientAreaToDecorationsHint = true; + window.Classes.Add("fix_maximized_padding"); + + Win32Properties.AddWndProcHookCallback(window, (IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam, ref bool handled) => + { + // Custom WM_NCHITTEST + if (msg == 0x0084) + { + handled = true; + + if (window.WindowState == WindowState.FullScreen || window.WindowState == WindowState.Maximized) + return 1; // HTCLIENT + + var p = IntPtrToPixelPoint(lParam); + GetWindowRect(hWnd, out var rcWindow); + + var borderThickness = (int)(4 * window.RenderScaling); + int y = 1; + int x = 1; + if (p.X >= rcWindow.left && p.X < rcWindow.left + borderThickness) + x = 0; + else if (p.X < rcWindow.right && p.X >= rcWindow.right - borderThickness) + x = 2; + + if (p.Y >= rcWindow.top && p.Y < rcWindow.top + borderThickness) + y = 0; + else if (p.Y < rcWindow.bottom && p.Y >= rcWindow.bottom - borderThickness) + y = 2; + + var zone = y * 3 + x; + switch (zone) + { + case 0: + return 13; // HTTOPLEFT + case 1: + return 12; // HTTOP + case 2: + return 14; // HTTOPRIGHT + case 3: + return 10; // HTLEFT + case 4: + return 1; // HTCLIENT + case 5: + return 11; // HTRIGHT + case 6: + return 16; // HTBOTTOMLEFT + case 7: + return 15; // HTBOTTOM + default: + return 17; // HTBOTTOMRIGHT + } + } + + return IntPtr.Zero; + }); + } + + public string FindGitExecutable() + { + var reg = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); + + var git = reg.OpenSubKey(@"SOFTWARE\GitForWindows"); + if (git?.GetValue("InstallPath") is string installPath) + return Path.Combine(installPath, "bin", "git.exe"); + + var builder = new StringBuilder("git.exe", 259); + if (!PathFindOnPath(builder, null)) + return null; + + var exePath = builder.ToString(); + if (!string.IsNullOrEmpty(exePath)) + return exePath; + + return null; + } + + public string FindTerminal(Models.ShellOrTerminal shell) + { + switch (shell.Type) + { + case "git-bash": + if (string.IsNullOrEmpty(OS.GitExecutable)) + break; + + var binDir = Path.GetDirectoryName(OS.GitExecutable)!; + var bash = Path.Combine(binDir, "bash.exe"); + if (!File.Exists(bash)) + break; + + return bash; + case "pwsh": + var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); + + var pwsh = localMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\pwsh.exe"); + if (pwsh != null) + { + var path = pwsh.GetValue(null) as string; + if (File.Exists(path)) + return path; + } + + var pwshFinder = new StringBuilder("powershell.exe", 512); + if (PathFindOnPath(pwshFinder, null)) + return pwshFinder.ToString(); + + break; + case "cmd": + return @"C:\Windows\System32\cmd.exe"; + case "wt": + var wtFinder = new StringBuilder("wt.exe", 512); + if (PathFindOnPath(wtFinder, null)) + return wtFinder.ToString(); + + break; + } + + return string.Empty; + } + + public List FindExternalTools() + { + var finder = new Models.ExternalToolsFinder(); + finder.VSCode(FindVSCode); + finder.VSCodeInsiders(FindVSCodeInsiders); + finder.VSCodium(FindVSCodium); + finder.Fleet(() => $@"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Programs\Fleet\Fleet.exe"); + finder.FindJetBrainsFromToolbox(() => $@"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\JetBrains\Toolbox"); + finder.SublimeText(FindSublimeText); + finder.TryAdd("Visual Studio", "vs", FindVisualStudio, GenerateCommandlineArgsForVisualStudio); + return finder.Founded; + } + + public void OpenBrowser(string url) + { + var info = new ProcessStartInfo("cmd", $"/c start \"\" \"{url}\""); + info.CreateNoWindow = true; + Process.Start(info); + } + + public void OpenTerminal(string workdir) + { + if (!File.Exists(OS.ShellOrTerminal)) + { + App.RaiseException(workdir, $"Terminal is not specified! Please confirm that the correct shell/terminal has been configured."); + return; + } + + var startInfo = new ProcessStartInfo(); + startInfo.WorkingDirectory = workdir; + startInfo.FileName = OS.ShellOrTerminal; + + // Directly launching `Windows Terminal` need to specify the `-d` parameter + if (OS.ShellOrTerminal.EndsWith("wt.exe", StringComparison.OrdinalIgnoreCase)) + startInfo.Arguments = $"-d \"{workdir}\""; + + Process.Start(startInfo); + } + + public void OpenInFileManager(string path, bool select) + { + string fullpath; + if (File.Exists(path)) + { + fullpath = new FileInfo(path).FullName; + select = true; + } + else + { + fullpath = new DirectoryInfo(path!).FullName; + fullpath += Path.DirectorySeparatorChar; + } + + if (select) + { + OpenFolderAndSelectFile(fullpath); + } + else + { + Process.Start(new ProcessStartInfo(fullpath) + { + UseShellExecute = true, + CreateNoWindow = true, + }); + } + } + + public void OpenWithDefaultEditor(string file) + { + var info = new FileInfo(file); + var start = new ProcessStartInfo("cmd", $"/c start \"\" \"{info.FullName}\""); + start.CreateNoWindow = true; + Process.Start(start); + } + + private void FixWindowFrameOnWin10(Window w) + { + // Schedule the DWM frame extension to run in the next render frame + // to ensure proper timing with the window initialization sequence + Dispatcher.UIThread.InvokeAsync(() => + { + var platformHandle = w.TryGetPlatformHandle(); + if (platformHandle == null) + return; + + var margins = new MARGINS { cxLeftWidth = 1, cxRightWidth = 1, cyTopHeight = 1, cyBottomHeight = 1 }; + DwmExtendFrameIntoClientArea(platformHandle.Handle, ref margins); + }, DispatcherPriority.Render); + } + + private PixelPoint IntPtrToPixelPoint(IntPtr param) + { + var v = IntPtr.Size == 4 ? param.ToInt32() : (int)(param.ToInt64() & 0xFFFFFFFF); + return new PixelPoint((short)(v & 0xffff), (short)(v >> 16)); + } + + #region EXTERNAL_EDITOR_FINDER + private string FindVSCode() + { + var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); + + // VSCode (system) + var systemVScode = localMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1"); + if (systemVScode != null) + return systemVScode.GetValue("DisplayIcon") as string; + + var currentUser = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.CurrentUser, + Microsoft.Win32.RegistryView.Registry64); + + // VSCode (user) + var vscode = currentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{771FD6B0-FA20-440A-A002-3B3BAC16DC50}_is1"); + if (vscode != null) + return vscode.GetValue("DisplayIcon") as string; + + return string.Empty; + } + + private string FindVSCodeInsiders() + { + var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); + + // VSCode - Insiders (system) + var systemVScodeInsiders = localMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{1287CAD5-7C8D-410D-88B9-0D1EE4A83FF2}_is1"); + if (systemVScodeInsiders != null) + return systemVScodeInsiders.GetValue("DisplayIcon") as string; + + var currentUser = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.CurrentUser, + Microsoft.Win32.RegistryView.Registry64); + + // VSCode - Insiders (user) + var vscodeInsiders = currentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{217B4C08-948D-4276-BFBB-BEE930AE5A2C}_is1"); + if (vscodeInsiders != null) + return vscodeInsiders.GetValue("DisplayIcon") as string; + + return string.Empty; + } + + private string FindVSCodium() + { + var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); + + // VSCodium (system) + var systemVSCodium = localMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{88DA3577-054F-4CA1-8122-7D820494CFFB}_is1"); + if (systemVSCodium != null) + return systemVSCodium.GetValue("DisplayIcon") as string; + + var currentUser = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.CurrentUser, + Microsoft.Win32.RegistryView.Registry64); + + // VSCodium (user) + var vscodium = currentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{2E1F05D1-C245-4562-81EE-28188DB6FD17}_is1"); + if (vscodium != null) + return vscodium.GetValue("DisplayIcon") as string; + + return string.Empty; + } + + private string FindSublimeText() + { + var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); + + // Sublime Text 4 + var sublime = localMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Sublime Text_is1"); + if (sublime != null) + { + var icon = sublime.GetValue("DisplayIcon") as string; + return Path.Combine(Path.GetDirectoryName(icon)!, "subl.exe"); + } + + // Sublime Text 3 + var sublime3 = localMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Sublime Text 3_is1"); + if (sublime3 != null) + { + var icon = sublime3.GetValue("DisplayIcon") as string; + return Path.Combine(Path.GetDirectoryName(icon)!, "subl.exe"); + } + + return string.Empty; + } + + private string FindVisualStudio() + { + var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); + + // Get default class for VisualStudio.Launcher.sln - the handler for *.sln files + if (localMachine.OpenSubKey(@"SOFTWARE\Classes\VisualStudio.Launcher.sln\CLSID") is Microsoft.Win32.RegistryKey launcher) + { + // Get actual path to the executable + if (launcher.GetValue(string.Empty) is string CLSID && + localMachine.OpenSubKey(@$"SOFTWARE\Classes\CLSID\{CLSID}\LocalServer32") is Microsoft.Win32.RegistryKey devenv && + devenv.GetValue(string.Empty) is string localServer32) + return localServer32!.Trim('\"'); + } + + return string.Empty; + } + #endregion + + private void OpenFolderAndSelectFile(string folderPath) + { + var pidl = ILCreateFromPathW(folderPath); + + try + { + SHOpenFolderAndSelectItems(pidl, 0, 0, 0); + } + finally + { + ILFree(pidl); + } + } + + private string GenerateCommandlineArgsForVisualStudio(string repo) + { + var sln = FindVSSolutionFile(new DirectoryInfo(repo), 4); + return string.IsNullOrEmpty(sln) ? $"\"{repo}\"" : $"\"{sln}\""; + } + + private string FindVSSolutionFile(DirectoryInfo dir, int leftDepth) + { + var files = dir.GetFiles(); + foreach (var f in files) + { + if (f.Name.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)) + return f.FullName; + } + + if (leftDepth <= 0) + return null; + + var subDirs = dir.GetDirectories(); + foreach (var subDir in subDirs) + { + var first = FindVSSolutionFile(subDir, leftDepth - 1); + if (!string.IsNullOrEmpty(first)) + return first; + } + + return null; + } + } +} diff --git a/src/Resources/Controls.xaml b/src/Resources/Controls.xaml deleted file mode 100644 index 30a338bc..00000000 --- a/src/Resources/Controls.xaml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Converters.xaml b/src/Resources/Converters.xaml deleted file mode 100644 index ef9b7eeb..00000000 --- a/src/Resources/Converters.xaml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Fonts/JetBrainsMono-Bold.ttf b/src/Resources/Fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 00000000..8c93043d Binary files /dev/null and b/src/Resources/Fonts/JetBrainsMono-Bold.ttf differ diff --git a/src/Resources/Fonts/JetBrainsMono-Italic.ttf b/src/Resources/Fonts/JetBrainsMono-Italic.ttf new file mode 100644 index 00000000..ccc9d6a5 Binary files /dev/null and b/src/Resources/Fonts/JetBrainsMono-Italic.ttf differ diff --git a/src/Resources/Fonts/JetBrainsMono-Regular.ttf b/src/Resources/Fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..dff66cc5 Binary files /dev/null and b/src/Resources/Fonts/JetBrainsMono-Regular.ttf differ diff --git a/src/Resources/Grammars/haxe.json b/src/Resources/Grammars/haxe.json new file mode 100644 index 00000000..3f78154d --- /dev/null +++ b/src/Resources/Grammars/haxe.json @@ -0,0 +1,2490 @@ +{ + "information_for_contributors": [ + "This file has been copied from https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/haxe.tmLanguage", + "and converted to JSON using https://marketplace.visualstudio.com/items?itemName=pedro-w.tmlanguage", + "The original file was licensed under the MIT License", + "https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/LICENSE.md" + ], + "fileTypes": [ + "hx", + "dump" + ], + "name": "Haxe", + "scopeName": "source.hx", + "uuid": "67c72f9f-862c-4e48-8951-dcc22c0bb4ea", + "patterns": [ + { + "include": "#all" + } + ], + "repository": { + "all": { + "patterns": [ + { + "include": "#global" + }, + { + "include": "#package" + }, + { + "include": "#import" + }, + { + "include": "#using" + }, + { + "match": "\\b(final)\\b(?=\\s+(class|interface|extern|private)\\b)", + "name": "storage.modifier.hx" + }, + { + "include": "#abstract" + }, + { + "include": "#class" + }, + { + "include": "#enum" + }, + { + "include": "#interface" + }, + { + "include": "#typedef" + }, + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "global": { + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#conditional-compilation" + } + ] + }, + "block": { + "begin": "\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.hx" + } + }, + "patterns": [ + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "block-contents": { + "patterns": [ + { + "include": "#global" + }, + { + "include": "#regex" + }, + { + "include": "#array" + }, + { + "include": "#constants" + }, + { + "include": "#strings" + }, + { + "include": "#metadata" + }, + { + "include": "#method" + }, + { + "include": "#variable" + }, + { + "include": "#modifiers" + }, + { + "include": "#new-expr" + }, + { + "include": "#for-loop" + }, + { + "include": "#keywords" + }, + { + "include": "#arrow-function" + }, + { + "include": "#method-call" + }, + { + "include": "#enum-constructor-call" + }, + { + "include": "#punctuation-braces" + }, + { + "include": "#macro-reification" + }, + { + "include": "#operators" + }, + { + "include": "#operator-assignment" + }, + { + "include": "#punctuation-terminator" + }, + { + "include": "#punctuation-comma" + }, + { + "include": "#punctuation-accessor" + }, + { + "include": "#identifiers" + } + ] + }, + "identifiers": { + "patterns": [ + { + "include": "#constant-name" + }, + { + "include": "#type-name" + }, + { + "include": "#identifier-name" + } + ] + }, + "package": { + "begin": "package\\b", + "beginCaptures": { + "0": { + "name": "keyword.other.package.hx" + } + }, + "end": "$|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "patterns": [ + { + "include": "#type-path" + }, + { + "include": "#type-path-package-name" + } + ] + }, + "using": { + "begin": "using\\b", + "beginCaptures": { + "0": { + "name": "keyword.other.using.hx" + } + }, + "end": "$|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "patterns": [ + { + "include": "#type-path" + }, + { + "include": "#type-path-package-name" + } + ] + }, + "import": { + "begin": "import\\b", + "beginCaptures": { + "0": { + "name": "keyword.control.import.hx" + } + }, + "end": "$|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "patterns": [ + { + "include": "#type-path" + }, + { + "match": "\\b(as)\\b", + "name": "keyword.control.as.hx" + }, + { + "match": "\\b(in)\\b", + "name": "keyword.control.in.hx" + }, + { + "match": "\\*", + "name": "constant.language.import-all.hx" + }, + { + "match": "\\b([_A-Za-z]\\w*)\\b(?=\\s*(as|in|$|(;)))", + "name": "variable.other.hxt" + }, + { + "include": "#type-path-package-name" + } + ] + }, + "type-path": { + "patterns": [ + { + "include": "#global" + }, + { + "include": "#punctuation-accessor" + }, + { + "include": "#type-path-type-name" + } + ] + }, + "type-path-type-name": { + "match": "\\b(_*[A-Z]\\w*)\\b", + "name": "entity.name.type.hx" + }, + "type-path-package-name": { + "match": "\\b([_A-Za-z]\\w*)\\b", + "name": "support.package.hx" + }, + "abstract": { + "begin": "(?=abstract\\s+[A-Z])", + "end": "(?<=\\})|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "name": "meta.abstract.hx", + "patterns": [ + { + "include": "#abstract-name" + }, + { + "include": "#abstract-name-post" + }, + { + "include": "#abstract-block" + } + ] + }, + "abstract-name": { + "begin": "\\b(abstract)\\b", + "beginCaptures": { + "1": { + "name": "storage.type.class.hx" + } + }, + "end": "([_A-Za-z]\\w*)", + "endCaptures": { + "1": { + "name": "entity.name.type.class.hx" + } + }, + "patterns": [ + { + "include": "#global" + } + ] + }, + "abstract-name-post": { + "begin": "(?<=\\w)", + "end": "([\\{;])", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "patterns": [ + { + "include": "#global" + }, + { + "match": "\\b(from|to)\\b", + "name": "keyword.other.hx" + }, + { + "include": "#type" + }, + { + "match": "[\\(\\)]", + "name": "punctuation.definition.other.hx" + } + ] + }, + "abstract-block": { + "begin": "(?<=\\{)", + "end": "(\\})", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.end.hx" + } + }, + "name": "meta.block.hx", + "patterns": [ + { + "include": "#method" + }, + { + "include": "#modifiers" + }, + { + "include": "#variable" + }, + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "class": { + "begin": "(?=class)", + "end": "(?<=\\})|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "name": "meta.class.hx", + "patterns": [ + { + "include": "#class-name" + }, + { + "include": "#class-name-post" + }, + { + "include": "#class-block" + } + ] + }, + "class-name": { + "begin": "\\b(class)\\b", + "beginCaptures": { + "1": { + "name": "storage.type.class.hx" + } + }, + "end": "([_A-Za-z]\\w*)", + "endCaptures": { + "1": { + "name": "entity.name.type.class.hx" + } + }, + "name": "meta.class.identifier.hx", + "patterns": [ + { + "include": "#global" + } + ] + }, + "class-name-post": { + "begin": "(?<=\\w)", + "end": "([\\{;])", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "patterns": [ + { + "include": "#modifiers-inheritance" + }, + { + "include": "#type" + } + ] + }, + "class-block": { + "begin": "(?<=\\{)", + "end": "(\\})", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.end.hx" + } + }, + "name": "meta.block.hx", + "patterns": [ + { + "include": "#method" + }, + { + "include": "#modifiers" + }, + { + "include": "#variable" + }, + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "enum": { + "begin": "(?=enum\\s+[A-Z])", + "end": "(?<=\\})|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "name": "meta.enum.hx", + "patterns": [ + { + "include": "#enum-name" + }, + { + "include": "#enum-name-post" + }, + { + "include": "#enum-block" + } + ] + }, + "enum-name": { + "begin": "\\b(enum)\\b", + "beginCaptures": { + "1": { + "name": "storage.type.class.hx" + } + }, + "end": "([_A-Za-z]\\w*)", + "endCaptures": { + "1": { + "name": "entity.name.type.class.hx" + } + }, + "patterns": [ + { + "include": "#global" + } + ] + }, + "enum-name-post": { + "begin": "(?<=\\w)", + "end": "([\\{;])", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "patterns": [ + { + "include": "#type" + } + ] + }, + "enum-block": { + "begin": "(?<=\\{)", + "end": "(\\})", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.end.hx" + } + }, + "name": "meta.block.hx", + "patterns": [ + { + "include": "#global" + }, + { + "include": "#metadata" + }, + { + "include": "#parameters" + }, + { + "include": "#identifiers" + } + ] + }, + "interface": { + "begin": "(?=interface)", + "end": "(?<=\\})|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "name": "meta.interface.hx", + "patterns": [ + { + "include": "#interface-name" + }, + { + "include": "#interface-name-post" + }, + { + "include": "#interface-block" + } + ] + }, + "interface-name": { + "begin": "\\b(interface)\\b", + "beginCaptures": { + "1": { + "name": "storage.type.class.hx" + } + }, + "end": "([_A-Za-z]\\w*)", + "endCaptures": { + "1": { + "name": "entity.name.type.class.hx" + } + }, + "patterns": [ + { + "include": "#global" + } + ] + }, + "interface-name-post": { + "begin": "(?<=\\w)", + "end": "([\\{;])", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "patterns": [ + { + "include": "#global" + }, + { + "include": "#modifiers-inheritance" + }, + { + "include": "#type" + } + ] + }, + "interface-block": { + "begin": "(?<=\\{)", + "end": "(\\})", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.end.hx" + } + }, + "name": "meta.block.hx", + "patterns": [ + { + "include": "#method" + }, + { + "include": "#variable" + }, + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "typedef": { + "begin": "(?=typedef)", + "end": "(?<=\\})|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "name": "meta.typedef.hx", + "patterns": [ + { + "include": "#typedef-name" + }, + { + "include": "#typedef-name-post" + }, + { + "include": "#typedef-block" + } + ] + }, + "typedef-name": { + "begin": "\\b(typedef)\\b", + "beginCaptures": { + "1": { + "name": "storage.type.class.hx" + } + }, + "end": "([_A-Za-z]\\w*)", + "endCaptures": { + "1": { + "name": "entity.name.type.class.hx" + } + }, + "patterns": [ + { + "include": "#global" + } + ] + }, + "typedef-name-post": { + "begin": "(?<=\\w)", + "end": "(\\{)|(?=;)", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "patterns": [ + { + "include": "#global" + }, + { + "include": "#punctuation-brackets" + }, + { + "include": "#punctuation-separator" + }, + { + "include": "#operator-assignment" + }, + { + "include": "#type" + } + ] + }, + "typedef-block": { + "begin": "(?<=\\{)", + "end": "(\\})", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.end.hx" + } + }, + "name": "meta.block.hx", + "patterns": [ + { + "include": "#global" + }, + { + "include": "#metadata" + }, + { + "include": "#method" + }, + { + "include": "#variable" + }, + { + "include": "#modifiers" + }, + { + "include": "#punctuation-comma" + }, + { + "include": "#operator-optional" + }, + { + "include": "#typedef-extension" + }, + { + "include": "#typedef-simple-field-type-hint" + }, + { + "include": "#identifier-name" + }, + { + "include": "#strings" + } + ] + }, + "typedef-extension": { + "begin": ">", + "end": ",|$", + "patterns": [ + { + "include": "#type" + } + ] + }, + "typedef-simple-field-type-hint": { + "begin": ":", + "beginCaptures": { + "0": { + "name": "keyword.operator.type.annotation.hx" + } + }, + "end": "(?=\\}|,|;)", + "patterns": [ + { + "include": "#type" + } + ] + }, + "regex": { + "begin": "(~/)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.string.begin.hx" + } + }, + "end": "(/)([gimsu]*)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.hx" + }, + "2": { + "name": "keyword.other.hx" + } + }, + "name": "string.regexp.hx", + "patterns": [ + { + "include": "#regexp" + } + ] + }, + "regex-character-class": { + "patterns": [ + { + "match": "\\\\[wWsSdDtrnvf]|\\.", + "name": "constant.other.character-class.regexp" + }, + { + "match": "\\\\([0-7]{3}|x\\h\\h|u\\h\\h\\h\\h)", + "name": "constant.character.numeric.regexp" + }, + { + "match": "\\\\c[A-Z]", + "name": "constant.character.control.regexp" + }, + { + "match": "\\\\.", + "name": "constant.character.escape.backslash.regexp" + } + ] + }, + "regexp": { + "patterns": [ + { + "match": "\\\\[bB]|\\^|\\$", + "name": "keyword.control.anchor.regexp" + }, + { + "match": "\\\\[1-9]\\d*", + "name": "keyword.other.back-reference.regexp" + }, + { + "match": "[?+*]|\\{(\\d+,\\d+|\\d+,|,\\d+|\\d+)\\}\\??", + "name": "keyword.operator.quantifier.regexp" + }, + { + "match": "\\|", + "name": "keyword.operator.or.regexp" + }, + { + "begin": "(\\()((\\?=)|(\\?!))", + "beginCaptures": { + "1": { + "name": "punctuation.definition.group.regexp" + }, + "2": { + "name": "punctuation.definition.group.assertion.regexp" + }, + "3": { + "name": "meta.assertion.look-ahead.regexp" + }, + "4": { + "name": "meta.assertion.negative-look-ahead.regexp" + } + }, + "end": "(\\))", + "endCaptures": { + "1": { + "name": "punctuation.definition.group.regexp" + } + }, + "name": "meta.group.assertion.regexp", + "patterns": [ + { + "include": "#regexp" + } + ] + }, + { + "begin": "\\((\\?:)?", + "beginCaptures": { + "0": { + "name": "punctuation.definition.group.regexp" + }, + "1": { + "name": "punctuation.definition.group.capture.regexp" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.group.regexp" + } + }, + "name": "meta.group.regexp", + "patterns": [ + { + "include": "#regexp" + } + ] + }, + { + "begin": "(\\[)(\\^)?", + "beginCaptures": { + "1": { + "name": "punctuation.definition.character-class.regexp" + }, + "2": { + "name": "keyword.operator.negation.regexp" + } + }, + "end": "(\\])", + "endCaptures": { + "1": { + "name": "punctuation.definition.character-class.regexp" + } + }, + "name": "constant.other.character-class.set.regexp", + "patterns": [ + { + "match": "(?:.|(\\\\(?:[0-7]{3}|x\\h\\h|u\\h\\h\\h\\h))|(\\\\c[A-Z])|(\\\\.))\\-(?:[^\\]\\\\]|(\\\\(?:[0-7]{3}|x\\h\\h|u\\h\\h\\h\\h))|(\\\\c[A-Z])|(\\\\.))", + "captures": { + "1": { + "name": "constant.character.numeric.regexp" + }, + "2": { + "name": "constant.character.control.regexp" + }, + "3": { + "name": "constant.character.escape.backslash.regexp" + }, + "4": { + "name": "constant.character.numeric.regexp" + }, + "5": { + "name": "constant.character.control.regexp" + }, + "6": { + "name": "constant.character.escape.backslash.regexp" + } + }, + "name": "constant.other.character-class.range.regexp" + }, + { + "include": "#regex-character-class" + } + ] + }, + { + "include": "#regex-character-class" + } + ] + }, + "array": { + "begin": "\\[", + "beginCaptures": { + "0": { + "name": "punctuation.definition.array.begin.hx" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "punctuation.definition.array.end.hx" + } + }, + "name": "meta.array.literal.hx", + "patterns": [ + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "constants": { + "patterns": [ + { + "match": "\\b(true|false|null)\\b", + "name": "constant.language.hx" + }, + { + "match": "\\b(?:0[xX][0-9a-fA-F][_0-9a-fA-F]*([iu][0-9][0-9_]*)?)\\b", + "captures": { + "0": { + "name": "constant.numeric.hex.hx" + }, + "1": { + "name": "constant.numeric.suffix.hx" + } + } + }, + { + "match": "\\b(?:0[bB][01][_01]*([iu][0-9][0-9_]*)?)\\b", + "captures": { + "0": { + "name": "constant.numeric.bin.hx" + }, + "1": { + "name": "constant.numeric.suffix.hx" + } + } + }, + { + "match": "(?x)\n(?])", + "end": "(\\{)|(;)", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.begin.hx" + }, + "2": { + "name": "punctuation.terminator.hx" + } + }, + "patterns": [ + { + "include": "#parameters" + }, + { + "include": "#method-return-type-hint" + }, + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "method-block": { + "begin": "(?<=\\{)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "end": "(\\})", + "endCaptures": { + "1": { + "name": "punctuation.definition.block.end.hx" + } + }, + "name": "meta.method.block.hx", + "patterns": [ + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "parameters": { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "punctuation.definition.parameters.begin.hx" + } + }, + "end": "\\s*(\\)(?!\\s*->))", + "endCaptures": { + "1": { + "name": "punctuation.definition.parameters.end.hx" + } + }, + "name": "meta.parameters.hx", + "patterns": [ + { + "include": "#parameter" + } + ] + }, + "parameter": { + "begin": "(?<=\\(|,)", + "end": "(?=\\)(?!\\s*->)|,)", + "patterns": [ + { + "include": "#parameter-name" + }, + { + "include": "#parameter-type-hint" + }, + { + "include": "#parameter-assign" + }, + { + "include": "#punctuation-comma" + }, + { + "include": "#global" + } + ] + }, + "parameter-name": { + "begin": "(?<=\\(|,)", + "end": "([_a-zA-Z]\\w*)", + "endCaptures": { + "1": { + "name": "variable.parameter.hx" + } + }, + "patterns": [ + { + "include": "#global" + }, + { + "include": "#metadata" + }, + { + "include": "#operator-optional" + } + ] + }, + "parameter-type-hint": { + "begin": ":", + "beginCaptures": { + "0": { + "name": "keyword.operator.type.annotation.hx" + } + }, + "end": "(?=\\)(?!\\s*->)|,|=)", + "patterns": [ + { + "include": "#type" + } + ] + }, + "parameter-assign": { + "begin": "=", + "beginCaptures": { + "0": { + "name": "keyword.operator.assignment.hx" + } + }, + "end": "(?=\\)|,)", + "patterns": [ + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "arrow-function": { + "begin": "(\\()(?=[^(]*?\\)\\s*->)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.parameters.begin.hx" + } + }, + "end": "(\\))\\s*(->)", + "endCaptures": { + "1": { + "name": "punctuation.definition.parameters.end.hx" + }, + "2": { + "name": "storage.type.function.arrow.hx" + } + }, + "name": "meta.method.arrow.hx", + "patterns": [ + { + "include": "#arrow-function-parameter" + } + ] + }, + "arrow-function-parameter": { + "begin": "(?<=\\(|,)", + "end": "(?=\\)|,)", + "patterns": [ + { + "include": "#parameter-name" + }, + { + "include": "#arrow-function-parameter-type-hint" + }, + { + "include": "#parameter-assign" + }, + { + "include": "#punctuation-comma" + }, + { + "include": "#global" + } + ] + }, + "arrow-function-parameter-type-hint": { + "begin": ":", + "beginCaptures": { + "0": { + "name": "keyword.operator.type.annotation.hx" + } + }, + "end": "(?=\\)|,|=)", + "patterns": [ + { + "include": "#type" + } + ] + }, + "method-return-type-hint": { + "begin": "(?<=\\))\\s*(:)", + "beginCaptures": { + "1": { + "name": "keyword.operator.type.annotation.hx" + } + }, + "end": "(?=\\{|;|[a-z0-9])", + "patterns": [ + { + "include": "#type" + } + ] + }, + "operator-optional": { + "match": "(\\?)(?!\\s)", + "name": "keyword.operator.optional.hx" + }, + "variable": { + "begin": "(?=\\b(var|final)\\b)", + "end": "(?=$)|(;)", + "endCaptures": { + "1": { + "name": "punctuation.terminator.hx" + } + }, + "patterns": [ + { + "include": "#variable-name" + }, + { + "include": "#variable-name-next" + }, + { + "include": "#variable-assign" + }, + { + "include": "#variable-name-post" + } + ] + }, + "variable-name": { + "begin": "\\b(var|final)\\b", + "beginCaptures": { + "1": { + "name": "storage.type.variable.hx" + } + }, + "end": "(?=$)|([_a-zA-Z]\\w*)", + "endCaptures": { + "1": { + "name": "variable.other.hx" + } + }, + "patterns": [ + { + "include": "#operator-optional" + } + ] + }, + "variable-name-next": { + "begin": ",", + "beginCaptures": { + "0": { + "name": "punctuation.separator.comma.hx" + } + }, + "end": "([_a-zA-Z]\\w*)", + "endCaptures": { + "1": { + "name": "variable.other.hx" + } + }, + "patterns": [ + { + "include": "#global" + } + ] + }, + "variable-type-hint": { + "begin": ":", + "beginCaptures": { + "0": { + "name": "keyword.operator.type.annotation.hx" + } + }, + "end": "(?=$|;|,|=)", + "patterns": [ + { + "include": "#type" + } + ] + }, + "variable-assign": { + "begin": "=", + "beginCaptures": { + "0": { + "name": "keyword.operator.assignment.hx" + } + }, + "end": "(?=;|,)", + "patterns": [ + { + "include": "#block" + }, + { + "include": "#block-contents" + } + ] + }, + "variable-name-post": { + "begin": "(?<=\\w)", + "end": "(?=;)|(?==)", + "patterns": [ + { + "include": "#variable-accessors" + }, + { + "include": "#variable-type-hint" + }, + { + "include": "#block-contents" + } + ] + }, + "variable-accessors": { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "punctuation.definition.parameters.begin.hx" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.parameters.end.hx" + } + }, + "name": "meta.parameters.hx", + "patterns": [ + { + "include": "#global" + }, + { + "include": "#keywords-accessor" + }, + { + "include": "#accessor-method" + }, + { + "include": "#punctuation-comma" + } + ] + }, + "keywords-accessor": { + "match": "\\b(default|get|set|dynamic|never|null)\\b", + "name": "storage.type.property.hx" + }, + "accessor-method": { + "patterns": [ + { + "match": "\\b(get|set)_[_A-Za-z]\\w*\\b", + "name": "entity.name.function.hx" + } + ] + }, + "modifiers": { + "patterns": [ + { + "match": "\\b(enum)\\b", + "name": "storage.type.class" + }, + { + "match": "\\b(public|private|static|dynamic|inline|macro|extern|override|overload|abstract)\\b", + "name": "storage.modifier.hx" + }, + { + "match": "\\b(final)\\b(?=\\s+(public|private|static|dynamic|inline|macro|extern|override|overload|abstract|function))", + "name": "storage.modifier.hx" + } + ] + }, + "new-expr": { + "name": "new.expr.hx", + "begin": "(?", + "name": "keyword.operator.extractor.hx" + }, + { + "include": "#operator-assignment" + }, + { + "include": "#punctuation-comma" + }, + { + "include": "#keywords" + }, + { + "include": "#method-call" + }, + { + "include": "#identifiers" + } + ] + }, + { + "match": "\\b(if|else|return|do|while|for|break|continue|switch|case|default)\\b", + "name": "keyword.control.flow-control.hx" + }, + { + "match": "\\b(cast|untyped)\\b", + "name": "keyword.other.untyped.hx" + }, + { + "match": "\\btrace\\b", + "name": "keyword.other.trace.hx" + }, + { + "match": "\\$type\\b", + "name": "keyword.other.type.hx" + }, + { + "match": "\\__(global|this)__\\b", + "name": "keyword.other.untyped-property.hx" + }, + { + "match": "\\b(this|super)\\b", + "name": "variable.language.hx" + }, + { + "match": "\\bnew\\b", + "name": "keyword.operator.new.hx" + }, + { + "match": "\\b(abstract|class|enum|interface|typedef)\\b", + "name": "storage.type.hx" + }, + { + "match": "->", + "name": "storage.type.function.arrow.hx" + }, + { + "include": "#modifiers" + }, + { + "include": "#modifiers-inheritance" + } + ] + }, + "punctuation-braces": { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "meta.brace.round.hx" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "meta.brace.round.hx" + } + }, + "patterns": [ + { + "include": "#keywords" + }, + { + "include": "#block" + }, + { + "include": "#block-contents" + }, + { + "include": "#type-check" + } + ] + }, + "type-check": { + "begin": "(?>>|<<|>>)", + "name": "keyword.operator.bitwise.hx" + }, + { + "match": "(==|!=|<=|>=|<|>)", + "name": "keyword.operator.comparison.hx" + }, + { + "match": "(!)", + "name": "keyword.operator.logical.hx" + }, + { + "match": "(\\-\\-|\\+\\+)", + "name": "keyword.operator.increment-decrement.hx" + }, + { + "match": "(\\-|\\+|\\*|\\/|%)", + "name": "keyword.operator.arithmetic.hx" + }, + { + "match": "\\.\\.\\.", + "name": "keyword.operator.intiterator.hx" + }, + { + "match": "=>", + "name": "keyword.operator.arrow.hx" + }, + { + "match": "\\?\\?", + "name": "keyword.operator.nullcoalescing.hx" + }, + { + "match": "\\?\\.", + "name": "keyword.operator.safenavigation.hx" + }, + { + "match": "\\bis\\b(?!\\()", + "name": "keyword.other.hx" + }, + { + "begin": "\\?", + "beginCaptures": { + "0": { + "name": "keyword.operator.ternary.hx" + } + }, + "end": ":", + "endCaptures": { + "0": { + "name": "keyword.operator.ternary.hx" + } + }, + "patterns": [ + { + "include": "#block-contents" + } + ] + } + ] + }, + "punctuation-comma": { + "match": ",", + "name": "punctuation.separator.comma.hx" + }, + "punctuation-accessor": { + "match": "\\.", + "name": "punctuation.accessor.hx" + }, + "punctuation-terminator": { + "match": ";", + "name": "punctuation.terminator.hx" + }, + "constant-name": { + "match": "\\b([_A-Z][_A-Z0-9]*)\\b", + "name": "variable.other.hx" + }, + "type": { + "patterns": [ + { + "include": "#global" + }, + { + "include": "#macro-reification" + }, + { + "include": "#type-name" + }, + { + "include": "#type-parameters" + }, + { + "match": "->", + "name": "keyword.operator.type.function.hx" + }, + { + "match": "&", + "name": "keyword.operator.type.intersection.hx" + }, + { + "match": "\\?(?=\\s*[_A-Z])", + "name": "keyword.operator.optional" + }, + { + "match": "\\?(?!\\s*[_A-Z])", + "name": "punctuation.definition.tag" + }, + { + "begin": "(\\{)", + "beginCaptures": { + "0": { + "name": "punctuation.definition.block.begin.hx" + } + }, + "end": "(?<=\\})", + "patterns": [ + { + "include": "#typedef-block" + } + ] + }, + { + "include": "#function-type" + } + ] + }, + "function-type": { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "punctuation.definition.parameters.begin.hx" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.parameters.end.hx" + } + }, + "patterns": [ + { + "include": "#function-type-parameter" + } + ] + }, + "function-type-parameter": { + "begin": "(?<=\\(|,)", + "end": "(?=\\)|,)", + "patterns": [ + { + "include": "#global" + }, + { + "include": "#metadata" + }, + { + "include": "#operator-optional" + }, + { + "include": "#punctuation-comma" + }, + { + "include": "#function-type-parameter-name" + }, + { + "include": "#function-type-parameter-type-hint" + }, + { + "include": "#parameter-assign" + }, + { + "include": "#type" + }, + { + "include": "#global" + } + ] + }, + "function-type-parameter-name": { + "match": "([_a-zA-Z]\\w*)(?=\\s*:)", + "captures": { + "1": { + "name": "variable.parameter.hx" + } + } + }, + "function-type-parameter-type-hint": { + "begin": ":", + "beginCaptures": { + "0": { + "name": "keyword.operator.type.annotation.hx" + } + }, + "end": "(?=\\)|,|=)", + "patterns": [ + { + "include": "#type" + } + ] + }, + "type-name": { + "patterns": [ + { + "match": "\\b(Any|Array|ArrayAccess|Bool|Class|Date|DateTools|Dynamic|Enum|EnumValue|EReg|Float|IMap|Int|IntIterator|Iterable|Iterator|KeyValueIterator|KeyValueIterable|Lambda|List|ListIterator|ListNode|Map|Math|Null|Reflect|Single|Std|String|StringBuf|StringTools|Sys|Type|UInt|UnicodeString|ValueType|Void|Xml|XmlType)(?:(\\.)(_*[A-Z]\\w*[a-z]\\w*))*\\b", + "captures": { + "1": { + "name": "support.class.builtin.hx" + }, + "2": { + "name": "support.package.hx" + }, + "3": { + "name": "entity.name.type.hx" + } + } + }, + { + "match": "\\b(?)", + "endCaptures": { + "1": { + "name": "punctuation.definition.typeparameters.end.hx" + } + }, + "name": "meta.type-parameters.hx", + "patterns": [ + { + "include": "#type" + }, + { + "include": "#type-parameter-constraint-old" + }, + { + "include": "#type-parameter-constraint-new" + }, + { + "include": "#global" + }, + { + "include": "#regex" + }, + { + "include": "#array" + }, + { + "include": "#constants" + }, + { + "include": "#strings" + }, + { + "include": "#metadata" + }, + { + "include": "#punctuation-comma" + } + ] + }, + "type-parameter-constraint-old": { + "begin": "(:)\\s*(\\()", + "beginCaptures": { + "1": { + "name": "keyword.operator.type.annotation.hx" + }, + "2": { + "name": "punctuation.definition.constraint.begin.hx" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.constraint.end.hx" + } + }, + "patterns": [ + { + "include": "#type" + }, + { + "include": "#punctuation-comma" + } + ] + }, + "type-parameter-constraint-new": { + "match": ":", + "name": "keyword.operator.type.annotation.hxt" + }, + "identifier-name": { + "match": "\\b([_A-Za-z]\\w*)\\b", + "name": "variable.other.hx" + } + } +} diff --git a/src/Resources/Grammars/hxml.json b/src/Resources/Grammars/hxml.json new file mode 100644 index 00000000..3be42577 --- /dev/null +++ b/src/Resources/Grammars/hxml.json @@ -0,0 +1,72 @@ +{ + "information_for_contributors": [ + "This file has been copied from https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/hxml.tmLanguage", + "and converted to JSON using https://marketplace.visualstudio.com/items?itemName=pedro-w.tmlanguage", + "The original file was licensed under the MIT License", + "https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/LICENSE.md" + ], + "fileTypes": [ + "hxml" + ], + "foldingStartMarker": "--next", + "foldingStopMarker": "\\n\\n", + "keyEquivalent": "^@H", + "name": "Hxml", + "patterns": [ + { + "captures": { + "1": { + "name": "punctuation.definition.comment.hxml" + } + }, + "match": "(#).*$\\n?", + "name": "comment.line.number-sign.hxml" + }, + { + "begin": "(?" + }, + "directive": { + "name": "meta.tag.directive.jsp", + "begin": "(<)(%@)", + "end": "(%)(>)", + "beginCaptures": { + "1": { "name": "punctuation.definition.tag.jsp" }, + "2": { "name": "entity.name.tag.jsp" } + }, + "endCaptures": { + "1": { "name": "entity.name.tag.jsp" }, + "2": { "name": "punctuation.definition.tag.jsp" } + }, + "patterns": [ + { + "match": "\\b(attribute|include|page|tag|taglib|variable)\\b(?!\\s*=)", + "name": "keyword.control.directive.jsp" + }, + { "include": "text.html.basic#attribute" } + ] + }, + "scriptlet": { + "name": "meta.tag.scriptlet.jsp", + "contentName": "meta.embedded.block.java", + "begin": "(<)(%[\\s!=])", + "end": "(%)(>)", + "beginCaptures": { + "1": { "name": "punctuation.definition.tag.jsp" }, + "2": { "name": "entity.name.tag.jsp" } + }, + "endCaptures": { + "1": { "name": "entity.name.tag.jsp" }, + "2": { "name": "punctuation.definition.tag.jsp" } + }, + "patterns": [ + { + "match": "\\{(?=\\s*(%>|$))", + "comment": "consume trailing curly brackets for fragmented scriptlets" + }, + { "include": "source.java" } + ] + }, + "expression": { + "name": "string.template.expression.jsp", + "contentName": "meta.embedded.block.java", + "begin": "[$#]\\{", + "end": "\\}", + "beginCaptures": { + "0": { "name": "punctuation.definition.template-expression.begin.jsp" } + }, + "endCaptures": { + "0": { "name": "punctuation.definition.template-expression.end.jsp" } + }, + "patterns": [ + { "include": "#escape" }, + { "include": "source.java" } + ] + }, + "escape": { + "match": "\\\\.", + "name": "constant.character.escape.jsp" + } + } +} diff --git a/src/Resources/Grammars/kotlin.json b/src/Resources/Grammars/kotlin.json new file mode 100644 index 00000000..2857f717 --- /dev/null +++ b/src/Resources/Grammars/kotlin.json @@ -0,0 +1,703 @@ +{ + "information_for_contributors": [ + "This file has been copied from https://github.com/eclipse/buildship/blob/6bb773e7692f913dec27105129ebe388de34e68b/org.eclipse.buildship.kotlindsl.provider/kotlin.tmLanguage.json", + "The original file was licensed under the Eclipse Public License, Version 1.0", + "https://github.com/eclipse-buildship/buildship/blob/6bb773e7692f913dec27105129ebe388de34e68b/README.md" + ], + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Kotlin", + "scopeName": "source.kotlin", + "patterns": [ + { + "include": "#import" + }, + { + "include": "#package" + }, + { + "include": "#code" + } + ], + "fileTypes": [ + "kts" + ], + "repository": { + "import": { + "begin": "\\b(import)\\b\\s?([\\w+.]*\\w+)?\\s*", + "beginCaptures": { + "1": { + "name": "storage.type.import.kotlin" + }, + "2": { + "name": "storage.modifier.import.kotlin" + } + }, + "end": ";|$", + "name": "meta.import.kotlin", + "contentName": "entity.name.package.kotlin", + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#hard-keywords" + }, + { + "match": "\\*", + "name": "variable.language.wildcard.kotlin" + } + ] + }, + "package": { + "begin": "\\b(package)\\b\\s*", + "beginCaptures": { + "1": { + "name": "storage.type.package.kotlin" + } + }, + "end": ";|$", + "name": "meta.package.kotlin", + "contentName": "entity.name.package.kotlin", + "patterns": [ + { + "include": "#comments" + } + ] + }, + "code": { + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#keywords" + }, + { + "include": "#annotation-simple" + }, + { + "include": "#annotation-site-list" + }, + { + "include": "#annotation-site" + }, + { + "include": "#class-declaration" + }, + { + "include": "#object-declaration" + }, + { + "include": "#type-alias" + }, + { + "include": "#function-declaration" + }, + { + "include": "#variable-declaration" + }, + { + "include": "#constant-declaration" + }, + { + "include": "#variable" + }, + { + "include": "#object" + }, + { + "include": "#type-constraint" + }, + { + "include": "#type-annotation" + }, + { + "include": "#function-call" + }, + { + "include": "#property.reference" + }, + { + "include": "#method-reference" + }, + { + "include": "#key" + }, + { + "include": "#string" + }, + { + "include": "#string-empty" + }, + { + "include": "#string-multiline" + }, + { + "include": "#character" + }, + { + "include": "#lambda-arrow" + }, + { + "include": "#operators" + }, + { + "include": "#self-reference" + }, + { + "include": "#decimal-literal" + }, + { + "include": "#hex-literal" + }, + { + "include": "#binary-literal" + }, + { + "include": "#boolean-literal" + }, + { + "include": "#null-literal" + }, + { + "match": ",", + "name": "punctuation.separator.delimiter.kotlin" + }, + { + "match": "\\.", + "name": "punctuation.separator.period.kotlin" + }, + { + "match": "\\?\\.", + "name": "punctuation.accessor.optional.kotlin" + } + ] + }, + "comments": { + "patterns": [ + { + "include": "#comment-line" + }, + { + "include": "#comment-block" + }, + { + "include": "#comment-javadoc" + } + ] + }, + "comment-line": { + "begin": "//", + "end": "$", + "name": "comment.line.double-slash.kotlin" + }, + "comment-block": { + "begin": "/\\*(?!\\*)", + "end": "\\*/", + "name": "comment.block.kotlin" + }, + "comment-javadoc": { + "patterns": [ + { + "begin": "/\\*\\*", + "end": "\\*/", + "name": "comment.block.javadoc.kotlin", + "patterns": [ + { + "match": "@(author|deprecated|return|see|serial|since|version)\\b", + "name": "keyword.other.documentation.javadoc.kotlin" + }, + { + "match": "(@param)\\s+(\\S+)", + "captures": { + "1": { + "name": "keyword.other.documentation.javadoc.kotlin" + }, + "2": { + "name": "variable.parameter.kotlin" + } + } + }, + { + "match": "(@(?:exception|throws))\\s+(\\S+)", + "captures": { + "1": { + "name": "keyword.other.documentation.javadoc.kotlin" + }, + "2": { + "name": "entity.name.type.class.kotlin" + } + } + }, + { + "match": "{(@link)\\s+(\\S+)?#([\\w$]+\\s*\\([^\\(\\)]*\\)).*}", + "captures": { + "1": { + "name": "keyword.other.documentation.javadoc.kotlin" + }, + "2": { + "name": "entity.name.type.class.kotlin" + }, + "3": { + "name": "variable.parameter.kotlin" + } + } + } + ] + } + ] + }, + "keywords": { + "patterns": [ + { + "include": "#prefix-modifiers" + }, + { + "include": "#postfix-modifiers" + }, + { + "include": "#soft-keywords" + }, + { + "include": "#hard-keywords" + }, + { + "include": "#control-keywords" + }, + { + "include": "#map-keywords" + } + ] + }, + "prefix-modifiers": { + "match": "\\b(abstract|final|enum|open|annotation|sealed|data|override|final|lateinit|private|protected|public|internal|inner|companion|noinline|crossinline|vararg|reified|tailrec|operator|infix|inline|external|const|suspend|value)\\b", + "name": "storage.modifier.other.kotlin" + }, + "postfix-modifiers": { + "match": "\\b(where|by|get|set)\\b", + "name": "storage.modifier.other.kotlin" + }, + "soft-keywords": { + "match": "\\b(catch|finally|field)\\b", + "name": "keyword.soft.kotlin" + }, + "hard-keywords": { + "match": "\\b(as|typeof|is|in)\\b", + "name": "keyword.hard.kotlin" + }, + "control-keywords": { + "match": "\\b(if|else|while|do|when|try|throw|break|continue|return|for)\\b", + "name": "keyword.control.kotlin" + }, + "map-keywords": { + "match": "\\b(to)\\b", + "name": "keyword.map.kotlin" + }, + "annotation-simple": { + "match": "(?<([^<>]|\\g)+>)?", + "captures": { + "1": { + "name": "storage.type.class.kotlin" + }, + "2": { + "name": "entity.name.type.class.kotlin" + }, + "3": { + "patterns": [ + { + "include": "#type-parameter" + } + ] + } + } + }, + "object-declaration": { + "match": "\\b(object)\\s+(\\b\\w+\\b|`[^`]+`)", + "captures": { + "1": { + "name": "storage.type.object.kotlin" + }, + "2": { + "name": "entity.name.type.object.kotlin" + } + } + }, + "type-alias": { + "match": "\\b(typealias)\\s+(\\b\\w+\\b|`[^`]+`)\\s*(?<([^<>]|\\g)+>)?", + "captures": { + "1": { + "name": "storage.type.alias.kotlin" + }, + "2": { + "name": "entity.name.type.kotlin" + }, + "3": { + "patterns": [ + { + "include": "#type-parameter" + } + ] + } + } + }, + "function-declaration": { + "begin": "\\b(fun)\\b\\s*(?<([^<>]|\\g)+>)?\\s*(?:(\\w+)\\.)?(\\b\\w+\\b|`[^`]+`)\\(", + "beginCaptures": { + "1": { + "name": "storage.type.function.kotlin" + }, + "2": { + "patterns": [ + { + "include": "#type-parameter" + } + ] + }, + "4": { + "name": "entity.name.type.class.extension.kotlin" + }, + "5": { + "name": "entity.name.function.declaration.kotlin" + } + }, + "end": "\\)", + "endCaptures": { + "1": { + "name": "keyword.operator.assignment.type.kotlin" + } + }, + "patterns": [ + { + "include": "#parameter-declaration" + } + ] + }, + "parameter-declaration": { + "match": "\\b(\\w+)\\s*(:)\\s*(\\w+)(\\?)?(,)?", + "captures": { + "1": { + "name": "variable.parameter.kotlin" + }, + "2": { + "name": "keyword.operator.assignment.type.kotlin" + }, + "3": { + "name": "entity.name.type.kotlin" + }, + "4": { + "name": "keyword.operator.optional" + }, + "5": { + "name": "punctuation.separator.delimiter.kotlin" + } + } + }, + "variable-declaration": { + "match": "\\b(var)\\b\\s*(?<([^<>]|\\g)+>)?", + "captures": { + "1": { + "name": "storage.type.variable.kotlin" + }, + "2": { + "patterns": [ + { + "include": "#type-parameter" + } + ] + } + } + }, + "constant-declaration": { + "match": "\\b(val)\\b\\s*(?<([^<>]|\\g)+>)?", + "captures": { + "1": { + "name": "storage.type.variable.readonly.kotlin" + }, + "2": { + "patterns": [ + { + "include": "#type-parameter" + } + ] + } + } + }, + "variable" : { + "match": "\\b(\\w+)(?=\\s*[:=])", + "captures": { + "1": { + "name": "variable.other.definition.kotlin" + } + } + }, + "object" : { + "match": "\\b(\\w+)(?=\\.)", + "captures": { + "1": { + "name": "variable.other.object.kotlin" + } + } + }, + "type-parameter": { + "patterns": [ + { + "match": "(:)?\\s*(\\b\\w+\\b)(\\?)?", + "captures": { + "1": { + "name": "keyword.operator.assignment.kotlin" + }, + "2": { + "name": "entity.name.type.kotlin" + }, + "3": { + "name": "keyword.operator.optional" + } + } + }, + { + "match": "\\b(in|out)\\b", + "name": "storage.modifier.kotlin" + } + ] + }, + "type-annotation": { + "match": "(?|(?[<(]([^<>()\"']|\\g)+[)>]))+", + "captures": { + "0": { + "patterns": [ + { + "include": "#type-parameter" + } + ] + } + } + }, + "function-call": { + "match": "(?:(\\?\\.)|(\\.))?(\\b\\w+\\b|`[^`]+`)\\s*(?<([^<>]|\\g)+>)?\\s*(?=[({])", + "captures": { + "1": { + "name": "punctuation.accessor.optional.kotlin" + }, + "2": { + "name": "punctuation.separator.period.kotlin" + }, + "3": { + "name": "entity.name.function.call.kotlin" + }, + "4": { + "patterns": [ + { + "include": "#type-parameter" + } + ] + } + } + }, + "property.reference": { + "match": "(?:(\\?\\.)|(\\.))(\\w+)\\b", + "captures": { + "1": { + "name": "punctuation.accessor.optional.kotlin" + }, + "2": { + "name": "punctuation.separator.period.kotlin" + }, + "3": { + "name": "variable.other.property.kotlin" + } + } + }, + "method-reference": { + "match": "\\??::(\\b\\w+\\b|`[^`]+`)", + "captures": { + "1": { + "name": "entity.name.function.reference.kotlin" + } + } + }, + "key": { + "match": "\\b(\\w=)\\s*(=)", + "captures": { + "1": { + "name": "variable.parameter.kotlin" + }, + "2": { + "name": "keyword.operator.assignment.kotlin" + } + } + }, + "string-empty": { + "match": "(?", + "name": "storage.type.function.arrow.kotlin" + }, + "operators": { + "patterns": [ + { + "match": "(===?|\\!==?|<=|>=|<|>)", + "name": "keyword.operator.comparison.kotlin" + }, + { + "match": "(\\?:)", + "name": "keyword.operator.elvis.kotlin" + }, + { + "match": "([+*/%-]=)", + "name": "keyword.operator.assignment.arithmetic.kotlin" + }, + { + "match": "(=)", + "name": "keyword.operator.assignment.kotlin" + }, + { + "match": "([+*/%-])", + "name": "keyword.operator.arithmetic.kotlin" + }, + { + "match": "(!|&&|\\|\\|)", + "name": "keyword.operator.logical.kotlin" + }, + { + "match": "(--|\\+\\+)", + "name": "keyword.operator.increment-decrement.kotlin" + }, + { + "match": "(\\.\\.)", + "name": "keyword.operator.range.kotlin" + } + ] + }, + "self-reference": { + "match": "\\b(this|super)(@\\w+)?\\b", + "name": "variable.language.this.kotlin" + } + } +} diff --git a/src/Resources/Grammars/toml.json b/src/Resources/Grammars/toml.json new file mode 100644 index 00000000..6be4678f --- /dev/null +++ b/src/Resources/Grammars/toml.json @@ -0,0 +1,346 @@ +{ + "version": "1.0.0", + "scopeName": "source.toml", + "uuid": "8b4e5008-c50d-11ea-a91b-54ee75aeeb97", + "information_for_contributors": [ + "Originally was maintained by aster (galaster@foxmail.com). This notice is only kept here for the record, please don't send e-mails about bugs and other issues.", + "This file has been copied from https://github.com/kkiyama117/coc-toml/blob/main/toml.tmLanguage.json", + "The original file was licensed under the MIT License", + "https://github.com/kkiyama117/coc-toml/blob/main/LICENSE" + ], + "patterns": [ + { + "include": "#commentDirective" + }, + { + "include": "#comment" + }, + { + "include": "#table" + }, + { + "include": "#entryBegin" + }, + { + "include": "#value" + } + ], + "repository": { + "comment": { + "captures": { + "1": { + "name": "comment.line.number-sign.toml" + }, + "2": { + "name": "punctuation.definition.comment.toml" + } + }, + "comment": "Comments", + "match": "\\s*((#).*)$" + }, + "commentDirective": { + "captures": { + "1": { + "name": "meta.preprocessor.toml" + }, + "2": { + "name": "punctuation.definition.meta.preprocessor.toml" + } + }, + "comment": "Comments", + "match": "\\s*((#):.*)$" + }, + "table": { + "patterns": [ + { + "name": "meta.table.toml", + "match": "^\\s*(\\[)\\s*((?:(?:(?:[A-Za-z0-9_+-]+)|(?:\"[^\"]+\")|(?:'[^']+'))\\s*\\.?\\s*)+)\\s*(\\])", + "captures": { + "1": { + "name": "punctuation.definition.table.toml" + }, + "2": { + "patterns": [ + { + "match": "(?:[A-Za-z0-9_+-]+)|(?:\"[^\"]+\")|(?:'[^']+')", + "name": "support.type.property-name.table.toml" + }, + { + "match": "\\.", + "name": "punctuation.separator.dot.toml" + } + ] + }, + "3": { + "name": "punctuation.definition.table.toml" + } + } + }, + { + "name": "meta.array.table.toml", + "match": "^\\s*(\\[\\[)\\s*((?:(?:(?:[A-Za-z0-9_+-]+)|(?:\"[^\"]+\")|(?:'[^']+'))\\s*\\.?\\s*)+)\\s*(\\]\\])", + "captures": { + "1": { + "name": "punctuation.definition.array.table.toml" + }, + "2": { + "patterns": [ + { + "match": "(?:[A-Za-z0-9_+-]+)|(?:\"[^\"]+\")|(?:'[^']+')", + "name": "support.type.property-name.array.toml" + }, + { + "match": "\\.", + "name": "punctuation.separator.dot.toml" + } + ] + }, + "3": { + "name": "punctuation.definition.array.table.toml" + } + } + }, + { + "begin": "(\\{)", + "end": "(\\})", + "name": "meta.table.inline.toml", + "beginCaptures": { + "1": { + "name": "punctuation.definition.table.inline.toml" + } + }, + "endCaptures": { + "1": { + "name": "punctuation.definition.table.inline.toml" + } + }, + "patterns": [ + { + "include": "#comment" + }, + { + "match": ",", + "name": "punctuation.separator.table.inline.toml" + }, + { + "include": "#entryBegin" + }, + { + "include": "#value" + } + ] + } + ] + }, + "entryBegin": { + "name": "meta.entry.toml", + "match": "\\s*((?:(?:(?:[A-Za-z0-9_+-]+)|(?:\"[^\"]+\")|(?:'[^']+'))\\s*\\.?\\s*)+)\\s*(=)", + "captures": { + "1": { + "patterns": [ + { + "match": "(?:[A-Za-z0-9_+-]+)|(?:\"[^\"]+\")|(?:'[^']+')", + "name": "support.type.property-name.toml" + }, + { + "match": "\\.", + "name": "punctuation.separator.dot.toml" + } + ] + }, + "2": { + "name": "punctuation.eq.toml" + } + } + }, + "value": { + "patterns": [ + { + "name": "string.quoted.triple.basic.block.toml", + "begin": "\"\"\"", + "end": "\"\"\"", + "patterns": [ + { + "match": "\\\\([btnfr\"\\\\\\n/ ]|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})", + "name": "constant.character.escape.toml" + }, + { + "match": "\\\\[^btnfr/\"\\\\\\n]", + "name": "invalid.illegal.escape.toml" + } + ] + }, + { + "name": "string.quoted.single.basic.line.toml", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "match": "\\\\([btnfr\"\\\\\\n/ ]|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})", + "name": "constant.character.escape.toml" + }, + { + "match": "\\\\[^btnfr/\"\\\\\\n]", + "name": "invalid.illegal.escape.toml" + } + ] + }, + { + "name": "string.quoted.triple.literal.block.toml", + "begin": "'''", + "end": "'''" + }, + { + "name": "string.quoted.single.literal.line.toml", + "begin": "'", + "end": "'" + }, + { + "captures": { + "1": { + "name": "constant.other.time.datetime.offset.toml" + } + }, + "match": "(? + M41 512c0-128 46-241 138-333C271 87 384 41 512 41s241 46 333 138c92 92 138 205 138 333s-46 241-138 333c-92 92-205 138-333 138s-241-46-333-138C87 753 41 640 41 512zm87 0c0 108 36 195 113 271s164 113 271 113c108 0 195-36 271-113s113-164 113-271-36-195-113-271c-77-77-164-113-271-113-108 0-195 36-271 113C164 317 128 404 128 512zm256 148V292l195 113L768 512l-195 113-195 113v-77zm148-113-61 36V440l61 36 61 36-61 36z + M304 464a128 128 0 01128-128c71 0 128 57 128 128v224a32 32 0 01-64 0V592h-128v95a32 32 0 01-64 0v-224zm64 1v64h128v-64a64 64 0 00-64-64c-35 0-64 29-64 64zM688 337c18 0 32 14 32 32v319a32 32 0 01-32 32c-18 0-32-14-32-32v-319a32 32 0 0132-32zM84 911l60-143A446 446 0 0164 512C64 265 265 64 512 64s448 201 448 448-201 448-448 448c-54 0-105-9-153-27l-242 22a32 32 0 01-32-44zm133-150-53 126 203-18 13 5c41 15 85 23 131 23 212 0 384-172 384-384S724 128 512 128 128 300 128 512c0 82 26 157 69 220l20 29z + M296 392h64v64h-64zM296 582v160h128V582h-64v-62h-64v62zm80 48v64h-32v-64h32zM360 328h64v64h-64zM296 264h64v64h-64zM360 456h64v64h-64zM360 200h64v64h-64zM855 289 639 73c-6-6-14-9-23-9H192c-18 0-32 14-32 32v832c0 18 14 32 32 32h640c18 0 32-14 32-32V311c0-9-3-17-9-23zM790 326H602V138L790 326zm2 562H232V136h64v64h64v-64h174v216c0 23 19 42 42 42h216v494z + M851 755q0 23-16 39l-78 78q-16 16-39 16t-39-16l-168-168-168 168q-16 16-39 16t-39-16l-78-78q-16-16-16-39t16-39l168-168-168-168q-16-16-16-39t16-39l78-78q16-16 39-16t39 16l168 168 168-168q16-16 39-16t39 16l78 78q16 16 16 39t-16 39l-168 168 168 168q16 16 16 39z + M71 1024V0h661L953 219V1024H71zm808-731-220-219H145V951h735V293zM439 512h-220V219h220V512zm-74-219H292v146h74v-146zm0 512h74v73h-220v-73H292v-146H218V585h147v219zm294-366h74V512H512v-73h74v-146H512V219h147v219zm74 439H512V585h220v293zm-74-219h-74v146h74v-146z + M128 384a43 43 0 0043-43V213a43 43 0 0143-43h128a43 43 0 000-85H213a128 128 0 00-128 128v128a43 43 0 0043 43zm213 469H213a43 43 0 01-43-43v-128a43 43 0 00-85 0v128a128 128 0 00128 128h128a43 43 0 000-85zm384-299a43 43 0 000-85h-49A171 171 0 00555 347V299a43 43 0 00-85 0v49A171 171 0 00347 469H299a43 43 0 000 85h49A171 171 0 00469 677V725a43 43 0 0085 0v-49A171 171 0 00677 555zm-213 43a85 85 0 1185-85 85 85 0 01-85 85zm384 43a43 43 0 00-43 43v128a43 43 0 01-43 43h-128a43 43 0 000 85h128a128 128 0 00128-128v-128a43 43 0 00-43-43zM811 85h-128a43 43 0 000 85h128a43 43 0 0143 43v128a43 43 0 0085 0V213a128 128 0 00-128-128z + M128 256h192a64 64 0 110 128H128a64 64 0 110-128zm576 192h192a64 64 0 010 128h-192a64 64 0 010-128zm-576 192h192a64 64 0 010 128H128a64 64 0 010-128zm576 0h192a64 64 0 010 128h-192a64 64 0 010-128zm0-384h192a64 64 0 010 128h-192a64 64 0 010-128zM128 448h192a64 64 0 110 128H128a64 64 0 110-128zm384-320a64 64 0 0164 64v640a64 64 0 01-128 0V192a64 64 0 0164-64z + M832 64H192c-18 0-32 14-32 32v832c0 18 14 32 32 32h640c18 0 32-14 32-32V96c0-18-14-32-32-32zM736 596 624 502 506 596V131h230v318z + M509 546 780 275 871 366 509 728 147 366 238 275zM509 728h-362v128h724v-128z + M757 226a143 143 0 00-55 276 96 96 0 01-88 59h-191a187 187 0 00-96 27V312a143 143 0 10-96 0v399a143 143 0 10103 2 96 96 0 0188-59h191a191 191 0 00187-151 143 143 0 00-43-279zM280 130a48 48 0 110 96 48 48 0 010-96zm0 764a48 48 0 110-96 48 48 0 010 96zM757 417a48 48 0 110-96 48 48 0 010 96z + M896 128h-64V64c0-35-29-64-64-64s-64 29-64 64v64h-64c-35 0-64 29-64 64s29 64 64 64h64v64c0 35 29 64 64 64s64-29 64-64V256h64c35 0 64-29 64-64s-29-64-64-64zm-204 307C673 481 628 512 576 512H448c-47 0-90 13-128 35V372C394 346 448 275 448 192c0-106-86-192-192-192S64 86 64 192c0 83 54 154 128 180v280c-74 26-128 97-128 180c0 106 86 192 192 192s192-86 192-192c0-67-34-125-84-159c22-20 52-33 84-33h128c122 0 223-85 249-199c-19 4-37 7-57 7c-26 0-51-5-76-13zM256 128c35 0 64 29 64 64s-29 64-64 64s-64-29-64-64s29-64 64-64zm0 768c-35 0-64-29-64-64s29-64 64-64s64 29 64 64s-29 64-64 64z + M512 597m-1 0a1 1 0 103 0a1 1 0 10-3 0ZM810 393 732 315 448 600 293 444 214 522l156 156 78 78 362-362z + M512 32C246 32 32 250 32 512s218 480 480 480 480-218 480-480S774 32 512 32zm269 381L496 698c-26 26-61 26-83 0L243 528c-26-26-26-61 0-83s61-26 83 0l128 128 240-240c26-26 61-26 83 0 26 19 26 54 3 80z + M747 467c29 0 56 4 82 12v-363c0-47-38-84-84-84H125c-47 0-84 38-84 84v707c0 47 38 84 84 84h375a287 287 0 01-43-152c0-160 129-289 289-289zm-531-250h438c19 0 34 15 34 34s-15 34-34 34H216c-19 0-34-15-34-34s15-34 34-34zm0 179h263c19 0 34 15 34 34s-15 34-34 34H216c-19 0-34-15-34-34s15-34 34-34zm131 247h-131c-19 0-34-15-34-34s15-34 34-34h131c19 0 34 15 34 34s-15 34-34 34zM747 521c-130 0-236 106-236 236S617 992 747 992s236-106 236-236S877 521 747 521zm11 386v-65h-130c-12 0-22-10-22-22s10-22 22-22h260l-130 108zm108-192H606l130-108v65h130c12 0 22 10 22 22s-10 22-22 22z + M529 511c115 0 212 79 239 185h224a62 62 0 017 123l-7 0-224 0a247 247 0 01-479 0H65a62 62 0 01-7-123l7-0h224a247 247 0 01239-185zm0 124a124 124 0 100 247 124 124 0 000-247zm0-618c32 0 58 24 61 55l0 7V206c89 11 165 45 225 103a74 74 0 0122 45l0 9v87a62 62 0 01-123 7l-0-7v-65l-6-4c-43-33-97-51-163-53l-17-0c-74 0-133 18-180 54l-6 4v65a62 62 0 01-55 61l-7 0a62 62 0 01-61-55l-0-7V362c0-20 8-39 23-53 60-58 135-92 224-103V79c0-34 28-62 62-62z + M512 926c-229 0-414-186-414-414S283 98 512 98s414 186 414 414-186 414-414 414zm0-73c189 0 341-153 341-341S701 171 512 171 171 323 171 512s153 341 341 341zm-6-192L284 439l52-52 171 171 171-171L728 439l-222 222z + M512 57c251 0 455 204 455 455S763 967 512 967 57 763 57 512 261 57 512 57zm181 274c-11-11-29-11-40 0L512 472 371 331c-11-11-29-11-40 0-11 11-11 29 0 40L471 512 331 653c-11 11-11 29 0 40 11 11 29 11 40 0l141-141 141 141c11 11 29 11 40 0 11-11 11-29 0-40L552 512l141-141c11-11 11-29 0-40z + M591 907A85 85 0 01427 875h114a299 299 0 0050 32zM725 405c130 0 235 105 235 235s-105 235-235 235-235-105-235-235 105-235 235-235zM512 64a43 43 0 0143 43v24c126 17 229 107 264 225A298 298 0 00725 341l-4 0A235 235 0 00512 213l-5 0c-125 4-224 104-228 229l-0 6v167a211 211 0 01-26 101l-4 7-14 23h211a298 298 0 0050 85l-276-0a77 77 0 01-66-39c-13-22-14-50-2-73l2-4 22-36c10-17 16-37 17-57l0-7v-167C193 287 313 153 469 131V107a43 43 0 0139-43zm345 505L654 771a149 149 0 00202-202zM725 491a149 149 0 00-131 220l202-202A149 149 0 00725 491z + M797 829a49 49 0 1049 49 49 49 0 00-49-49zm147-114A49 49 0 10992 764a49 49 0 00-49-49zM928 861a49 49 0 1049 49A49 49 0 00928 861zm-5-586L992 205 851 64l-71 71a67 67 0 00-94 0l235 235a67 67 0 000-94zm-853 128a32 32 0 00-32 50 1291 1291 0 0075 112L288 552c20 0 25 21 8 37l-93 86a1282 1282 0 00120 114l100-32c19-6 28 15 14 34l-40 55c26 19 53 36 82 53a89 89 0 00115-20 1391 1391 0 00256-485l-188-188s-306 224-595 198z + M1280 704c0 141-115 256-256 256H288C129 960 0 831 0 672c0-126 80-232 192-272A327 327 0 01192 384c0-177 143-320 320-320 119 0 222 64 277 160C820 204 857 192 896 192c106 0 192 86 192 192 0 24-5 48-13 69C1192 477 1280 580 1280 704zm-493-128H656V352c0-18-14-32-32-32h-96c-18 0-32 14-32 32v224h-131c-29 0-43 34-23 55l211 211c12 12 33 12 45 0l211-211c20-20 6-55-23-55z + M853 102H171C133 102 102 133 102 171v683C102 891 133 922 171 922h683C891 922 922 891 922 853V171C922 133 891 102 853 102zM390 600l-48 48L205 512l137-137 48 48L301 512l88 88zM465 819l-66-18L559 205l66 18L465 819zm218-171L634 600 723 512l-88-88 48-48L819 512 683 649z + M684 736 340 736l0-53 344 1-0 53zM552 565l-213-2 0-53 212 2-0 53zM684 392 340 392l0-53 344 1-0 53zM301 825c-45 0-78-9-100-27-22-18-33-43-33-75v-116c0-22-4-37-12-45-7-9-20-13-40-13v-61c19 0 32-4 40-12 8-9 12-24 12-46v-116c0-32 11-57 33-75 22-18 56-27 100-27h24v61h-24a35 35 0 00-27 12 41 41 0 00-11 29v116c0 35-10 60-31 75a66 66 0 01-31 14c11 2 22 6 31 14 20 17 31 42 31 75v116c0 12 4 22 11 29 7 8 16 12 27 12h24v61h-24zM701 764h24c10 0 19-4 27-12a41 41 0 0011-29v-116c0-33 10-58 31-75 9-7 19-12 31-14a66 66 0 01-31-14c-20-15-31-40-31-75v-116a41 41 0 00-11-29 35 35 0 00-27-12h-24v-61h24c45 0 78 9 100 27 22 18 33 43 33 75v116c0 22 4 37 11 46 8 8 21 12 40 12v61c-19 0-33 4-40 13-7 8-11 23-11 45v116c0 32-11 57-33 75-22 18-55 27-100 27h-24v-61z + M128 854h768v86H128zM390 797c13 13 29 19 48 19s35-6 45-19l291-288c26-22 26-64 0-90L435 83l-61 61L426 192l-272 269c-22 22-22 64 0 90l237 246zm93-544 211 211-32 32H240l243-243zM707 694c0 48 38 86 86 86 48 0 86-38 86-86 0-22-10-45-26-61L794 576l-61 61c-13 13-26 35-26 58z + M0 512M1024 512M512 0M512 1024M796 471A292 292 0 00512 256a293 293 0 00-284 215H0v144h228A293 293 0 00512 832a291 291 0 00284-217H1024V471h-228M512 688A146 146 0 01366 544A145 145 0 01512 400c80 0 146 63 146 144A146 146 0 01512 688 + M796 561a5 5 0 014 7l-39 90a5 5 0 004 7h100a5 5 0 014 8l-178 247a5 5 0 01-9-4l32-148a5 5 0 00-5-6h-89a5 5 0 01-4-7l86-191a5 5 0 014-3h88zM731 122a73 73 0 0173 73v318a54 54 0 00-8-1H731V195H244v634h408l-16 73H244a73 73 0 01-73-73V195a73 73 0 0173-73h488zm-219 366v73h-195v-73h195zm146-146v73H317v-73h341z + M645 448l64 64 220-221L704 64l-64 64 115 115H128v90h628zM375 576l-64-64-220 224L314 960l64-64-116-115H896v-90H262z + M608 0q48 0 88 23t63 63 23 87v70h55q35 0 67 14t57 38 38 57 14 67V831q0 34-14 66t-38 57-57 38-67 13H426q-34 0-66-13t-57-38-38-57-14-66v-70h-56q-34 0-66-14t-57-38-38-57-13-67V174q0-47 23-87T109 23 196 0h412m175 244H426q-46 0-86 22T278 328t-26 85v348H608q47 0 86-22t63-62 25-85l1-348m-269 318q18 0 31 13t13 31-13 31-31 13-31-13-13-31 13-31 31-13m0-212q13 0 22 9t11 22v125q0 14-9 23t-22 10-23-7-11-22l-1-126q0-13 10-23t23-10z + M896 811l-128 0c-23 0-43-19-43-43 0-23 19-43 43-43l107 0c13 0 21-9 21-21L896 107c0-13-9-21-21-21L448 85c-13 0-21 9-21 21l0 21c0 23-19 43-43 43-23 0-43-19-43-43L341 85c0-47 38-85 85-85l469 0c47 0 85 38 85 85l0 640C981 772 943 811 896 811zM683 299l0 640c0 47-38 85-85 85L128 1024c-47 0-85-38-85-85L43 299c0-47 38-85 85-85l469 0C644 213 683 252 683 299zM576 299 149 299c-13 0-21 9-21 21l0 597c0 13 9 21 21 21l427 0c13 0 21-9 21-21L597 320C597 307 589 299 576 299z + M280 145l243 341 0-0 45 63-0 0 79 110a143 143 0 11-36 75l-88-123-92 126c1 4 1 9 1 13l0 5a143 143 0 11-36-95l82-113L221 188l60-43zm473 541a70 70 0 100 140 70 70 0 000-140zm-463 0a70 70 0 100 140 70 70 0 000-140zM772 145l59 43-232 319-45-63L772 145z + M128 183C128 154 154 128 183 128h521c30 0 55 26 55 55v38c0 17-17 34-34 34s-34-17-34-34v-26H196v495h26c17 0 34 17 34 34s-17 34-34 34h-38c-30 0-55-26-55-55V183zM380 896h-34c-26 0-47-21-47-47v-90h68V828h64V896H380c4 0 0 0 0 0zM759 828V896h90c26 0 47-21 47-47v-90h-68V828h-68zM828 435H896V346c0-26-21-47-47-47h-90v68H828v68zM435 299v68H367V439H299V346C299 320 320 299 346 299h90zM367 649H299v-107h68v107zM546 367V299h107v68h-107zM828 546H896v107h-68v-107zM649 828V896h-107v-68h107zM730 508v188c0 17-17 34-34 34h-188c-17 0-34-17-34-34s17-34 34-34h102l-124-124c-13-13-13-34 0-47 13-13 34-13 47 0l124 124V512c0-17 17-34 34-34 21-4 38 9 38 30z + M889 0H135c-32 0-59 26-59 59v906c0 32 26 59 59 59h753c32 0 59-26 59-59v-906c1-33-26-59-58-59zm-165 177c31 0 56 25 56 56s-25 56-56 56-56-25-56-56 25-56 56-56zm-212 0c31 0 56 25 56 56S543 288 512 288s-56-25-56-56S481 177 512 177zm-212 0c31 0 56 25 56 56s-25 56-56 56-56-25-56-56 25-56 56-56zm209 606H285c-25 0-44-20-44-44 0-25 20-44 44-44h224c25 0 44 20 44 44 0 24-20 44-44 44zm230-212H285c-25 0-44-20-44-44 0-25 20-44 44-44h453c25 0 44 20 44 44 1 24-20 44-44 44z + M854 307 611 73c-6-6-14-9-22-9H296c-4 0-8 4-8 8v56c0 4 4 8 8 8h277l219 211V824c0 4 4 8 8 8h56c4 0 8-4 8-8V330c0-9-4-17-10-23zM553 201c-6-6-14-9-23-9H192c-18 0-32 14-32 32v704c0 18 14 32 32 32h512c18 0 32-14 32-32V397c0-9-3-17-9-23L553 201zM568 753c0 4-3 7-8 7h-225c-4 0-8-3-8-7v-42c0-4 3-7 8-7h225c4 0 8 3 8 7v42zm0-220c0 4-3 7-8 7H476v85c0 4-3 7-7 7h-42c-4 0-7-3-7-7V540h-85c-4 0-8-3-8-7v-42c0-4 3-7 8-7H420v-85c0-4 3-7 7-7h42c4 0 7 3 7 7V484h85c4 0 8 3 8 7v42z + M256 224l0 115L512 544l256-205 0-115-256 205L256 224zM512 685l-256-205L256 595 512 800 768 595l0-115L512 685z + M768 800V685L512 480 256 685V800l256-205L768 800zM512 339 768 544V429L512 224 256 429V544l256-205z + M509 546l271-271 91 91-348 349-1-1-13 13-363-361 91-91z + M652 157a113 113 0 11156 161L731 395 572 236l80-80 1 1zM334 792v0H175v-159l358-358 159 159-358 358zM62 850h900v113H62v-113z + M469 235V107h85v128h-85zm-162-94 85 85-60 60-85-85 60-60zm469 60-85 85-60-60 85-85 60 60zm-549 183A85 85 0 01302 341H722a85 85 0 0174 42l131 225A85 85 0 01939 652V832a85 85 0 01-85 85H171a85 85 0 01-85-85v-180a85 85 0 0112-43l131-225zM722 427H302l-100 171h255l10 29a59 59 0 002 5c2 4 5 9 9 14 8 9 18 17 34 17 16 0 26-7 34-17a72 72 0 0011-18l0-0 10-29h255l-100-171zM853 683H624a155 155 0 01-12 17C593 722 560 747 512 747c-48 0-81-25-99-47a155 155 0 01-12-17H171v149h683v-149z + M576 832C576 867 547 896 512 896 477 896 448 867 448 832 448 797 477 768 512 768 547 768 576 797 576 832ZM512 256C477 256 448 285 448 320L448 640C448 675 477 704 512 704 547 704 576 675 576 640L576 320C576 285 547 256 512 256ZM1024 896C1024 967 967 1024 896 1024L128 1024C57 1024 0 967 0 896 0 875 5 855 14 837L14 837 398 69 398 69C420 28 462 0 512 0 562 0 604 28 626 69L1008 835C1018 853 1024 874 1024 896ZM960 896C960 885 957 875 952 865L952 864 951 863 569 98C557 77 536 64 512 64 488 64 466 77 455 99L452 105 92 825 93 825 71 867C66 876 64 886 64 896 64 931 93 960 128 960L896 960C931 960 960 931 960 896Z + M928 128l-416 0-32-64-352 0-64 128 896 0zM904 704l75 0 45-448-1024 0 64 640 484 0c-105-38-180-138-180-256 0-150 122-272 272-272s272 122 272 272c0 22-3 43-8 64zM1003 914l-198-175c17-29 27-63 27-99 0-106-86-192-192-192s-192 86-192 192 86 192 192 192c36 0 70-10 99-27l175 198c23 27 62 28 87 3l6-6c25-25 23-64-3-87zM640 764c-68 0-124-56-124-124s56-124 124-124 124 56 124 124-56 124-124 124z + M0 512M1024 512M512 0M512 1024M520 168C291 168 95 311 16 512c79 201 275 344 504 344 229 0 425-143 504-344-79-201-275-344-504-344zm0 573c-126 0-229-103-229-229s103-229 229-229c126 0 229 103 229 229s-103 229-229 229zm0-367c-76 0-137 62-137 137s62 137 137 137S657 588 657 512s-62-137-137-137z + M734 128c-33-19-74-8-93 25l-41 70c-28-6-58-9-90-9-294 0-445 298-445 298s82 149 231 236l-31 54c-19 33-8 74 25 93 33 19 74 8 93-25L759 222C778 189 767 147 734 128zM305 512c0-115 93-208 207-208 14 0 27 1 40 4l-37 64c-1 0-2 0-2 0-77 0-140 63-140 140 0 26 7 51 20 71l-37 64C324 611 305 564 305 512zM771 301 700 423c13 27 20 57 20 89 0 110-84 200-192 208l-51 89c12 1 24 2 36 2 292 0 446-298 446-298S895 388 771 301z + M826 498 538 250c-11-9-26-1-26 14v496c0 15 16 23 26 14L826 526c8-7 8-21 0-28zm-320 0L218 250c-11-9-26-1-26 14v496c0 15 16 23 26 14L506 526c4-4 6-9 6-14 0-5-2-10-6-14z + M1024 896v128H0V704h128v192h768V704h128v192zM576 555 811 320 896 405l-384 384-384-384L213 320 448 555V0h128v555z + M959 320H960v640A64 64 0 01896 1024H192A64 64 0 01128 960V64A64 64 0 01192 0H640v321h320L959 320zM320 544c0 17 14 32 32 32h384A32 32 0 00768 544c0-17-14-32-32-32H352A32 32 0 00320 544zm0 128c0 17 14 32 32 32h384a32 32 0 0032-32c0-17-14-32-32-32H352a32 32 0 00-32 32zm0 128c0 17 14 32 32 32h384a32 32 0 0032-32c0-17-14-32-32-32H352a32 32 0 00-32 32z + M683 85l213 213v598a42 42 0 01-42 42H170A43 43 0 01128 896V128C128 104 147 85 170 85H683zm-213 384H341v85h128v128h85v-128h128v-85h-128V341h-85v128z + M949 727l-217-231a33 33 0 00-48 0 33 33 0 000 48l157 172H389a35 35 0 00-35 35c0 19 16 34 35 34h452l-160 179a34 34 0 005 54c14 10 33 7 45-5l219-237a33 33 0 000-49zM719 196h131c-24-91-95-160-185-185v131c0 27 25 54 54 54zM129 846l1-747s-7-37 36-33h359v52s-7 76 32 133a191 191 0 00146 84h91v126h66v-191H719a126 126 0 01-127-127V0H155c-51 0-91 40-91 91v767c0 51 40 91 91 91h193v-66H155c0-0-26 4-26-36z + M416 832H128V128h384v192C512 355 541 384 576 384L768 384v32c0 19 13 32 32 32S832 435 832 416v-64c0-6 0-19-6-25l-256-256c-6-6-19-6-25-6H128A64 64 0 0064 128v704C64 867 93 896 129 896h288c19 0 32-13 32-32S435 832 416 832zM576 172 722 320H576V172zM736 512C614 512 512 614 512 736S614 960 736 960s224-102 224-224S858 512 736 512zM576 736C576 646 646 576 736 576c32 0 58 6 83 26l-218 218c-19-26-26-51-26-83zm160 160c-32 0-64-13-96-32l224-224c19 26 32 58 32 96 0 90-70 160-160 160z + M896 320c0-19-6-32-19-45l-192-192c-13-13-26-19-45-19H192c-38 0-64 26-64 64v768c0 38 26 64 64 64h640c38 0 64-26 64-64V320zm-256 384H384c-19 0-32-13-32-32s13-32 32-32h256c19 0 32 13 32 32s-13 32-32 32zm166-384H640V128l192 192h-26z + M599 425 599 657 425 832 425 425 192 192 832 192Z + M505 74c-145 3-239 68-239 68-12 8-15 25-7 37 9 13 25 15 38 6 0 0 184-136 448 2 12 7 29 3 36-10 8-13 3-29-12-37-71-38-139-56-199-63-23-3-44-3-65-3m17 111c-254-3-376 201-376 201-8 12-5 29 7 37 12 8 29 4 39-10 0 0 103-178 329-175 226 3 325 173 325 173 8 12 24 17 37 9 14-8 17-24 9-37 0 0-117-195-370-199m-31 106c-72 5-140 31-192 74C197 449 132 603 204 811c5 14 20 21 34 17 14-5 21-20 16-34-66-191-7-316 79-388 84-69 233-85 343-17 54 34 96 93 118 151 22 58 20 114 3 141-18 28-54 38-86 30-32-8-58-31-59-80-1-73-58-118-118-125-57-7-123 24-140 92-32 125 49 302 238 361 14 4 29-3 34-17 4-14-3-29-18-34-163-51-225-206-202-297 10-41 46-55 84-52 37 4 69 26 69 73 2 70 48 117 100 131 52 13 112-3 144-52 33-50 28-120 3-188-26-68-73-136-140-178a356 356 0 00-213-52m15 104v0c-76 3-152 42-195 125-56 106-31 215 7 293 38 79 90 131 90 131 10 11 27 11 38 0s11-26 0-38c0 0-46-47-79-116s-54-157-8-244c48-90 133-111 208-90 76 22 140 88 138 186-2 15 9 28 24 29 15 1 27-10 29-27 3-122-79-210-176-239a246 246 0 00-75-9m9 213c-15 0-26 13-26 27 0 0 1 63 36 124 36 61 112 119 244 107 15-1 26-13 25-28-1-15-14-26-30-25-116 11-165-33-193-81-28-47-29-98-29-98a27 27 0 00-27-27z + m211 611a142 142 0 00-90-4v-190a142 142 0 0090-4v198zm0 262v150h-90v-146a142 142 0 0090-4zm0-723a142 142 0 00-90-4v-146h90zm-51 246a115 115 0 11115-115 115 115 0 01-115 115zm0 461a115 115 0 11115-115 115 115 0 01-115 115zm256-691h563v90h-563zm0 461h563v90h-563zm0-282h422v90h-422zm0 474h422v90h-422z + M853 267H514c-4 0-6-2-9-4l-38-66c-13-21-38-36-64-36H171c-41 0-75 34-75 75v555c0 41 34 75 75 75h683c41 0 75-34 75-75V341c0-41-34-75-75-75zm-683-43h233c4 0 6 2 9 4l38 66c13 21 38 36 64 36H853c6 0 11 4 11 11v75h-704V235c0-6 4-11 11-11zm683 576H171c-6 0-11-4-11-11V480h704V789c0 6-4 11-11 11z + M1088 227H609L453 78a11 11 0 00-7-3H107a43 43 0 00-43 43v789a43 43 0 0043 43h981a43 43 0 0043-43V270a43 43 0 00-43-43zM757 599c0 5-5 9-10 9h-113v113c0 5-4 9-9 9h-56c-5 0-9-4-9-9V608h-113c-5 0-10-4-10-9V543c0-5 5-9 10-9h113V420c0-5 4-9 9-9h56c5 0 9 4 9 9V533h113c5 0 10 4 10 9v56z + M922 450c-6-9-15-13-26-13h-11V341c0-41-34-75-75-75H514c-4 0-6-2-9-4l-38-66c-13-21-38-36-64-36H171c-41 0-75 34-75 75v597c0 6 2 13 6 19 6 9 15 13 26 13h640c13 0 26-9 30-21l128-363c4-11 2-21-4-30zM171 224h233c4 0 6 2 9 4l38 66c13 21 38 36 64 36H811c6 0 11 4 11 11v96H256c-13 0-26 9-30 21l-66 186V235c0-6 4-11 11-11zm574 576H173l105-299h572l-105 299z + M509 556l93 149h124l-80-79 49-50 165 164-165 163-49-50 79-79h-163l-96-153 41-65zm187-395 165 164-165 163-49-50L726 360H530l-136 224H140v-70h215l136-224h236l-80-79 49-50z + M939 94v710L512 998 85 805V94h-64A21 21 0 010 73v-0C0 61 10 51 21 51h981c12 0 21 10 21 21v0c0 12-10 21-21 21h-64zm-536 588L512 624l109 58c6 3 13 4 20 3a32 32 0 0026-37l-21-122 88-87c5-5 8-11 9-18a32 32 0 00-27-37l-122-18-54-111a32 32 0 00-57 0l-54 111-122 18c-7 1-13 4-18 9a33 33 0 001 46l88 87-21 122c-1 7-0 14 3 20a32 32 0 0043 14z + M236 542a32 32 0 109 63l86-12a180 180 0 0022 78l-71 47a32 32 0 1035 53l75-50a176 176 0 00166 40L326 529zM512 16C238 16 16 238 16 512s222 496 496 496 496-222 496-496S786 16 512 16zm0 896c-221 0-400-179-400-400a398 398 0 0186-247l561 561A398 398 0 01512 912zm314-154L690 622a179 179 0 004-29l85 12a32 32 0 109-63l-94-13v-49l94-13a32 32 0 10-9-63l-87 12a180 180 0 00-20-62l71-47A32 32 0 10708 252l-75 50a181 181 0 00-252 10l-115-115A398 398 0 01512 112c221 0 400 179 400 400a398 398 0 01-86 247z + M884 159l-18-18a43 43 0 00-38-12l-235 43a166 166 0 00-101 60L400 349a128 128 0 00-148 47l-120 171a21 21 0 005 29l17 12a128 128 0 00178-32l27-38 124 124-38 27a128 128 0 00-32 178l12 17a21 21 0 0029 5l171-120a128 128 0 0047-148l117-92A166 166 0 00853 431l43-235a43 43 0 00-12-38zm-177 249a64 64 0 110-90 64 64 0 010 90zm-373 312a21 21 0 010 30l-139 139a21 21 0 01-30 0l-30-30a21 21 0 010-30l139-139a21 21 0 0130 0z + M525 0C235 0 0 235 0 525c0 232 150 429 359 498 26 5 36-11 36-25 0-12-1-54-1-97-146 31-176-63-176-63-23-61-58-76-58-76-48-32 3-32 3-32 53 3 81 54 81 54 47 80 123 57 153 43 4-34 18-57 33-70-116-12-239-57-239-259 0-57 21-104 54-141-5-13-23-67 5-139 0 0 44-14 144 54 42-11 87-17 131-17s90 6 131 17C756 203 801 217 801 217c29 72 10 126 5 139 34 37 54 83 54 141 0 202-123 246-240 259 19 17 36 48 36 97 0 70-1 127-1 144 0 14 10 30 36 25 209-70 359-266 359-498C1050 235 814 0 525 0z + M590 74 859 342V876c0 38-31 68-68 68H233c-38 0-68-31-68-68V142c0-38 31-68 68-68h357zm-12 28H233a40 40 0 00-40 38L193 142v734a40 40 0 0038 40L233 916h558a40 40 0 0040-38L831 876V354L578 102zM855 371h-215c-46 0-83-36-84-82l0-2V74h28v213c0 30 24 54 54 55l2 0h215v28zM57 489m28 0 853 0q28 0 28 28l0 284q0 28-28 28l-853 0q-28 0-28-28l0-284q0-28 28-28ZM157 717c15 0 29-6 37-13v-51h-41v22h17v18c-2 2-6 3-10 3-21 0-30-13-30-34 0-21 12-34 28-34 9 0 15 4 20 9l14-17C184 610 172 603 156 603c-29 0-54 21-54 57 0 37 24 56 54 56zM245 711v-108h-34v108h34zm69 0v-86H341V603H262v22h28V711h24zM393 711v-108h-34v108h34zm66 6c15 0 29-6 37-13v-51h-41v22h17v18c-2 2-6 3-10 3-21 0-30-13-30-34 0-21 12-34 28-34 9 0 15 4 20 9l14-17C485 610 474 603 458 603c-29 0-54 21-54 57 0 37 24 56 54 56zm88-6v-36c0-13-2-28-3-40h1l10 24 25 52H603v-108h-23v36c0 13 2 28 3 40h-1l-10-24L548 603H523v108h23zM677 717c30 0 51-22 51-57 0-36-21-56-51-56-30 0-51 20-51 56 0 36 21 57 51 57zm3-23c-16 0-26-12-26-32 0-19 10-31 26-31 16 0 26 11 26 31S696 694 680 694zm93 17v-38h13l21 38H836l-25-43c12-5 19-15 19-31 0-26-20-34-44-34H745v108h27zm16-51H774v-34h15c16 0 25 4 25 16s-9 18-25 18zM922 711v-22h-43v-23h35v-22h-35V625h41V603H853v108h68z + M30 271l241 0 0-241-241 0 0 241zM392 271l241 0 0-241-241 0 0 241zM753 30l0 241 241 0 0-241-241 0zM30 632l241 0 0-241-241 0 0 241zM392 632l241 0 0-241-241 0 0 241zM753 632l241 0 0-241-241 0 0 241zM30 994l241 0 0-241-241 0 0 241zM392 994l241 0 0-241-241 0 0 241zM753 994l241 0 0-241-241 0 0 241z + M0 512M1024 512M512 0M512 1024M955 323q0 23-16 39l-414 414-78 78q-16 16-39 16t-39-16l-78-78-207-207q-16-16-16-39t16-39l78-78q16-16 39-16t39 16l168 169 375-375q16-16 39-16t39 16l78 78q16 16 16 39z + M416 64H768v64h-64v704h64v64H448v-64h64V512H416a224 224 0 1 1 0-448zM576 832h64V128H576v704zM416 128H512v320H416a160 160 0 0 1 0-320z + M24 512A488 488 0 01512 24A488 488 0 011000 512A488 488 0 01512 1000A488 488 0 0124 512zm447-325v327L243 619l51 111 300-138V187H471z + M832 64h128v278l-128-146V64zm64 448L512 73 128 512H0L448 0h128l448 512h-128zm0 83V1024H640V704c0-35-29-64-64-64h-128a64 64 0 00-64 64v320H128V595l384-424 384 424z + M512 0C229 0 0 229 0 512c0 283 229 512 512 512s512-229 512-512c0-283-229-512-512-512zm0 958C266 958 66 758 66 512S266 66 512 66 958 266 958 512 758 958 512 958zM192 416h96a32 32 0 0032-32v-32a32 32 0 00-32-32H192a32 32 0 00-32 32v32a32 32 0 0032 32zM384 416h96a32 32 0 0032-32v-32a32 32 0 00-32-32h-96a32 32 0 00-32 32v32a32 32 0 0032 32zM576 416h96a32 32 0 0032-32v-32a32 32 0 00-32-32h-96a32 32 0 00-32 32v32a32 32 0 0032 32zM832 320h-64a32 32 0 00-32 32v128h-160a32 32 0 00-32 32v32a32 32 0 0032 32h256a32 32 0 0032-32v-192a32 32 0 00-32-32zM320 544v-32a32 32 0 00-32-32H192a32 32 0 00-32 32v32a32 32 0 0032 32h96a32 32 0 0032-32zM384 576h96a32 32 0 0032-32v-32a32 32 0 00-32-32h-96a32 32 0 00-32 32v32a32 32 0 0032 32zM800 640H256a32 32 0 00-32 32v32a32 32 0 0032 32h544a32 32 0 0032-32v-32a32 32 0 00-32-32z + M973 358a51 51 0 0151 51v563a51 51 0 01-51 51H51a51 51 0 01-51-51V410a51 51 0 0151-51h256a51 51 0 110 102H102v461h819V461h-205a51 51 0 110-102h256zM51 102a51 51 0 110-102h256c141 0 256 115 256 256v388l66-66a51 51 0 1172 72l-154 154a51 51 0 01-72 0l-154-154a51 51 0 1172-72L461 644V256c0-85-69-154-154-154H51z + M512 0C229 0 0 229 0 512s229 512 512 512 512-229 512-512S795 0 512 0zM512 928c-230 0-416-186-416-416S282 96 512 96s416 186 416 416S742 928 512 928zM538 343c47 0 83-38 83-78 0-32-21-61-62-61-55 0-82 45-82 77C475 320 498 343 538 343zM533 729c-8 0-11-10-3-40l43-166c16-61 11-100-22-100-39 0-131 40-211 108l16 27c25-17 68-35 78-35 8 0 7 10 0 36l-38 158c-23 89 1 110 34 110 33 0 118-30 196-110l-19-25C575 717 543 729 533 729z + M412 66C326 132 271 233 271 347c0 17 1 34 4 50-41-48-98-79-162-83a444 444 0 00-46 196c0 207 142 382 337 439h2c19 0 34 15 34 33 0 11-6 21-14 26l1 14C183 973 0 763 0 511 0 272 166 70 393 7A35 35 0 01414 0c19 0 34 15 34 33a33 33 0 01-36 33zm200 893c86-66 141-168 141-282 0-17-1-34-4-50 41 48 98 79 162 83a444 444 0 0046-196c0-207-142-382-337-439h-2a33 33 0 01-34-33c0-11 6-21 14-26L596 0C841 51 1024 261 1024 513c0 239-166 441-393 504A35 35 0 01610 1024a33 33 0 01-34-33 33 33 0 0136-33zM512 704a192 192 0 110-384 192 192 0 010 384z + M512 64A447 447 0 0064 512c0 248 200 448 448 448s448-200 448-448S760 64 512 64zM218 295h31c54 0 105 19 145 55 13 12 13 31 3 43a35 35 0 01-22 10 36 36 0 01-21-7 155 155 0 00-103-39h-31a32 32 0 01-31-31c0-18 13-31 30-31zm31 433h-31a32 32 0 01-31-31c0-16 13-31 31-31h31A154 154 0 00403 512 217 217 0 01620 295h75l-93-67a33 33 0 01-7-43 33 33 0 0143-7l205 148-205 148a29 29 0 01-18 6 32 32 0 01-31-31c0-10 4-19 13-25l93-67H620a154 154 0 00-154 154c0 122-97 220-217 220zm390 118a29 29 0 01-18 6 32 32 0 01-31-31c0-10 4-19 13-25l93-67h-75c-52 0-103-19-143-54-12-12-13-31-1-43a30 30 0 0142-3 151 151 0 00102 39h75L602 599a33 33 0 01-7-43 33 33 0 0143-7l205 148-203 151z + M922 39H102A65 65 0 0039 106v609a65 65 0 0063 68h94v168a34 34 0 0019 31 30 30 0 0012 3 30 30 0 0022-10l182-192H922a65 65 0 0063-68V106A65 65 0 00922 39zM288 378h479a34 34 0 010 68H288a34 34 0 010-68zm0-135h479a34 34 0 010 68H288a34 34 0 010-68zm0 270h310a34 34 0 010 68H288a34 34 0 010-68z + M875 117H149C109 117 75 151 75 192v640c0 41 34 75 75 75h725c41 0 75-34 75-75V192c0-41-34-75-75-75zM139 832V192c0-6 4-11 11-11h331v661H149c-6 0-11-4-11-11zm747 0c0 6-4 11-11 11H544v-661H875c6 0 11 4 11 11v640z + M40 9 15 23 15 31 9 28 9 20 34 5 24 0 0 14 0 34 25 48 25 28 49 14zM26 29 26 48 49 34 49 15z + M892 251c-5-11-18-18-30-18H162c-12 0-23 7-30 18-5 11-5 26 2 35l179 265v320c0 56 44 102 99 102h200c55 0 99-46 99-102v-320l179-266c9-11 9-24 4-34zm-345 540c0 18-14 35-34 35-18 0-34-14-34-35v-157c0-18 14-34 34-34 18 0 34 14 34 34v157zM512 205c18 0 34-14 34-35V87c0-20-16-35-34-35s-34 14-34 35v84c1 20 16 34 34 34zM272 179c5 18 23 30 40 24 17-6 28-24 23-42l-25-51c-5-18-23-30-40-24s-28 24-23 42L272 179zM777 127c5-18-6-36-23-42-17-6-35 5-40 24l-25 51c-5 18 6 37 23 42 17 6 35-5 40-24l25-52z + M416 192m32 0 448 0q32 0 32 32l0 0q0 32-32 32l-448 0q-32 0-32-32l0 0q0-32 32-32ZM416 448m32 0 448 0q32 0 32 32l0 0q0 32-32 32l-448 0q-32 0-32-32l0 0q0-32 32-32ZM416 704m32 0 448 0q32 0 32 32l0 0q0 32-32 32l-448 0q-32 0-32-32l0 0q0-32 32-32ZM96 320l128-192 128 192h-256zM96 640l128 192 128-192h-256zM190 320h64v320H190z + M408 232C408 210 426 192 448 192h416a40 40 0 110 80H448a40 40 0 01-40-40zM408 512c0-22 18-40 40-40h416a40 40 0 110 80H448A40 40 0 01408 512zM448 752A40 40 0 00448 832h416a40 40 0 100-80H448zM32 480l132 0 0-128 64 0 0 128 132 0 0 64-132 0 0 128-64 0 0-128-132 0Z + M408 232C408 210 426 192 448 192h416a40 40 0 110 80H448a40 40 0 01-40-40zM408 512c0-22 18-40 40-40h416a40 40 0 110 80H448A40 40 0 01408 512zM448 752A40 40 0 00448 832h416a40 40 0 100-80H448zM32 480l328 0 0 64-328 0Z + M 968 418 l -95 94 c -59 59 -146 71 -218 37 L 874 331 a 64 64 0 0 0 0 -90 L 783 150 a 64 64 0 0 0 -90 0 L 475 368 c -34 -71 -22 -159 37 -218 l 94 -94 c 75 -75 196 -75 271 0 l 90 90 c 75 75 75 196 0 271 z M 332 693 a 64 64 0 0 1 0 -90 l 271 -271 c 25 -25 65 -25 90 0 s 25 65 0 90 L 422 693 a 64 64 0 0 1 -90 0 z M 151 783 l 90 90 a 64 64 0 0 0 90 0 l 218 -218 c 34 71 22 159 -37 218 l -86 94 a 192 192 0 0 1 -271 0 l -98 -98 a 192 192 0 0 1 0 -271 l 94 -86 c 59 -59 146 -71 218 -37 L 151 693 a 64 64 0 0 0 0 90 z + M0 33h1024v160H0zM0 432h1024v160H0zM0 831h1024v160H0z + M512 0C233 0 7 223 0 500C6 258 190 64 416 64c230 0 416 200 416 448c0 53 43 96 96 96s96-43 96-96c0-283-229-512-512-512zm0 1023c279 0 505-223 512-500c-6 242-190 436-416 436c-230 0-416-200-416-448c0-53-43-96-96-96s-96 43-96 96c0 283 229 512 512 512z + M976 0h-928A48 48 0 000 48v652a48 48 0 0048 48h416V928H200a48 48 0 000 96h624a48 48 0 000-96H560v-180h416a48 48 0 0048-48V48A48 48 0 00976 0zM928 652H96V96h832v556z + M832 464h-68V240a128 128 0 00-128-128h-248a128 128 0 00-128 128v224H192c-18 0-32 14-32 32v384c0 18 14 32 32 32h640c18 0 32-14 32-32v-384c0-18-14-32-32-32zm-292 237v53a8 8 0 01-8 8h-40a8 8 0 01-8-8v-53a48 48 0 1156 0zm152-237H332V240a56 56 0 0156-56h248a56 56 0 0156 56v224z + M908 366h-25V248a18 18 0 00-0-2 20 20 0 00-5-13L681 7 681 7a19 19 0 00-4-3c-0-0-1-1-1-1a29 29 0 00-4-2L671 1a24 24 0 00-5-1H181a40 40 0 00-40 40v326h-25c-32 0-57 26-57 57v298c0 32 26 57 57 57h25v204c0 22 18 40 40 40H843a40 40 0 0040-40v-204h25c32 0 57-26 57-57V424a57 57 0 00-57-57zM181 40h465v205c0 11 9 20 20 20h177v101H181V40zm413 527c0 89-54 143-134 143-81 0-128-61-128-138 0-82 52-143 132-143 84 0 129 63 129 138zm-440 139V433h62v220h108v52h-170zm690 267H181v-193H843l0 193zm18-280a305 305 0 01-91 15c-50 0-86-12-111-37-25-23-39-59-38-99 0-90 66-142 155-142 35 0 62 7 76 13l-13 49c-15-6-33-12-63-12-51 0-90 29-90 88 0 56 35 89 86 89 14 0 25-2 30-4v-57h-42v-48h101v143zM397 570c0 53 25 91 66 91 42 0 65-40 65-92 0-49-23-91-66-91-42 0-66 40-66 93z + M192 192m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM192 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM192 832m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0ZM864 160H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 480H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 800H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32z + M824 645V307c0-56-46-102-102-102h-102V102l-154 154 154 154V307h102v338c-46 20-82 67-82 123 0 72 61 133 133 133 72 0 133-61 133-133 0-56-36-102-82-123zm-51 195c-41 0-72-31-72-72s31-72 72-72c41 0 72 31 72 72s-31 72-72 72zM384 256c0-72-61-133-133-133-72 0-133 61-133 133 0 56 36 102 82 123v266C154 666 118 712 118 768c0 72 61 133 133 133 72 0 133-61 133-133 0-56-36-102-82-123V379C348 358 384 312 384 256zM323 768c0 41-31 72-72 72-41 0-72-31-72-72s31-72 72-72c41 0 72 31 72 72zM251 328c-41 0-72-31-72-72s31-72 72-72c41 0 72 31 72 72s-31 72-72 72z + M896 64H128C96 64 64 96 64 128v768c0 32 32 64 64 64h768c32 0 64-32 64-64V128c0-32-32-64-64-64z m-64 736c0 16-17 32-32 32H224c-18 0-32-12-32-32V224c0-16 16-32 32-32h576c15 0 32 16 32 32v576zM512 384c-71 0-128 57-128 128s57 128 128 128 128-57 128-128-57-128-128-128z + M0 512M1024 512M512 0M512 1024M813 448c-46 0-83 37-83 83 0 46 37 83 83 83 46 0 83-37 83-83 0-46-37-83-83-83zM211 448C165 448 128 485 128 531c0 46 37 83 83 83 46 0 83-37 83-83 0-46-37-83-83-83zM512 448c-46 0-83 37-83 83 0 46 37 83 83 83 46 0 83-37 83-83C595 485 558 448 512 448z + M299 811 299 725 384 725 384 811 299 811M469 811 469 725 555 725 555 811 469 811M640 811 640 725 725 725 725 811 640 811M299 640 299 555 384 555 384 640 299 640M469 640 469 555 555 555 555 640 469 640M640 640 640 555 725 555 725 640 640 640M299 469 299 384 384 384 384 469 299 469M469 469 469 384 555 384 555 469 469 469M640 469 640 384 725 384 725 469 640 469M299 299 299 213 384 213 384 299 299 299M469 299 469 213 555 213 555 299 469 299M640 299 640 213 725 213 725 299 640 299Z + M64 363l0 204 265 0L329 460c0-11 6-18 14-20C349 437 355 437 362 441c93 60 226 149 226 149 33 22 34 60 0 82 0 0-133 89-226 149-14 9-32-3-32-18l-1-110L64 693l0 117c0 41 34 75 75 75l746 0c41 0 75-34 75-74L960 364c0-0 0-1 0-1L64 363zM64 214l0 75 650 0-33-80c-16-38-62-69-103-69l-440 0C97 139 64 173 64 214z + M683 409v204L1024 308 683 0v191c-413 0-427 526-427 526c117-229 203-307 427-307zm85 492H102V327h153s38-63 114-122H51c-28 0-51 27-51 61v697c0 34 23 61 51 61h768c28 0 51-27 51-61V614l-102 100v187z + M841 627A43 43 0 00811 555h-299v85h196l-183 183A43 43 0 00555 896h299v-85h-196l183-183zM299 170H213v512H85l171 171 171-171H299zM725 128h-85c-18 0-34 11-40 28l-117 313h91L606 384h154l32 85h91l-117-313A43 43 0 00725 128zm-88 171 32-85h26l32 85h-90z + M512 0a512 512 0 01512 512 57 57 0 01-114 0 398 398 0 10-398 398 57 57 0 010 114A512 512 0 01512 0zm367 600 121 120a57 57 0 01-80 81l-40-40V967a57 57 0 01-50 57l-7 0a57 57 0 01-57-57v-205l-40 40a57 57 0 01-75 5l-5-5a57 57 0 01-0-80l120-121a80 80 0 01113-0zM512 272a57 57 0 0157 57V499h114a57 57 0 0156 50L740 556a57 57 0 01-57 57H512a57 57 0 01-57-57v-228a57 57 0 0150-57L512 272z + M640 96c-158 0-288 130-288 288 0 17 3 31 5 46L105 681 96 691V928h224v-96h96v-96h96v-95c38 18 82 31 128 31 158 0 288-130 288-288s-130-288-288-288zm0 64c123 0 224 101 224 224s-101 224-224 224a235 235 0 01-109-28l-8-4H448v96h-96v96H256v96H160v-146l253-254 12-11-3-17C419 417 416 400 416 384c0-123 101-224 224-224zm64 96a64 64 0 100 128 64 64 0 100-128z + M544 85c49 0 90 37 95 85h75a96 96 0 0196 89L811 267a32 32 0 01-28 32L779 299a32 32 0 01-32-28L747 267a32 32 0 00-28-32L715 235h-91a96 96 0 01-80 42H395c-33 0-62-17-80-42L224 235a32 32 0 00-32 28L192 267v576c0 16 12 30 28 32l4 0h128a32 32 0 0132 28l0 4a32 32 0 01-32 32h-128a96 96 0 01-96-89L128 843V267a96 96 0 0189-96L224 171h75a96 96 0 0195-85h150zm256 256a96 96 0 0196 89l0 7v405a96 96 0 01-89 96L800 939h-277a96 96 0 01-96-89L427 843v-405a96 96 0 0189-96L523 341h277zm-256-192H395a32 32 0 000 64h150a32 32 0 100-64z + m186 532 287 0 0 287c0 11 9 20 20 20s20-9 20-20l0-287 287 0c11 0 20-9 20-20s-9-20-20-20l-287 0 0-287c0-11-9-20-20-20s-20 9-20 20l0 287-287 0c-11 0-20 9-20 20s9 20 20 20z + M432 0h160c27 0 48 21 48 48v336h175c36 0 53 43 28 68L539 757c-15 15-40 15-55 0L180 452c-25-25-7-68 28-68H384V48c0-27 21-48 48-48zm592 752v224c0 27-21 48-48 48H48c-27 0-48-21-48-48V752c0-27 21-48 48-48h293l98 98c40 40 105 40 145 0l98-98H976c27 0 48 21 48 48zm-248 176c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40zm128 0c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40z + M592 768h-160c-27 0-48-21-48-48V384h-175c-36 0-53-43-28-68L485 11c15-15 40-15 55 0l304 304c25 25 7 68-28 68H640v336c0 27-21 48-48 48zm432-16v224c0 27-21 48-48 48H48c-27 0-48-21-48-48V752c0-27 21-48 48-48h272v16c0 62 50 112 112 112h160c62 0 112-50 112-112v-16h272c27 0 48 21 48 48zm-248 176c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40zm128 0c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40z + M563 555c0 28-23 51-51 51-28 0-51-23-51-51L461 113c0-28 23-51 51-51s51 23 51 51L563 555 563 555zM85 535c0-153 81-287 201-362 24-15 55-8 70 16C371 214 363 245 340 260 248 318 187 419 187 535c0 180 146 325 325 325 180-0 325-146 325-325 0-119-64-223-160-280-24-14-32-46-18-70 14-24 46-32 70-18 125 74 210 211 210 367 0 236-191 427-427 427C276 963 85 772 85 535 + M277 85a149 149 0 00-43 292v230a32 32 0 0064 0V555h267A160 160 0 00725 395v-12a149 149 0 10-64-5v17a96 96 0 01-96 96H299V383A149 149 0 00277 85zM228 720a32 32 0 00-37-52 150 150 0 00-53 68 32 32 0 1060 23 85 85 0 0130-39zm136-52a32 32 0 00-37 52 86 86 0 0130 39 32 32 0 1060-23 149 149 0 00-53-68zM204 833a32 32 0 10-55 32 149 149 0 0063 58 32 32 0 0028-57 85 85 0 01-36-33zm202 32a32 32 0 00-55-32 85 85 0 01-36 33 32 32 0 0028 57 149 149 0 0063-58z + M467 556c0-0 0-1 0-1C467 555 467 556 467 556zM467 556c0-0 0-0 0-0C467 556 467 556 467 556zM467 556c-0 0-0 0-0 0C467 557 467 557 467 556zM468 549C468 532 468 541 468 549L468 549zM468 549c0 1-0 1-0 2C468 551 468 550 468 549zM468 552c-0 1-0 2-0 3C467 554 468 553 468 552zM736 549C736 532 736 541 736 549L736 549zM289 378l0 179 89 0c-1 80-89 89-89 89l45 45c0 0 129-15 134-134L467 378 289 378zM959 244l0 536c0 99-80 179-179 179L244 959c-99 0-179-80-179-179L65 244c0-99 80-179 179-179l536 0C879 65 959 145 959 244zM869 289c0-74-60-134-134-134L289 155c-74 0-134 60-134 134l0 447c0 74 60 134 134 134l447 0c74 0 134-60 134-134L869 289zM557 557l89 0c-1 80-89 89-89 89l45 45c0 0 129-15 134-134L735 378 557 378 557 557z + m224 154a166 166 0 00-166 166v192a166 166 0 00166 166h64v-76h-64a90 90 0 01-90-90v-192a90 90 0 0190-90h320a90 90 0 0190 90v192a90 90 0 01-90 90h-128v77h128a166 166 0 00166-167v-192a166 166 0 00-166-166h-320zm166 390a90 90 0 0190-90h128v-76h-128a166 166 0 00-166 166v192a166 166 0 00166 166h320a166 166 0 00166-166v-192a166 166 0 00-166-166h-64v77h64a90 90 0 0190 90v192a90 90 0 01-90 90h-320a90 90 0 01-90-90v-192z + M512 128M706 302a289 289 0 00-173 44 27 27 0 1029 46 234 234 0 01125-36c23 0 45 3 66 9 93 28 161 114 161 215C914 704 813 805 687 805H337C211 805 110 704 110 580c0-96 61-178 147-210C282 263 379 183 495 183a245 245 0 01210 119z + M364 512h67v108h108v67h-108v108h-67v-108h-108v-67h108v-108zm298-64A107 107 0 01768 555C768 614 720 660 660 660h-108v-54h-108v-108h-94v108h-94c4-21 22-47 44-51l-1-12a75 75 0 0171-75a128 128 0 01239-7a106 106 0 0153-14z + M115 386l19 33c17 29 44 50 76 60l116 33c34 10 58 41 58 77v80c0 22 12 42 32 52s32 30 32 52v78c0 31 30 54 60 45 32-9 57-35 65-68l6-22c8-34 30-63 61-80l16-9c30-17 48-49 48-83v-17c0-25-10-50-28-68l-8-8c-18-18-42-28-68-28H514c-22 0-44-6-64-17l-69-39c-9-5-15-13-18-22-6-19 2-40 20-49l12-6c13-7 29-8 43-3l46 15c16 5 34-1 44-15 9-14 8-33-2-46l-27-33c-20-24-20-59 1-83l31-37c18-21 20-50 7-73l-5-8c-7-0-14-1-21-1-186 0-343 122-396 290zM928 512c0-74-19-143-53-203L824 330c-31 13-48 48-37 80l34 101c7 21 24 37 45 42l58 15c2-18 4-36 4-55zM0 512a512 512 0 111024 0 512 512 0 11-1024 0z + M1024 64v704h-128v128h-128v128h-768v-704h128v-128h128v-128zM64 960h640v-576h-640zM320 128v64h576v512h64v-576zM192 256v64h576v512h64v-576zM432 688L576 832H480L384 736 288 832H192l144-144L192 544h96L384 640l96-96H576z + M853 256h-43v512h43c47 0 85-38 85-85v-341c0-47-38-85-85-85zM725 768V171h128V85h-341v85H640v85H171c-47 0-85 38-85 85v341c0 47 38 85 85 85h469V853h-128v85h341v-85H725v-86zm-469-171v-171h384v171H256z + M960 146v91C960 318 759 384 512 384S64 318 64 238V146C64 66 265 0 512 0s448 66 448 146zM960 352v206C960 638 759 704 512 704S64 638 64 558V352c96 66 272 97 448 97S864 418 960 352zm0 320v206C960 958 759 1024 512 1024S64 958 64 878V672c96 66 272 97 448 97S864 738 960 672z + M883 567l-128-128c-17-17-43-17-60 0l-128 128c-17 17-17 43 0 60 17 17 43 17 60 0l55-55V683c0 21-21 43-43 43H418c-13-38-43-64-77-77V375c51-17 85-64 85-119 0-73-60-128-128-128-73 0-128 55-128 128 0 55 34 102 85 119v269c-51 17-85 64-85 119 0 73 55 128 128 128 55 0 102-34 119-85H640c73 0 128-55 128-128v-111l55 55c9 9 17 13 30 13 13 0 21-4 30-13 17-13 17-43 0-55zM299 213c26 0 43 17 43 43 0 21-21 43-43 43-26 0-43-21-43-43 0-26 17-43 43-43zm0 597c-26 0-43-21-43-43 0-26 17-43 43-43s43 17 43 43c0 21-17 43-43 43zM725 384c-73 0-128-60-128-128 0-73 55-128 128-128s128 55 128 128c0 68-55 128-128 128zm0-171c-26 0-43 17-43 43s17 43 43 43 43-17 43-43-17-43-43-43z + M293 122v244h439V146l171 175V829a73 73 0 01-73 73h-98V536H293v366H195a73 73 0 01-73-73V195a73 73 0 0173-73h98zm366 512v268H366V634h293zm-49 49h-195v73h195v-73zm49-561v171H366V122h293z + M0 551V472c0-11 9-19 19-19h984c11 0 19 9 19 19v79c0 11-9 19-19 19H19c-11 0-19-9-19-19zM114 154v240c0 11-9 19-19 19H19C9 413 0 404 0 394V79C0 35 35 0 79 0h315c11 0 19 9 19 19v75c0 11-9 19-19 19H154c-21 0-39 18-39 39zm795 0c0-22-17-39-39-39h-240c-11 0-19-9-19-19V19c0-11 9-19 19-19h315c43 0 79 35 79 79v315c0 11-9 19-19 19h-75c-11 0-19-9-19-19l-1-240zm0 716v-240c0-11 9-19 19-19h75c11 0 19 9 19 19v315c0 43-35 79-79 79h-315c-11 0-19-9-19-19v-75c0-11 9-19 19-19H870c21-1 39-18 39-40zm-795 0c0 21 18 39 39 39h240c11 0 19 9 19 19v75c0 11-9 19-19 19H79C35 1023 0 988 0 945v-315c0-11 9-19 19-19h75c11 0 19 9 19 19V870z + M702 677 590 565a148 148 0 10-25 27L676 703zm-346-200a115 115 0 11115 115A115 115 0 01355 478z + M928 500a21 21 0 00-19-20L858 472a11 11 0 01-9-9c-1-6-2-13-3-19a11 11 0 015-12l46-25a21 21 0 0010-26l-8-22a21 21 0 00-24-13l-51 10a11 11 0 01-12-6c-3-6-6-11-10-17a11 11 0 011-13l34-39a21 21 0 001-28l-15-18a20 20 0 00-27-4l-45 27a11 11 0 01-13-1c-5-4-10-9-15-12a11 11 0 01-3-12l19-49a21 21 0 00-9-26l-20-12a21 21 0 00-27 6L650 193a9 9 0 01-11 3c-1-1-12-5-20-7a11 11 0 01-7-10l1-52a21 21 0 00-17-22l-23-4a21 21 0 00-24 14L532 164a11 11 0 01-11 7h-20a11 11 0 01-11-7l-17-49a21 21 0 00-24-15l-23 4a21 21 0 00-17 22l1 52a11 11 0 01-8 11c-5 2-15 6-19 7c-4 1-8 0-12-4l-33-40A21 21 0 00313 146l-20 12A21 21 0 00285 184l19 49a11 11 0 01-3 12c-5 4-10 8-15 12a11 11 0 01-13 1L228 231a21 21 0 00-27 4L186 253a21 21 0 001 28L221 320a11 11 0 011 13c-3 5-7 11-10 17a11 11 0 01-12 6l-51-10a21 21 0 00-24 13l-8 22a21 21 0 0010 26l46 25a11 11 0 015 12l0 3c-1 6-2 11-3 16a11 11 0 01-9 9l-51 8A21 21 0 0096 500v23A21 21 0 00114 544l51 8a11 11 0 019 9c1 6 2 13 3 19a11 11 0 01-5 12l-46 25a21 21 0 00-10 26l8 22a21 21 0 0024 13l51-10a11 11 0 0112 6c3 6 6 11 10 17a11 11 0 01-1 13l-34 39a21 21 0 00-1 28l15 18a20 20 0 0027 4l45-27a11 11 0 0113 1c5 4 10 9 15 12a11 11 0 013 12l-19 49a21 21 0 009 26l20 12a21 21 0 0027-6L374 832c3-3 7-5 10-4c7 3 12 5 20 7a11 11 0 018 10l-1 52a21 21 0 0017 22l23 4a21 21 0 0024-14l17-50a11 11 0 0111-7h20a11 11 0 0111 7l17 49a21 21 0 0020 15a19 19 0 004 0l23-4a21 21 0 0017-22l-1-52a11 11 0 018-10c8-3 13-5 18-7l1 0c6-2 9 0 11 3l34 41A21 21 0 00710 878l20-12a21 21 0 009-26l-18-49a11 11 0 013-12c5-4 10-8 15-12a11 11 0 0113-1l45 27a21 21 0 0027-4l15-18a21 21 0 00-1-28l-34-39a11 11 0 01-1-13c3-5 7-11 10-17a11 11 0 0112-6l51 10a21 21 0 0024-13l8-22a21 21 0 00-10-26l-46-25a11 11 0 01-5-12l0-3c1-6 2-11 3-16a11 11 0 019-9l51-8a21 21 0 0018-21v-23zm-565 188a32 32 0 01-51 5a270 270 0 011-363a32 32 0 0151 6l91 161a32 32 0 010 31zM512 782a270 270 0 01-57-6a32 32 0 01-20-47l92-160a32 32 0 0127-16h184a32 32 0 0130 41c-35 109-137 188-257 188zm15-328L436 294a32 32 0 0121-47a268 268 0 0155-6c120 0 222 79 257 188a32 32 0 01-30 41h-184a32 32 0 01-28-16z + M900 287c40 69 60 144 60 225s-20 156-60 225c-40 69-94 123-163 163-69 40-144 60-225 60s-156-20-225-60c-69-40-123-94-163-163C84 668 64 593 64 512s20-156 60-225 94-123 163-163c69-40 144-60 225-60s156 20 225 60 123 94 163 163zM762 512c0-9-3-16-9-22L578 315l-44-44c-6-6-13-9-22-9s-16 3-22 9l-44 44-176 176c-6 6-9 13-9 22s3 16 9 22l44 44c6 6 13 9 22 9s16-3 22-9l92-92v269c0 9 3 16 9 22 6 6 13 9 22 9h62c8 0 16-3 22-9 6-6 9-13 9-22V486l92 92c6 6 13 9 22 9 8 0 16-3 22-9l44-44c6-6 9-13 9-22z + M512 939C465 939 427 900 427 853 427 806 465 768 512 768 559 768 597 806 597 853 597 900 559 939 512 939M555 85 555 555 747 363 807 423 512 719 217 423 277 363 469 555 469 85 555 85Z + M961 320 512 577 63 320 512 62l449 258zM512 628 185 442 63 512 512 770 961 512l-123-70L512 628zM512 821 185 634 63 704 512 962l449-258L839 634 512 821z + M363 491h64v107h107v64h-107v107h-64v-107h-107v-64h107v-107zm149-235 256 128-256 128-64-32v-11H427l-171-85 256-128zm256 384-256 128-64-32v-53h64l0 0 0-0h43v-21l128-64 85 43zm0-128-213 107v-43h-107v-53l64 32 171-85 85 43zm-512 0 85-43v85l-85-43z + M447 561a26 26 0 0126 26v171H421v-171a26 26 0 0126-26zm-98 65a26 26 0 0126 26v104H323v-104a26 26 0 0126-26zm0 0M561 268a32 32 0 0132 30v457h-65V299a32 32 0 0132-32zm0 0M675 384a26 26 0 0126 26v348H649v-350a26 26 0 0126-24zm0 0M801 223v579H223V223h579M805 171H219A49 49 0 00171 219v585A49 49 0 00219 853h585A49 49 0 00853 805V219A49 49 0 00805 171z + M576 160H448c-18 0-32-14-32-32s14-32 32-32h128c18 0 32 14 32 32s-14 32-32 32zm243 186 36-36c13-13 13-33 0-45s-33-13-45 0l-33 33C708 233 614 192 512 192c-212 0-384 172-384 384s172 384 384 384 384-172 384-384c0-86-29-166-77-230zM544 894V864c0-18-14-32-32-32s-32 14-32 32v30C329 879 209 759 194 608H224c18 0 32-14 32-32s-14-32-32-32h-30C209 393 329 273 480 258V288c0 18 14 32 32 32s32-14 32-32v-30C695 273 815 393 830 544H800c-18 0-32 14-32 32s14 32 32 32h30C815 759 695 879 544 894zm108-471-160 128c-14 11-16 31-5 45 6 8 16 12 25 12 7 0 14-2 20-7l160-128c14-11 16-31 5-45-11-14-31-16-45-5z + M558 545 790 403c24-15 31-47 16-71-15-24-46-31-70-17L507 457 277 315c-24-15-56-7-71 17-15 24-7 56 17 71l232 143V819c0 28 23 51 51 51 28 0 51-23 51-51V545h0zM507 0l443 256v512L507 1024 63 768v-512L507 0z + M770 320a41 41 0 00-56-14l-252 153L207 306a41 41 0 10-43 70l255 153 2 296a41 41 0 0082 0l-2-295 255-155a41 41 0 0014-56zM481 935a42 42 0 01-42 0L105 741a42 42 0 01-21-36v-386a42 42 0 0121-36L439 89a42 42 0 0142 0l335 193a42 42 0 0121 36v87h84v-87a126 126 0 00-63-109L523 17a126 126 0 00-126 0L63 210a126 126 0 00-63 109v386a126 126 0 0063 109l335 193a126 126 0 00126 0l94-54-42-72zM1029 700h-126v-125a42 42 0 00-84 0v126h-126a42 42 0 000 84h126v126a42 42 0 1084 0v-126h126a42 42 0 000-84z + M416 587c21 0 37 17 37 37v299A37 37 0 01416 960h-299a37 37 0 01-37-37v-299c0-21 17-37 37-37h299zm448 0c21 0 37 17 37 37v299A37 37 0 01864 960h-299a37 37 0 01-37-37v-299c0-21 17-37 37-37h299zM758 91l183 189a37 37 0 010 52l-182 188a37 37 0 01-53 1l-183-189a37 37 0 010-52l182-188a37 37 0 0153-1zM416 139c21 0 37 17 37 37v299A37 37 0 01416 512h-299a37 37 0 01-37-37v-299c0-21 17-37 37-37h299z + M653 435l-26 119H725c9 0 13 4 13 13v47c0 9-4 13-13 13h-107l-21 115c0 9-4 13-13 13h-47c-9 0-13-4-13-13l21-111H427l-21 115c0 9-4 13-13 13H346c-9 0-13-4-13-13l21-107h-85c-4-9-9-21-13-34v-38c0-9 4-13 13-13h98l26-119H294c-9 0-13-4-13-13V375c0-9 4-13 13-13h115l13-81c0-9 4-13 13-13h43c9 0 13 4 13 13L469 363h119l13-81c0-9 4-13 13-13h47c9 0 13 4 13 13l-13 77h85c9 0 13 4 13 13v47c0 9-4 13-13 13h-98v4zM512 0C230 0 0 230 0 512c0 145 60 282 166 375L90 1024H512c282 0 512-230 512-512S794 0 512 0zm-73 559h124l26-119h-128l-21 119z + M875 128h-725A107 107 0 0043 235v555A107 107 0 00149 896h725a107 107 0 00107-107v-555A107 107 0 00875 128zm-115 640h-183v-58l25-3c15 0 19-8 14-24l-22-61H419l-28 82 39 2V768h-166v-58l18-3c18-2 22-11 26-24l125-363-40-4V256h168l160 448 39 3zM506 340l-72 218h145l-71-218h-2z + M177 156c-22 5-33 17-36 37c-10 57-33 258-13 278l445 445c23 23 61 23 84 0l246-246c23-23 23-61 0-84l-445-445C437 120 231 145 177 156zM331 344c-26 26-69 26-95 0c-26-26-26-69 0-95s69-26 95 0C357 276 357 318 331 344z + M683 537h-144v-142h-142V283H239a44 44 0 00-41 41v171a56 56 0 0014 34l321 321a41 41 0 0058 0l174-174a41 41 0 000-58zm-341-109a41 41 0 110-58a41 41 0 010 58zM649 284V142h-69v142h-142v68h142v142h69v-142h142v-68h-142z + M996 452 572 28A96 96 0 00504 0H96C43 0 0 43 0 96v408a96 96 0 0028 68l424 424c37 37 98 37 136 0l408-408c37-37 37-98 0-136zM224 320c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96zm1028 268L844 996c-37 37-98 37-136 0l-1-1L1055 647c34-34 53-79 53-127s-19-93-53-127L663 0h97a96 96 0 0168 28l424 424c37 37 37 98 0 136z + M765 118 629 239l-16 137-186 160 54 59 183-168 144 4 136-129 47-43-175-12L827 67zM489 404c-66 0-124 55-124 125s54 121 124 121c66 0 120-55 120-121H489l23-121c-8-4-16-4-23-4zM695 525c0 114-93 207-206 207s-206-94-206-207 93-207 206-207c16 0 27 0 43 4l43-207c-27-4-54-8-85-8-229 0-416 188-416 419s187 419 416 419c225 0 408-180 416-403v-12l-210-4z + M144 112h736c18 0 32 14 32 32v736c0 18-14 32-32 32H144c-18 0-32-14-32-32V144c0-18 14-32 32-32zm112 211v72a9 9 0 003 7L386 509 259 615a9 9 0 00-3 7v72a9 9 0 0015 7L493 516a9 9 0 000-14l-222-186a9 9 0 00-15 7zM522 624a10 10 0 00-10 10v60a10 10 0 0010 10h237a10 10 0 0010-10v-60a10 10 0 00-10-10H522z + M170 831 513 489 855 831 960 726 512 278 64 726 170 831zM512 278h448v-128h-896v128h448z + M897 673v13c0 51-42 93-93 93h-10c-1 0-2 0-2 0H220c-23 0-42 19-42 42v13c0 23 19 42 42 42h552c14 0 26 12 26 26 0 14-12 26-26 26H220c-51 0-93-42-93-93v-13c0-51 42-93 93-93h20c1-0 2-0 2-0h562c23 0 42-19 42-42v-13c0-11-5-22-13-29-8-7-17-11-28-10H660c-14 0-26-12-26-26 0-14 12-26 26-26h144c24-1 47 7 65 24 18 17 29 42 29 67zM479 98c-112 0-203 91-203 203 0 44 14 85 38 118l132 208c15 24 50 24 66 0l133-209c23-33 37-73 37-117 0-112-91-203-203-203zm0 327c-68 0-122-55-122-122s55-122 122-122 122 55 122 122-55 122-122 122z + M912 800a48 48 0 1 1 0 96h-416a48 48 0 1 1 0-96h416z m-704-704A112 112 0 0 1 256 309.184V480h80a48 48 0 0 1 0 96H256v224h81.664a48 48 0 1 1 0 96H256a96 96 0 0 1-96-96V309.248A112 112 0 0 1 208 96z m704 384a48 48 0 1 1 0 96h-416a48 48 0 0 1 0-96h416z m0-320a48 48 0 1 1 0 96h-416a48 48 0 0 1 0-96h416z + M30 0 30 30 0 15z + M0 0 0 30 30 15z + M762 1024C876 818 895 504 448 514V768L64 384l384-384v248c535-14 595 472 314 776z + M832 464H332V240c0-31 25-56 56-56h248c31 0 56 25 56 56v68c0 4 4 8 8 8h56c4 0 8-4 8-8v-68c0-71-57-128-128-128H388c-71 0-128 57-128 128v224h-68c-18 0-32 14-32 32v384c0 18 14 32 32 32h640c18 0 32-14 32-32V496c0-18-14-32-32-32zM540 701v53c0 4-4 8-8 8h-40c-4 0-8-4-8-8v-53c-12-9-20-23-20-39 0-27 22-48 48-48s48 22 48 48c0 16-8 30-20 39z + M170 831l343-342L855 831l105-105-448-448L64 726 170 831z + M667 607c-3-2-7-14-0-38 73-77 118-187 118-290C784 115 668 0 508 0 348 0 236 114 236 278c0 104 45 215 119 292 7 24-2 33-8 35C274 631 0 725 0 854L0 1024l1024 0 0-192C989 714 730 627 667 607L667 607z + M880 128A722 722 0 01555 13a77 77 0 00-85 0 719 719 0 01-325 115c-40 4-71 38-71 80v369c0 246 329 446 439 446 110 0 439-200 439-446V207c0-41-31-76-71-80zM465 692a36 36 0 01-53 0L305 579a42 42 0 010-57 36 36 0 0153 0l80 85L678 353a36 36 0 0153 0 42 42 0 01-0 57L465 692z + M812 864h-29V654c0-21-11-40-28-52l-133-88 134-89c18-12 28-31 28-52V164h28c18 0 32-14 32-32s-14-32-32-32H212c-18 0-32 14-32 32s14 32 32 32h30v210c0 21 11 40 28 52l133 88-134 89c-18 12-28 31-28 52V864H212c-18 0-32 14-32 32s14 32 32 32h600c18 0 32-14 32-32s-14-32-32-32zM441 566c18-12 28-31 28-52s-11-40-28-52L306 373V164h414v209l-136 90c-18 12-28 31-28 52 0 21 11 40 28 52l135 89V695c-9-7-20-13-32-19-30-15-93-41-176-41-63 0-125 14-175 38-12 6-22 12-31 18v-36l136-90z + M0 512M1024 512M512 0M512 1024M762 412v100h-500v-100h-150v200h800v-200h-150z + M519 459 222 162a37 37 0 10-52 52l297 297L169 809a37 37 0 1052 52l297-297 297 297a37 37 0 1052-52l-297-297 297-297a37 37 0 10-52-52L519 459z + M1024 565V459H0V565H1024ZM512 0M512 1024 + M153 154h768v768h-768v-768zm64 64v640h640v-640h-640z + M256 128l0 192L64 320l0 576 704 0 0-192 192 0L960 128 256 128zM704 832 128 832 128 384l576 0L704 832zM896 640l-128 0L768 320 320 320 320 192l576 0L896 640z + M248 221a77 77 0 00-30-21c-18-7-40-10-68-5a224 224 0 00-45 13c-5 2-10 5-15 8l-3 2v68l11-9c10-8 21-14 34-19 13-5 26-7 39-7 12 0 21 3 28 10 6 6 9 16 9 29l-62 9c-14 2-26 6-36 11a80 80 0 00-25 20c-7 8-12 17-15 27-6 21-6 44 1 65a70 70 0 0041 43c10 4 21 6 34 6a80 80 0 0063-28v22h64V298c0-16-2-31-6-44a91 91 0 00-18-33zm-41 121v15c0 8-1 15-4 22a48 48 0 01-24 29 44 44 0 01-33 2 29 29 0 01-10-6 25 25 0 01-6-9 30 30 0 01-2-12c0-5 1-9 2-14a21 21 0 015-9 28 28 0 0110-7 83 83 0 0120-5l42-6zm323-68a144 144 0 00-16-42 87 87 0 00-28-29 75 75 0 00-41-11 73 73 0 00-44 14c-6 5-12 11-17 17V64H326v398h59v-18c8 10 18 17 30 21 6 2 13 3 21 3 16 0 31-4 43-11 12-7 23-18 31-31a147 147 0 0019-46 248 248 0 006-57c0-17-2-33-5-49zm-55 49c0 15-1 28-4 39-2 11-6 20-10 27a41 41 0 01-15 15 37 37 0 01-36 1 44 44 0 01-13-12 59 59 0 01-9-18A76 76 0 01384 352v-33c0-10 1-20 4-29 2-8 6-15 10-22a43 43 0 0115-13 37 37 0 0119-5 35 35 0 0132 18c4 6 7 14 9 23 2 9 3 20 3 31zM154 634a58 58 0 0120-15c14-6 35-7 49-1 7 3 13 6 20 12l21 17V572l-6-4a124 124 0 00-58-14c-20 0-38 4-54 11-16 7-30 17-41 30-12 13-20 29-26 46-6 17-9 36-9 57 0 18 3 36 8 52 6 16 14 30 24 42 10 12 23 21 38 28 15 7 32 10 50 10 15 0 28-2 39-5 11-3 21-8 30-14l5-4v-57l-13 6a26 26 0 01-5 2c-3 1-6 2-8 3-2 1-15 6-15 6-4 2-9 3-14 4a63 63 0 01-38-4 53 53 0 01-20-14 70 70 0 01-13-24 111 111 0 01-5-34c0-13 2-26 5-36 3-10 8-19 14-26zM896 384h-256V320h288c21 1 32 12 32 32v384c0 18-12 32-32 32H504l132 133-45 45-185-185c-16-21-16-25 0-45l185-185L637 576l-128 128H896V384z + M128 691H6V38h838v160h-64V102H70v525H128zM973 806H154V250h819v557zm-755-64h691V314H218v429zM365 877h448v64h-448z + M853 267H514c-4 0-6-2-9-4l-38-66c-13-21-38-36-64-36H171c-41 0-75 34-75 75v555c0 41 34 75 75 75h683c41 0 75-34 75-75V341c0-41-34-75-75-75zm-683-43h233c4 0 6 2 9 4l38 66c13 21 38 36 64 36H853c6 0 11 4 11 11v75h-704V235c0-6 4-11 11-11zm683 576H171c-6 0-11-4-11-11V480h704V789c0 6-4 11-11 11z + M896 96 614 96c-58 0-128-19-179-51C422 38 390 19 358 19L262 19 128 19c-70 0-128 58-128 128l0 736c0 70 58 128 128 128l768 0c70 0 128-58 128-128L1024 224C1024 154 966 96 896 96zM704 685 544 685l0 160c0 19-13 32-32 32s-32-13-32-32l0-160L320 685c-19 0-32-13-32-32 0-19 13-32 32-32l160 0L480 461c0-19 13-32 32-32s32 13 32 32l0 160L704 621c19 0 32 13 32 32C736 666 723 685 704 685zM890 326 102 326 102 250c0-32 32-64 64-64l659 0c38 0 64 32 64 64L890 326z + M1182 527a91 91 0 00-88-117H92a91 91 0 00-88 117l137 441A80 80 0 00217 1024h752a80 80 0 0076-56zM133 295a31 31 0 0031 31h858a31 31 0 0031-31A93 93 0 00959 203H226a93 93 0 00-94 92zM359 123h467a31 31 0 0031-31A92 92 0 00765 0H421a92 92 0 00-92 92 31 31 0 0031 31z + diff --git a/src/Resources/Icons.xaml b/src/Resources/Icons.xaml deleted file mode 100644 index 1c6470cc..00000000 --- a/src/Resources/Icons.xaml +++ /dev/null @@ -1,76 +0,0 @@ - - M557.7 545.3 789.9 402.7c24-15 31.3-46.5 16.4-70.5c-14.8-23.8-46-31.2-70-16.7L506.5 456.6 277.1 315.4c-24.1-14.8-55.6-7.3-70.5 16.8c-14.8 24.1-7.3 55.6 16.8 70.5l231.8 142.6V819.1c0 28.3 22.9 51.2 51.2 51.2c28.3 0 51.2-22.9 51.2-51.2V545.3h.1zM506.5 0l443.4 256v511.9L506.5 1023.9 63.1 767.9v-511.9L506.5 0z - M491 256h469c13 0 21-9 21-21v-171c0-13-9-21-21-21h-469c-13 0-21 9-21 21V128H256V64c0-13-9-21-21-21h-171c-13 0-21 9-21 21v171c0 13 9 21 21 21H128v597h341v64c0 13 9 21 21 21h469c13 0 21-9 21-21v-171c0-13-9-21-21-21h-469c-13 0-21 9-21 21V811H171v-299h299v64c0 13 9 21 21 21h469c13 0 21-9 21-21v-171c0-13-9-21-21-21h-469c-13 0-21 9-21 21V469H171V256h64c13 0 21-9 21-21V171h213v64c0 13 9 21 21 21z - M170 470l0 84 86 0 0-84-86 0zM86 598l0-172 852 0 0 172-852 0zM256 298l0-84-86 0 0 84 86 0zM86 170l852 0 0 172-852 0 0-172zM170 726l0 84 86 0 0-84-86 0zM86 854l0-172 852 0 0 172-852 0z - M853.3 960H170.7V64h469.3l213.3 213.3zM821.3 298.7H618.7V96z - M888.8 0H135.2c-32.3 0-58.9 26.1-58.9 58.9v906.2c0 32.3 26.1 58.9 58.9 58.9h753.2c32.3 0 58.9-26.1 58.9-58.9v-906.2c.5-32.8-26.1-58.9-58.4-58.9zm-164.9 176.6c30.7 0 55.8 25.1 55.8 55.8s-25.1 55.8-55.8 55.8s-55.8-25.1-55.8-55.8s24.6-55.8 55.8-55.8zm-212 0c30.7 0 55.8 25.1 55.8 55.8S542.7 288.3 512 288.3s-55.8-25.1-55.8-55.8S481.3 176.6 512 176.6zm-212 0c30.7 0 55.8 25.1 55.8 55.8s-25.1 55.8-55.8 55.8s-55.8-25.1-55.8-55.8s25.1-55.8 55.8-55.8zm208.9 606.2H285.2c-24.6 0-44-20-44-44c0-24.6 20-44 44-44h223.7c24.6 0 44 20 44 44c0 24.1-19.5 44-44 44zm229.9-212H285.2c-24.6 0-44-20-44-44c0-24.6 20-44 44-44h453.1c24.6 0 44 20 44 44c.5 24.1-19.5 44-43.5 44z - m186 532 287 0 0 287c0 11 9 20 20 20s20-9 20-20l0-287 287 0c11 0 20-9 20-20s-9-20-20-20l-287 0 0-287c0-11-9-20-20-20s-20 9-20 20l0 287-287 0c-11 0-20 9-20 20s9 20 20 20z - M682.7 42.7H85.3v682.7h85.3V128h512V42.7zM256 213.3l4.5 768H896V213.3H256zm554.7 682.7H341.3V298.7h469.3v597.3z - M204 291c45-11 77-49 77-96c0-53-43-98-98-98c-53 0-98 45-98 98c0 47 34 87 77 96v91c0 13 9 21 21 21h236c2 38 32 68 70 68h372c41 0 73-32 73-73v-38c0-41-32-73-73-73h-370c-38 0-70 30-70 68H204V291zm258 74h2c0-15 13-30 30-30h372c15 0 30 13 30 30v38c0 15-13 30-30 30h-375c-15 0-30-13-30-30v-38zM183 250c-30 0-55-26-55-55s26-55 55-55s55 26 55 55s-26 55-55 55zM679 495c-134 0-244 109-244 244s109 244 244 244c134 0 244-109 244-244s-109-244-244-244zm159 268h-134v134h-50V764H521v-50h134v-134h50v134h134V764zM244 766H185c-13 0-23-11-23-23s11-23 23-23h59c13 0 23 11 23 23s-11 23-23 23zM368 766h-42c-9 0-17-8-17-17v-13c0-9 8-17 17-17h42c9 0 17 8 17 17v13c0 9-8 17-17 17zM183 766c-12 0-21-9-21-21V320c0-12 9-21 21-21c12 0 21 9 21 21v425c0 12-10 21-21 21z - M785 898H341c-58 0-102-44-102-102v-188c0-10 7-17 17-17s17 7 17 17v188c0 38 31 68 68 68h444c38 0 68-31 68-68v-444c0-38-31-68-68-68h-222c-10 0-17-10-17-17s7-17 17-17H785c58 0 102 44 102 102v444c0 55-44 102-102 102zM181 488h-17c-10 0-17-7-17-17s7-17 17-17h17c10 0 17 7 17 17s-10 17-17 17zM461 181c-10 0-17-10-17-17v-17c0-10 7-17 17-17s17 7 17 17v17c0 7-7 17-17 17zM375 625H283c-61 0-113-65-113-119v-68-3l-14-14c-14-14-20-31-20-48 0-17 7-34 20-44 10-10 27-17 41-17v-10c0-17 7-34 20-44 14-14 38-20 58-17 0-14 7-27 17-38 20-17 48-24 72-14 3-10 10-20 17-31 27-20 68-20 92 3l68 68c44 44 72 102 72 164 0 126-106 232-239 232zm-171-160v41c0 38 38 85 79 85H375c113 0 205-89 205-201 0-51-20-102-58-140l-68-68c-14-10-31-10-44 0-7 7-10 14-10 20s3 14 10 20c7 7 7 17 0 24-7 7-17 7-24 0l-24-20c-14-10-31-10-44 0-3 7-7 14-7 20s3 14 10 20l20 24c7 7 7 17 0 24-7 7-17 7-24 0l-34-31c-14-14-31-14-44-3-3 7-7 14-7 20s3 14 7 20L297 375c7 7 7 17 0 24-7 7-17 7-24 0l-48-48c-14-10-31-10-44 0-7 7-10 14-10 20s3 14 10 20l106 99c7 7 7 17 0 24-7 7-17 7-24 0L205 464z - - M205 500h614v60H205z - M153 154h768v768h-768v-768zm64 64v640h640v-640h-640z - M256 128l0 192L64 320l0 576 704 0 0-192 192 0L960 128 256 128zM704 832 128 832 128 384l576 0L704 832zM896 640l-128 0L768 320 320 320 320 192l576 0L896 640z - M519 459 222 162a37 37 0 10-52 52l297 297L169 809a37 37 0 1052 52l297-297 297 297a37 37 0 1052-52l-297-297 297-297a37 37 0 10-52-52L519 459z - M512 597m-1 0a1 1 0 103 0a1 1 0 10-3 0ZM810 393 732 315 448 600 293 444 214 522l156 156 78 78 362-362z - M512 0C233 0 7 223 0 500C6 258 190 64 416 64c230 0 416 200 416 448c0 53 43 96 96 96s96-43 96-96c0-283-229-512-512-512zm0 1023c279 0 505-223 512-500c-6 242-190 436-416 436c-230 0-416-200-416-448c0-53-43-96-96-96s-96 43-96 96c0 283 229 512 512 512z - M702 677 590 565a148 148 0 10-25 27L676 703zm-346-200a115 115 0 11115 115A115 115 0 01355 478z - M352 64h320L960 352v320L672 960h-320L64 672v-320L352 64zm161 363L344 256 260 341 429 512l-169 171L344 768 513 597 682 768l85-85L598 512l169-171L682 256 513 427z - M899 870l-53-306H864c14 0 26-12 26-26V346c0-14-12-26-26-26H618V138c0-14-12-26-26-26H432c-14 0-26 12-26 26v182H160c-14 0-26 12-26 26v192c0 14 12 26 26 26h18l-53 306c0 2 0 3 0 4c0 14 12 26 26 26h723c2 0 3 0 4 0c14-2 24-16 21-30zM204 390h272V182h72v208h272v104H204V390zm468 440V674c0-4-4-8-8-8h-48c-4 0-8 4-8 8v156H416V674c0-4-4-8-8-8h-48c-4 0-8 4-8 8v156H203l45-260H776l45 260H672z - M512 64C265 64 64 265 64 512s201 448 448 448s448-201 448-448S759 64 512 64zm238 642-46 46L512 558 318 750l-46-46L467 512 274 318l46-46L512 467l194-194 46 46L558 512l193 194z - M797 829a49 49 0 1049 49 49 49 0 00-49-49zm147-114A49 49 0 10992 764a49 49 0 00-49-49zM928 861a49 49 0 1049 49A49 49 0 00928 861zm-5-586L992 205 851 64l-71 71a67 67 0 00-94 0l235 235a67 67 0 000-94zm-853 128a32 32 0 00-32 50 1291 1291 0 0075 112L288 552c20 0 25 21 8 37l-93 86a1282 1282 0 00120 114l100-32c19-6 28 15 14 34l-40 55c26 19 53 36 82 53a89 89 0 00115-20 1391 1391 0 00256-485l-188-188s-306 224-595 198z - - M0 33h1024v160H0zM0 432h1024v160H0zM0 831h1024v160H0z - M1024 610v-224H640v48H256V224h128V0H0v224h128v752h512v48h384V800H640v48H256V562h384v48z - M30 271l241 0 0-241-241 0 0 241zM392 271l241 0 0-241-241 0 0 241zM753 30l0 241 241 0 0-241-241 0zM30 632l241 0 0-241-241 0 0 241zM392 632l241 0 0-241-241 0 0 241zM753 632l241 0 0-241-241 0 0 241zM30 994l241 0 0-241-241 0 0 241zM392 994l241 0 0-241-241 0 0 241zM753 994l241 0 0-241-241 0 0 241z - M509 546l271-271 91 91-348 349-1-1-13 13-363-361 91-91z - M256 224l0 115L512 544l256-205 0-115-256 205L256 224zM512 685l-256-205L256 595 512 800 768 595l0-115L512 685z - M170 831l343-342L855 831l105-105-448-448L64 726 170 831z - M768 800V685L512 480 256 685V800l256-205L768 800zM512 339 768 544V429L512 224 256 429V544l256-205z - M1231 0v372H120V0zM1352 484v56H0v-56zM1147 939H205V737h942v203M1231 1024V652H120V1024z - M75 100 27 51 76 3z - M27 3 L 75 51 L 27 100z - - M520 168C291 168 95 311 16 512c79 201 275 344 504 344 229 0 425-143 504-344-79-201-275-344-504-344zm0 573c-126 0-229-103-229-229s103-229 229-229c126 0 229 103 229 229s-103 229-229 229zm0-367c-76 0-137 62-137 137s62 137 137 137S657 588 657 512s-62-137-137-137z - M734 128c-33-19-74-8-93 25l-41 70c-28-6-58-9-90-9-294 0-445 298-445 298s82 149 231 236l-31 54c-19 33-8 74 25 93 33 19 74 8 93-25L759 222C778 189 767 147 734 128zM305 512c0-115 93-208 207-208 14 0 27 1 40 4l-37 64c-1 0-2 0-2 0-77 0-140 63-140 140 0 26 7 51 20 71l-37 64C324 611 305 564 305 512zM771 301 700 423c13 27 20 57 20 89 0 110-84 200-192 208l-51 89c12 1 24 2 36 2 292 0 446-298 446-298S895 388 771 301z - - M808 195h-592v634h592V195zm-32 602h-528V227h528v570zM429 322h246v16H429zM358 355c14 0 27-11 27-27s-11-27-27-27-27 11-27 27 13 27 27 27zm0-37c6 0 11 5 11 11 0 6-5 11-11 11s-11-5-11-11c2-6 6-11 11-11zM429 443h246v16H429zM358 478c14 0 27-11 27-27s-11-27-27-27-27 11-27 27 13 27 27 27zm0-38c6 0 11 5 11 11 0 6-5 11-11 11s-11-5-11-11c2-6 6-11 11-11zM429 565h246v16H429zM358 600c14 0 27-11 27-27s-11-27-27-27-27 11-27 27 13 27 27 27zm0-38c6 0 11 5 11 11 0 6-5 11-11 11s-11-5-11-11c2-6 6-11 11-11zM429 686h246v16H429zM358 722c14 0 27-11 27-27s-11-27-27-27-27 11-27 27 13 27 27 27zm0-37c6 0 11 5 11 11 0 6-5 11-11 11s-11-5-11-11c2-6 6-11 11-11z - M716.3 383.1c0 38.4-6.5 76-19.4 111.8l-10.7 29.7 229.6 229.5c44.5 44.6 44.5 117.1 0 161.6a113.6 113.6 0 01-80.8 33.5a113.6 113.6 0 01-80.8-33.5L529 694l-32 13a331.6 331.6 0 01-111.9 19.4A333.5 333.5 0 0150 383.1c0-39 6.8-77.2 20-113.6L285 482l194-195-214-210A331 331 0 01383.1 50A333.5 333.5 0 01716.3 383.1zM231.6 31.6l-22.9 9.9a22.2 22.2 0 00-5.9 4.2a19.5 19.5 0 000 27.5l215 215.2L288.4 417.8 77.8 207.1a26 26 0 00-17.2-7.1a22.8 22.8 0 00-21.5 15a400.5 400.5 0 00-7.5 16.6A381.6 381.6 0 000 384c0 211.7 172.2 384 384 384c44.3 0 87.6-7.5 129-22.3L743.1 975.8A163.5 163.5 0 00859.5 1024c43.9 0 85.3-17.1 116.4-48.2a164.8 164.8 0 000-233L745.5 513C760.5 471.5 768 428 768 384C768 172 596 0 384 0c-53 0-104 10.5-152.5 31.5z - M928 500a21 21 0 00-19-20L858 472a11 11 0 01-9-9c-1-6-2-13-3-19a11 11 0 015-12l46-25a21 21 0 0010-26l-8-22a21 21 0 00-24-13l-51 10a11 11 0 01-12-6c-3-6-6-11-10-17a11 11 0 011-13l34-39a21 21 0 001-28l-15-18a20 20 0 00-27-4l-45 27a11 11 0 01-13-1c-5-4-10-9-15-12a11 11 0 01-3-12l19-49a21 21 0 00-9-26l-20-12a21 21 0 00-27 6L650 193a9 9 0 01-11 3c-1-1-12-5-20-7a11 11 0 01-7-10l1-52a21 21 0 00-17-22l-23-4a21 21 0 00-24 14L532 164a11 11 0 01-11 7h-20a11 11 0 01-11-7l-17-49a21 21 0 00-24-15l-23 4a21 21 0 00-17 22l1 52a11 11 0 01-8 11c-5 2-15 6-19 7c-4 1-8 0-12-4l-33-40A21 21 0 00313 146l-20 12A21 21 0 00285 184l19 49a11 11 0 01-3 12c-5 4-10 8-15 12a11 11 0 01-13 1L228 231a21 21 0 00-27 4L186 253a21 21 0 001 28L221 320a11 11 0 011 13c-3 5-7 11-10 17a11 11 0 01-12 6l-51-10a21 21 0 00-24 13l-8 22a21 21 0 0010 26l46 25a11 11 0 015 12l0 3c-1 6-2 11-3 16a11 11 0 01-9 9l-51 8A21 21 0 0096 500v23A21 21 0 00114 544l51 8a11 11 0 019 9c1 6 2 13 3 19a11 11 0 01-5 12l-46 25a21 21 0 00-10 26l8 22a21 21 0 0024 13l51-10a11 11 0 0112 6c3 6 6 11 10 17a11 11 0 01-1 13l-34 39a21 21 0 00-1 28l15 18a20 20 0 0027 4l45-27a11 11 0 0113 1c5 4 10 9 15 12a11 11 0 013 12l-19 49a21 21 0 009 26l20 12a21 21 0 0027-6L374 832c3-3 7-5 10-4c7 3 12 5 20 7a11 11 0 018 10l-1 52a21 21 0 0017 22l23 4a21 21 0 0024-14l17-50a11 11 0 0111-7h20a11 11 0 0111 7l17 49a21 21 0 0020 15a19 19 0 004 0l23-4a21 21 0 0017-22l-1-52a11 11 0 018-10c8-3 13-5 18-7l1 0c6-2 9 0 11 3l34 41A21 21 0 00710 878l20-12a21 21 0 009-26l-18-49a11 11 0 013-12c5-4 10-8 15-12a11 11 0 0113-1l45 27a21 21 0 0027-4l15-18a21 21 0 00-1-28l-34-39a11 11 0 01-1-13c3-5 7-11 10-17a11 11 0 0112-6l51 10a21 21 0 0024-13l8-22a21 21 0 00-10-26l-46-25a11 11 0 01-5-12l0-3c1-6 2-11 3-16a11 11 0 019-9l51-8a21 21 0 0018-21v-23zm-565 188a32 32 0 01-51 5a270 270 0 011-363a32 32 0 0151 6l91 161a32 32 0 010 31zM512 782a270 270 0 01-57-6a32 32 0 01-20-47l92-160a32 32 0 0127-16h184a32 32 0 0130 41c-35 109-137 188-257 188zm15-328L436 294a32 32 0 0121-47a268 268 0 0155-6c120 0 222 79 257 188a32 32 0 01-30 41h-184a32 32 0 01-28-16z - M296 912H120c-4.4 0-8-3.6-8-8V520c0-4.4 3.6-8 8-8h176c4.4 0 8 3.6 8 8v384c0 4.4-3.6 8-8 8zM600 912H424c-4.4 0-8-3.6-8-8V121c0-4.4 3.6-8 8-8h176c4.4 0 8 3.6 8 8v783c0 4.4-3.6 8-8 8zM904 912H728c-4.4 0-8-3.6-8-8V280c0-4.4 3.6-8 8-8h176c4.4 0 8 3.6 8 8v624c0 4.4-3.6 8-8 8z - M550 627h-81v-21a142 142 0 0113-64a198 198 0 0152-57a390 390 0 0047-42a56 56 0 0012-34a58 58 0 00-21-45a81 81 0 00-56-19a85 85 0 00-57 20a103 103 0 00-32 59l-82-10a136 136 0 0149-96A172 172 0 01512 276a178 178 0 01123 40a122 122 0 0145 94a103 103 0 01-17 56a366 366 0 01-71 72A136 136 0 00556 576a128 128 0 00-6 51zm-81 120v-89h89v89zM512 64a448 448 0 10448 448A448 448 0 00512 64zm0 832a384 384 0 010-768a389 389 0 01384 384a389 389 0 01-384 384z - M64 864h896V288h-396a64 64 0 01-57-35L460 160H64v704zm-64 32V128a32 32 0 0132-32h448a32 32 0 0129 18L564 224H992a32 32 0 0132 32v640a32 32 0 01-32 32H32a32 32 0 01-32-32z - M448 64l128 128h448v768H0V64z - M832 960l192-512H192L0 960zM128 384L0 960V128h288l128 128h416v128z - M959 320H960v640A64 64 0 01896 1024H192A64 64 0 01128 960V64A64 64 0 01192 0H640v321h320L959 320zM320 544c0 17 14 32 32 32h384A32 32 0 00768 544c0-17-14-32-32-32H352A32 32 0 00320 544zm0 128c0 17 14 32 32 32h384a32 32 0 0032-32c0-17-14-32-32-32H352a32 32 0 00-32 32zm0 128c0 17 14 32 32 32h384a32 32 0 0032-32c0-17-14-32-32-32H352a32 32 0 00-32 32z - M854 307 611 73c-6-6-14-9-22-9H296c-4 0-8 4-8 8v56c0 4 4 8 8 8h277l219 211V824c0 4 4 8 8 8h56c4 0 8-4 8-8V330c0-9-4-17-10-23zM553 201c-6-6-14-9-23-9H192c-18 0-32 14-32 32v704c0 18 14 32 32 32h512c18 0 32-14 32-32V397c0-9-3-17-9-23L553 201zM568 753c0 4-3 7-8 7h-225c-4 0-8-3-8-7v-42c0-4 3-7 8-7h225c4 0 8 3 8 7v42zm0-220c0 4-3 7-8 7H476v85c0 4-3 7-7 7h-42c-4 0-7-3-7-7V540h-85c-4 0-8-3-8-7v-42c0-4 3-7 8-7H420v-85c0-4 3-7 7-7h42c4 0 7 3 7 7V484h85c4 0 8 3 8 7v42z - M683 409v204L1024 308 683 0v191c-413 0-427 526-427 526c117-229 203-307 427-307zm85 492H102V327h153s38-63 114-122H51c-28 0-51 27-51 61v697c0 34 23 61 51 61h768c28 0 51-27 51-61V614l-102 100v187z - M599 425 599 657 425 832 425 425 192 192 832 192Z - M71 1024V0h661L953 219V1024H71zm808-731-220-219H145V951h735V293zM439 512h-220V219h220V512zm-74-219H292v146h74v-146zm0 512h74v73h-220v-73H292v-146H218V585h147v219zm294-366h74V512H512v-73h74v-146H512V219h147v219zm74 439H512V585h220v293zm-74-219h-74v146h74v-146z - - M1024 896v128H0V704h128v192h768V704h128v192zM576 555 811 320 896 405l-384 384-384-384L213 320 448 555V0h128v555z - M432 0h160c27 0 48 21 48 48v336h175c36 0 53 43 28 68L539 757c-15 15-40 15-55 0L180 452c-25-25-7-68 28-68H384V48c0-27 21-48 48-48zm592 752v224c0 27-21 48-48 48H48c-27 0-48-21-48-48V752c0-27 21-48 48-48h293l98 98c40 40 105 40 145 0l98-98H976c27 0 48 21 48 48zm-248 176c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40zm128 0c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40z - M592 768h-160c-27 0-48-21-48-48V384h-175c-36 0-53-43-28-68L485 11c15-15 40-15 55 0l304 304c25 25 7 68-28 68H640v336c0 27-21 48-48 48zm432-16v224c0 27-21 48-48 48H48c-27 0-48-21-48-48V752c0-27 21-48 48-48h272v16c0 62 50 112 112 112h160c62 0 112-50 112-112v-16h272c27 0 48 21 48 48zm-248 176c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40zm128 0c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40z - M961 320 512 577 63 320 512 62l449 258zM512 628 185 442 63 512 512 770 961 512l-123-70L512 628zM512 821 185 634 63 704 512 962l449-258L839 634 512 821z - M144 112h736c18 0 32 14 32 32v736c0 18-14 32-32 32H144c-18 0-32-14-32-32V144c0-18 14-32 32-32zm112 211v72a9 9 0 003 7L386 509 259 615a9 9 0 00-3 7v72a9 9 0 0015 7L493 516a9 9 0 000-14l-222-186a9 9 0 00-15 7zM522 624a10 10 0 00-10 10v60a10 10 0 0010 10h237a10 10 0 0010-10v-60a10 10 0 00-10-10H522z - M509 556l93 149h124l-80-79 49-50 165 164-165 163-49-50 79-79h-163l-96-153 41-65zm187-395 165 164-165 163-49-50L726 360H530l-136 224H140v-70h215l136-224h236l-80-79 49-50z - - M796 471A292 292 0 00512 256a293 293 0 00-284 215H0v144h228A293 293 0 00512 832a291 291 0 00284-217H1024V471h-228M512 688A146 146 0 01366 544A145 145 0 01512 400c80 0 146 63 146 144A146 146 0 01512 688 - M0 586l404 119 498-410-386 441-2 251 155-205 279 83L1170 37z - M24 512A488 488 0 01512 24A488 488 0 011000 512A488 488 0 01512 1000A488 488 0 0124 512zm447-325v327L243 619l51 111 300-138V187H471z - M715 254h-405l-58 57h520zm-492 86v201h578V340zm405 143h-29v-29H425v29h-29v-57h231v57zm-405 295h578V559H223zm174-133h231v57h-29v-29H425v29h-29v-57z - M869 145a145 145 0 10-289 0c0 56 33 107 83 131c-5 96-77 128-201 175c-52 20-110 42-160 74V276A144 144 0 00242 0a145 145 0 00-145 145c0 58 35 108 84 131v461a144 144 0 00-84 131a145 145 0 10289 0a144 144 0 00-84-131c5-95 77-128 201-175c122-46 274-103 280-287a145 145 0 0085-132zM242 61a83 83 0 110 167a83 83 0 010-167zm0 891a84 84 0 110-167a84 84 0 010 167zM724 228a84 84 0 110-167a84 84 0 010 167z - M896 128h-64V64c0-35-29-64-64-64s-64 29-64 64v64h-64c-35 0-64 29-64 64s29 64 64 64h64v64c0 35 29 64 64 64s64-29 64-64V256h64c35 0 64-29 64-64s-29-64-64-64zm-204 307C673 481 628 512 576 512H448c-47 0-90 13-128 35V372C394 346 448 275 448 192c0-106-86-192-192-192S64 86 64 192c0 83 54 154 128 180v280c-74 26-128 97-128 180c0 106 86 192 192 192s192-86 192-192c0-67-34-125-84-159c22-20 52-33 84-33h128c122 0 223-85 249-199c-19 4-37 7-57 7c-26 0-51-5-76-13zM256 128c35 0 64 29 64 64s-29 64-64 64s-64-29-64-64s29-64 64-64zm0 768c-35 0-64-29-64-64s29-64 64-64s64 29 64 64s-29 64-64 64z - M902 479v-1c0-133-112-242-250-242c-106 0-196 64-232 154c-28-20-62-32-100-32c-76 0-140 49-160 116c-52 37-86 97-86 165c0 112 90 202 202 202h503c112 0 202-90 202-202c0-65-31-123-79-160z - M364 512h67v108h108v67h-108v108h-67v-108h-108v-67h108v-108zm298-64A107 107 0 01768 555C768 614 720 660 660 660h-108v-54h-108v-108h-94v108h-94c4-21 22-47 44-51l-1-12a75 75 0 0171-75a128 128 0 01239-7a106 106 0 0153-14z - M177 156c-22 5-33 17-36 37c-10 57-33 258-13 278l445 445c23 23 61 23 84 0l246-246c23-23 23-61 0-84l-445-445C437 120 231 145 177 156zM331 344c-26 26-69 26-95 0c-26-26-26-69 0-95s69-26 95 0C357 276 357 318 331 344z - M683 537h-144v-142h-142V283H239a44 44 0 00-41 41v171a56 56 0 0014 34l321 321a41 41 0 0058 0l174-174a41 41 0 000-58zm-341-109a41 41 0 110-58a41 41 0 010 58zM649 284V142h-69v142h-142v68h142v142h69v-142h142v-68h-142z - - M800 928l-512 0 0-704 224 0 0 292 113-86 111 86 0-292 128 0 0 640c0 35-29 64-64 64zM625 388l-81 64 0-260 160 0 0 260-79-64zM192 160l0 32c0 18 14 32 32 32l32 0 0 704-32 0c-35 0-64-29-64-64l0-704c0-35 29-64 64-64l576 0c24 0 44 13 55 32l-631 0c-18 0-32 14-32 32z - M719 85 388 417l-209-165L87 299v427l92 47 210-164L720 939 939 850V171zM186 610V412l104 104zm526 55L514 512l198-153z - M426.7 554.7v-85.3h341.3v85.3h-341.3m0 256v-85.3h170.7v85.3h-170.7m0-512V213.3h512v85.3H426.7M256 725.3h106.7L213.3 874.7 64 725.3H170.7V298.7H64L213.3 149.3 362.7 298.7H256v426.7z - M854 170c-189-189-495-189-684 0s-189 495 0 684 495 189 684 0 187-495 0-684zM213 706c-89-137-74-325 48-444 122-122 307-137 444-48L213 706zm106 105 493-493c89 137 74 325-48 444-120 122-307 137-444 48z - M469 235V107h85v128h-85zm-162-94 85 85-60 60-85-85 60-60zm469 60-85 85-60-60 85-85 60 60zm-549 183A85 85 0 01302 341H722a85 85 0 0174 42l131 225A85 85 0 01939 652V832a85 85 0 01-85 85H171a85 85 0 01-85-85v-180a85 85 0 0112-43l131-225zM722 427H302l-100 171h255l10 29a59 59 0 002 5c2 4 5 9 9 14 8 9 18 17 34 17 16 0 26-7 34-17a72 72 0 0011-18l0-0 10-29h255l-100-171zM853 683H624a155 155 0 01-12 17C593 722 560 747 512 747c-48 0-81-25-99-47a155 155 0 01-12-17H171v149h683v-149z - \ No newline at end of file diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/CL.png b/src/Resources/Images/ExternalToolIcons/JetBrains/CL.png new file mode 100644 index 00000000..9d9cf6bd Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/CL.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/DB.png b/src/Resources/Images/ExternalToolIcons/JetBrains/DB.png new file mode 100644 index 00000000..c7326287 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/DB.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/DL.png b/src/Resources/Images/ExternalToolIcons/JetBrains/DL.png new file mode 100644 index 00000000..2715c1dd Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/DL.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/DS.png b/src/Resources/Images/ExternalToolIcons/JetBrains/DS.png new file mode 100644 index 00000000..417f8337 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/DS.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/GO.png b/src/Resources/Images/ExternalToolIcons/JetBrains/GO.png new file mode 100644 index 00000000..744938fc Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/GO.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/JB.png b/src/Resources/Images/ExternalToolIcons/JetBrains/JB.png new file mode 100644 index 00000000..e9a3d1c0 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/JB.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/PC.png b/src/Resources/Images/ExternalToolIcons/JetBrains/PC.png new file mode 100644 index 00000000..38ae8274 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/PC.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/PS.png b/src/Resources/Images/ExternalToolIcons/JetBrains/PS.png new file mode 100644 index 00000000..e5e2ceaf Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/PS.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/PY.png b/src/Resources/Images/ExternalToolIcons/JetBrains/PY.png new file mode 100644 index 00000000..38ae8274 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/PY.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/QA.png b/src/Resources/Images/ExternalToolIcons/JetBrains/QA.png new file mode 100644 index 00000000..499b2eff Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/QA.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/QD.png b/src/Resources/Images/ExternalToolIcons/JetBrains/QD.png new file mode 100644 index 00000000..225f3652 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/QD.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/RD.png b/src/Resources/Images/ExternalToolIcons/JetBrains/RD.png new file mode 100644 index 00000000..a2fa3d33 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/RD.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/RM.png b/src/Resources/Images/ExternalToolIcons/JetBrains/RM.png new file mode 100644 index 00000000..1adfa654 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/RM.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/RR.png b/src/Resources/Images/ExternalToolIcons/JetBrains/RR.png new file mode 100644 index 00000000..ee27634a Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/RR.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/WRS.png b/src/Resources/Images/ExternalToolIcons/JetBrains/WRS.png new file mode 100644 index 00000000..2d8f9c36 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/WRS.png differ diff --git a/src/Resources/Images/ExternalToolIcons/JetBrains/WS.png b/src/Resources/Images/ExternalToolIcons/JetBrains/WS.png new file mode 100644 index 00000000..09dda1fc Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/JetBrains/WS.png differ diff --git a/src/Resources/Images/ExternalToolIcons/beyond_compare.png b/src/Resources/Images/ExternalToolIcons/beyond_compare.png new file mode 100644 index 00000000..c7aaf18b Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/beyond_compare.png differ diff --git a/src/Resources/Images/ExternalToolIcons/codium.png b/src/Resources/Images/ExternalToolIcons/codium.png new file mode 100644 index 00000000..10abb719 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/codium.png differ diff --git a/src/Resources/Images/ExternalToolIcons/fleet.png b/src/Resources/Images/ExternalToolIcons/fleet.png new file mode 100644 index 00000000..5e9d84f6 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/fleet.png differ diff --git a/src/Resources/Images/ExternalToolIcons/git.png b/src/Resources/Images/ExternalToolIcons/git.png new file mode 100644 index 00000000..a05a1322 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/git.png differ diff --git a/src/Resources/Images/ExternalToolIcons/kdiff3.png b/src/Resources/Images/ExternalToolIcons/kdiff3.png new file mode 100644 index 00000000..1e2a0bbb Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/kdiff3.png differ diff --git a/src/Resources/Images/ExternalToolIcons/meld.png b/src/Resources/Images/ExternalToolIcons/meld.png new file mode 100644 index 00000000..b9885f15 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/meld.png differ diff --git a/src/Resources/Images/ExternalToolIcons/p4merge.png b/src/Resources/Images/ExternalToolIcons/p4merge.png new file mode 100644 index 00000000..010d8f6f Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/p4merge.png differ diff --git a/src/Resources/Images/ExternalToolIcons/plastic_merge.png b/src/Resources/Images/ExternalToolIcons/plastic_merge.png new file mode 100644 index 00000000..0d82fc86 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/plastic_merge.png differ diff --git a/src/Resources/Images/ExternalToolIcons/rider.png b/src/Resources/Images/ExternalToolIcons/rider.png new file mode 100644 index 00000000..6ab3b8cb Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/rider.png differ diff --git a/src/Resources/Images/ExternalToolIcons/sublime_text.png b/src/Resources/Images/ExternalToolIcons/sublime_text.png new file mode 100644 index 00000000..89e4a286 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/sublime_text.png differ diff --git a/src/Resources/Images/ExternalToolIcons/tortoise_merge.png b/src/Resources/Images/ExternalToolIcons/tortoise_merge.png new file mode 100644 index 00000000..eb5b1a8a Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/tortoise_merge.png differ diff --git a/src/Resources/Images/ExternalToolIcons/vs.png b/src/Resources/Images/ExternalToolIcons/vs.png new file mode 100644 index 00000000..d79fbcfd Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/vs.png differ diff --git a/src/Resources/Images/ExternalToolIcons/vscode.png b/src/Resources/Images/ExternalToolIcons/vscode.png new file mode 100644 index 00000000..23b6af54 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/vscode.png differ diff --git a/src/Resources/Images/ExternalToolIcons/vscode_insiders.png b/src/Resources/Images/ExternalToolIcons/vscode_insiders.png new file mode 100644 index 00000000..38fe8a1e Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/vscode_insiders.png differ diff --git a/src/Resources/Images/ExternalToolIcons/win_merge.png b/src/Resources/Images/ExternalToolIcons/win_merge.png new file mode 100644 index 00000000..8f9f53d4 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/win_merge.png differ diff --git a/src/Resources/Images/ExternalToolIcons/xcode.png b/src/Resources/Images/ExternalToolIcons/xcode.png new file mode 100644 index 00000000..faccf441 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/xcode.png differ diff --git a/src/Resources/Images/ExternalToolIcons/zed.png b/src/Resources/Images/ExternalToolIcons/zed.png new file mode 100644 index 00000000..07c4c50f Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/zed.png differ diff --git a/src/Resources/Images/ShellIcons/cmd.png b/src/Resources/Images/ShellIcons/cmd.png new file mode 100644 index 00000000..efa27dd4 Binary files /dev/null and b/src/Resources/Images/ShellIcons/cmd.png differ diff --git a/src/Resources/Images/ShellIcons/custom.png b/src/Resources/Images/ShellIcons/custom.png new file mode 100644 index 00000000..0175688f Binary files /dev/null and b/src/Resources/Images/ShellIcons/custom.png differ diff --git a/src/Resources/Images/ShellIcons/deepin-terminal.png b/src/Resources/Images/ShellIcons/deepin-terminal.png new file mode 100644 index 00000000..78eef1b4 Binary files /dev/null and b/src/Resources/Images/ShellIcons/deepin-terminal.png differ diff --git a/src/Resources/Images/ShellIcons/foot.png b/src/Resources/Images/ShellIcons/foot.png new file mode 100644 index 00000000..c5dbd0a5 Binary files /dev/null and b/src/Resources/Images/ShellIcons/foot.png differ diff --git a/src/Resources/Images/ShellIcons/ghostty.png b/src/Resources/Images/ShellIcons/ghostty.png new file mode 100644 index 00000000..e394a517 Binary files /dev/null and b/src/Resources/Images/ShellIcons/ghostty.png differ diff --git a/src/Resources/Images/ShellIcons/git-bash.png b/src/Resources/Images/ShellIcons/git-bash.png new file mode 100644 index 00000000..767e0a4e Binary files /dev/null and b/src/Resources/Images/ShellIcons/git-bash.png differ diff --git a/src/Resources/Images/ShellIcons/gnome-terminal.png b/src/Resources/Images/ShellIcons/gnome-terminal.png new file mode 100644 index 00000000..f9edd2e3 Binary files /dev/null and b/src/Resources/Images/ShellIcons/gnome-terminal.png differ diff --git a/src/Resources/Images/ShellIcons/iterm2.png b/src/Resources/Images/ShellIcons/iterm2.png new file mode 100644 index 00000000..16fbd3bd Binary files /dev/null and b/src/Resources/Images/ShellIcons/iterm2.png differ diff --git a/src/Resources/Images/ShellIcons/kitty.png b/src/Resources/Images/ShellIcons/kitty.png new file mode 100644 index 00000000..465c2863 Binary files /dev/null and b/src/Resources/Images/ShellIcons/kitty.png differ diff --git a/src/Resources/Images/ShellIcons/konsole.png b/src/Resources/Images/ShellIcons/konsole.png new file mode 100644 index 00000000..e1dbcd49 Binary files /dev/null and b/src/Resources/Images/ShellIcons/konsole.png differ diff --git a/src/Resources/Images/ShellIcons/lxterminal.png b/src/Resources/Images/ShellIcons/lxterminal.png new file mode 100644 index 00000000..99f62683 Binary files /dev/null and b/src/Resources/Images/ShellIcons/lxterminal.png differ diff --git a/src/Resources/Images/ShellIcons/mac-terminal.png b/src/Resources/Images/ShellIcons/mac-terminal.png new file mode 100644 index 00000000..569af756 Binary files /dev/null and b/src/Resources/Images/ShellIcons/mac-terminal.png differ diff --git a/src/Resources/Images/ShellIcons/mate-terminal.png b/src/Resources/Images/ShellIcons/mate-terminal.png new file mode 100644 index 00000000..d48cedfa Binary files /dev/null and b/src/Resources/Images/ShellIcons/mate-terminal.png differ diff --git a/src/Resources/Images/ShellIcons/ptyxis.png b/src/Resources/Images/ShellIcons/ptyxis.png new file mode 100644 index 00000000..9202f6e1 Binary files /dev/null and b/src/Resources/Images/ShellIcons/ptyxis.png differ diff --git a/src/Resources/Images/ShellIcons/pwsh.png b/src/Resources/Images/ShellIcons/pwsh.png new file mode 100644 index 00000000..12edf757 Binary files /dev/null and b/src/Resources/Images/ShellIcons/pwsh.png differ diff --git a/src/Resources/Images/ShellIcons/warp.png b/src/Resources/Images/ShellIcons/warp.png new file mode 100644 index 00000000..7d604d8e Binary files /dev/null and b/src/Resources/Images/ShellIcons/warp.png differ diff --git a/src/Resources/Images/ShellIcons/wezterm.png b/src/Resources/Images/ShellIcons/wezterm.png new file mode 100644 index 00000000..ed7a659f Binary files /dev/null and b/src/Resources/Images/ShellIcons/wezterm.png differ diff --git a/src/Resources/Images/ShellIcons/wt.png b/src/Resources/Images/ShellIcons/wt.png new file mode 100644 index 00000000..f2a874f4 Binary files /dev/null and b/src/Resources/Images/ShellIcons/wt.png differ diff --git a/src/Resources/Images/ShellIcons/xfce4-terminal.png b/src/Resources/Images/ShellIcons/xfce4-terminal.png new file mode 100644 index 00000000..9eda3d00 Binary files /dev/null and b/src/Resources/Images/ShellIcons/xfce4-terminal.png differ diff --git a/src/Resources/Images/github.png b/src/Resources/Images/github.png new file mode 100644 index 00000000..d3c211da Binary files /dev/null and b/src/Resources/Images/github.png differ diff --git a/src/Resources/Images/unreal.png b/src/Resources/Images/unreal.png new file mode 100644 index 00000000..01ceeb31 Binary files /dev/null and b/src/Resources/Images/unreal.png differ diff --git a/src/Resources/Locales/de_DE.axaml b/src/Resources/Locales/de_DE.axaml new file mode 100644 index 00000000..032e5ce7 --- /dev/null +++ b/src/Resources/Locales/de_DE.axaml @@ -0,0 +1,778 @@ + + + + + + Info + Über SourceGit + Open Source & freier Git GUI Client + Worktree hinzufügen + Ordner: + Pfad für diesen Worktree. Relativer Pfad wird unterstützt. + Branch Name: + Optional. Standard ist der Zielordnername. + Branch verfolgen: + Remote-Branch verfolgen + Was auschecken: + Neuen Branch erstellen + Existierender Branch + OpenAI Assistent + Neu generieren + Verwende OpenAI, um Commit-Nachrichten zu generieren + Als Commit-Nachricht verwenden + Patch + Patch-Datei: + Wähle die anzuwendende .patch-Datei + Ignoriere Leerzeichenänderungen + Patch anwenden + Leerzeichen: + Stash anwenden + Nach dem Anwenden löschen + Änderungen des Index wiederherstellen + Stash: + Archivieren... + Speichere Archiv in: + Wähle Archivpfad aus + Revision: + Archiv erstellen + SourceGit Askpass + ALS UNVERÄNDERT ANGENOMMENE DATEIEN + KEINE ALS UNVERÄNDERT ANGENOMMENEN DATEIEN + ENTFERNEN + Aktualisieren + BINÄRE DATEI NICHT UNTERSTÜTZT!!! + Bisect + Abbrechen + Schlecht + Bisecting. Ist der aktuelle HEAD gut oder fehlerhaft? + Gut + Überspringen + Bisecting. Aktuellen Commit als gut oder schlecht markieren und einen anderen auschecken. + Blame + BLAME WIRD BEI DIESER DATEI NICHT UNTERSTÜTZT!!! + Auschecken von ${0}$... + Mit ${0}$ vergleichen + Mit Worktree vergleichen + Branch-Namen kopieren + Benutzerdefinierte Aktion + Lösche ${0}$... + Lösche alle ausgewählten {0} Branches + Fast-Forward zu ${0}$ + Fetche ${0}$ in ${1}$ hinein... + Git Flow - Abschließen ${0}$ + Merge ${0}$ in ${1}$ hinein... + Merge ausgewählte {0} Branches in aktuellen hinein + Pull ${0}$ + Pull ${0}$ in ${1}$ hinein... + Push ${0}$ + Rebase ${0}$ auf ${1}$... + Benenne ${0}$ um... + Setze verfolgten Branch... + Branch Vergleich + Ungültiger upstream! + Bytes + ABBRECHEN + Auf Vorgänger-Revision zurücksetzen + Auf diese Revision zurücksetzen + Generiere Commit-Nachricht + ANZEIGE MODUS ÄNDERN + Zeige als Datei- und Ordnerliste + Zeige als Pfadliste + Zeige als Dateisystembaum + Branch auschecken + Commit auschecken + Commit: + Warnung: Beim Auschecken eines Commits wird dein HEAD losgelöst (detached) sein! + Lokale Änderungen: + Verwerfen + Stashen & wieder anwenden + Alle Submodule updaten + Branch: + Cherry Pick + Quelle an Commit-Nachricht anhängen + Commit(s): + Alle Änderungen committen + Hautplinie: + Normalerweise ist es nicht möglich einen Merge zu cherry-picken, da unklar ist welche Seite des Merges die Hauptlinie ist. Diese Option ermöglicht es die Änderungen relativ zum ausgewählten Vorgänger zu wiederholen. + Stashes löschen + Du versuchst alle Stashes zu löschen. Möchtest du wirklich fortfahren? + Remote Repository klonen + Extra Parameter: + Zusätzliche Argumente für das Klonen des Repositories. Optional. + Lokaler Name: + Repository-Name. Optional. + Übergeordneter Ordner: + Submodule initialisieren und aktualisieren + Repository URL: + SCHLIESSEN + Editor + Commit auschecken + Diesen Commit cherry-picken + Mehrere cherry-picken + Mit HEAD vergleichen + Mit Worktree vergleichen + Author + Committer + Information + SHA + Betreff + Benutzerdefinierte Aktion + Interactives Rebase von ${0}$ auf diesen Commit + Merge in ${0}$ hinein + Merge ... + Rebase von ${0}$ auf diesen Commit + Reset ${0}$ auf diesen Commit + Commit rückgängig machen + Umformulieren + Als Patch speichern... + Squash in den Vorgänger + Squash Nachfolger Commits bis hier + ÄNDERUNGEN + Änderungen durchsuchen... + DATEIEN + LFS DATEI + Dateien durchsuchen... + Submodule + INFORMATION + AUTOR + GEÄNDERT + NACHFOLGER + COMMITTER + Prüfe Refs, die diesen Commit enthalten + COMMIT ENTHALTEN IN + Zeigt nur die ersten 100 Änderungen. Alle Änderungen im ÄNDERUNGEN Tab. + COMMIT-NACHRICHT + VORGÄNGER + REFS + SHA + Im Browser öffnen + Details + Betreff + Commit-Nachricht + Repository Einstellungen + COMMIT TEMPLATE + Template Inhalt: + Template Name: + BENUTZERDEFINIERTE AKTION + Argumente: + ${REPO} - Repository Pfad; ${SHA} - SHA-Wert des selektierten Commits + Ausführbare Datei: + Name: + Geltungsbereich: + Branch + Commit + Repository + Auf Beenden der Aktion warten + Email Adresse + Email Adresse + GIT + Remotes automatisch fetchen + Minute(n) + Standard Remote + Bevorzugter Merge Modus + TICKETSYSTEM + Beispiel Azure DevOps Rule hinzufügen + Beispiel für Gitee Issue Regel einfügen + Beispiel für Gitee Pull Request Regel einfügen + Beispiel für Github-Regel hinzufügen + Beispiel für Gitlab Issue Regel einfügen + Beispiel für Gitlab Merge Request einfügen + Beispiel für Jira-Regel hinzufügen + Neue Regel + Ticketnummer Regex-Ausdruck: + Name: + Ergebnis-URL: + Verwende bitte $1, $2 um auf Regex-Gruppenwerte zuzugreifen. + OPEN AI + Bevorzugter Service: + Der ausgewählte 'Bevorzugte Service' wird nur in diesem Repository gesetzt und verwendet. Wenn keiner gesetzt ist und mehrere Servies verfügbar sind wird ein Kontextmenü zur Auswahl angezeigt. + HTTP Proxy + HTTP Proxy für dieses Repository + Benutzername + Benutzername für dieses Repository + Arbeitsplätze + Farbe + Name + Zuletzt geöffnete Tabs beim Starten wiederherstellen + WEITER + Leerer Commit erkannt! Möchtest du trotzdem fortfahren (--allow-empty)? + ALLES STAGEN & COMMITTEN + Leerer Commit erkannt! Möchtest du trotzdem fortfahren (--allow-empty) oder alle Änderungen stagen und dann committen? + Konventionelle Commit-Hilfe + Breaking Change: + Geschlossenes Ticket: + Änderungen im Detail: + Gültigkeitsbereich: + Kurzbeschreibung: + Typ der Änderung: + Kopieren + Kopiere gesamten Text + Ganzen Pfad kopieren + Pfad kopieren + Branch erstellen... + Basierend auf: + Erstellten Branch auschecken + Lokale Änderungen: + Verwerfen + Stashen & wieder anwenden + Neuer Branch-Name: + Branch-Namen eingeben. + Leerzeichen werden durch Bindestriche ersetzt. + Lokalen Branch erstellen + Tag erstellen... + Neuer Tag auf: + Mit GPG signieren + Anmerkung: + Optional. + Tag-Name: + Empfohlenes Format: v1.0.0-alpha + Nach Erstellung auf alle Remotes pushen + Erstelle neuen Tag + Art: + Mit Anmerkung + Ohne Anmerkung + Halte Strg gedrückt, um direkt auszuführen + Ausschneiden + Branch löschen + Branch: + Du löscht gerade einen Remote-Branch!!! + Auch Remote-Branch ${0}$ löschen + Mehrere Branches löschen + Du versuchst mehrere Branches auf einmal zu löschen. Kontrolliere noch einmal vor dem Fortfahren! + Remote löschen + Remote: + Pfad: + Ziel: + Alle Nachfolger werden aus der Liste entfernt. + Dadurch wird es nur aus der Liste entfernt, nicht von der Festplatte! + Bestätige löschen von Gruppe + Bestätige löschen von Repository + Lösche Submodul + Submodul Pfad: + Tag löschen + Tag: + Von Remote Repositories löschen + BINÄRER VERGLEICH + NEU + ALT + Kopieren + Dateimodus geändert + Erste Differenz + Ignoriere Leerzeichenänderungen + Letzte Differenz + LFS OBJEKT ÄNDERUNG + Nächste Änderung + KEINE ÄNDERUNG ODER NUR ZEILEN-ENDE ÄNDERUNGEN + Vorherige Änderung + Als Patch speichern + Zeige versteckte Symbole + Nebeneinander + SUBMODUL + NEU + Seiten wechseln + Syntax Hervorhebung + Zeilenumbruch + Aktiviere Block-Navigation + Öffne in Merge Tool + Alle Zeilen anzeigen + Weniger Zeilen anzeigen + Mehr Zeilen anzeigen + WÄHLE EINE DATEI AUS UM ÄNDERUNGEN ANZUZEIGEN + Öffne in Merge Tool + Änderungen verwerfen + Alle Änderungen in der Arbeitskopie. + Änderungen: + Ignorierte Dateien inkludieren + Insgesamt {0} Änderungen werden verworfen + Du kannst das nicht rückgängig machen!!! + Lesezeichen: + Neuer Name: + Ziel: + Ausgewählte Gruppe bearbeiten + Ausgewähltes Repository bearbeiten + Führe benutzerdefinierte Aktion aus + Name der Aktion: + Fetch + Alle Remotes fetchen + Aktiviere '--force' Option + Ohne Tags fetchen + Remote: + Remote-Änderungen fetchen + Als unverändert annehmen + Verwerfen... + Verwerfe {0} Dateien... + Verwerfe Änderungen in ausgewählten Zeilen + Öffne externes Merge Tool + Löse mit ${0}$ + Als Patch speichern... + Stagen + {0} Dateien stagen + Änderungen in ausgewählten Zeilen stagen + Stash... + {0} Dateien stashen... + Unstage + {0} Dateien unstagen + Änderungen in ausgewählten Zeilen unstagen + "Meine" verwenden (checkout --ours) + "Ihre" verwenden (checkout --theirs) + Datei Historie + ÄNDERUNGEN + INHALT + Git-Flow + Development-Branch: + Feature: + Feature-Prefix: + FLOW - Finish Feature + FLOW - Finish Hotfix + FLOW - Finish Release + Ziel: + Hotfix: + Hotfix-Prefix: + Git-Flow initialisieren + Branch behalten + Production-Branch: + Release: + Release-Prefix: + Feature starten... + FLOW - Feature starten + Hotfix starten... + FLOW - Hotfix starten + Name eingeben + Release starten... + FLOW - Release starten + Versions-Tag-Prefix: + Git LFS + Verfolgungsmuster hinzufügen... + Muster ist ein Dateiname + Eigenes Muster: + Verfolgungsmuster zu Git LFS hinzufügen + Fetch + Führt `git lfs fetch` aus um Git LFS Objekte herunterzuladen. Das aktualisiert nicht die Arbeitskopie. + LFS Objekte fetchen + Installiere Git LFS Hooks + Sperren anzeigen + Keine gesperrten Dateien + Sperre + Zeige nur meine Sperren + LFS Sperren + Entsperren + Erzwinge entsperren + Prune + Führt `git lfs prune` aus um alte LFS Dateien von lokalem Speicher zu löschen + Pull + Führt `git lfs pull` aus um alle Git LFS Dasteien für aktuellen Ref & Checkout herunterzuladen + LFS Objekte pullen + Push + Pushe große Dateien in der Warteschlange zum Git LFS Endpunkt + LFS Objekte pushen + Remote: + Verfolge alle '{0}' Dateien + Verfolge alle *{0} Dateien + Verlauf + AUTOR + AUTOR ZEITPUNKT + GRAPH & COMMIT-NACHRICHT + SHA + COMMIT ZEITPUNKT + {0} COMMITS AUSGEWÄHLT + Halte 'Strg' oder 'Umschalt', um mehrere Commits auszuwählen. + Halte ⌘ oder ⇧, um mehrere Commits auszuwählen + TIPPS: + Tastaturkürzel Referenz + GLOBAL + Aktuelles Popup schließen + Klone neues Repository + Aktuellen Tab schließen + Zum nächsten Tab wechseln + Zum vorherigen Tab wechseln + Neuen Tab erstellen + Einstellungen öffnen + REPOSITORY + Gestagte Änderungen committen + Gestagte Änderungen committen und pushen + Alle Änderungen stagen und committen + Neuen Branch basierend auf ausgewählten Commit erstellen + Ausgewählte Änderungen verwerfen + Fetch, wird direkt ausgeführt + Dashboard Modus (Standard) + Commit-Suchmodus + Pull, wird direkt ausgeführt + Push, wird direkt ausgeführt + Erzwinge Neuladen des Repositorys + Ausgewählte Änderungen stagen/unstagen + Wechsle zu 'Änderungen' + Wechsle zu 'Verlauf' + Wechsle zu 'Stashes' + TEXTEDITOR + Suchpanel schließen + Suche nächste Übereinstimmung + Suche vorherige Übereinstimmung + Öffne Suchpanel + Verwerfen + Stagen + Unstagen + Initialisiere Repository + Pfad: + Cherry-Pick wird durchgeführt. + Verarbeite commit + Merge request wird durchgeführt. + Verarbeite + Rebase wird durchgeführt. + Angehalten bei + Revert wird durchgeführt. + Reverte commit + Interaktiver Rebase + Auf: + Ziel Branch: + Link kopieren + In Browser öffnen + FEHLER + INFO + Branch mergen + Ziel-Branch: + Merge Option: + Quelle: + Merge (mehrere) + Alle Änderungen committen + Strategie: + Ziele: + Bewege Repository Knoten + Wähle Vorgänger-Knoten für: + Name: + Git wurde NICHT konfiguriert. Gehe bitte zuerst in die [Einstellungen] und konfiguriere Git. + App-Daten Ordner öffnen + Öffne mit... + Optional. + Neue Seite erstellen + Lesezeichen + Tab schließen + Andere Tabs schließen + Rechte Tabs schließen + Kopiere Repository-Pfad + Repositories + Einfügen + Vor {0} Tagen + Vor 1 Stunde + Vor {0} Stunden + Gerade eben + Letzter Monat + Leztes Jahr + Vor {0} Minuten + Vor {0} Monaten + Vor {0} Jahren + Gestern + Einstellungen + OPEN AI + Analysierung des Diff Befehl + API Schlüssel + Generiere Nachricht Befehl + Modell + Name + Server + Streaming aktivieren + DARSTELLUNG + Standardschriftart + Editor Tab Breite + Schriftgröße + Standard + Texteditor + Monospace-Schriftart + Verwende die Monospace-Schriftart nur im Texteditor + Design + Design-Anpassungen + Fixe Tab-Breite in Titelleiste + Verwende nativen Fensterrahmen + DIFF/MERGE TOOL + Installationspfad + Installationspfad zum Diff/Merge Tool + Tool + ALLGEMEIN + Beim Starten nach Updates suchen + Datumsformat + Sprache + Commit-Historie + Zeige Autor Zeitpunkt anstatt Commit Zeitpunkt + Zeige Nachfolger in den Commit Details + Zeige Tags im Commit Graph + Längenvorgabe für Commit-Nachrichten + GIT + Aktiviere Auto-CRLF + Klon Standardordner + Benutzer Email + Globale Git Benutzer Email + Aktivere --prune beim fetchen + Aktiviere --ignore-cr-at-eol beim Unterschied + Diese App setzt Git (>= 2.25.1) voraus + Installationspfad + Aktiviere HTTP SSL Verifizierung + Benutzername + Globaler Git Benutzername + Git Version + GPG SIGNIERUNG + Commit-Signierung + GPG Format + GPG Installationspfad + Installationspfad zum GPG Programm + Tag-Signierung + Benutzer Signierungsschlüssel + GPG Benutzer Signierungsschlüssel + EINBINDUNGEN + SHELL/TERMINAL + Pfad + Shell/Terminal + Remote löschen + Ziel: + Worktrees löschen + Worktree Informationen in `$GIT_COMMON_DIR/worktrees` löschen + Pull + Remote-Branch: + Lokaler Branch: + Lokale Änderungen: + Verwerfen + Stashen & wieder anwenden + Remote: + Pull (Fetch & Merge) + Rebase anstatt Merge verwenden + Push + Sicherstellen, dass Submodule gepusht wurden + Erzwinge Push + Lokaler Branch: + Remote: + Push + Remote-Branch: + Remote-Branch verfolgen + Alle Tags pushen + Tag zum Remote pushen + Zu allen Remotes pushen + Remote: + Tag: + Schließen + Aktuellen Branch rebasen + Lokale Änderungen stashen & wieder anwenden + Auf: + Rebase: + Remote hinzufügen + Remote bearbeiten + Name: + Remote Name + Repository URL: + Remote Git Repository URL + URL kopieren + Löschen... + Bearbeiten... + Fetch + Im Browser öffnen + Prune + Bestätige das entfernen des Worktrees + Aktiviere `--force` Option + Ziel: + Branch umbenennen + Neuer Name: + Eindeutiger Name für diesen Branch + Branch: + ABBRECHEN + Änderungen automatisch von Remote fetchen... + Sortieren + Nach Commit Datum + Nach Name + Aufräumen (GC & Prune) + Führt `git gc` auf diesem Repository aus. + Filter aufheben + Repository Einstellungen + WEITER + Benutzerdefinierte Aktionen + Keine benutzerdefinierten Aktionen + Alle Änderungen verwerfen + Aktiviere '--reflog' Option + Öffne im Datei-Browser + Suche Branches/Tags/Submodule + Sichtbarkeit im Graphen + Aufheben + Im Graph ausblenden + Im Graph filtern + Aktiviere '--first-parent' Option + LAYOUT + Horizontal + Vertikal + COMMIT SORTIERUNG + Commit Zeitpunkt + Topologie + LOKALE BRANCHES + Zum HEAD wechseln + Erstelle Branch + BENACHRICHTIGUNGEN LÖSCHEN + Nur aktuellen Branch im Graphen hervorheben + Öffne in {0} + Öffne in externen Tools + Aktualisiern + REMOTES + REMOTE HINZUFÜGEN + Commit suchen + Autor + Committer + Inhalt + Dateiname + Commit-Nachricht + SHA + Aktueller Branch + Zeige Tags als Baum + ÜBERSPRINGEN + Statistiken + SUBMODULE + SUBMODUL HINZUFÜGEN + SUBMODUL AKTUALISIEREN + TAGS + NEUER TAG + Nach Erstellungsdatum + Nach Namen + Sortiere + Öffne im Terminal + Verwende relative Zeitangaben in Verlauf + Logs ansehen + Öffne '{0}' im Browser + WORKTREES + WORKTREE HINZUFÜGEN + PRUNE + Git Repository URL + Aktuellen Branch auf Revision zurücksetzen + Rücksetzmodus: + Verschiebe zu: + Aktueller Branch: + Zeige im Datei-Explorer + Commit rückgängig machen + Commit: + Commit Änderungen rückgängig machen + Commit Nachricht umformulieren + Verwende 'Shift+Enter' um eine neue Zeile einzufügen. 'Enter' ist das Kürzel für den OK Button + Bitte warten... + SPEICHERN + Speichern als... + Patch wurde erfolgreich gespeichert! + Durchsuche Repositories + Hauptverzeichnis: + Suche nach Updates... + Neue Version ist verfügbar: + Suche nach Updates fehlgeschlagen! + Download + Diese Version überspringen + Software Update + Es sind momentan kein Updates verfügbar. + Setze verfolgten Branch + Branch: + Upstream Verfolgung aufheben + Upstream: + SHA kopieren + Zum Commit wechseln + Squash Commits + In: + SSH privater Schlüssel: + Pfad zum privaten SSH Schlüssel + START + Stash + Automatisch wiederherstellen nach dem Stashen + Die Arbeitsdateien bleiben unverändert, aber ein Stash wird gespeichert. + Inklusive nicht-verfolgter Dateien + Behalte gestagte Dateien + Name: + Optional. Name dieses Stashes + Nur gestagte Änderungen + Gestagte und unstagte Änderungen der ausgewähleten Datei(en) werden gestasht!!! + Lokale Änderungen stashen + Anwenden + Entfernen + Als Path speichern... + Stash entfernen + Entfernen: + Stashes + ÄNDERUNGEN + STASHES + Statistiken + COMMITS + COMMITTER + ÜBERSICHT + MONAT + WOCHE + AUTOREN: + COMMITS: + SUBMODULE + Submodul hinzufügen + Relativen Pfad kopieren + Untergeordnete Submodule fetchen + Öffne Submodul Repository + Relativer Pfad: + Relativer Ordner um dieses Submodul zu speichern. + Submodul löschen + OK + Tag-Namen kopieren + Tag-Nachricht kopieren + Lösche ${0}$... + Merge ${0}$ in ${1}$ hinein... + Pushe ${0}$... + Submodule aktualisieren + Alle Submodule + Initialisiere wenn nötig + Rekursiv + Submodul: + Verwende `--remote` Option + URL: + Logs + ALLES LÖSCHEN + Kopieren + Löschen + Warnung + Willkommensseite + Erstelle Gruppe + Erstelle Untergruppe + Klone Repository + Lösche + DRAG & DROP VON ORDNER UNTERSTÜTZT. BENUTZERDEFINIERTE GRUPPIERUNG UNTERSTÜTZT. + Bearbeiten + Bewege in eine andere Gruppe + Öffne alle Repositories + Öffne Repository + Öffne Terminal + Klon Standardordner erneut nach Repositories durchsuchen + Suche Repositories... + Sortieren + Änderungen + Git Ignore + Ignoriere alle *{0} Dateien + Ignoriere *{0} Datein im selben Ordner + Ignoriere Dateien im selben Ordner + Ignoriere nur diese Datei + Amend + Du kannst diese Datei jetzt stagen. + COMMIT + COMMIT & PUSH + Template/Historie + Klick-Ereignis auslösen + Commit (Bearbeitung) + Alle Änderungen stagen und committen + Du hast {0} Datei(en) gestaged, aber nur {1} werden angezeigt ({2} sind herausgefiltert). Willst du trotzdem fortfahren? + KONFLIKTE ERKANNT + EXTERNES MERGE-TOOL ÖFFNEN + ALLE KONFLIKTE IN EXTERNEM MERGE-TOOL ÖFFNEN + DATEI KONFLIKTE GELÖST + MEINE VERSION VERWENDEN + DEREN VERSION VERWENDEN + NICHT-VERFOLGTE DATEIEN INKLUDIEREN + KEINE BISHERIGEN COMMIT-NACHRICHTEN + KEINE COMMIT TEMPLATES + Rechtsklick auf selektierte Dateien und wähle die Konfliktlösungen aus. + SignOff + GESTAGED + UNSTAGEN + ALLES UNSTAGEN + UNSTAGED + STAGEN + ALLES STAGEN + ALS UNVERÄNDERT ANGENOMMENE ANZEIGEN + Template: ${0}$ + ARBEITSPLATZ: + Arbeitsplätze konfigurieren... + WORKTREE + Pfad kopieren + Sperren + Entfernen + Entsperren + diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml new file mode 100644 index 00000000..ee035551 --- /dev/null +++ b/src/Resources/Locales/en_US.axaml @@ -0,0 +1,805 @@ + + About + About SourceGit + Opensource & Free Git GUI Client + Add Worktree + Location: + Path for this worktree. Relative path is supported. + Branch Name: + Optional. Default is the destination folder name. + Track Branch: + Tracking remote branch + What to Checkout: + Create New Branch + Existing Branch + AI Assistant + RE-GENERATE + Use AI to generate commit message + APPLY AS COMMIT MESSAGE + Patch + Patch File: + Select .patch file to apply + Ignore whitespace changes + Apply Patch + Whitespace: + Apply Stash + Delete after applying + Reinstate the index's changes + Stash: + Archive... + Save Archive To: + Select archive file path + Revision: + Archive + SourceGit Askpass + FILES ASSUME UNCHANGED + NO FILES ASSUMED AS UNCHANGED + REMOVE + Load Image... + Refresh + BINARY FILE NOT SUPPORTED!!! + Bisect + Abort + Bad + Bisecting. Is current HEAD good or bad? + Good + Skip + Bisecting. Mark current commit as good or bad and checkout another one. + Blame + BLAME ON THIS FILE IS NOT SUPPORTED!!! + Checkout ${0}$... + Compare with ${0}$ + Compare with Worktree + Copy Branch Name + Custom Action + Delete ${0}$... + Delete selected {0} branches + Fast-Forward to ${0}$ + Fetch ${0}$ into ${1}$... + Git Flow - Finish ${0}$ + Merge ${0}$ into ${1}$... + Merge selected {0} branches into current + Pull ${0}$ + Pull ${0}$ into ${1}$... + Push ${0}$ + Rebase ${0}$ on ${1}$... + Rename ${0}$... + Reset ${0}$ to ${1}$... + Set Tracking Branch... + Branch Compare + Invalid upstream! + Bytes + CANCEL + Reset to Parent Revision + Reset to This Revision + Generate commit message + CHANGE DISPLAY MODE + Show as File and Dir List + Show as Path List + Show as Filesystem Tree + Checkout Branch + Checkout Commit + Commit: + Warning: By doing a commit checkout, your Head will be detached + Local Changes: + Discard + Stash & Reapply + Update all submodules + Branch: + Checkout & Fast-Forward + Fast-Forward to: + Cherry Pick + Append source to commit message + Commit(s): + Commit all changes + Mainline: + Usually you cannot cherry-pick a merge because you do not know which side of the merge should be considered the mainline. This option allows cherry-pick to replay the change relative to the specified parent. + Clear Stashes + You are trying to clear all stashes. Are you sure to continue? + Clone Remote Repository + Extra Parameters: + Additional arguments to clone repository. Optional. + Local Name: + Repository name. Optional. + Parent Folder: + Initialize & update submodules + Repository URL: + CLOSE + Editor + Checkout Commit + Cherry-Pick Commit + Cherry-Pick ... + Compare with HEAD + Compare with Worktree + Author + Committer + Information + SHA + Subject + Custom Action + Interactively Rebase ${0}$ on Here + Merge to ${0}$ + Merge ... + Rebase ${0}$ on Here + Reset ${0}$ to Here + Revert Commit + Reword + Save as Patch... + Squash into Parent + Squash Children into Here + CHANGES + changed file(s) + Search Changes... + FILES + LFS File + Search Files... + Submodule + INFORMATION + AUTHOR + CHANGED + CHILDREN + COMMITTER + Check refs that contains this commit + COMMIT IS CONTAINED BY + Shows only the first 100 changes. See all changes on the CHANGES tab. + MESSAGE + PARENTS + REFS + SHA + Open in Browser + Description + SUBJECT + Enter commit subject + Repository Configure + COMMIT TEMPLATE + Template Content: + Template Name: + CUSTOM ACTION + Arguments: + ${REPO} - Repository's path; ${BRANCH} - Selected branch; ${SHA} - Selected commit's SHA + Executable File: + Name: + Scope: + Branch + Commit + Repository + Wait for action exit + Email Address + Email address + GIT + Fetch remotes automatically + Minute(s) + Default Remote + Preferred Merge Mode + ISSUE TRACKER + Add Sample Azure DevOps Rule + Add Sample Gitee Issue Rule + Add Sample Gitee Pull Request Rule + Add Sample Github Rule + Add Sample GitLab Issue Rule + Add Sample GitLab Merge Request Rule + Add Sample Jira Rule + New Rule + Issue Regex Expression: + Rule Name: + Result URL: + Please use $1, $2 to access regex groups values. + AI + Preferred Service: + If the 'Preferred Service' is set, SourceGit will only use it in this repository. Otherwise, if there is more than one service available, a context menu to choose one of them will be shown. + HTTP Proxy + HTTP proxy used by this repository + User Name + User name for this repository + Workspaces + Color + Name + Restore tabs on startup + CONTINUE + Empty commit detected! Do you want to continue (--allow-empty)? + STAGE ALL & COMMIT + Empty commit detected! Do you want to continue (--allow-empty) or stage all then commit? + Conventional Commit Helper + Breaking Change: + Closed Issue: + Detail Changes: + Scope: + Short Description: + Type of Change: + Copy + Copy All Text + Copy Full Path + Copy Path + Create Branch... + Based On: + Check out the created branch + Local Changes: + Discard + Stash & Reapply + New Branch Name: + Enter branch name. + Spaces will be replaced with dashes. + Create Local Branch + Overwrite existing branch + Create Tag... + New Tag At: + GPG signing + Tag Message: + Optional. + Tag Name: + Recommended format: v1.0.0-alpha + Push to all remotes after created + Create New Tag + Kind: + annotated + lightweight + Hold Ctrl to start directly + Cut + De-initialize Submodule + Force de-init even if it contains local changes. + Submodule: + Delete Branch + Branch: + You are about to delete a remote branch!!! + Also delete remote branch ${0}$ + Delete Multiple Branches + You are trying to delete multiple branches at one time. Be sure to double-check before taking action! + Delete Remote + Remote: + Path: + Target: + All children will be removed from list. + This will only remove it from list, not from disk! + Confirm Deleting Group + Confirm Deleting Repository + Delete Submodule + Submodule Path: + Delete Tag + Tag: + Delete from remote repositories + BINARY DIFF + NEW + OLD + Copy + File Mode Changed + First Difference + Ignore All Whitespace Changes + Last Difference + LFS OBJECT CHANGE + Next Difference + NO CHANGES OR ONLY EOL CHANGES + Previous Difference + Save as Patch + Show hidden symbols + Side-By-Side Diff + SUBMODULE + DELETED + NEW + Swap + Syntax Highlighting + Line Word Wrap + Enable Block-Navigation + Open in Merge Tool + Show All Lines + Decrease Number of Visible Lines + Increase Number of Visible Lines + SELECT FILE TO VIEW CHANGES + Open in Merge Tool + Discard Changes + All local changes in working copy. + Changes: + Include ignored files + {0} changes will be discarded + You can't undo this action!!! + Bookmark: + New Name: + Target: + Edit Selected Group + Edit Selected Repository + Run Custom Action + Action Name: + Fetch + Fetch all remotes + Force override local refs + Fetch without tags + Remote: + Fetch Remote Changes + Assume unchanged + Discard... + Discard {0} files... + Discard Changes in Selected Line(s) + Open External Merge Tool + Resolve Using ${0}$ + Save as Patch... + Stage + Stage {0} files + Stage Changes in Selected Line(s) + Stash... + Stash {0} files... + Unstage + Unstage {0} files + Unstage Changes in Selected Line(s) + Use Mine (checkout --ours) + Use Theirs (checkout --theirs) + File History + CHANGE + CONTENT + Git-Flow + Development Branch: + Feature: + Feature Prefix: + FLOW - Finish Feature + FLOW - Finish Hotfix + FLOW - Finish Release + Target: + Push to remote(s) after performing finish + Squash during merge + Hotfix: + Hotfix Prefix: + Initialize Git-Flow + Keep branch + Production Branch: + Release: + Release Prefix: + Start Feature... + FLOW - Start Feature + Start Hotfix... + FLOW - Start Hotfix + Enter name + Start Release... + FLOW - Start Release + Version Tag Prefix: + Git LFS + Add Track Pattern... + Pattern is file name + Custom Pattern: + Add Track Pattern to Git LFS + Fetch + Run `git lfs fetch` to download Git LFS objects. This does not update the working copy. + Fetch LFS Objects + Install Git LFS hooks + Show Locks + No Locked Files + Lock + Show only my locks + LFS Locks + Unlock + Force Unlock + Prune + Run `git lfs prune` to delete old LFS files from local storage + Pull + Run `git lfs pull` to download all Git LFS files for current ref & checkout + Pull LFS Objects + Push + Push queued large files to the Git LFS endpoint + Push LFS Objects + Remote: + Track files named '{0}' + Track all *{0} files + HISTORY + AUTHOR + AUTHOR TIME + GRAPH & SUBJECT + SHA + COMMIT TIME + SELECTED {0} COMMITS + Hold 'Ctrl' or 'Shift' to select multiple commits. + Hold ⌘ or ⇧ to select multiple commits. + TIPS: + Keyboard Shortcuts Reference + GLOBAL + Cancel current popup + Clone new repository + Close current page + Go to next page + Go to previous page + Create new page + Open Preferences dialog + Switch active workspace + Switch active page + REPOSITORY + Commit staged changes + Commit and push staged changes + Stage all changes and commit + Creates a new branch based on selected commit + Discard selected changes + Fetch, starts directly + Dashboard mode (Default) + Commit search mode + Pull, starts directly + Push, starts directly + Force to reload this repository + Stage/Unstage selected changes + Switch to 'Changes' + Switch to 'Histories' + Switch to 'Stashes' + TEXT EDITOR + Close search panel + Find next match + Find previous match + Open with external diff/merge tool + Open search panel + Discard + Stage + Unstage + Initialize Repository + Path: + Cherry-Pick in progress. + Processing commit + Merge in progress. + Merging + Rebase in progress. + Stopped at + Revert in progress. + Reverting commit + Interactive Rebase + On: + Target Branch: + Copy Link + Open in Browser + ERROR + NOTICE + Workspaces + Pages + Merge Branch + Into: + Merge Option: + Source: + Merge (Multiple) + Commit all changes + Strategy: + Targets: + Move Repository Node + Select parent node for: + Name: + Git has NOT been configured. Please to go [Preferences] and configure it first. + Open Data Storage Directory + Open with... + Optional. + Create New Page + Bookmark + Close Tab + Close Other Tabs + Close Tabs to the Right + Copy Repository Path + Repositories + Paste + {0} days ago + 1 hour ago + {0} hours ago + Just now + Last month + Last year + {0} minutes ago + {0} months ago + {0} years ago + Yesterday + Preferences + AI + Analyze Diff Prompt + API Key + Generate Subject Prompt + Model + Name + Server + Enable Streaming + APPEARANCE + Default Font + Editor Tab Width + Font Size + Default + Editor + Monospace Font + Use monospace font only in text editor + Theme + Theme Overrides + Use fixed tab width in titlebar + Use native window frame + DIFF/MERGE TOOL + Install Path + Input path for diff/merge tool + Tool + GENERAL + Check for updates on startup + Date Format + Language + History Commits + Show author time instead of commit time in graph + Show children in the commit details + Show tags in commit graph + Subject Guide Length + GIT + Enable Auto CRLF + Default Clone Dir + User Email + Global git user email + Enable --prune on fetch + Enable --ignore-cr-at-eol in diff + Git (>= 2.25.1) is required by this app + Install Path + Enable HTTP SSL Verify + User Name + Global git user name + Git version + GPG SIGNING + Commit GPG signing + GPG Format + Program Install Path + Input path for installed gpg program + Tag GPG signing + User Signing Key + User's gpg signing key + INTEGRATION + SHELL/TERMINAL + Path + Shell/Terminal + Prune Remote + Target: + Prune Worktrees + Prune worktree information in `$GIT_COMMON_DIR/worktrees` + Pull + Remote Branch: + Into: + Local Changes: + Discard + Stash & Reapply + Update all submodules + Remote: + Pull (Fetch & Merge) + Use rebase instead of merge + Push + Make sure submodules have been pushed + Force push + Local Branch: + Remote: + Push Changes To Remote + Remote Branch: + Set as tracking branch + Push all tags + Push Tag To Remote + Push to all remotes + Remote: + Tag: + Quit + Rebase Current Branch + Stash & reapply local changes + On: + Rebase: + Add Remote + Edit Remote + Name: + Remote name + Repository URL: + Remote git repository URL + Copy URL + Delete... + Edit... + Fetch + Open In Browser + Prune + Confirm to Remove Worktree + Enable `--force` Option + Target: + Rename Branch + New Name: + Unique name for this branch + Branch: + ABORT + Auto fetching changes from remotes... + Sort + By Committer Date + By Name + Cleanup(GC & Prune) + Run `git gc` command for this repository. + Clear all + Clear + Configure this repository + CONTINUE + Custom Actions + No Custom Actions + Discard all changes + Enable '--reflog' Option + Open in File Browser + Search Branches/Tags/Submodules + Visibility in Graph + Unset + Hide in commit graph + Filter in commit graph + Enable '--first-parent' Option + LAYOUT + Horizontal + Vertical + COMMITS ORDER + Commit Date + Topologically + LOCAL BRANCHES + Navigate to HEAD + Create Branch + CLEAR NOTIFICATIONS + Only highlight current branch in graph + Open in {0} + Open in External Tools + Refresh + REMOTES + Add Remote + Search Commit + Author + Committer + Content + File + Message + SHA + Current Branch + Show Submodules as Tree + Show Tags as Tree + SKIP + Statistics + SUBMODULES + Add Submodule + Update Submodule + TAGS + New Tag + By Creator Date + By Name + Sort + Open in Terminal + Use relative time in histories + View Logs + Visit '{0}' in Browser + WORKTREES + Add Worktree + Prune + Git Repository URL + Reset Current Branch To Revision + Reset Mode: + Move To: + Current Branch: + Reset Branch (Without Checkout) + Move To: + Branch: + Reveal in File Explorer + Revert Commit + Commit: + Commit revert changes + Reword Commit Message + Use 'Shift+Enter' to input a new line. 'Enter' is the hotkey of OK button + Running. Please wait... + SAVE + Save As... + Patch has been saved successfully! + Scan Repositories + Root Dir: + Check for Updates... + New version of this software is available: + Check for updates failed! + Download + Skip This Version + Software Update + There are currently no updates available. + Set Tracking Branch + Branch: + Unset upstream + Upstream: + Copy SHA + Go to + Squash Commits + Into: + SSH Private Key: + Private SSH key store path + START + Stash + Auto-restore after stashing + Your working files remain unchanged, but a stash is saved. + Include untracked files + Keep staged files + Message: + Optional. Name of this stash + Only staged changes + Both staged and unstaged changes of selected file(s) will be stashed!!! + Stash Local Changes + Apply + Drop + Save as Patch... + Drop Stash + Drop: + STASHES + CHANGES + STASHES + Statistics + COMMITS + COMMITTER + OVERVIEW + MONTH + WEEK + AUTHORS: + COMMITS: + SUBMODULES + Add Submodule + Copy Relative Path + De-initialize Submodule + Fetch nested submodules + Open Submodule Repository + Relative Path: + Relative folder to store this module. + Delete Submodule + STATUS + modified + not initialized + revision changed + unmerged + URL + OK + Copy Tag Name + Copy Tag Message + Delete ${0}$... + Merge ${0}$ into ${1}$... + Push ${0}$... + Update Submodules + All submodules + Initialize as needed + Recursively + Submodule: + Use --remote option + URL: + Logs + CLEAR ALL + Copy + Delete + Warning + Welcome Page + Create Group + Create Sub-Group + Clone Repository + Delete + DRAG & DROP FOLDER SUPPORTED. CUSTOM GROUPING SUPPORTED. + Edit + Move to Another Group + Open All Repositories + Open Repository + Open Terminal + Rescan Repositories in Default Clone Dir + Search Repositories... + Sort + LOCAL CHANGES + Git Ignore + Ignore all *{0} files + Ignore *{0} files in the same folder + Ignore files in the same folder + Ignore this file only + Amend + You can stage this file now. + COMMIT + COMMIT & PUSH + Template/Histories + Trigger click event + Commit (Edit) + Stage all changes and commit + You have staged {0} file(s) but only {1} file(s) displayed ({2} files are filtered out). Do you want to continue? + CONFLICTS DETECTED + OPEN EXTERNAL MERGETOOL + OPEN ALL CONFLICTS IN EXTERNAL MERGETOOL + FILE CONFLICTS ARE RESOLVED + USE MINE + USE THEIRS + INCLUDE UNTRACKED FILES + NO RECENT INPUT MESSAGES + NO COMMIT TEMPLATES + Reset Author + Right-click the selected file(s), and make your choice to resolve conflicts. + SignOff + STAGED + UNSTAGE + UNSTAGE ALL + UNSTAGED + STAGE + STAGE ALL + VIEW ASSUME UNCHANGED + Template: ${0}$ + WORKSPACE: + Configure Workspaces... + WORKTREE + Copy Path + Lock + Remove + Unlock + diff --git a/src/Resources/Locales/en_US.xaml b/src/Resources/Locales/en_US.xaml deleted file mode 100644 index 648a03fb..00000000 --- a/src/Resources/Locales/en_US.xaml +++ /dev/null @@ -1,546 +0,0 @@ - - START - SURE - SAVE - CLOSE - CANCEL - CLICK TO GO - Reveal in File Explorer - Save As ... - Save File to ... - Copy Path - {0} Bytes - FILTER - Optional. - SELECT FOLDER - NOTICE - - URL : - Git Repository URL - Parent Folder : - Relative foler to store this module. Optional. - - SSH Private Key : - Private SSH key store path - - About - SourceGit - OPEN SOURCE GIT CLIENT - - Patch - Apply Patch - Patch File : - Select .patch file to apply - Whitespace : - Ignore whitespace changes - No Warn - Turns off the trailing whitespace warning - Warn - Outputs warnings for a few such errors, but applies - Error - Raise errors and refuses to apply the patch - Error All - Similar to 'error', but shows more - - Archive ... - Archive - Revision : - Save Archive To : - Select archive file path - - Blame - - SUBMODULES - Add Submodule - Fetch nested submodules - Open Submodule Repository - Copy Relative Path - Delete Submodule - - Cherry-Pick This Commit - Cherry Pick - Commit : - Commit all changes - - Clone Remote Repository - Repository URL : - Git Repository URL - Parent Folder : - Folder to contain this repository - Local Name : - Repository name. Optional. - Remote Name : - Remote name. Optional. - Extra Parameters : - Additional arguments to clone repository. Optional. - - INFORMATION - AUTHOR - COMMITTER - SHA - PARENTS - REFS - MESSAGE - CHANGED - CHANGES - Search Files ... - FILES - - Configure - User Name - User name for this repository - Email Address - Email address - HTTP Proxy - HTTP proxy used by this repository - - Create Branch - Create Local Branch - Based On : - New Branch Name : - Enter branch name. - Local Changes : - Stash & Reapply - Discard - Check out after created - Git do not hold any branch until you do first commit. - - Create Tag - New Tag At : - Tag Name : - Recommanded format :v1.0.0-alpha - Tag Message : - Optional. - - Open In File Browser - Open In Visual Studio Code - Open In Git Bash - Refresh - Search Commit - Statistics - Cleanup(GC & Prune) - Configure this repository - WORKSPACE - LOCAL BRANCHES - NEW BRANCH - REMOTES - ADD REMOTE - TAGS - NEW TAG - SUBMODULES - ADD SUBMODULE - UPDATE SUBMODULE - SUBTREES - ADD/LINK SUBTREE - RESOLVE - CONTINUE - ABORT - - GIT FLOW - Initialize Git-Flow - Production Branch : - Development Branch : - Feature : - Release : - Hotfix : - Feature Prefix : - Release Prefix : - Hotfix Prefix : - Version Tag Prefix : - Start Feature ... - Start Release ... - Start Hotfix ... - GIT FLOW - Start Feature - GIT FLOW - Start Release - GIT FLOW - Start Hotfix - Enter name - GIT FLOW - Finish Feature - GIT FLOW - Finish Release - GIT FLOW - Finish Hotfix - {0} branch name is required. - {0} branch name contains invalid characters. - {0} prefix is required. - {0} contains invalid characters. - Development branch is same with production! - Keep branch - - Bookmark - Open - Open Container Folder - - Push '{0}' - Discard all changes - Fast-Forward to '{0}' - Pull '{0}' - Pull '{0}' into '{1}' - Checkout '{0}' - Merge '{0}' into '{1}' - Rebase '{0}' on '{1}' - Git Flow - Finish '{0}' - Rename '{0}' - Delete '{0}' - Tracking ... - Copy Branch Name - Unset Upstream - - Fetch ... - Prune - Edit ... - Delete ... - Copy URL - - Reset '{0}' to Here - Rebase '{0}' to Here - Cherry-Pick This Commit - Reword - Squash Into Parent - Revert Commit - Save as Patch ... - Copy Commit SHA - Copy Commit Info - - Push '{0}' - Delete '{0}' - Copy Tag Name - - Apply - Pop - Drop - - Unstage - Stage... - Discard... - Stash... - Unstage {0} files - Stage {0} files... - Discard {0} files... - Stash {0} files... - Save As Patch... - Assume unchaged - - Confirm To Delete Branch - Branch : - - Confirm To Delete Remote - Remote : - - Confirm To Delete Tag - Tag : - Delete from remote repositories - - Confirm To Delete Submodule - Submodule Path : - - Next Difference - Previous Difference - Toggle One-Side/Two-Sides - Open With Merge Tool - SELECT FILE TO VIEW CHANGES - NO CHANGES OR ONLY EOL CHANGES - BINARY DIFF - OLD : - NEW : - LFS OBJECT CHANGE - Copy - - Confirm To Discard Changes - Changes : - You can't undo this action!!! - All local changes in working copy. - Total {0} changes will be discard - - Fetch - Fetch Remote Changes - Remote : - Fetch all remotes - Prune remote dead branches - - File History - USE THIS VERSION - - CHANGE DISPLAY MODE - Show as Grid - Show as List - Show as Tree - - SELECT FOLDER - SELECTED : - - Histories - SEARCH SHA/SUBJECT/AUTHOR. PRESS ENTER TO SEARCH, ESC TO QUIT - CLEAR - Switch Curve/Polyline Graph Mode - Switch Horizontal/Vertical Layout - SELECTED {0} COMMITS - - Initialize Repository - Path : - Invalid repository detected. Run `git init` under this path? - - Source Git - Open Main Menu - ERROR - - NEW PAGE - Repositories - WELCOME PAGE - Close Tab - Close Other Tabs - Close Tabs to the Right - Bookmark - Copy Path - - Merge Branch - Source Branch : - Into : - Merge Option : - - Open Repository - Open Terminal - Clone Repository - REPOSITORIES - Delete - Search Repositories ... - Sort - DRAG & DROP FOLDER HERE - - Pull - Pull (Fetch & Merge) - Remote : - Branch : - Into : - Use rebase instead of merge - Stash & reapply local changes - - Push - Push Changes To Remote - Local Branch : - Remote : - Remote Branch : - Push all tags - Force push - - Push Tag To Remote - Tag : - Remote : - - Rebase Current Branch - Rebase : - On : - Stash & reapply local changes - - Add Remote - Edit Remote - Name : - Remote name - Repository URL : - Remote git repository URL - - Rename Branch - Branch : - New Name : - Unique name for this branch - - Reset Current Branch To Revision - Current Branch : - Move To : - Reset Mode : - - Confirm To Revert Commit - Commit : - Commit revert changes - - Preference - GENERAL - Display Language - Window Font - Content Font - Use dark theme - Restore windows - Max History Commits - GIT - Install Path - Input path for git.exe - Git version - Default Clone Dir - Default path to clone repo into - User Name - Global git user name - User Email - Global git user email - Enable Auto CRLF - Fetch remotes automatically (need restart) - MERGE - Merger - Install Path - Input path for merge tool - Select Git Executable File - Select {0} Install Path - - Stash - Stash Local Changes - Message : - Optional. Name of this stash - Include untracked files - - Stashes - STASHES - CHANGES - - Confirm To Drop Stash - Drop : - - COMMIT : {0} -> {1} - - Changes - UNSTAGED - VIEW ASSUME UNCHANGED - STAGE - STAGE ALL - STAGED - UNSTAGE - UNSTAGE ALL - CONFLICTS DETECTED - USE THEIRS - USE MINE - OPEN MERGE - Enter commit message - MESSAGE HISTORIES - Amend - COMMIT - CTRL + Enter - COMMIT & PUSH - NO RECENT INPUT MESSAGES - RECENT INPUT MESSAGES - INCLUDE UNTRACKED FILES - - Cherry-Pick merge request detected! Press 'Abort' to restore original HEAD - Rebase merge request detected! Press 'Abort' to restore original HEAD - Revert merge request detected! Press 'Abort' to restore original HEAD - Merge request detected! Press 'Abort' to restore original HEAD - - NOTICE - Restart required to apply changes in preference. Restart now? - - Add/Link SubTree - Source URL : - Branch/Commit : - Local Relative Path : - Squash commits? - - Edit SubTree - Source URL : - Local Relative Path : - - Unlink SubTree - Local Relative Path : - This will only remove links. - - Pull Changes Of SubTree - Push Changes Of SubTree - Local Relative Path : - Remote : - Branch : - Squash commits? - - Edit ... - Unlink ... - Pull ... - Push ... - - HOTKEYS - KEY - DESCRIPTION - Create a new page - Close current active page - Switch to next page - Switch to page at given index - Toggle search bar if possible - Reload current repository if possible - Stage or unstage selected files - Close current popup panel - - Reword Commit Message - On : - Message : - - Squash HEAD Into Parent - HEAD : - To : - Reword : - - Statistics - WEEK - MONTH - YEAR - Total Committers: {0} - Total Commits:{0} - COMMITTER - COMMITS - - FILES ASSUME UNCHANGED - REMOVE - NO FILES ASSUMED AS UNCHANGED - - SUN - MON - TUE - WED - THU - FRI - SAT - - Jan - Feb - Mar - Apr - May - Jun - Jul - Aug - Sep - Oct - Nov - Dec - - By Name - By Recently Opened - By Bookmark Color - - GPG SIGNING - Commit GPG signing - Install Path - Input path for installed gpg program - User Signing Key - User's gpg signing key - - Git has NOT been configured. Please to go [Preference] and configure it first. - Path[{0}] not exists! - Can NOT locate bash.exe. Make sure bash.exe exists under the same folder with git.exe - BINARY FILE NOT SUPPORTED!!! - BLAME ON THIS FILE IS NOT SUPPORTED!!! - GIT_DIR for this repository NOT FOUND! - Initialize Git-flow failed! - Bad git-flow branch type! - EXISTS and FULL ACCESS CONTROL needed - Remote git URL not supported - Bad local repository name - Remote name can NOT be null - Bad name for remote. Regex: ^[\\w\\-\\.]+$ - Duplicated remote name! - Branch name can NOT be null - Bad name for branch. Regex: ^[\\w\\-/\\.]+$ - Duplicated branch name! - Tag name can NOT be null - Bad name for tag. Regex: ^[\\w\\-\\.]+$ - Duplicated tag name! - Commit message can NOT be empty - Invalid path for patch file - Invalid relative path - Invalid path for archive file - This field is required - You are removing repository '{0}'. Are you sure to continue? - You are trying to clear all stashes. Are you sure to continue? - Patch has been saved successfully! - \ No newline at end of file diff --git a/src/Resources/Locales/es_ES.axaml b/src/Resources/Locales/es_ES.axaml new file mode 100644 index 00000000..4f1a4452 --- /dev/null +++ b/src/Resources/Locales/es_ES.axaml @@ -0,0 +1,809 @@ + + + + + + Acerca de + Acerca de SourceGit + Cliente Git GUI de código abierto y gratuito + Agregar Worktree + Ubicación: + Ruta para este worktree. Se admite ruta relativa. + Nombre de la Rama: + Opcional. Por defecto es el nombre de la carpeta de destino. + Rama de Seguimiento: + Seguimiento de rama remota + Qué Checkout: + Crear Nueva Rama + Rama Existente + Asistente OpenAI + RE-GENERAR + Usar OpenAI para generar mensaje de commit + APLICAR CÓMO MENSAJE DE COMMIT + Aplicar Parche + Archivo del Parche: + Seleccionar archivo .patch para aplicar + Ignorar cambios de espacios en blanco + Aplicar Parche + Espacios en Blanco: + Aplicar Stash + Borrar después de aplicar + Restaurar los cambios del índice + Stash: + Archivar... + Guardar Archivo en: + Seleccionar ruta del archivo + Revisión: + Archivar + SourceGit Askpass + ARCHIVOS ASUMIDOS COMO SIN CAMBIOS + NO HAY ARCHIVOS ASUMIDOS COMO SIN CAMBIOS + REMOVER + Cargar Imagen... + Refrescar + ¡ARCHIVO BINARIO NO SOPORTADO! + Bisect + Abortar + Malo + Bisecting. ¿Es el HEAD actual bueno o malo? + Bueno + Saltar + Bisecting. Marcar el commit actual cómo bueno o malo y revisar otro. + Blame + ¡BLAME EN ESTE ARCHIVO NO SOPORTADO! + Checkout ${0}$... + Comparar con ${0}$ + Comparar con Worktree + Copiar Nombre de Rama + Acción personalizada + Eliminar ${0}$... + Eliminar {0} ramas seleccionadas + Fast-Forward a ${0}$ + Fetch ${0}$ en ${1}$... + Git Flow - Finalizar ${0}$ + Merge ${0}$ en ${1}$... + Hacer merge de las ramas {0} seleccionadas hacia la rama actual + Pull ${0}$ + Pull ${0}$ en ${1}$... + Push ${0}$ + Rebase ${0}$ en ${1}$... + Renombrar ${0}$... + Resetear ${0}$ a ${1}$... + Establecer Rama de Seguimiento... + Comparar Ramas + ¡Upstream inválido! + Bytes + CANCELAR + Resetear a Revisión Padre + Resetear a Esta Revisión + Generar mensaje de commit + CAMBIAR MODO DE VISUALIZACIÓN + Mostrar como Lista de Archivos y Directorios + Mostrar como Lista de Rutas + Mostrar como Árbol de Sistema de Archivos + Checkout Rama + Checkout Commit + Commit: + Advertencia: Al hacer un checkout de commit, tu Head se separará + Cambios Locales: + Descartar + Stash & Reaplicar + Actualizar todos los submódulos + Rama: + Checkout & Fast-Forward + Fast-Forward a: + Cherry Pick + Añadir fuente al mensaje de commit + Commit(s): + Commit todos los cambios + Mainline: + Normalmente no puedes cherry-pick un merge porque no sabes qué lado del merge debe considerarse la línea principal. Esta opción permite que cherry-pick reproduzca el cambio en relación con el padre especificado. + Limpiar Stashes + Estás intentando limpiar todos los stashes. ¿Estás seguro de continuar? + Clonar Repositorio Remoto + Parámetros Adicionales: + Argumentos adicionales para clonar el repositorio. Opcional. + Nombre Local: + Nombre del repositorio. Opcional. + Carpeta Padre: + Inicializar y actualizar submódulos + URL del Repositorio: + CERRAR + Editor + Checkout Commit + Cherry-Pick Este Commit + Cherry-Pick ... + Comparar con HEAD + Comparar con Worktree + Autor + Committer + Información + SHA + Asunto + Acción personalizada + Rebase Interactivo ${0}$ hasta Aquí + Merge a ${0}$ + Merge ... + Rebase ${0}$ hasta Aquí + Reset ${0}$ hasta Aquí + Revertir Commit + Reescribir + Guardar como Parche... + Squash en Parent + Squash Commits Hijos hasta Aquí + CAMBIOS + archivo(s) modificado(s) + Buscar Cambios... + ARCHIVOS + Archivo LFS + Buscar Archivos... + Submódulo + INFORMACIÓN + AUTOR + CAMBIADO + HIJOS + COMMITTER + Ver refs que contienen este commit + COMMIT ESTÁ CONTENIDO EN + Muestra solo los primeros 100 cambios. Ver todos los cambios en la pestaña CAMBIOS. + MENSAJE + PADRES + REFS + SHA + Abrir en Navegador + Descripción + ASUNTO + Introducir asunto del commit + Configurar Repositorio + PLANTILLA DE COMMIT + Contenido de la Plantilla: + Nombre de la Plantilla: + ACCIÓN PERSONALIZADA + Argumentos: + ${REPO} - Ruta del repositorio; ${SHA} - SHA del commit seleccionado + Archivo Ejecutable: + Nombre: + Alcance: + Rama + Commit + Repositorio + Esperar la acción de salida + Dirección de Email + Dirección de email + GIT + Fetch remotos automáticamente + Minuto(s) + Remoto por Defecto + Modo preferido de Merge + SEGUIMIENTO DE INCIDENCIAS + Añadir Regla de Ejemplo para Azure DevOps + Añadir Regla de Ejemplo para Incidencias de Gitee + Añadir Regla de Ejemplo para Pull Requests de Gitee + Añadir Regla de Ejemplo para Github + Añadir Regla de Ejemplo para Incidencias de GitLab + Añadir Regla de Ejemplo para Merge Requests de GitLab + Añadir Regla de Ejemplo para Jira + Nueva Regla + Expresión Regex para Incidencias: + Nombre de la Regla: + URL Resultante: + Por favor, use $1, $2 para acceder a los valores de los grupos regex. + OPEN AI + Servicio Preferido: + Si el 'Servicio Preferido' está establecido, SourceGit sólo lo usará en este repositorio. De lo contrario, si hay más de un servicio disponible, se mostrará un menú de contexto para elegir uno. + Proxy HTTP + Proxy HTTP utilizado por este repositorio + Nombre de Usuario + Nombre de usuario para este repositorio + Espacios de Trabajo + Color + Nombre + Restaurar pestañas al iniciar + CONTINUAR + ¡Commit vacío detectado! ¿Quieres continuar (--allow-empty)? + HACER STAGE A TODO & COMMIT + ¡Commit vacío detectado! ¿Quieres continuar (--allow-empty) o hacer stage a todo y después commit? + Asistente de Commit Convencional + Cambio Importante: + Incidencia Cerrada: + Detalles del Cambio: + Alcance: + Descripción Corta: + Tipo de Cambio: + Copiar + Copiar Todo el Texto + Copiar Ruta Completa + Copiar Ruta + Crear Rama... + Basado En: + Checkout de la rama creada + Cambios Locales: + Descartar + Stash & Reaplicar + Nombre de la Nueva Rama: + Introduzca el nombre de la rama. + Los espacios serán reemplazados con guiones. + Crear Rama Local + Sobrescribir la rama existente + Crear Etiqueta... + Nueva Etiqueta En: + Firma GPG + Mensaje de la Etiqueta: + Opcional. + Nombre de la Etiqueta: + Formato recomendado: v1.0.0-alpha + Push a todos los remotos después de crear + Crear Nueva Etiqueta + Tipo: + anotada + ligera + Mantenga Ctrl para iniciar directamente + Cortar + Desinicializar Submódulo + Forzar desinicialización incluso si contiene cambios locales. + Submódulo: + Eliminar Rama + Rama: + ¡Estás a punto de eliminar una rama remota! + También eliminar la rama remota ${0}$ + Eliminar Múltiples Ramas + Estás intentando eliminar múltiples ramas a la vez. ¡Asegúrate de revisar antes de tomar acción! + Eliminar Remoto + Remoto: + Ruta: + Destino: + Todos los hijos serán removidos de la lista. + ¡Esto solo lo removera de la lista, no del disco! + Confirmar Eliminación de Grupo + Confirmar Eliminación de Repositorio + Eliminar Submódulo + Ruta del Submódulo: + Eliminar Etiqueta + Etiqueta: + Eliminar de los repositorios remotos + DIFERENCIA BINARIA + NUEVO + ANTIGUO + Copiar + Modo de Archivo Cambiado + Primera Diferencia + Ignorar Cambio de Espacios en Blanco + Última Diferencia + CAMBIO DE OBJETO LFS + Siguiente Diferencia + SIN CAMBIOS O SOLO CAMBIOS DE EOL + Diferencia Anterior + Guardar como Parche + Mostrar símbolos ocultos + Diferencia Lado a Lado + SUBMÓDULO + BORRADO + NUEVO + Intercambiar + Resaltado de Sintaxis + Ajuste de Línea + Habilitar navegación en bloque + Abrir en Herramienta de Merge + Mostrar Todas las Líneas + Disminuir Número de Líneas Visibles + Aumentar Número de Líneas Visibles + SELECCIONA ARCHIVO PARA VER CAMBIOS + Abrir en Herramienta de Merge + Descartar Cambios + Todos los cambios locales en la copia de trabajo. + Cambios: + Incluir archivos ignorados + Total {0} cambios serán descartados + ¡No puedes deshacer esta acción! + Marcador: + Nuevo Nombre: + Destino: + Editar Grupo Seleccionado + Editar Repositorio Seleccionado + Ejecutar Acción Personalizada + Nombre de la Acción: + Fetch + Fetch todos los remotos + Utilizar opción '--force' + Fetch sin etiquetas + Remoto: + Fetch Cambios Remotos + Asumir sin cambios + Descartar... + Descartar {0} archivos... + Descartar Cambios en Línea(s) Seleccionada(s) + Abrir Herramienta de Merge Externa + Resolver usando ${0}$ + Guardar como Parche... + Stage + Stage {0} archivos + Stage Cambios en Línea(s) Seleccionada(s) + Stash... + Stash {0} archivos... + Unstage + Unstage {0} archivos + Unstage Cambios en Línea(s) Seleccionada(s) + Usar Míos (checkout --ours) + Usar Suyos (checkout --theirs) + Historial de Archivos + CAMBIO + CONTENIDO + Git-Flow + Rama de Desarrollo: + Feature: + Prefijo de Feature: + FLOW - Finalizar Feature + FLOW - Finalizar Hotfix + FLOW - Finalizar Release + Destino: + Push al/los remoto(s) después de Finalizar + Squash durante el merge + Hotfix: + Prefijo de Hotfix: + Inicializar Git-Flow + Mantener rama + Rama de Producción: + Release: + Prefijo de Release: + Iniciar Feature... + FLOW - Iniciar Feature + Iniciar Hotfix... + FLOW - Iniciar Hotfix + Introducir nombre + Iniciar Release... + FLOW - Iniciar Release + Prefijo de Etiqueta de Versión: + Git LFS + Añadir Patrón de Seguimiento... + El patrón es el nombre del archivo + Patrón Personalizado: + Añadir Patrón de Seguimiento a Git LFS + Fetch + Ejecuta `git lfs fetch` para descargar objetos Git LFS. Esto no actualiza la copia de trabajo. + Fetch Objetos LFS + Instalar hooks de Git LFS + Mostrar Bloqueos + No hay archivos bloqueados + Bloquear + Mostrar solo mis bloqueos + Bloqueos LFS + Desbloquear + Forzar Desbloqueo + Prune + Ejecuta `git lfs prune` para eliminar archivos LFS antiguos del almacenamiento local + Pull + Ejecuta `git lfs pull` para descargar todos los archivos Git LFS para la referencia actual y hacer checkout + Pull Objetos LFS + Push + Push archivos grandes en cola al endpoint de Git LFS + Push Objetos LFS + Remoto: + Seguir archivos llamados '{0}' + Seguir todos los archivos *{0} + Historias + AUTOR + HORA DEL AUTOR + GRÁFICO & ASUNTO + SHA + FECHA DE COMMIT + {0} COMMITS SELECCIONADOS + Mantén 'Ctrl' o 'Shift' para seleccionar múltiples commits. + Mantén ⌘ o ⇧ para seleccionar múltiples commits. + CONSEJOS: + Referencia de Atajos de Teclado + GLOBAL + Cancelar popup actual + Clonar repositorio nuevo + Cerrar página actual + Ir a la siguiente página + Ir a la página anterior + Crear nueva página + Abrir diálogo de preferencias + Cambiar espacio de trabajo activo + Cambiar página activa + REPOSITORIO + Commit cambios staged + Commit y push cambios staged + Stage todos los cambios y commit + Crea una nueva rama basada en el commit seleccionado + Descartar cambios seleccionados + Fetch, empieza directamente + Modo Dashboard (Por Defecto) + Modo de búsqueda de commits + Pull, empieza directamente + Push, empieza directamente + Forzar a recargar este repositorio + Stage/Unstage cambios seleccionados + Cambiar a 'Cambios' + Cambiar a 'Historias' + Cambiar a 'Stashes' + EDITOR DE TEXTO + Cerrar panel de búsqueda + Buscar siguiente coincidencia + Buscar coincidencia anterior + Abrir con herramienta diff/merge externa + Abrir panel de búsqueda + Descartar + Stage + Unstage + Inicializar Repositorio + Ruta: + Cherry-Pick en progreso. + Procesando commit + Merge en progreso. + Haciendo merge + Rebase en progreso. + Pausado en + Revert en progreso. + Haciendo revert del commit + Rebase Interactivo + En: + Rama Objetivo: + Copiar Enlace + Abrir en el Navegador + ERROR + AVISO + Espacios de trabajo + Páginas + Merge Rama + En: + Opción de Merge: + Rama Fuente: + Merge (Multiplo) + Commit todos los cambios + Estrategia: + Destino: + Mover Nodo del Repositorio + Seleccionar nodo padre para: + Nombre: + Git NO ha sido configurado. Por favor, ve a [Preferencias] y configúralo primero. + Abrir Directorio de Datos de la App + Abrir Con... + Opcional. + Crear Nueva Página + Marcador + Cerrar Pestaña + Cerrar Otras Pestañas + Cerrar Pestañas a la Derecha + Copiar Ruta del Repositorio + Repositorios + Pegar + Hace {0} días + Hace 1 hora + Hace {0} horas + Justo ahora + Último mes + Último año + Hace {0} minutos + Hace {0} meses + Hace {0} años + Ayer + Preferencias + OPEN AI + Analizar Diff Prompt + Clave API + Generar Subject Prompt + Modelo + Nombre + Servidor + Activar Transmisión + APARIENCIA + Fuente por defecto + Ancho de la Pestaña del Editor + Tamaño de fuente + Por defecto + Editor + Fuente Monospace + Usar solo fuente monospace en el editor de texto + Tema + Sobreescritura de temas + Usar ancho de pestaña fijo en la barra de título + Usar marco de ventana nativo + HERRAMIENTA DIFF/MERGE + Ruta de instalación + Introducir ruta para la herramienta diff/merge + Herramienta + GENERAL + Buscar actualizaciones al iniciar + Formato de Fecha + Idioma + Commits en el historial + Mostrar hora del autor en lugar de la hora del commit en el gráfico + Mostrar hijos en los detalles de commit + Mostrar etiquetas en el gráfico de commit + Longitud de la guía del asunto + GIT + Habilitar Auto CRLF + Directorio de clonado por defecto + Email de usuario + Email global del usuario git + Habilitar --prune para fetch + Habilitar --ignore-cr-at-eol en diff + Se requiere Git (>= 2.25.1) para esta aplicación + Ruta de instalación + Habilitar verificación HTTP SSL + Nombre de usuario + Nombre global del usuario git + Versión de Git + FIRMA GPG + Firma GPG en commit + Formato GPG + Ruta de instalación del programa + Introducir ruta para el programa gpg instalado + Firma GPG en etiqueta + Clave de firma del usuario + Clave de firma gpg del usuario + INTEGRACIÓN + SHELL/TERMINAL + Ruta + Shell/Terminal + Podar Remoto + Destino: + Podar Worktrees + Podar información de worktree en `$GIT_COMMON_DIR/worktrees` + Pull + Rama Remota: + En: + Cambios Locales: + Descartar + Stash & Reaplicar + Actualizar todos los submódulos + Remoto: + Pull (Fetch & Merge) + Usar rebase en lugar de merge + Push + Asegurarse de que los submódulos se hayan hecho push + Forzar push + Rama Local: + Remoto: + Push Cambios al Remoto + Rama Remota: + Establecer como rama de seguimiento + Push todas las etiquetas + Push Etiqueta al Remoto + Push a todos los remotos + Remoto: + Etiqueta: + Salir + Rebase Rama Actual + Stash & reaplicar cambios locales + En: + Rebase: + Añadir Remoto + Editar Remoto + Nombre: + Nombre remoto + URL del Repositorio: + URL del repositorio git remoto + Copiar URL + Borrar... + Editar... + Fetch + Abrir En Navegador + Podar (Prune) + Confirmar para Eliminar Worktree + Utilizar Opción `--force` + Destino: + Renombrar Rama + Nuevo Nombre: + Nombre único para esta rama + Rama: + ABORTAR + Auto fetching cambios desde remotos... + Ordenar + Por Fecha de Committer + Por Nombre + Limpiar (GC & Prune) + Ejecutar comando `git gc` para este repositorio. + Limpiar todo + Limpiar + Configurar este repositorio + CONTINUAR + Acciones Personalizadas + No hay ninguna Acción Personalizada + Descartar todos los cambios + Habilitar Opción '--reflog' + Abrir en el Explorador + Buscar Ramas/Etiquetas/Submódulos + Visibilidad en el Gráfico + Desestablecer + Ocultar en el Gráfico de Commits + Filtrar en el Gráfico de Commits + Habilitar Opción '--first-parent' + DISPOSICIÓN + Horizontal + Vertical + ORDEN DE COMMITS + Fecha de Commit + Topológicamente + RAMAS LOCALES + Navegar a HEAD + Crear Rama + LIMPIAR NOTIFICACIONES + Resaltar solo la rama actual en el gráfico + Abrir en {0} + Abrir en Herramientas Externas + Refrescar + REMOTOS + AÑADIR REMOTO + Buscar Commit + Autor + Committer + Contenido + Archivo + Mensaje + SHA + Rama Actual + Mostrar Submódulos como Árbol + Mostrar Etiquetas como Árbol + OMITIR + Estadísticas + SUBMÓDULOS + AÑADIR SUBMÓDULO + ACTUALIZAR SUBMÓDULO + ETIQUETAS + NUEVA ETIQUETA + Por Fecha de Creación + Por Nombre + Ordenar + Abrir en Terminal + Usar tiempo relativo en las historias + Ver Logs + Visitar '{0}' en el Navegador + WORKTREES + AÑADIR WORKTREE + PRUNE + URL del Repositorio Git + Resetear Rama Actual a Revisión + Modo de Reset: + Mover a: + Rama Actual: + Resetear Rama (Sin hacer Checkout) + Mover A: + Rama: + Revelar en el Explorador de Archivos + Revertir Commit + Commit: + Commit revertir cambios + Reescribir Mensaje de Commit + Usa 'Shift+Enter' para introducir una nueva línea. 'Enter' es el atajo del botón OK + Ejecutando. Por favor espera... + GUARDAR + Guardar Como... + ¡El parche se ha guardado exitosamente! + Escanear Repositorios + Directorio Raíz: + Buscar Actualizaciones... + Nueva versión de este software disponible: + ¡Error al buscar actualizaciones! + Descargar + Omitir Esta Versión + Actualización de Software + Actualmente no hay actualizaciones disponibles. + Establecer Rama de Seguimiento + Rama: + Desestablecer upstream + Upstream: + Copiar SHA + Ir a + Squash Commits + En: + Clave Privada SSH: + Ruta de almacenamiento de la clave privada SSH + INICIAR + Stash + Restaurar automáticamente después del stashing + Tus archivos de trabajo permanecen sin cambios, pero se guarda un stash. + Incluir archivos no rastreados + Mantener archivos staged + Mensaje: + Opcional. Nombre de este stash + Solo cambios staged + ¡Tanto los cambios staged como los no staged de los archivos seleccionados serán stashed! + Stash Cambios Locales + Aplicar + Eliminar + Guardar como Parche... + Eliminar Stash + Eliminar: + Stashes + CAMBIOS + STASHES + Estadísticas + COMMITS + COMMITTER + GENERAL + MES + SEMANA + AUTORES: + COMMITS: + SUBMÓDULOS + Añadir Submódulo + Copiar Ruta Relativa + Desinicializar Submódulo + Fetch submódulos anidados + Abrir Repositorio del Submódulo + Ruta Relativa: + Carpeta relativa para almacenar este módulo. + Eliminar Submódulo + ESTADO + modificado + no inicializado + revisión cambiada + unmerged + URL + OK + Copiar Nombre de la Etiqueta + Copiar Mensaje de la Etiqueta + Eliminar ${0}$... + Merge ${0}$ en ${1}$... + Push ${0}$... + Actualizar Submódulos + Todos los submódulos + Inicializar según sea necesario + Recursivamente + Submódulo: + Usar opción --remote + URL: + Logs + LIMPIAR TODO + Copiar + Borrar + Advertencia + Página de Bienvenida + Crear Grupo + Crear Sub-Grupo + Clonar Repositorio + Eliminar + SOPORTA ARRASTRAR Y SOLTAR CARPETAS. SOPORTA AGRUPACIÓN PERSONALIZADA. + Editar + Mover a Otro Grupo + Abrir Todos Los Repositorios + Abrir Repositorio + Abrir Terminal + Reescanear Repositorios en el Directorio de Clonado por Defecto + Buscar Repositorios... + Ordenar + Cambios + Git Ignore + Ignorar todos los archivos *{0} + Ignorar archivos *{0} en la misma carpeta + Ignorar archivos en la misma carpeta + Ignorar solo este archivo + Enmendar + Puedes hacer stage a este archivo ahora. + COMMIT + COMMIT & PUSH + Plantilla/Historias + Activar evento de clic + Commit (Editar) + Hacer stage a todos los cambios y commit + Tienes {0} archivo(s) en stage, pero solo {1} archivo(s) mostrado(s) ({2} archivo(s) están filtrados). ¿Quieres continuar? + CONFLICTOS DETECTADOS + ABRIR HERRAMIENTA DE MERGE EXTERNA + ABRIR TODOS LOS CONFLICTOS EN HERRAMIENTA DE MERGE EXTERNA + LOS CONFLICTOS DE ARCHIVOS ESTÁN RESUELTOS + USAR MÍOS + USAR SUYOS + INCLUIR ARCHIVOS NO RASTREADOS + NO HAY MENSAJES DE ENTRADA RECIENTES + NO HAY PLANTILLAS DE COMMIT + Restablecer Autor + Haz clic derecho en el(los) archivo(s) seleccionado(s) y elige tu opción para resolver conflictos. + Firmar + STAGED + UNSTAGE + UNSTAGE TODO + UNSTAGED + STAGE + STAGE TODO + VER ASSUME UNCHANGED + Plantilla: ${0}$ + ESPACIO DE TRABAJO: + Configura Espacios de Trabajo... + WORKTREE + Copiar Ruta + Bloquear + Eliminar + Desbloquear + diff --git a/src/Resources/Locales/fr_FR.axaml b/src/Resources/Locales/fr_FR.axaml new file mode 100644 index 00000000..635d74c2 --- /dev/null +++ b/src/Resources/Locales/fr_FR.axaml @@ -0,0 +1,745 @@ + + + + + + À propos + À propos de SourceGit + Client Git Open Source et Gratuit + Ajouter un Worktree + Emplacement : + Chemin vers ce worktree. Relatif supporté. + Nom de branche: + Optionnel. Nom du dossier de destination par défaut. + Suivre la branche : + Suivi de la branche distante + Que récupérer : + Créer une nouvelle branche + Branche existante + Assistant IA + RE-GÉNÉRER + Utiliser l'IA pour générer un message de commit + APPLIQUER COMME MESSAGE DE COMMIT + Appliquer + Fichier de patch : + Selectionner le fichier .patch à appliquer + Ignorer les changements d'espaces blancs + Appliquer le patch + Espaces blancs : + Appliquer le Stash + Supprimer après application + Rétablir les changements de l'index + Stash: + Archiver... + Enregistrer l'archive sous : + Sélectionnez le chemin du fichier d'archive + Révision : + Archiver + SourceGit Askpass + FICHIERS PRÉSUMÉS INCHANGÉS + PAS DE FICHIERS PRÉSUMÉS INCHANGÉS + SUPPRIMER + Rafraîchir + FICHIER BINAIRE NON SUPPORTÉ !!! + Blâme + LE BLÂME SUR CE FICHIER N'EST PAS SUPPORTÉ!!! + Récupérer ${0}$... + Comparer avec ${0}$ + Comparer avec le worktree + Copier le nom de la branche + Action personnalisée + Supprimer ${0}$... + Supprimer {0} branches sélectionnées + Fast-Forward vers ${0}$ + Fetch ${0}$ vers ${1}$... + Git Flow - Terminer ${0}$ + Fusionner ${0}$ dans ${1}$... + Fusionner les {0} branches sélectionnées dans celle en cours + Tirer ${0}$ + Tirer ${0}$ dans ${1}$... + Pousser ${0}$ + Rebaser ${0}$ sur ${1}$... + Renommer ${0}$... + Définir la branche de suivi... + Comparer les branches + Branche en amont invalide! + Octets + ANNULER + Réinitialiser à la révision parente + Réinitialiser à cette révision + Générer un message de commit + CHANGER LE MODE D'AFFICHAGE + Afficher comme liste de dossiers/fichiers + Afficher comme liste de chemins + Afficher comme arborescence + Récupérer la branche + Récupérer ce commit + Commit : + Avertissement: une récupération vers un commit aboutiera vers un HEAD détaché + Changements locaux : + Annuler + Mettre en stash et réappliquer + Branche : + Cherry-Pick de ce commit + Ajouter la source au message de commit + Commit : + Commit tous les changements + Ligne principale : + Habituellement, on ne peut pas cherry-pick un commit car on ne sait pas quel côté devrait être considéré comme principal. Cette option permet de rejouer les changements relatifs au parent spécifié. + Supprimer les stashes + Vous essayez de supprimer tous les stashes. Êtes-vous sûr de vouloir continuer ? + Cloner repository distant + Paramètres supplémentaires : + Arguments additionnels au clônage. Optionnel. + Nom local : + Nom de dépôt. Optionnel. + Dossier parent : + Initialiser et mettre à jour les sous-modules + URL du dépôt : + FERMER + Éditeur + Récupérer ce commit + Cherry-Pick ce commit + Cherry-Pick ... + Comparer avec HEAD + Comparer avec le worktree + Informations + SHA + Action personnalisée + Rebase interactif de ${0}$ ici + Fusionner dans ${0}$ + Fusionner ... + Rebaser ${0}$ ici + Réinitialiser ${0}$ ici + Annuler le commit + Reformuler + Enregistrer en tant que patch... + Squash dans le parent + Squash les commits enfants ici + CHANGEMENTS + Rechercher les changements... + FICHIERS + Fichier LFS + Rechercher des fichiers... + Sous-module + INFORMATIONS + AUTEUR + CHANGÉ + ENFANTS + COMMITTER + Vérifier les références contenant ce commit + LE COMMIT EST CONTENU PAR + Afficher seulement les 100 premiers changements. Voir tous les changements dans l'onglet CHANGEMENTS. + MESSAGE + PARENTS + REFS + SHA + Ouvrir dans le navigateur + Description + Entrez le message du commit + Configurer le dépôt + MODÈLE DE COMMIT + Contenu de modèle: + Nom de modèle: + ACTION PERSONNALISÉE + Arguments : + ${REPO} - Chemin du repository; ${SHA} - SHA du commit sélectionné + Fichier exécutable : + Nom : + Portée : + Branche + Commit + Repository + Attendre la fin de l'action + Adresse e-mail + Adresse e-mail + GIT + Fetch les dépôts distants automatiquement + minute(s) + Dépôt par défaut + SUIVI DES PROBLÈMES + Ajouter une règle d'exemple Azure DevOps + Ajouter une règle d'exemple Gitee + Ajouter une règle d'exemple pour Pull Request Gitee + Ajouter une règle d'exemple Github + Ajouter une règle d'exemple pour Incidents GitLab + Ajouter une règle d'exemple pour Merge Request GitLab + Ajouter une règle d'exemple Jira + Nouvelle règle + Issue Regex Expression: + Nom de règle : + URL résultant: + Veuillez utiliser $1, $2 pour accéder aux valeurs des groupes regex. + IA + Service préféré: + Si le 'Service préféré' est défini, SourceGit l'utilisera seulement dans ce repository. Sinon, si plus d'un service est disponible, un menu contextuel permettant de choisir l'un d'eux sera affiché. + Proxy HTTP + Proxy HTTP utilisé par ce dépôt + Nom d'utilisateur + Nom d'utilisateur pour ce dépôt + Espaces de travail + Couleur + Nom + Restaurer les onglets au démarrage + Assistant Commits Conventionnels + Changement Radical : + Incident Clos : + Détail des Modifications : + Portée : + Courte Description : + Type de Changement : + Copier + Copier tout le texte + Copier le chemin complet + Copier le chemin + Créer une branche... + Basé sur : + Récupérer la branche créée + Changements locaux : + Rejeter + Stash & Réappliquer + Nom de la nouvelle branche : + Entrez le nom de la branche. + Les espaces seront remplacés par des tirets. + Créer une branche locale + Créer un tag... + Nouveau tag à : + Signature GPG + Message du tag : + Optionnel. + Nom du tag : + Format recommandé : v1.0.0-alpha + Pousser sur tous les dépôts distants après création + Créer un nouveau tag + Type : + annoté + léger + Maintenir Ctrl pour commencer directement + Couper + Supprimer la branche + Branche : + Vous êtes sur le point de supprimer une branche distante !!! + Supprimer également la branche distante ${0}$ + Supprimer plusieurs branches + Vous essayez de supprimer plusieurs branches à la fois. Assurez-vous de revérifier avant de procéder ! + Supprimer Remote + Remote : + Chemin: + Cible : + Tous les enfants seront retirés de la liste. + Cela le supprimera uniquement de la liste, pas du disque ! + Confirmer la suppression du groupe + Confirmer la suppression du dépôt + Supprimer le sous-module + Chemin du sous-module : + Supprimer le tag + Tag : + Supprimer des dépôts distants + DIFF BINAIRE + NOUVEAU + ANCIEN + Copier + Mode de fichier changé + Première différence + Ignorer les changements d'espaces + Dernière différence + CHANGEMENT D'OBJET LFS + Différence suivante + PAS DE CHANGEMENT OU SEULEMENT EN FIN DE LIGNE + Différence précédente + Enregistrer en tant que patch + Afficher les caractères invisibles + Diff côte-à-côte + SOUS-MODULE + NOUVEAU + Permuter + Coloration syntaxique + Retour à la ligne + Activer la navigation par blocs + Ouvrir dans l'outil de fusion + Voir toutes les lignes + Réduit le nombre de ligne visibles + Augmente le nombre de ligne visibles + SÉLECTIONNEZ UN FICHIER POUR VOIR LES CHANGEMENTS + Ouvrir dans l'outil de fusion + Rejeter les changements + Tous les changements dans la copie de travail. + Changements : + Inclure les fichiers ignorés + {0} changements seront rejetés + Vous ne pouvez pas annuler cette action !!! + Signet : + Nouveau nom : + Cible : + Éditer le groupe sélectionné + Éditer le dépôt sélectionné + Lancer action personnalisée + Nom de l'action : + Fetch + Fetch toutes les branches distantes + Outrepasser les vérifications de refs + Fetch sans les tags + Remote : + Récupérer les changements distants + Présumer inchangé + Rejeter... + Rejeter {0} fichiers... + Rejeter les changements dans les lignes sélectionnées + Ouvrir l'outil de fusion externe + Résoudre en utilisant ${0}$ + Enregistrer en tant que patch... + Indexer + Indexer {0} fichiers + Indexer les changements dans les lignes sélectionnées + Stash... + Stash {0} fichiers... + Désindexer + Désindexer {0} fichiers + Désindexer les changements dans les lignes sélectionnées + Utiliser les miennes (checkout --ours) + Utiliser les leurs (checkout --theirs) + Historique du fichier + MODIFICATION + CONTENU + Git-Flow + Branche de développement : + Feature: + Feature Prefix: + FLOW - Terminer Feature + FLOW - Terminer Hotfix + FLOW - Terminer Release + Cible: + Hotfix: + Hotfix Prefix: + Initialiser Git-Flow + Garder la branche + Branche de production : + Release : + Release Prefix : + Commencer Feature... + FLOW - Commencer Feature + Commencer Hotfix... + FLOW - Commencer Hotfix + Saisir le nom + Commencer Release... + FLOW - Commencer Release + Préfixe Tag de Version : + Git LFS + Ajouter un pattern de suivi... + Le pattern est un nom de fichier + Pattern personnalisé : + Ajouter un pattern de suivi à Git LFS + Fetch + Lancer `git lfs fetch` pour télécharger les objets Git LFS. Cela ne met pas à jour la copie de travail. + Fetch les objets LFS + Installer les hooks Git LFS + Afficher les verrous + Pas de fichiers verrouillés + Verrouiller + Afficher seulement mes verrous + Verrous LFS + Déverouiller + Forcer le déverouillage + Elaguer + Lancer `git lfs prune` pour supprimer les anciens fichier LFS du stockage local + Pull + Lancer `git lfs pull` pour télécharger tous les fichier Git LFS de la référence actuelle & récupérer + Pull les objets LFS + Pousser + Transférer les fichiers volumineux en file d'attente vers le point de terminaison Git LFS + Pousser les objets LFS + Dépôt : + Suivre les fichiers appelés '{0}' + Suivre tous les fichiers *{0} + Historique + AUTEUR + HEURE DE L'AUTEUR + GRAPHE & SUJET + SHA + HEURE DE COMMIT + {0} COMMITS SÉLECTIONNÉS + Maintenir 'Ctrl' ou 'Shift' enfoncée pour sélectionner plusieurs commits. + Maintenir ⌘ ou ⇧ enfoncée pour sélectionner plusieurs commits. + CONSEILS: + Référence des raccourcis clavier + GLOBAL + Annuler le popup en cours + Cloner un nouveau dépôt + Fermer la page en cours + Aller à la page suivante + Aller à la page précédente + Créer une nouvelle page + Ouvrir le dialogue des préférences + DÉPÔT + Commit les changements de l'index + Commit et pousser les changements de l'index + Ajouter tous les changements et commit + Créer une nouvelle branche basée sur le commit actuel + Rejeter les changements sélectionnés + Fetch, démarre directement + Mode tableau de bord (Défaut) + Recherche de commit + Pull, démarre directement + Push, démarre directement + Forcer le rechargement du dépôt + Ajouter/Retirer les changements sélectionnés de l'index + Basculer vers 'Changements' + Basculer vers 'Historique' + Basculer vers 'Stashes' + ÉDITEUR DE TEXTE + Fermer le panneau de recherche + Trouver la prochaine correspondance + Trouver la correspondance précédente + Ouvrir le panneau de recherche + Rejeter + Indexer + Retirer de l'index + Initialiser le repository + Chemin : + Cherry-Pick en cours. + Traitement du commit + Merge request en cours. + Fusionnement + Rebase en cours. + Arrêté à + Annulation en cours. + Annulation du commit + Rebase interactif + Sur : + Branche cible : + Copier le lien + Ouvrir dans le navigateur + ERREUR + NOTICE + Merger la branche + Dans : + Option de merge: + Source: + Fusionner (Plusieurs) + Commit tous les changement + Stratégie: + Cibles: + Déplacer le noeud du repository + Sélectionnier le noeud parent pour : + Nom : + Git n'a PAS été configuré. Veuillez d'abord le faire dans le menu Préférence. + Ouvrir le dossier AppData + Ouvrir avec... + Optionnel. + Créer un nouvel onglet + Bookmark + Fermer l'onglet + Fermer les autres onglets + Fermer les onglets à droite + Copier le chemin vers le dépôt + Dépôts + Coller + il y a {0} jours + il y a 1 heure + il y a {0} heures + A l'instant + Le mois dernier + L'an dernier + il y a {0} minutes + il y a {0} mois + il y a {0} ans + Hier + Préférences + IA + Analyser Diff Prompt + Clé d'API + Générer le sujet de Prompt + Modèle + Nom + Serveur + Activer le streaming + APPARENCE + Police par défaut + Largeur de tab dans l'éditeur + Taille de police + Défaut + Éditeur + Police monospace + N'utiliser que des polices monospace pour l'éditeur de texte + Thème + Dérogations de thème + Utiliser des onglets de taille fixe dans la barre de titre + Utiliser un cadre de fenêtre natif + OUTIL DIFF/MERGE + Chemin d'installation + Saisir le chemin d'installation de l'outil diff/merge + Outil + GÉNÉRAL + Vérifier les mises à jour au démarrage + Format de date + Language + Historique de commits + Afficher l'heure de l'auteur au lieu de l'heure de validation dans le graphique + Afficher les enfants dans les détails du commit + Afficher les tags dans le graphique des commits + Guide de longueur du sujet + GIT + Activer auto CRLF + Répertoire de clônage par défaut + E-mail utilsateur + E-mail utilsateur global + Activer --prune pour fetch + Cette application requière Git (>= 2.25.1) + Chemin d'installation + Activer la vérification HTTP SSL + Nom d'utilisateur + Nom d'utilisateur global + Version de Git + SIGNATURE GPG + Signature GPG de commit + Format GPG + Chemin d'installation du programme + Saisir le chemin d'installation vers le programme GPG + Signature GPG de tag + Clé de signature de l'utilisateur + Clé de signature GPG de l'utilisateur + INTEGRATION + SHELL/TERMINAL + Chemin + Shell/Terminal + Élaguer une branche distant + Cible : + Élaguer les Worktrees + Élaguer les information de worktree dans `$GIT_COMMON_DIR/worktrees` + Pull + Branche distante : + Dans : + Changements locaux : + Rejeter + Stash & Réappliquer + Dépôt distant : + Pull (Fetch & Merge) + Utiliser rebase au lieu de merge + Pousser + Assurez-vous que les submodules ont été poussés + Poussage forcé + Branche locale : + Dépôt distant : + Pousser les changements vers le dépôt distant + Branche distante : + Définir comme branche de suivi + Pousser tous les tags + Pousser les tags vers le dépôt distant + Pousser tous les dépôts distants + Dépôt distant : + Tag : + Quitter + Rebase la branche actuelle + Stash & réappliquer changements locaux + Sur : + Rebase : + Ajouter dépôt distant + Modifier dépôt distant + Nom : + Nom du dépôt distant + URL du repository : + URL du dépôt distant + Copier l'URL + Supprimer... + Editer... + Fetch + Ouvrir dans le navigateur + Elaguer + Confirmer la suppression du Worktree + Activer l'option `--force` + Cible : + la branche + Nouveau nom : + Nom unique pour cette branche + Branche : + ABORT + Fetch automatique des changements depuis les dépôts... + Nettoyage(GC & Elaguage) + Lancer `git gc` pour ce repository. + Tout effacer + Configurer ce repository + CONTINUER + Actions personnalisées + Pas d'actions personnalisées + Rejeter tous les changements + Activer l'option '--reflog' + Ouvrir dans l'explorateur de fichiers + Rechercher Branches/Tags/Submodules + Visibilité dans le graphique + Réinitialiser + Cacher dans le graphique des commits + Filtrer dans le graphique des commits + Activer l'option '--first-parent' + DISPOSITION + Horizontal + Vertical + ORDRE DES COMMITS + Date du commit + Topologiquement + BRANCHES LOCALES + Naviguer vers le HEAD + Créer une branche + EFFACER LES NOTIFICATIONS + Mettre la branche courante en surbrillance dans le graph + Ouvrir dans {0} + Ouvrir dans un outil externe + Rafraîchir + DEPOTS DISTANTS + AJOUTER DEPOT DISTANT + Rechercher un commit + Auteur + Committer + Fichier + Message + SHA + Branche actuelle + Voir les Tags en tant qu'arbre + PASSER + Statistiques + SUBMODULES + AJOUTER SUBMODULE + METTRE A JOUR SUBMODULE + TAGS + NOUVEAU TAG + Par date de créateur + Par nom + Trier + Ouvrir dans un terminal + Utiliser le temps relatif dans les historiques + WORKTREES + AJOUTER WORKTREE + ELAGUER + URL du repository Git + Reset branche actuelle à la révision + Reset Mode: + Déplacer vers : + Branche actuelle : + Ouvrir dans l'explorateur de fichier + Annuler le Commit + Commit : + Commit les changements de l'annulation + Reformuler le message de commit + Utiliser 'Maj+Entrée' pour insérer une nouvelle ligne. 'Entrée' est la touche pour valider + En exécution. Veuillez patienter... + SAUVEGARDER + Sauvegarder en tant que... + Le patch a été sauvegardé ! + Analyser les repositories + Dossier racine : + Rechercher des mises à jour... + Une nouvelle version du logiciel est disponible : + La vérification de mise à jour à échouée ! + Télécharger + Passer cette version + Mise à jour du logiciel + Il n'y a pas de mise à jour pour le moment. + Définir la branche suivie + Branche: + Retirer la branche amont + En amont: + Copier le SHA + Aller à + Squash les commits + Dans : + Clé privée SSH : + Chemin du magasin de clés privées SSH + START + Stash + Auto-restauration après le stash + Vos fichiers de travail restent inchangés, mais une sauvegarde est enregistrée. + Inclure les fichiers non-suivis + Garder les fichiers indexés + Message : + Optionnel. Nom de ce stash + Seulement les changements indexés + Les modifications indexées et non-indexées des fichiers sélectionnés seront stockées!!! + Stash les changements locaux + Appliquer + Effacer + Sauver comme Patch... + Effacer le Stash + Effacer : + Stashes + CHANGEMENTS + STASHES + Statistiques + COMMITS + COMMITTER + APERCU + MOIS + SEMAINE + AUTEURS : + COMMITS: + SOUS-MODULES + Ajouter un sous-module + Copier le chemin relatif + Fetch les sous-modules imbriqués + Ouvrir le dépôt de sous-module + Relative Path: + Relative folder to store this module. + Supprimer le sous-module + OK + Copier le nom du Tag + Copier le message du tag + Supprimer ${0}$... + Fusionner ${0}$ dans ${1}$... + Pousser ${0}$... + Actualiser les sous-modules + Tous les sous-modules + Initialiser au besoin + Récursivement + Sous-module : + Utiliser l'option --remote + URL : + Avertissement + Page d'accueil + Créer un groupe + Créer un sous-groupe + Cloner un dépôt + Supprimer + GLISSER / DEPOSER DE DOSSIER SUPPORTÉ. GROUPAGE PERSONNALISÉ SUPPORTÉ. + Éditer + Déplacer vers un autre groupe + Ouvrir tous les dépôts + Ouvrir un dépôt + Ouvrir le terminal + Réanalyser les repositories dans le dossier de clonage par défaut + Chercher des dépôts... + Trier + Changements + Git Ignore + Ignorer tous les *{0} fichiers + Ignorer *{0} fichiers dans le même dossier + Ignorer les fichiers dans le même dossier + N'ignorer que ce fichier + Amender + Vous pouvez indexer ce fichier. + COMMIT + COMMIT & POUSSER + Modèles/Historiques + Trigger click event + Commit (Modifier) + Indexer tous les changements et commit + CONFLITS DÉTECTÉS + LES CONFLITS DE FICHIER SONT RÉSOLUS + INCLURE LES FICHIERS NON-SUIVIS + PAS DE MESSAGE D'ENTRÉE RÉCENT + PAS DE MODÈLES DE COMMIT + Faites un clique droit sur les fichiers sélectionnés et faites vos choix pour la résoluion des conflits. + SignOff + INDEXÉ + RETIRER DE L'INDEX + RETIRER TOUT DE L'INDEX + NON INDEXÉ + INDEXER + INDEXER TOUT + VOIR LES FICHIERS PRÉSUMÉS INCHANGÉS + Modèle: ${0}$ + ESPACE DE TRAVAIL : + Configurer les espaces de travail... + WORKTREE + Copier le chemin + Verrouiller + Supprimer + Déverrouiller + diff --git a/src/Resources/Locales/it_IT.axaml b/src/Resources/Locales/it_IT.axaml new file mode 100644 index 00000000..3aef0043 --- /dev/null +++ b/src/Resources/Locales/it_IT.axaml @@ -0,0 +1,788 @@ + + + + + + Informazioni + Informazioni su SourceGit + Client GUI Git open source e gratuito + Aggiungi Worktree + Posizione: + Percorso per questo worktree. Supportato il percorso relativo. + Nome Branch: + Facoltativo. Predefinito è il nome della cartella di destinazione. + Traccia Branch: + Traccia branch remoto + Di cosa fare il checkout: + Crea nuovo branch + Branch esistente + Assistente AI + RIGENERA + Usa AI per generare il messaggio di commit + APPLICA COME MESSAGGIO DI COMMIT + Applica + File Patch: + Seleziona file .patch da applicare + Ignora modifiche agli spazi + Applica Patch + Spazi: + Applica lo stash + Rimuovi dopo aver applicato + Ripristina le modifiche all'indice + Stash: + Archivia... + Salva Archivio In: + Seleziona il percorso del file archivio + Revisione: + Archivia + Richiedi Password SourceGit + FILE ASSUNTI COME INVARIATI + NESSUN FILE ASSUNTO COME INVARIATO + RIMUOVI + Aggiorna + FILE BINARIO NON SUPPORTATO!!! + Biseca + Annulla + Cattiva + Bisecando. La HEAD corrente è buona o cattiva? + Buona + Salta + Bisecando. Marca il commit corrente come buono o cattivo e fai checkout di un altro. + Attribuisci + L'ATTRIBUZIONE SU QUESTO FILE NON È SUPPORTATA!!! + Checkout ${0}$... + Confronta con ${0}$ + Confronta con Worktree + Copia Nome Branch + Azione personalizzata + Elimina ${0}$... + Elimina i {0} branch selezionati + Avanzamento Veloce a ${0}$ + Recupera ${0}$ in ${1}$... + Git Flow - Completa ${0}$ + Unisci ${0}$ in ${1}$... + Unisci i {0} branch selezionati in quello corrente + Scarica ${0}$ + Scarica ${0}$ in ${1}$... + Invia ${0}$ + Riallinea ${0}$ su ${1}$... + Rinomina ${0}$... + Imposta Branch di Tracciamento... + Confronto Branch + Upstream non valido + Byte + ANNULLA + Ripristina la Revisione Padre + Ripristina Questa Revisione + Genera messaggio di commit + CAMBIA MODALITÀ DI VISUALIZZAZIONE + Mostra come elenco di file e cartelle + Mostra come elenco di percorsi + Mostra come albero del filesystem + Checkout Branch + Checkout Commit + Commit: + Avviso: Effettuando un checkout del commit, la tua HEAD sarà separata + Modifiche Locali: + Scarta + Stasha e Ripristina + Aggiorna tutti i sottomoduli + Branch: + Cherry Pick + Aggiungi sorgente al messaggio di commit + Commit(s): + Conferma tutte le modifiche + Mainline: + Di solito non è possibile fare cherry-pick sdi una unione perché non si sa quale lato deve essere considerato il mainline. Questa opzione consente di riprodurre la modifica relativa al genitore specificato. + Cancella Stash + Stai per cancellare tutti gli stash. Sei sicuro di voler continuare? + Clona Repository Remoto + Parametri Extra: + Argomenti addizionali per clonare il repository. Facoltativo. + Nome Locale: + Nome del repository. Facoltativo. + Cartella Principale: + Inizializza e aggiorna i sottomoduli + URL del Repository: + CHIUDI + Editor + Checkout Commit + Cherry-Pick Questo Commit + Cherry-Pick... + Confronta con HEAD + Confronta con Worktree + Autore + Committer + Informazioni + SHA + Oggetto + Azione Personalizzata + Riallinea Interattivamente ${0}$ fino a Qui + Unisci a ${0}$ + Unisci ... + Riallinea ${0}$ fino a Qui + Ripristina ${0}$ fino a Qui + Annulla Commit + Modifica + Salva come Patch... + Compatta nel Genitore + Compatta Commit Figli fino a Qui + MODIFICHE + Cerca Modifiche... + FILE + File LFS + Cerca File... + Sottomodulo + INFORMAZIONI + AUTORE + MODIFICATO + FIGLI + CHI HA COMMITTATO + Controlla i riferimenti che contengono questo commit + IL COMMIT È CONTENUTO DA + Mostra solo le prime 100 modifiche. Vedi tutte le modifiche nella scheda MODIFICHE. + MESSAGGIO + GENITORI + RIFERIMENTI + SHA + Apri nel Browser + Descrizione + OGGETTO + Inserisci l'oggetto del commit + Configura Repository + TEMPLATE DI COMMIT + Contenuto Template: + Nome Template: + AZIONE PERSONALIZZATA + Argomenti: + ${REPO} - Percorso del repository; ${SHA} - SHA del commit selezionato + File Eseguibile: + Nome: + Ambito: + Branch + Commit + Repository + Attendi la fine dell'azione + Indirizzo Email + Indirizzo email + GIT + Recupera automaticamente i remoti + Minuto/i + Remoto Predefinito + Modalità di Merge Preferita + TRACCIAMENTO ISSUE + Aggiungi una regola di esempio per Azure DevOps + Aggiungi una regola di esempio per un Issue Gitee + Aggiungi una regola di esempio per un Pull Request Gitee + Aggiungi una regola di esempio per GitHub + Aggiungi una regola di esempio per Issue GitLab + Aggiungi una regola di esempio per una Merge Request GitLab + Aggiungi una regola di esempio per Jira + Nuova Regola + Espressione Regex Issue: + Nome Regola: + URL Risultato: + Utilizza $1, $2 per accedere ai valori dei gruppi regex. + AI + Servizio preferito: + Se il 'Servizio Preferito' é impostato, SourceGit utilizzerà solo quello per questo repository. Altrimenti, se ci sono più servizi disponibili, verrà mostrato un menu contestuale per sceglierne uno. + Proxy HTTP + Proxy HTTP usato da questo repository + Nome Utente + Nome utente per questo repository + Spazi di Lavoro + Colore + Nome + Ripristina schede all'avvio + CONTINUA + Trovato un commit vuoto! Vuoi procedere (--allow-empty)? + STAGE DI TUTTO E COMMITTA + Trovato un commit vuoto! Vuoi procedere (--allow-empty) o fare lo stage di tutto e committare? + Guida Commit Convenzionali + Modifica Sostanziale: + Issue Chiusa: + Dettaglio Modifiche: + Ambito: + Descrizione Breve: + Tipo di Modifica: + Copia + Copia Tutto il Testo + Copia Intero Percorso + Copia Percorso + Crea Branch... + Basato Su: + Checkout del Branch Creato + Modifiche Locali: + Scarta + Stasha e Ripristina + Nome Nuovo Branch: + Inserisci il nome del branch. + Gli spazi verranno rimpiazzati con dei trattini. + Crea Branch Locale + Crea Tag... + Nuovo Tag Su: + Firma con GPG + Messaggio Tag: + Facoltativo. + Nome Tag: + Formato consigliato: v1.0.0-alpha + Invia a tutti i remoti dopo la creazione + Crea Nuovo Tag + Tipo: + annotato + leggero + Tieni premuto Ctrl per avviare direttamente + Taglia + Elimina Branch + Branch: + Stai per eliminare un branch remoto!!! + Elimina anche il branch remoto ${0}$ + Elimina Branch Multipli + Stai per eliminare più branch contemporaneamente. Controlla attentamente prima di procedere! + Elimina Remoto + Remoto: + Percorso: + Destinazione: + Tutti i figli verranno rimossi dalla lista. + Lo rimuoverà solamente dalla lista, non dal disco! + Conferma Eliminazione Gruppo + Conferma Eliminazione Repository + Elimina Sottomodulo + Percorso Sottomodulo: + Elimina Tag + Tag: + Elimina dai repository remoti + DIFF BINARIO + NUOVO + VECCHIO + Copia + Modalità File Modificata + Prima differenza + Ignora Modifiche agli Spazi + Ultima differenza + MODIFICA OGGETTO LFS + Differenza Successiva + NESSUNA MODIFICA O SOLO CAMBIAMENTI DI FINE LINEA + Differenza Precedente + Salva come Patch + Mostra Simboli Nascosti + Diff Affiancato + SOTTOMODULO + NUOVO + Scambia + Evidenziazione Sintassi + Avvolgimento delle Parole + Abilita la navigazione a blocchi + Apri nello Strumento di Merge + Mostra Tutte le Righe + Diminuisci Numero di Righe Visibili + Aumenta Numero di Righe Visibili + SELEZIONA UN FILE PER VISUALIZZARE LE MODIFICHE + Apri nello Strumento di Merge + Scarta Modifiche + Tutte le modifiche locali nella copia di lavoro. + Modifiche: + Includi file ignorati + Un totale di {0} modifiche saranno scartate + Questa azione non può essere annullata!!! + Segnalibro: + Nuovo Nome: + Destinazione: + Modifica Gruppo Selezionato + Modifica Repository Selezionato + Esegui Azione Personalizzata + Nome Azione: + Recupera + Recupera da tutti i remoti + Forza la sovrascrittura dei riferimenti locali + Recupera senza tag + Remoto: + Recupera Modifiche Remote + Presumi invariato + Scarta... + Scarta {0} file... + Scarta Modifiche nelle Righe Selezionate + Apri Strumento di Merge Esterno + Risolvi Usando ${0}$ + Salva come Patch... + Stage + Stage di {0} file + Stage delle Modifiche nelle Righe Selezionate + Stasha... + Stasha {0} file... + Rimuovi da Stage + Rimuovi da Stage {0} file + Rimuovi le Righe Selezionate da Stage + Usa Il Mio (checkout --ours) + Usa Il Loro (checkout --theirs) + Cronologia File + MODIFICA + CONTENUTO + Git-Flow + Branch di Sviluppo: + Feature: + Prefisso Feature: + FLOW - Completa Feature + FLOW - Completa Hotfix + FLOW - Completa Rilascio + Target: + Invia al remote dopo aver finito + Esegui squash durante il merge + Hotfix: + Prefisso Hotfix: + Inizializza Git-Flow + Mantieni branch + Branch di Produzione: + Rilascio: + Prefisso Rilascio: + Inizia Feature... + FLOW - Inizia Feature + Inizia Hotfix... + FLOW - Inizia Hotfix + Inserisci nome + Inizia Rilascio... + FLOW - Inizia Rilascio + Prefisso Tag Versione: + Git LFS + Aggiungi Modello di Tracciamento... + Il modello è un nome file + Modello Personalizzato: + Aggiungi Modello di Tracciamento a Git LFS + Recupera + Esegui `git lfs fetch` per scaricare gli oggetti Git LFS. Questo non aggiorna la copia di lavoro. + Recupera Oggetti LFS + Installa hook di Git LFS + Mostra Blocchi + Nessun File Bloccato + Blocca + Mostra solo i miei blocchi + Blocchi LFS + Sblocca + Forza Sblocco + Elimina + Esegui `git lfs prune` per eliminare vecchi file LFS dallo storage locale + Scarica + Esegui `git lfs pull` per scaricare tutti i file LFS per il ref corrente e fare il checkout + Scarica Oggetti LFS + Invia + Invia grandi file in coda al punto finale di Git LFS + Invia Oggetti LFS + Remoto: + Traccia file con nome '{0}' + Traccia tutti i file *{0} + STORICO + AUTORE + ORA AUTORE + GRAFICO E OGGETTO + SHA + ORA COMMIT + {0} COMMIT SELEZIONATI + Tieni premuto 'Ctrl' o 'Shift' per selezionare più commit. + Tieni premuto ⌘ o ⇧ per selezionare più commit. + SUGGERIMENTI: + Riferimento Scorciatoie da Tastiera + GLOBALE + Annulla il popup corrente + Clona una nuova repository + Chiudi la pagina corrente + Vai alla pagina successiva + Vai alla pagina precedente + Crea una nuova pagina + Apri la finestra delle preferenze + REPOSITORY + Committa le modifiche in tsage + Committa e invia le modifiche in stage + Fai lo stage di tutte le modifiche e committa + Crea un nuovo branch dal commit selezionato + Scarta le modifiche selezionate + Recupera, avvia direttamente + Modalità Dashboard (Predefinita) + Modalità ricerca commit + Scarica, avvia direttamente + Invia, avvia direttamente + Forza l'aggiornamento di questo repository + Aggiungi/Rimuovi da stage le modifiche selezionate + Passa a 'Modifiche' + Passa a 'Storico' + Passa a 'Stashes' + EDITOR TESTO + Chiudi il pannello di ricerca + Trova il prossimo risultato + Trova il risultato precedente + Apri con uno strumento di diff/merge esterno + Apri il pannello di ricerca + Scarta + Aggiungi in stage + Rimuovi + Inizializza Repository + Percorso: + Cherry-Pick in corso. + Elaborando il commit + Unione in corso. + Unendo + Riallineamento in corso. + Interrotto a + Ripristino in corso. + Ripristinando il commit + Riallinea Interattivamente + Su: + Branch di destinazione: + Copia il Link + Apri nel Browser + ERRORE + AVVISO + Unisci Branch + In: + Opzione di Unione: + Sorgente: + Unione (multipla) + Commit di tutte le modifiche + Strategia: + Obiettivi: + Sposta Nodo Repository + Seleziona nodo padre per: + Nome: + Git NON è configurato. Prima vai su [Preferenze] per configurarlo. + Apri Cartella Dati App + Apri con... + Opzionale. + Crea Nuova Pagina + Segnalibro + Chiudi Tab + Chiudi Altri Tab + Chiudi i Tab a Destra + Copia Percorso Repository + Repository + Incolla + {0} giorni fa + 1 ora fa + {0} ore fa + Proprio ora + Il mese scorso + L'anno scorso + {0} minuti fa + {0} mesi fa + {0} anni fa + Ieri + Preferenze + AI + Analizza il Prompt Differenza + Chiave API + Genera Prompt Oggetto + Modello + Nome + Server + Abilita streaming + ASPETTO + Font Predefinito + Larghezza della Tab Editor + Dimensione Font + Dimensione Font Predefinita + Dimensione Font Editor + Font Monospaziato + Usa solo font monospaziato nell'editor + Tema + Sostituzioni Tema + Usa larghezza fissa per i tab nella barra del titolo + Usa cornice finestra nativa + STRUMENTO DI DIFFERENZA/UNIONE + Percorso Installazione + Inserisci il percorso per lo strumento di differenza/unione + Strumento + GENERALE + Controlla aggiornamenti all'avvio + Formato data + Lingua + Numero massimo di commit nella cronologia + Mostra nel grafico l'orario dell'autore anziché quello del commit + Mostra i figli nei dettagli del commit + Mostra i tag nel grafico dei commit + Lunghezza Guida Oggetto + GIT + Abilita Auto CRLF + Cartella predefinita per cloni + Email Utente + Email utente Git globale + Abilita --prune durante il fetch + Abilita --ignore-cr-at-eol nel diff + Questa applicazione richiede Git (>= 2.25.1) + Percorso Installazione + Abilita la verifica HTTP SSL + Nome Utente + Nome utente Git globale + Versione di Git + FIRMA GPG + Firma GPG per commit + Formato GPG + Percorso Programma Installato + Inserisci il percorso per il programma GPG installato + Firma GPG per tag + Chiave Firma Utente + Chiave GPG dell'utente per la firma + INTEGRAZIONE + SHELL/TERMINALE + Percorso + Shell/Terminale + Potatura Remota + Destinazione: + Potatura Worktrees + Potatura delle informazioni di worktree in `$GIT_COMMON_DIR/worktrees` + Scarica + Branch Remoto: + In: + Modifiche Locali: + Scarta + Stasha e Riapplica + Remoto: + Scarica (Recupera e Unisci) + Riallineare anziché unire + Invia + Assicurati che i sottomoduli siano stati inviati + Forza l'invio + Branch Locale: + Remoto: + Invia modifiche al remoto + Branch Remoto: + Imposta come branch di tracking + Invia tutti i tag + Invia Tag al Remoto + Invia a tutti i remoti + Remoto: + Tag: + Esci + Riallinea Branch Corrente + Stasha e Riapplica modifiche locali + Su: + Riallinea: + Aggiungi Remoto + Modifica Remoto + Nome: + Nome del remoto + URL del Repository: + URL del repository Git remoto + Copia URL + Elimina... + Modifica... + Recupera + Apri nel Browser + Pota + Conferma Rimozione Worktree + Abilita opzione `--force` + Destinazione: + Rinomina Branch + Nuovo Nome: + Nome univoco per questo branch + Branch: + ANNULLA + Recupero automatico delle modifiche dai remoti... + Ordina + Per data del committer + Per nome + Pulizia (GC e Potatura) + Esegui il comando `git gc` per questo repository. + Cancella tutto + Configura questo repository + CONTINUA + Azioni Personalizzate + Nessuna Azione Personalizzata + Scarta tutte le modifiche + Abilita opzione '--reflog' + Apri nell'Esplora File + Cerca Branch/Tag/Sottomodulo + Visibilità nel grafico + Non impostato + Nascondi nel grafico dei commit + Filtra nel grafico dei commit + Abilita opzione '--first-parent' + LAYOUT + Orizzontale + Verticale + Ordine dei commit + Per data del commit + Topologicamente + BRANCH LOCALI + Vai a HEAD + Crea Branch + CANCELLA LE NOTIFICHE + Evidenzia nel grafico solo il branch corrente + Apri in {0} + Apri in Strumenti Esterni + Aggiorna + REMOTI + AGGIUNGI REMOTO + Cerca Commit + Autore + Committer + Contenuto + File + Messaggio + SHA + Branch Corrente + Mostra i Sottomoduli Come Albero + Mostra Tag come Albero + SALTA + Statistiche + SOTTOMODULI + AGGIUNGI SOTTOMODULI + AGGIORNA SOTTOMODULI + TAG + NUOVO TAG + Per data di creazione + Per nome + Ordina + Apri nel Terminale + Usa tempo relativo nello storico + Visualizza i Log + Visita '{0}' nel Browser + WORKTREE + AGGIUNGI WORKTREE + POTATURA + URL del Repository Git + Reset Branch Corrente alla Revisione + Modalità Reset: + Sposta a: + Branch Corrente: + Mostra nell'Esplora File + Ripristina Commit + Commit: + Commit delle modifiche di ripristino + Modifica Messaggio di Commit + Usa 'Shift+Enter' per inserire una nuova riga. 'Enter' è il tasto rapido per il pulsante OK + In esecuzione. Attendere... + SALVA + Salva come... + La patch è stata salvata con successo! + Scansiona Repository + Cartella Principale: + Controlla Aggiornamenti... + È disponibile una nuova versione del software: + Errore durante il controllo degli aggiornamenti! + Scarica + Salta questa versione + Aggiornamento Software + Non ci sono aggiornamenti disponibili. + Imposta il Branch + Branch: + Rimuovi upstream + Upstream: + Copia SHA + Vai a + Compatta Commit + In: + Chiave Privata SSH: + Percorso per la chiave SSH privata + AVVIA + Stasha + Auto-ripristino dopo lo stash + I tuoi file di lavoro rimangono inalterati, ma viene salvato uno stash. + Includi file non tracciati + Mantieni file in stage + Messaggio: + Opzionale. Nome di questo stash + Solo modifiche in stage + Sia le modifiche in stage che quelle non in stage dei file selezionati saranno stashate!!! + Stasha Modifiche Locali + Applica + Elimina + Salva come Patch... + Elimina Stash + Elimina: + STASH + MODIFICHE + STASH + Statistiche + COMMIT + COMMITTER + PANORAMICA + MESE + SETTIMANA + AUTORI: + COMMIT: + SOTTOMODULI + Aggiungi Sottomodulo + Copia Percorso Relativo + Recupera sottomoduli annidati + Apri Repository del Sottomodulo + Percorso Relativo: + Cartella relativa per memorizzare questo modulo. + Elimina Sottomodulo + STATO + modificato + non inizializzato + revisione cambiata + non unito + URL + OK + Copia Nome Tag + Copia Messaggio Tag + Elimina ${0}$... + Unisci ${0}$ in ${1}$... + Invia ${0}$... + Aggiorna Sottomoduli + Tutti i sottomoduli + Inizializza se necessario + Ricorsivamente + Sottomodulo: + Usa opzione --remote + URL: + Log + CANCELLA TUTTO + Copia + Elimina + Avviso + Pagina di Benvenuto + Crea Gruppo + Crea Sottogruppo + Clona Repository + Elimina + TRASCINA E RILASCIA CARTELLA SUPPORTATO. RAGGRUPPAMENTI PERSONALIZZATI SUPPORTATI. + Modifica + Sposta in un Altro Gruppo + Apri Tutti i Repository + Apri Repository + Apri Terminale + Riscansiona Repository nella Cartella Clone Predefinita + Cerca Repository... + Ordina + MODIFICHE LOCALI + Git Ignore + Ignora tutti i file *{0} + Ignora i file *{0} nella stessa cartella + Ignora i file nella stessa cartella + Ignora solo questo file + Modifica + Puoi aggiungere in stage questo file ora. + COMMIT + COMMIT E INVIA + Template/Storico + Attiva evento click + Commit (Modifica) + Stage di tutte le modifiche e fai il commit + Hai stageato {0} file ma solo {1} file mostrati ({2} file sono stati filtrati). Vuoi procedere? + CONFLITTI RILEVATI + APRI STRUMENTO DI MERGE ESTERNO + APRI TUTTI I CONFLITTI NELLO STRUMENTO DI MERGE ESTERNO + CONFLITTI NEI FILE RISOLTI + USO IL MIO + USO IL LORO + INCLUDI FILE NON TRACCIATI + NESSUN MESSAGGIO RECENTE INSERITO + NESSUN TEMPLATE DI COMMIT + Clicca con il tasto destro sul(i) file selezionato, quindi scegli come risolvere i conflitti. + SignOff + IN STAGE + RIMUOVI DA STAGE + RIMUOVI TUTTO DA STAGE + NON IN STAGE + FAI LO STAGE + FAI LO STAGE DI TUTTO + VISUALIZZA COME NON MODIFICATO + Template: ${0}$ + WORKSPACE: + Configura Workspaces... + WORKTREE + Copia Percorso + Blocca + Rimuovi + Sblocca + diff --git a/src/Resources/Locales/ja_JP.axaml b/src/Resources/Locales/ja_JP.axaml new file mode 100644 index 00000000..918a6b4d --- /dev/null +++ b/src/Resources/Locales/ja_JP.axaml @@ -0,0 +1,743 @@ + + + + + + 概要 + SourceGitについて + オープンソース & フリーなGit GUIクライアント + ワークツリーを追加 + 場所: + ワークツリーのパスを入力してください。相対パスも使用することができます。 + ブランチの名前: + 任意。デフォルトでは宛先フォルダ名が使用されます。 + 追跡するブランチ: + 追跡中のリモートブランチ + チェックアウトする内容: + 新しいブランチを作成 + 既存のブランチ + OpenAI アシスタント + 再生成 + OpenAIを使用してコミットメッセージを生成 + コミットメッセージとして適用 + 適用 + パッチファイル: + 適用する .patchファイルを選択 + 空白文字の変更を無視 + パッチを適用 + 空白文字: + スタッシュを適用 + 適用後に削除 + インデックスの変更を復元 + スタッシュ: + アーカイブ... + アーカイブの保存先: + アーカイブファイルのパスを選択 + リビジョン: + アーカイブ + SourceGit Askpass + 変更されていないとみなされるファイル + 変更されていないとみなされるファイルはありません + 削除 + 更新 + バイナリファイルはサポートされていません!!! + Blame + BLAMEではこのファイルはサポートされていません!!! + ${0}$ をチェックアウトする... + ワークツリーと比較 + ブランチ名をコピー + カスタムアクション + ${0}$を削除... + 選択中の{0}個のブランチを削除 + ${0}$ へ早送りする + ${0}$ から ${1}$ へフェッチする + Git Flow - Finish ${0}$ + ${0}$ を ${1}$ にマージする... + 選択中の{0}個のブランチを現在のブランチにマージする + ${0}$ をプルする + ${0}$ を ${1}$ にプルする... + ${0}$ をプッシュする + ${0}$ を ${1}$ でリベースする... + ${0}$ をリネームする... + トラッキングブランチを設定... + ブランチの比較 + 無効な上流ブランチ! + バイト + キャンセル + 親リビジョンにリセット + このリビジョンにリセット + コミットメッセージを生成 + 変更表示の切り替え + ファイルとディレクトリのリストを表示 + パスのリストを表示 + ファイルシステムのツリーを表示 + ブランチをチェックアウト + コミットをチェックアウト + コミット: + 警告: コミットをチェックアウトするとHEADが切断されます + ローカルの変更: + 破棄 + スタッシュして再適用 + ブランチ: + チェリーピック + ソースをコミットメッセージに追加 + コミット(複数可): + すべての変更をコミット + メインライン: + 通常、マージをチェリーピックすることはできません。どちらのマージ元をメインラインとして扱うべきかが分からないためです。このオプションを使用すると、指定した親に対して変更を再適用する形でチェリーピックを実行できます。 + スタッシュをクリア + すべてのスタッシュをクリアします。続行しますか? + リモートリポジトリをクローン + 追加の引数: + リポジトリをクローンする際の追加パラメータ(任意)。 + ローカル名: + リポジトリの名前(任意)。 + 親フォルダ: + サブモジュールを初期化して更新 + リポジトリのURL: + 閉じる + エディタ + コミットをチェックアウト + このコミットをチェリーピック + チェリーピック... + HEADと比較 + ワークツリーと比較 + 情報 + SHA + カスタムアクション + ${0}$ ブランチをここにインタラクティブリベース + ${0}$ にマージ + マージ... + ${0}$ をここにリベース + ${0}$ ブランチをここにリセット + コミットを戻す + 書き直す + パッチとして保存... + 親にスカッシュ + 子コミットをここにスカッシュ + 変更 + 変更を検索... + ファイル + LFSファイル + ファイルを検索... + サブモジュール + コミットの情報 + 著者 + 変更 + + コミッター + このコミットを含む参照を確認 + コミットが含まれるか確認 + 最初の100件の変更のみが表示されています。すべての変更は'変更'タブで確認できます。 + メッセージ + + 参照 + SHA + ブラウザで開く + 説明 + コミットのタイトルを入力 + リポジトリの設定 + コミットテンプレート + テンプレート内容: + テンプレート名: + カスタムアクション + 引数: + ${REPO} - リポジトリのパス; ${BRANCH} - 選択中のブランチ; ${SHA} - 選択中のコミットのSHA + 実行ファイル: + 名前: + スコープ: + ブランチ + コミット + リポジトリ + アクションの終了を待機 + Eメールアドレス + Eメールアドレス + GIT + 自動的にリモートからフェッチ 間隔: + 分(s) + リモートの初期値 + ISSUEトラッカー + サンプルのAzure DevOpsルールを追加 + サンプルのGitee Issueルールを追加 + サンプルのGiteeプルリクエストルールを追加 + サンプルのGithubルールを追加 + サンプルのGitLab Issueルールを追加 + サンプルのGitLabマージリクエストルールを追加 + サンプルのJiraルールを追加 + 新しいルール + Issueの正規表現: + ルール名: + リザルトURL: + 正規表現のグループ値に$1, $2を使用してください。 + AI + 優先するサービス: + 優先するサービスが設定されている場合、SourceGitはこのリポジトリでのみそれを使用します。そうでない場合で複数サービスが利用できる場合は、そのうちの1つを選択するためのコンテキストメニューが表示されます。 + HTTP プロキシ + このリポジトリで使用するHTTPプロキシ + ユーザー名 + このリポジトリにおけるユーザー名 + ワークスペース + + 名前 + 起動時にタブを復元 + Conventional Commitヘルパー + 破壊的変更: + 閉じたIssue: + 詳細な変更: + スコープ: + 短い説明: + 変更の種類: + コピー + すべてのテキストをコピー + 絶対パスをコピー + パスをコピー + ブランチを作成... + 派生元: + 作成したブランチにチェックアウト + ローカルの変更: + 破棄 + スタッシュして再適用 + 新しいブランチの名前: + ブランチの名前を入力 + スペースはダッシュに置き換えられます。 + ローカルブランチを作成 + タグを作成... + 付与されるコミット: + GPG署名を使用 + タグメッセージ: + 任意。 + タグの名前: + 推奨フォーマット: v1.0.0-alpha + 作成後にすべてのリモートにプッシュ + 新しいタグを作成 + 種類: + 注釈付き + 軽量 + Ctrlキーを押しながらクリックで実行 + 切り取り + ブランチを削除 + ブランチ: + リモートブランチを削除しようとしています!!! + もしリモートブランチを削除する場合、${0}$も削除します。 + 複数のブランチを削除 + 一度に複数のブランチを削除しようとしています! 操作を行う前に再度確認してください! + リモートを削除 + リモート: + パス: + 対象: + すべての子ノードがリストから削除されます。 + これはリストからのみ削除され、ディスクには保存されません! + グループを削除 + リポジトリを削除 + サブモジュールを削除 + サブモジュールのパス: + タグを削除 + タグ: + リモートリポジトリから削除 + バイナリの差分 + NEW + OLD + コピー + ファイルモードが変更されました + 先頭の差分 + 空白の変更を無視 + 最後の差分 + LFSオブジェクトの変更 + 次の差分 + 変更がない、もしくはEOLの変更のみ + 前の差分 + パッチとして保存 + 隠されたシンボルを表示 + 差分の分割表示 + サブモジュール + 新規 + スワップ + シンタックスハイライト + 行の折り返し + ブロックナビゲーションを有効化 + マージツールで開く + すべての行を表示 + 表示する行数を減らす + 表示する行数を増やす + ファイルを選択すると、変更内容が表示されます + マージツールで開く + 変更を破棄 + ワーキングディレクトリのすべての変更を破棄 + 変更: + 無視したファイルを含める + {0}個の変更を破棄します。 + この操作を元に戻すことはできません!!! + ブックマーク: + 新しい名前: + 対象: + 選択中のグループを編集 + 選択中のリポジトリを編集 + カスタムアクションを実行 + アクション名: + フェッチ + すべてのリモートをフェッチ + ローカル参照を強制的に上書き + タグなしでフェッチ + リモート: + リモートの変更をフェッチ + 変更されていないとみなされる + 破棄... + {0}個のファイルを破棄... + 選択された行の変更を破棄 + 外部マージツールで開く + ${0}$ を使用して解決 + パッチとして保存... + ステージ + {0}個のファイルをステージ... + 選択された行の変更をステージ + スタッシュ... + {0}個のファイルをスタッシュ... + アンステージ + {0}個のファイルをアンステージ... + 選択された行の変更をアンステージ + 自分の変更を使用 (checkout --ours) + 相手の変更を使用 (checkout --theirs) + ファイルの履歴 + 変更 + コンテンツ + Git-Flow + 開発ブランチ: + Feature: + Feature プレフィックス: + FLOW - Finish Feature + FLOW - Finish Hotfix + FLOW - Finish Release + 対象: + Hotfix: + Hotfix プレフィックス: + Git-Flowを初期化 + ブランチを保持 + プロダクション ブランチ: + Release: + Release プレフィックス: + Start Feature... + FLOW - Start Feature + Start Hotfix... + FLOW - Start Hotfix + 名前を入力 + Start Release... + FLOW - Start Release + Versionタグ プレフィックス: + Git LFS + トラックパターンを追加... + パターンをファイル名として扱う + カスタム パターン: + Git LFSにトラックパターンを追加 + フェッチ + `git lfs fetch`を実行して、Git LFSオブジェクトをダウンロードします。ワーキングコピーは更新されません。 + LFSオブジェクトをフェッチ + Git LFSフックをインストール + ロックを表示 + ロックされているファイルはありません + ロック + 私のロックのみ表示 + LFSロック + ロック解除 + 強制的にロック解除 + 削除 + `git lfs prune`を実行して、ローカルの保存領域から古いLFSファイルを削除します。 + プル + `git lfs pull`を実行して、現在の参照とチェックアウトのすべてのGit LFSファイルをダウンロードします。 + LFSオブジェクトをプル + プッシュ + キュー内の大容量ファイルをGit LFSエンドポイントにプッシュします。 + LFSオブジェクトをプッシュ + リモート: + {0}という名前のファイルをトラック + すべての*{0}ファイルをトラック + 履歴 + 著者 + 著者時間 + グラフ & コミットのタイトル + SHA + 日時 + {0} コミットを選択しました + 'Ctrl'キーまたは'Shift'キーを押すと、複数のコミットを選択できます。 + ⌘ または ⇧ キーを押して複数のコミットを選択します。 + TIPS: + キーボードショートカットを確認 + 総合 + 現在のポップアップをキャンセル + 新しくリポジトリをクローン + 現在のページを閉じる + 次のページに移動 + 前のページに移動 + 新しいページを作成 + 設定ダイアログを開く + リポジトリ + ステージ済みの変更をコミット + ステージ済みの変更をコミットしてプッシュ + 全ての変更をステージしてコミット + 選択中のコミットから新たなブランチを作成 + 選択した変更を破棄 + 直接フェッチを実行 + ダッシュボードモード (初期値) + コミット検索モード + 直接プルを実行 + 直接プッシュを実行 + 現在のリポジトリを強制的に再読み込み + 選択中の変更をステージ/アンステージ + '変更'に切り替える + '履歴'に切り替える + 'スタッシュ'に切り替える + テキストエディタ + 検索パネルを閉じる + 次のマッチを検索 + 前のマッチを検索 + 検索パネルを開く + 破棄 + ステージ + アンステージ + リポジトリの初期化 + パス: + チェリーピックが進行中です。'中止'を押すと元のHEADが復元されます。 + コミットを処理中 + マージリクエストが進行中です。'中止'を押すと元のHEADが復元されます。 + マージ中 + リベースが進行中です。'中止'を押すと元のHEADが復元されます。 + 停止しました + 元に戻す処理が進行中です。'中止'を押すと元のHEADが復元されます。 + コミットを元に戻しています + インタラクティブ リベース + On: + 対象のブランチ: + リンクをコピー + ブラウザで開く + エラー + 通知 + ブランチのマージ + 宛先: + マージオプション: + ソースブランチ: + マージ (複数) + すべての変更をコミット + マージ戦略: + 対象: + リポジトリノードの移動 + 親ノードを選択: + 名前: + Gitが設定されていません。まず[設定]に移動して設定を行ってください。 + アプリケーションデータのディレクトリを開く + 外部ツールで開く... + 任意。 + 新しいページを開く + ブックマーク + タブを閉じる + 他のタブを閉じる + 右のタブを閉じる + リポジトリパスをコピー + リポジトリ + 貼り付け + {0} 日前 + 1 時間前 + {0} 時間前 + たった今 + 先月 + 昨年 + {0} 分前 + {0} ヶ月前 + {0} 年前 + 昨日 + 設定 + AI + 差分分析プロンプト + APIキー + タイトル生成プロンプト + モデル + 名前 + サーバー + ストリーミングを有効化 + 外観 + デフォルトのフォント + エディタのタブ幅 + フォントサイズ + デフォルト + エディタ + 等幅フォント + テキストエディタでは等幅フォントのみを使用する + テーマ + テーマの上書き + タイトルバーの固定タブ幅を使用 + ネイティブウィンドウフレームを使用 + 差分/マージ ツール + インストール パス + 差分/マージ ツールのパスを入力 + ツール + 総合 + 起動時にアップデートを確認 + 日時のフォーマット + 言語 + コミット履歴 + グラフにコミット時間の代わりに著者の時間を表示する + コミット詳細に子コミットを表示 + コミットグラフにタグを表示 + コミットタイトル枠の大きさ + GIT + 自動CRLFを有効化 + デフォルトのクローンディレクトリ + ユーザー Eメールアドレス + グローバルgitのEメールアドレス + フェッチ時に--pruneを有効化 + Git (>= 2.25.1) はこのアプリで必要です + インストール パス + HTTP SSL 検証を有効にする + ユーザー名 + グローバルのgitユーザー名 + Gitバージョン + GPG 署名 + コミットにGPG署名を行う + GPGフォーマット + プログラムのインストールパス + インストールされたgpgプログラムのパスを入力 + タグにGPG署名を行う + ユーザー署名キー + ユーザーのGPG署名キー + 統合 + シェル/ターミナル + パス + シェル/ターミナル + リモートを削除 + 対象: + 作業ツリーを削除 + `$GIT_DIR/worktrees` の作業ツリー情報を削除 + プル + ブランチ: + 宛先: + ローカルの変更: + 破棄 + スタッシュして再適用 + リモート: + プル (フェッチ & マージ) + マージの代わりにリベースを使用 + プッシュ + サブモジュールがプッシュされていることを確認 + 強制的にプッシュ + ローカル ブランチ: + リモート: + 変更をリモートにプッシュ + リモート ブランチ: + 追跡ブランチとして設定 + すべてのタグをプッシュ + リモートにタグをプッシュ + すべてのリモートにプッシュ + リモート: + タグ: + 終了 + 現在のブランチをリベース + ローカルの変更をスタッシュして再適用 + On: + リベース: + リモートを追加 + リモートを編集 + 名前: + リモートの名前 + リポジトリのURL: + リモートのgitリポジトリのURL + URLをコピー + 削除... + 編集... + フェッチ + ブラウザで開く + 削除 + ワークツリーの削除を確認 + `--force` オプションを有効化 + 対象: + ブランチの名前を編集 + 新しい名前: + このブランチにつける一意な名前 + ブランチ: + 中止 + リモートから変更を自動取得中... + クリーンアップ(GC & Prune) + このリポジトリに対して`git gc`コマンドを実行します。 + すべてのフィルターをクリア + リポジトリの設定 + 続ける + カスタムアクション + カスタムアクションがありません + すべての変更を破棄 + `--reflog` オプションを有効化 + ファイルブラウザーで開く + ブランチ/タグ/サブモジュールを検索 + 解除 + コミットグラフで非表示 + コミットグラフでフィルター + `--first-parent` オプションを有効化 + レイアウト + 水平 + 垂直 + コミットの並び順 + 日時 + トポロジカルソート + ローカル ブランチ + HEADに移動 + ブランチを作成 + 通知をクリア + グラフで現在のブランチを強調表示 + {0} で開く + 外部ツールで開く + 更新 + リモート + リモートを追加 + コミットを検索 + 著者 + コミッター + ファイル + メッセージ + SHA + 現在のブランチ + タグをツリーとして表示 + スキップ + 統計 + サブモジュール + サブモジュールを追加 + サブモジュールを更新 + タグ + 新しいタグを作成 + 作成者日時 + 名前 + ソート + ターミナルで開く + 履歴に相対時間を使用 + ワークツリー + ワークツリーを追加 + 削除 + GitリポジトリのURL + 現在のブランチをリビジョンにリセット + リセットモード: + 移動先: + 現在のブランチ: + ファイルエクスプローラーで表示 + コミットを戻す + コミット: + コミットの変更を戻す + コミットメッセージを書き直す + 改行には'Shift+Enter'キーを使用します。 'Enter"はOKボタンのホットキーとして機能します。 + 実行中です。しばらくお待ちください... + 保存 + 名前を付けて保存... + パッチが正常に保存されました! + リポジトリをスキャン + ルートディレクトリ: + 更新を確認 + 新しいバージョンのソフトウェアが利用可能です: + 更新の確認に失敗しました! + ダウンロード + このバージョンをスキップ + ソフトウェアの更新 + 利用可能なアップデートはありません + トラッキングブランチを設定 + ブランチ: + 上流ブランチを解除 + 上流ブランチ: + SHAをコピー + Go to + スカッシュコミット + 宛先: + SSH プライベートキー: + プライベートSSHキーストアのパス + スタート + スタッシュ + スタッシュ後に自動で復元 + 作業ファイルは変更されず、スタッシュが保存されます。 + 追跡されていないファイルを含める + ステージされたファイルを保持 + メッセージ: + オプション. このスタッシュの名前を入力 + ステージされた変更のみ + 選択したファイルの、ステージされた変更とステージされていない変更の両方がスタッシュされます!!! + ローカルの変更をスタッシュ + 適用 + 破棄 + パッチとして保存 + スタッシュを破棄 + 破棄: + スタッシュ + 変更 + スタッシュ + 統計 + コミット + コミッター + 概要 + 月間 + 週間 + 著者: + コミット: + サブモジュール + サブモジュールを追加 + 相対パスをコピー + ネストされたサブモジュールを取得する + サブモジュールのリポジトリを開く + 相対パス: + このモジュールを保存するフォルダの相対パス + サブモジュールを削除 + OK + タグ名をコピー + タグメッセージをコピー + ${0}$ を削除... + ${0}$ を ${1}$ にマージ... + ${0}$ をプッシュ... + サブモジュールを更新 + すべてのサブモジュール + 必要に応じて初期化 + 再帰的に更新 + サブモジュール: + --remoteオプションを使用 + URL: + 警告 + ようこそ + グループを作成 + サブグループを作成 + リポジトリをクローンする + 削除 + ドラッグ & ドロップでフォルダを追加できます. グループを作成したり、変更したりできます。 + 編集 + 別のグループに移動 + すべてのリポジトリを開く + リポジトリを開く + ターミナルを開く + デフォルトのクローンディレクトリ内のリポジトリを再スキャン + リポジトリを検索... + ソート + 変更 + Git Ignore + すべての*{0}ファイルを無視 + 同じフォルダ内の*{0}ファイルを無視 + 同じフォルダ内のファイルを無視 + このファイルのみを無視 + Amend + このファイルを今すぐステージできます。 + コミット + コミットしてプッシュ + メッセージのテンプレート/履歴 + クリックイベントをトリガー + コミット (Edit) + すべての変更をステージしてコミット + 競合が検出されました + ファイルの競合は解決されました + 追跡されていないファイルを含める + 最近の入力メッセージはありません + コミットテンプレートはありません + 選択したファイルを右クリックし、競合を解決する操作を選択してください。 + サインオフ + ステージしたファイル + ステージを取り消し + すべてステージを取り消し + 未ステージのファイル + ステージへ移動 + すべてステージへ移動 + 変更されていないとみなしたものを表示 + テンプレート: ${0}$ + ワークスペース: + ワークスペースを設定... + ワークツリー + パスをコピー + ロック + 削除 + ロック解除 + diff --git a/src/Resources/Locales/pt_BR.axaml b/src/Resources/Locales/pt_BR.axaml new file mode 100644 index 00000000..f448a908 --- /dev/null +++ b/src/Resources/Locales/pt_BR.axaml @@ -0,0 +1,679 @@ + + + + + + Sobre + Sobre o SourceGit + Cliente Git GUI Livre e de Código Aberto + Adicionar Worktree + Localização: + Caminho para este worktree. Caminho relativo é suportado. + Nome do Branch: + Opcional. O padrão é o nome da pasta de destino. + Rastrear Branch: + Rastreando branch remoto + O que Checar: + Criar Novo Branch + Branch Existente + Assietente IA + Utilizar IA para gerar mensagem de commit + Patch + Arquivo de Patch: + Selecione o arquivo .patch para aplicar + Ignorar mudanças de espaço em branco + Aplicar Patch + Espaço em Branco: + Arquivar... + Salvar Arquivo Como: + Selecione o caminho do arquivo de arquivo + Revisão: + Arquivar + SourceGit Askpass + ARQUIVOS CONSIDERADOS SEM ALTERAÇÕES + NENHUM ARQUIVO CONSIDERADO SEM ALTERAÇÕES + REMOVER + Atualizar + ARQUIVO BINÁRIO NÃO SUPORTADO!!! + Blame + BLAME NESTE ARQUIVO NÃO É SUPORTADO!!! + Checkout ${0}$... + Comparar com ${0}$ + Comparar com Worktree + Copiar Nome do Branch + Excluir ${0}$... + Excluir {0} branches selecionados + Fast-Forward para ${0}$ + Buscar ${0}$ em ${1}$... + Git Flow - Finalizar ${0}$ + Mesclar ${0}$ em ${1}$... + Puxar ${0}$ + Puxar ${0}$ para ${1}$... + Subir ${0}$ + Rebase ${0}$ em ${1}$... + Renomear ${0}$... + Definir Branch de Rastreamento... + Comparação de Branches + Bytes + CANCELAR + Resetar para Revisão Pai + Resetar para Esta Revisão + Gerar mensagem de commit + ALTERAR MODO DE EXIBIÇÃO + Exibir como Lista de Arquivos e Diretórios + Exibir como Lista de Caminhos + Exibir como Árvore de Sistema de Arquivos + Checkout Branch + Checkout Commit + Commit: + Aviso: Ao fazer o checkout de um commit, seu Head ficará desanexado + Alterações Locais: + Descartar + Stash & Reaplicar + Branch: + Cherry-Pick + Adicionar origem à mensagem de commit + Commit(s): + Commitar todas as alterações + Mainline: + Geralmente você não pode fazer cherry-pick de um merge commit porque você não sabe qual lado do merge deve ser considerado na mainline. Esta opção permite ao cherry-pick reaplicar a mudança relativa ao parent especificado. + Limpar Stashes + Você está tentando limpar todas as stashes. Tem certeza que deseja continuar? + Clonar Repositório Remoto + Parâmetros Extras: + Argumentos adicionais para clonar o repositório. Opcional. + Nome Local: + Nome do repositório. Opcional. + Pasta Pai: + URL do Repositório: + FECHAR + Editor + Checar Commit + Cherry-Pick este commit + Cherry-Pick ... + Comparar com HEAD + Comparar com Worktree + Informações + SHA + Ação customizada + Rebase Interativo ${0}$ até Aqui + Rebase ${0}$ até Aqui + Resetar ${0}$ até Aqui + Reverter Commit + Modificar Mensagem + Salvar como Patch... + Mesclar ao Commit Pai + Mesclar commits filhos para este + ALTERAÇÕES + Buscar Alterações... + ARQUIVOS + Arquivo LFS + Submódulo + INFORMAÇÃO + AUTOR + ALTERADO + COMMITTER + Verificar referências que contenham este commit + COMMIT EXISTE EM + Mostra apenas as primeiras 100 alterações. Veja todas as alterações na aba ALTERAÇÕES. + MENSAGEM + PAIS + REFERÊNCIAS + SHA + Abrir no navegador + Descrição + Insira o assunto do commit + Configurar Repositório + TEMPLATE DE COMMIT + Conteúdo do Template: + Nome do Template: + AÇÃO CUSTOMIZADA + Argumentos: + ${REPO} - Caminho do repositório; ${SHA} - SHA do commit selecionado + Caminho do executável: + Nome: + Escopo: + Commit + Repositório + Endereço de email + Endereço de email + GIT + Buscar remotos automaticamente + Minuto(s) + Remoto padrão + RASTREADOR DE PROBLEMAS + Adicionar Regra de Exemplo do Azure DevOps + Adicionar Regra de Exemplo do Github + Adicionar Regra de Exemplo do GitLab + Adicionar regra de exemplo de Merge Request do GitLab + Adicionar Regra de Exemplo do Jira + Nova Regra + Expressão Regex de Issue: + Nome da Regra: + URL de Resultado: + Por favor, use $1, $2 para acessar os valores de grupos do regex. + IA + Serviço desejado: + Se o 'Serviço desejado' for definido, SourceGit usará ele neste Repositório. Senão, caso haja mais de um serviço disponível, será exibido um menu para seleção. + Proxy HTTP + Proxy HTTP usado por este repositório + Nome de Usuário + Nome de usuário para este repositório + Workspaces + Cor + Nome + Restaurar abas ao inicializar + Assistente de Conventional Commit + Breaking Change: + Ticket encerrado: + Detalhes: + Escopo: + Breve resumo: + Tipo de mudança: + Copiar + Copiar todo o texto + Copiar Caminho + Criar Branch... + Baseado Em: + Checar o branch criado + Alterações Locais: + Descartar + Guardar & Reaplicar + Nome do Novo Branch: + Insira o nome do branch. + Criar Branch Local + Criar Tag... + Nova Tag Em: + Assinatura GPG + Mensagem da Tag: + Opcional. + Nome da Tag: + Formato recomendado: v1.0.0-alpha + Enviar para todos os remotos após criação + Criar Nova Tag + Tipo: + anotada + leve + Pressione Ctrl para iniciar diretamente + Recortar + Excluir Branch + Branch: + Você está prestes a excluir uma branch remota!!! + Também excluir branch remoto ${0}$ + Excluir Múltiplos Branches + Você está tentando excluir vários branches de uma vez. Certifique-se de verificar antes de agir! + Excluir Remoto + Remoto: + Alvo: + Confirmar Exclusão do Grupo + Confirmar Exclusão do Repositório + Excluir Submódulo + Caminho do Submódulo: + Excluir Tag + Tag: + Excluir dos repositórios remotos + DIFERENÇA BINÁRIA + NOVO + ANTIGO + Copiar + Modo de Arquivo Alterado + Ignorar mudanças de espaço em branco + MUDANÇA DE OBJETO LFS + Próxima Diferença + SEM MUDANÇAS OU APENAS MUDANÇAS DE EOL + Diferença Anterior + Salvar como um Patch + Exibir símbolos ocultos + Diferença Lado a Lado + SUBMÓDULO + NOVO + Trocar + Realce de Sintaxe + Quebra de Linha + Abrir na Ferramenta de Mesclagem + Exibir todas as linhas + Diminuir Número de Linhas Visíveis + Aumentar Número de Linhas Visíveis + SELECIONE O ARQUIVO PARA VISUALIZAR AS MUDANÇAS + Abrir na Ferramenta de Mesclagem + Descartar Alterações + Todas as alterações locais na cópia de trabalho. + Alterações: + Incluir arquivos ignorados + Um total de {0} alterações será descartado + Você não pode desfazer esta ação!!! + Favorito: + Novo Nome: + Alvo: + Editar Grupo Selecionado + Editar Repositório Selecionado + Executar ação customizada + Nome da ação: + Buscar + Buscar todos os remotos + Buscar sem tags + Remoto: + Buscar Alterações Remotas + Assumir não alterado + Descartar... + Descartar {0} arquivos... + Descartar Alterações nas Linhas Selecionadas + Abrir Ferramenta de Mesclagem Externa + Salvar Como Patch... + Preparar + Preparar {0} arquivos + Preparar Alterações nas Linhas Selecionadas + Stash... + Stash {0} arquivos... + Desfazer Preparação + Desfazer Preparação de {0} arquivos + Desfazer Preparação nas Linhas Selecionadas + Usar Meu (checkout --ours) + Usar Deles (checkout --theirs) + Histórico de Arquivos + MUDANÇA + CONTEUDO + Git-Flow + Branch de Desenvolvimento: + Feature: + Prefixo da Feature: + FLOW - Concluir Feature + FLOW - Concluir Hotfix + FLOW - Concluir Release + Alvo: + Hotfix: + Prefixo do Hotfix: + Inicializar Git-Flow + Manter branch + Branch de Produção: + Release: + Prefixo da Release: + Iniciar Feature... + FLOW - Iniciar Feature + Iniciar Hotfix... + FLOW - Iniciar Hotfix + Digite o nome + Iniciar Release... + FLOW - Iniciar Release + Prefixo da Tag de Versão: + Git LFS + Adicionar Padrão de Rastreamento... + Padrão é nome do arquivo + Padrão Personalizado: + Adicionar Padrão de Rastreamento ao Git LFS + Buscar + Execute `git lfs fetch` para baixar objetos Git LFS. Isso não atualiza a cópia de trabalho. + Buscar Objetos LFS + Instalar hooks do Git LFS + Exibir bloqueios + Sem Arquivos Bloqueados + Bloquear + Exibir apenas meus bloqueios + Bloqueios LFS + Desbloquear + Forçar Desbloqueio + Prune + Execute `git lfs prune` para excluir arquivos LFS antigos do armazenamento local + Puxar + Execute `git lfs pull` para baixar todos os arquivos Git LFS para a referência atual e checkout + Puxar Objetos LFS + Enviar + Envie arquivos grandes enfileirados para o endpoint Git LFS + Enviar Objetos LFS + Remoto: + Rastrear arquivos nomeados '{0}' + Rastrear todos os arquivos *{0} + Históricos + AUTOR + DATA DO AUTOR + GRÁFICO & ASSUNTO + SHA + HORA DO COMMIT + SELECIONADO {0} COMMITS + Segure 'Ctrl' ou 'Shift' para selecionar múltiplos commits. + Segure ⌘ ou ⇧ para selecionar múltiplos commits. + DICAS: + Referência de Atalhos de Teclado + GLOBAL + Cancelar popup atual + Fechar página atual + Ir para a próxima página + Ir para a página anterior + Criar nova página + Abrir diálogo de preferências + REPOSITÓRIO + Commitar mudanças preparadas + Commitar e enviar mudanças preparadas + Preparar todas as mudanças e commitar + Cria um novo branch partindo do commit selecionado + Descartar mudanças selecionadas + Buscar, imediatamente + Modo de Dashboard (Padrão) + Modo de busca de commits + Puxar, imediatamente + Enviar, imediatamente + Forçar recarregamento deste repositório + Preparar/Despreparar mudanças selecionadas + Alternar para 'Mudanças' + Alternar para 'Históricos' + Alternar para 'Stashes' + EDITOR DE TEXTO + Fechar painel de busca + Encontrar próxima correspondência + Encontrar correspondência anterior + Abrir painel de busca + Descartar + Preparar + Despreparar + Inicializar Repositório + Caminho: + Cherry-Pick em andamento. + Merge em andamento. + Rebase em andamento. + Revert em andamento. + Rebase Interativo + Em: + Ramo Alvo: + Copiar link + Abrir no navegador + ERRO + AVISO + Mesclar Ramo + Para: + Opção de Mesclagem: + Mover nó do repositório + Selecionar nó pai para: + Nome: + O Git NÃO foi configurado. Por favor, vá para [Preferências] e configure primeiro. + Abrir Pasta de Dados do Aplicativo + Abrir Com... + Opcional. + Criar Nova Página + Adicionar aos Favoritos + Fechar Aba + Fechar Outras Abas + Fechar Abas à Direita + Copiar Caminho do Repositório + Repositórios + Colar + {0} dias atrás + 1 hora atrás + {0} horas atrás + Agora mesmo + Mês passado + Ano passado + {0} minutos atrás + {0} meses atrás + {0} anos atrás + Ontem + Preferências + INTELIGÊNCIA ARTIFICIAL + Prompt para Analisar Diff + Chave da API + Prompt para Gerar Título + Modelo + Nome + Servidor + APARÊNCIA + Fonte Padrão + Tamanho da Fonte + Padrão + Editor + Fonte Monoespaçada + Usar fonte monoespaçada apenas no editor de texto + Tema + Substituições de Tema + Usar largura fixa de aba na barra de título + Usar moldura de janela nativa + FERRAMENTA DE DIFF/MERGE + Caminho de Instalação + Insira o caminho para a ferramenta de diff/merge + Ferramenta + GERAL + Verificar atualizações na inicialização + Idioma + Commits do Histórico + Exibir data do autor em vez da data do commit no gráfico + Comprimento do Guia de Assunto + GIT + Habilitar Auto CRLF + Diretório de Clone Padrão + Email do Usuário + Email global do usuário git + Habilita --prune ao buscar + Git (>= 2.25.1) é necessário para este aplicativo + Caminho de Instalação + Nome do Usuário + Nome global do usuário git + Versão do Git + ASSINATURA GPG + Assinatura GPG de commit + Formato GPG + Caminho de Instalação do Programa + Insira o caminho para o programa gpg instalado + Assinatura GPG de tag + Chave de Assinatura do Usuário + Chave de assinatura gpg do usuário + INTEGRAÇÃO + SHELL/TERMINAL + Caminho + Shell/Terminal + Prunar Remoto + Alvo: + Podar Worktrees + Podar informações de worktree em `$GIT_COMMON_DIR/worktrees` + Puxar + Branch Remoto: + Para: + Alterações Locais: + Descartar + Guardar & Reaplicar + Remoto: + Puxar (Buscar & Mesclar) + Usar rebase em vez de merge + Empurrar + Certifica de que submodules foram enviadas + Forçar push + Branch Local: + Remoto: + Empurrar Alterações para o Remoto + Branch Remoto: + Definir como branch de rastreamento + Empurrar todas as tags + Empurrar Tag para o Remoto + Empurrar para todos os remotos + Remoto: + Tag: + Sair + Rebase da Branch Atual + Guardar & reaplicar alterações locais + Em: + Rebase: + Adicionar Remoto + Editar Remoto + Nome: + Nome do remoto + URL do Repositório: + URL do repositório git remoto + Copiar URL + Excluir... + Editar... + Buscar + Abrir no Navegador + Podar + Confirmar Remoção de Worktree + Habilitar Opção `--force` + Alvo: + Renomear Branch + Novo Nome: + Nome único para este branch + Branch: + ABORTAR + Buscando automaticamente mudanças dos remotos... + Limpar (GC & Podar) + Execute o comando `git gc` para este repositório. + Limpar tudo + Configurar este repositório + CONTINUAR + Ações customizada + Nenhuma ação customizada + Descartar todas as alterações + Habilitar opção '--reflog' + Abrir no Navegador de Arquivos + Pesquisar Branches/Tags/Submódulos + Desfazer + Esconder no gráfico de commit + Incluir no gráfico de commit + Habilitar opção '--first-parent' + Data do Commit + Topologicamente + BRANCHES LOCAIS + Navegar para HEAD + Criar Branch + Abrir em {0} + Abrir em Ferramentas Externas + Atualizar + REMOTOS + ADICIONAR REMOTO + Pesquisar Commit + Autor + Committer + Arquivo + Mensagem + SHA + Branch Atual + Exibir Tags como Árvore + Estatísticas + SUBMÓDULOS + ADICIONAR SUBMÓDULO + ATUALIZAR SUBMÓDULO + TAGS + NOVA TAG + Abrir no Terminal + WORKTREES + ADICIONAR WORKTREE + PODAR + URL do Repositório Git + Resetar Branch Atual para Revisão + Modo de Reset: + Mover Para: + Branch Atual: + Revelar no Explorador de Arquivos + Reverter Commit + Commit: + Commitar alterações de reversão + Reescrever Mensagem do Commit + Use 'Shift+Enter' para inserir uma nova linha. 'Enter' é a tecla de atalho do botão OK + Executando. Por favor, aguarde... + SALVAR + Salvar Como... + Patch salvo com sucesso! + Escanear Repositórios + Diretório Raiz: + Verificar atualizações... + Nova versão deste software disponível: + Falha ao verificar atualizações! + Baixar + Ignorar esta versão + Atualização de Software + Não há atualizações disponíveis no momento. + Copiar SHA + Squash Commits + Squash commits em: + Chave SSH Privada: + Caminho para a chave SSH privada + INICIAR + Stash + Incluir arquivos não rastreados + Manter arquivos em stage + Mensagem: + Opcional. Nome deste stash + Apenas mudanças em stage + Tanto mudanças em stage e fora de stage dos arquivos selecionados serão enviadas para stash!!! + Guardar Alterações Locais + Aplicar + Descartar + Descartar Stash + Descartar: + Stashes + ALTERAÇÕES + STASHES + Estatísticas + COMMITS + COMMITTER + VISÃO GERAL + MÊS + SEMANA + AUTORES: + COMMITS: + SUBMÓDULOS + Adicionar Submódulo + Copiar Caminho Relativo + Buscar submódulos aninhados + Abrir Repositório do Submódulo + Caminho Relativo: + Pasta relativa para armazenar este módulo. + Excluir Submódulo + OK + Copiar Nome da Tag + Copiar mensage da Tag + Excluir ${0}$... + Mesclar ${0}$ em ${1}$... + Enviar ${0}$... + Atualizar Submódulos + Todos os submódulos + Inicializar conforme necessário + Recursivamente + Submódulo: + Usar opção --remote + URL: + Aviso + Página de Boas-vindas + Criar Grupo Raíz + Criar Subgrupo + Clonar Repositório + Excluir + ARRASTAR E SOLTAR PASTAS SUPORTADO. AGRUPAMENTO PERSONALIZADO SUPORTADO. + Editar + Mover para Outro Grupo + Abrir Todos os Repositórios + Abrir Repositório + Abrir Terminal + Reescanear Repositórios no Diretório de Clone Padrão + Buscar Repositórios... + Ordenar + Alterações + Git Ignore + Ignorar todos os arquivos *{0} + Ignorar arquivos *{0} na mesma pasta + Ignorar arquivos na mesma pasta + Ignorar apenas este arquivo + Corrigir + Você pode stagear este arquivo agora. + COMMIT + COMMITAR E ENVIAR + Modelo/Históricos + Acionar evento de clique + Preparar todas as mudanças e commitar + CONFLITOS DETECTADOS + CONFLITOS DE ARQUIVO RESOLVIDOS + INCLUIR ARQUIVOS NÃO RASTREADOS + SEM MENSAGENS DE ENTRADA RECENTES + SEM MODELOS DE COMMIT + Clique com o botão direito nos arquivos selecionados e escolha como resolver conflitos. + STAGED + UNSTAGE + UNSTAGE TODOS + UNSTAGED + STAGE + STAGE TODOS + VER SUPOR NÃO ALTERADO + Template: ${0}$ + Workspaces: + Configurar workspaces... + WORKTREE + Copiar Caminho + Bloquear + Remover + Desbloquear + diff --git a/src/Resources/Locales/ru_RU.axaml b/src/Resources/Locales/ru_RU.axaml new file mode 100644 index 00000000..a625df98 --- /dev/null +++ b/src/Resources/Locales/ru_RU.axaml @@ -0,0 +1,807 @@ + + + + + + О программе + О SourceGit + Бесплатный графический клиент Git с исходным кодом + Добавить рабочий каталог + Расположение: + Путь к рабочему каталогу (поддерживается относительный путь) + Имя ветки: + Имя целевого каталога по умолчанию. (необязательно) + Отслеживание ветки: + Отслеживание внешней ветки + Переключиться на: + Создать новую ветку + Ветку из списка + Помощник OpenAI + ПЕРЕСОЗДАТЬ + Использовать OpenAI для создания сообщения о ревизии + ПРИМЕНИТЬ КАК СООБЩЕНИЕ РЕВИЗИИ + Исправить + Файл заплатки: + Выберите файл .patch для применения + Игнорировать изменения пробелов + Применить заплатку + Пробел: + Отложить + Удалить после применения + Восстановить изменения индекса + Отложенный: + Архивировать... + Сохранить архив в: + Выберите путь к архивному файлу + Ревизия: + Архив + Спросить разрешения SourceGit + НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ + СПИСОК ПУСТ + УДАЛИТЬ + Загрузить картинку... + Обновить + ДВОИЧНЫЙ ФАЙЛ НЕ ПОДДЕРЖИВАЕТСЯ!!! + Раздвоить + О + Плохая + Раздвоение. Текущая ГОЛОВА (HEAD) хорошая или плохая? + Хорошая + Пропустить + Раздвоение. Сделать текущую ревизию хорошей или плохой и переключиться на другой. + Расследование + РАССЛЕДОВАНИЕ В ЭТОМ ФАЙЛЕ НЕ ПОДДЕРЖИВАЕТСЯ!!! + Переключиться на ${0}$... + Сравнить с ${0}$ + Сравнить с рабочим каталогом + Копировать имя ветки + Изменить действие + Удалить ${0}$... + Удалить выбранные {0} ветки + Перемотать вперёд к ${0}$ + Извлечь ${0}$ в ${1}$... + Git-процесс - Завершение ${0}$ + Влить ${0}$ в ${1}$... + Влить {0} выделенных веток в текущую + Загрузить ${0}$ + Загрузить ${0}$ в ${1}$... + Выложить ${0}$ + Переместить ${0}$ на ${1}$... + Переименовать ${0}$... + Сбросить ${0}$ к ${1}$... + Отслеживать ветку... + Сравнение веток + Недопустимая основная ветка! + Байты + ОТМЕНА + Сбросить родительскую ревизию + Сбросить эту ревизию + Произвести сообщение о ревизии + ИЗМЕНИТЬ РЕЖИМ ОТОБРАЖЕНИЯ + Показывать в виде списка файлов и каталогов + Показывать в виде списка путей + Показывать в виде дерева файловой системы + Переключить ветку + Переключение ревизии + Ревизия: + Предупреждение: После переключения ревизии ваша Голова (HEAD) будет отсоединена + Локальные изменения: + Отклонить + Отложить и примненить повторно + Обновить все подкаталоги + Ветка: + Частичный выбор + Добавить источник для ревизии сообщения + Ревизия(и): + Ревизия всех изменений. + Основной: + Обычно вы не можете выделить слияние, потому что не знаете, какую сторону слияния следует считать основной. Эта опция позволяет отобразить изменения относительно указанного родительского элемента. + Очистить отложенные + Вы пытаетесь очистить все отложенные. Вы уверены, что хотите продолжить? + Клонировать внешний репозиторий + Расширенные параметры: + Дополнительные аргументы для клонирования репозитория. (необязательно). + Локальное имя: + Имя репозитория. (необязательно). + Родительский каталог: + Создать и обновить подмодуль + Адрес репозитория: + ЗАКРЫТЬ + Редактор + Переключиться на эту ревизию + Применить эту ревизию (cherry-pick) + Применить несколько ревизий ... + Сравнить c ГОЛОВОЙ (HEAD) + Сравнить с рабочим каталогом + Автор + Ревизор + Информацию + SHA + Субъект + Пользовательское действие + Интерактивное перемещение (rebase -i) ${0}$ сюда + Влить в ${0}$ + Влить ... + Переместить ${0}$ сюда + Сбросить ${0}$ сюда + Отменить ревизию + Изменить комментарий + Сохранить как заплатки... + Объединить с предыдущей ревизией + Объединить все следующие ревизии с этим + ИЗМЕНЕНИЯ + изменённый(х) файл(ов) + Найти изменения.... + ФАЙЛЫ + Файл LFS + Поиск файлов... + Подмодуль + ИНФОРМАЦИЯ + АВТОР + ИЗМЕНЁННЫЙ + ДОЧЕРНИЙ + РЕВИЗОР (ИСПОЛНИТЕЛЬ) + Найти все ветки с этой ревизией + ВЕТКИ С ЭТОЙ РЕВИЗИЕЙ + Отображаются только первые 100 изменений. Смотрите все изменения на вкладке ИЗМЕНЕНИЯ. + СООБЩЕНИЕ + РОДИТЕЛИ + ССЫЛКИ + SHA + Открыть в браузере + Описание + СУБЪЕКТ + Введите тему ревизии + Настройка репозитория + ШАБЛОН РЕВИЗИИ + Cодержание: + Название: + ПОЛЬЗОВАТЕЛЬСКОЕ ДЕЙСТВИЕ + Аргументы: + ${REPO} - Путь к репозиторию; ${SHA} - SHA ревизий + Исполняемый файл: + Имя: + Диапазон: + Ветка + Ревизия + Репозиторий + Ждать для выполения выхода + Адрес электронной почты + Адрес электронной почты + GIT + Автозагрузка изменений + Минут(а/ы) + Внешний репозиторий по умолчанию + Предпочтительный режим слияния + ОТСЛЕЖИВАНИЕ ПРОБЛЕМ + Добавить пример правила Azure DevOps + Добавить пример правила для тем в Gitea + Добавить пример правила запроса скачивания из Gitea + Добавить пример правила для Git + Добавить пример правила выдачи GitLab + Добавить пример правила запроса на слияние в GitLab + Добавить пример правила Jira + Новое правило + Проблема с регулярным выражением: + Имя правила: + Адрес результата: + Пожалуйста, используйте $1, $2 для доступа к значениям групп регулярных выражений. + ОТКРЫТЬ ИИ + Предпочтительный сервис: + Если «Предпочтительный сервис» установлен, SourceGit будет использовать только этот репозиторий. В противном случае, если доступно более одной услуги, будет отображено контекстное меню для выбора одной из них. + HTTP-прокси + HTTP-прокси для репозитория + Имя пользователя + Имя пользователя репозитория + Рабочие пространства + Цвет + Имя + Восстанавливать вкладки при запуске + ПРОДОЛЖИТЬ + Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty)? + Сформировать всё и зафиксировать ревизию + Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty) или отложить всё, затем зафиксировать ревизию? + Общепринятый помощник по ревизии + Кардинальные изменения: + Закрытая тема: + Детали изменений: + Область: + Короткое описание: + Тип изменения: + Копировать + Копировать весь текст + Копировать полный путь + Копировать путь + Создать ветку... + Основан на: + Переключиться на созданную ветку + Локальные изменения: + Отклонить + Отложить и применить повторно + Имя новой ветки: + Введите имя ветки. + Пробелы будут заменены на тире. + Создать локальную ветку + Перезаписать существующую ветку + Создать метку... + Новая метка у: + GPG подпись + Сообщение с меткой: + Необязательно. + Имя метки: + Рекомендуемый формат: v1.0.0-alpha + Выложить на все внешние репозитории после создания + Создать новую метку + Вид: + С примечаниями + Простой + Удерживайте Ctrl, чтобы сразу начать + Вырезать + Удалить подмодуль + Принудительно удалить даже если содержит локальные изменения. + Подмодуль: + Удалить ветку + Ветка: + Вы собираетесь удалить внешнюю ветку!!! + Также удалите внешнюю ветку ${0}$ + Удаление нескольких веток + Вы пытаетесь удалить несколько веток одновременно. Обязательно перепроверьте, прежде чем предпринимать какие-либо действия! + Удалить внешний репозиторий + Внешний репозиторий: + Путь: + Цель: + Все дочерние элементы будут удалены из списка. + Будет удалён из списка. На диске останется. + Подтвердите удаление группы + Подтвердите удаление репозитория + Удалить подмодуль + Путь подмодуля: + Удалить метку + Метка: + Удалить из внешнего репозитория + СРАВНЕНИЕ БИНАРНИКОВ + НОВЫЙ + СТАРЫЙ + Копировать + Режим файла изменён + Первое сравнение + Игнорировать изменения пробелов + Последнее сравнение + ИЗМЕНЕНИЕ ОБЪЕКТА LFS + Следующее сравнение + НИКАКИХ ИЗМЕНЕНИЙ ИЛИ МЕНЯЕТСЯ ТОЛЬКО EOL + Предыдущее сравнение + Сохранить как заплатку + Показывать скрытые символы + Сравнение рядом + ПОДМОДУЛЬ + УДАЛЁН + НОВЫЙ + Обмен + Подсветка синтаксиса + Перенос слов в строке + Разрешить навигацию по блокам + Открыть в инструменте слияния + Показывать все строки + Уменьшить количество видимых строк + Увеличить количество видимых строк + ВЫБЕРИТЕ ФАЙЛ ДЛЯ ПРОСМОТРА ИЗМЕНЕНИЙ + Открыть в инструменте слияния + Отклонить изменения + Все локальные изменения в рабочей копии. + Изменения: + Включить игнорируемые файлы + {0} изменений будут отменены + Вы не можете отменить это действие!!! + Закладка: + Новое имя: + Цель: + Редактировать выбранную группу + Редактировать выбранный репозиторий + Выполнить пользовательское действие + Имя действия: + Извлечь + Извлечь все внешние репозитории + Разрешить опцию (--force) + Извлечь без меток + Внешний репозиторий: + Извлечь внешние изменения + Не отслеживать + Отклонить... + Отклонить {0} файлов... + Отменить изменения в выбранной(ых) строке(ах) + Открыть расширенный инструмент слияния + Взять версию ${0}$ + Сохранить как файл заплатки... + Сформировать + Сформированные {0} файлы + Сформированные изменения в выбранной(ых) строке(ах) + Отложить... + Отложить {0} файлов... + Расформировать + Несформированные {0} файлы + Несформированные изменения в выбранной(ых) строке(ах) + Использовать мой (checkout --ours) + Использовать их (checkout --theirs) + История файлов + ИЗМЕНИТЬ + СОДЕРЖИМОЕ + Git-процесс + Ветка разработчика: + Свойство: + Свойство префикса: + ПРОЦЕСС - Свойства завершения + ПРОЦЕСС - Закончить исправление + ПРОЦЕСС - Завершить выпуск + Цель: + Выложить на удалённый(ые) после завершения + Втиснуть при слиянии + Исправление: + Префикс исправлений: + Создать Git-процесс + Держать ветку + Производственная ветка: + Выпуск: + Префикс выпуска: + Свойство запуска... + ПРОЦЕСС - Свойство запуска + Запуск исправлений... + ПРОЦЕСС - Запуск исправлений + Ввести имя + Запуск выпуска... + ПРОЦЕСС - Запуск выпуска + Префикс метки версии: + Git LFS (хранилище больших файлов) + Добавить шаблон отслеживания... + Шаблон — это имя файла + Изменить шаблон: + Добавить шаблон отслеживания в LFS Git + Извлечь + Запустить (git lfs fetch), чтобы загрузить объекты LFS Git. При этом рабочая копия не обновляется. + Извлечь объекты LFS + Установить перехват LFS Git + Показывать блокировки + Нет заблокированных файлов + Блокировка + Показывать только мои блокировки + Блокировки LFS + Разблокировать + Принудительно разблокировать + Обрезать + Запустить (git lfs prune), чтобы удалить старые файлы LFS из локального хранилища + Загрузить + Запустить (git lfs pull), чтобы загрузить все файлы LFS Git для текущей ссылки и проверить + Загрузить объекты LFS + Выложить + Отправляйте большие файлы, помещенные в очередь, в конечную точку LFS Git + Выложить объекты LFS + Внешнее хранилище: + Отслеживать файлы с именем «{0}» + Отслеживать все *{0} файлов + Истории + АВТОР + ВРЕМЯ АВТОРА + ГРАФ И СУБЪЕКТ + SHA + ВРЕМЯ РЕВИЗИИ + ВЫБРАННЫЕ {0} РЕВИЗИИ + Удерживайте Ctrl или Shift, чтобы выбрать несколько ревизий. + Удерживайте ⌘ или ⇧, чтобы выбрать несколько ревизий. + ПОДСКАЗКИ: + Ссылка на сочетания клавиш + ОБЩЕЕ + Закрыть окно + Клонировать репозиторий + Закрыть вкладку + Перейти на следующую вкладку + Перейти на предыдущую вкладку + Создать новую вкладку + Открыть диалоговое окно настроек + Переключить активное рабочее место + Переключить активную страницу + РЕПОЗИТОРИЙ + Зафиксировать сформированные изменения + Зафиксировать и выложить сформированные изменения + Сформировать все изменения и зафиксировать + Создать новую ветку на основе выбранной ветки + Отклонить выбранные изменения + Извлечение, запускается сразу + Режим доски (по умолчанию) + Режим поиска ревизий + Загрузить, запускается сразу + Выложить, запускается сразу + Принудительно перезагрузить репозиторий + Сформированные/Несформированные выбранные изменения + Переключить на «Изменения» + Переключить на «Истории» + Переключить на «Отложенные» + ТЕКСТОВЫЙ РЕДАКТОР + Закрыть панель поиска + Найти следующее совпадение + Найти предыдущее совпадение + Открыть с внешним инструментом сравнения/слияние + Открыть панель поиска + Отклонить + Сформировать + Расформировать + Создать репозиторий + Путь: + Выполняется частичный перенос ревизий (cherry-pick). + Обрабтка ревизии. + Выполняется слияние. + Выполяется. + Выполняется перенос. + Остановлен на + Выполняется отмена ревизии. + Выполняется отмена + Интерактивное перемещение + На: + Целевая ветка: + Копировать ссылку + Открыть в браузере + ОШИБКА + УВЕДОМЛЕНИЕ + Рабочие места + Страницы + Влить ветку + В: + Опции слияния: + Источник: + Влить несколько веток + Зафиксировать все изменения + Стратегия: + Цели: + Переместить репозиторий в другую группу + Выбрать группу для: + Имя: + Git НЕ был настроен. Пожалуйста, перейдите в [Настройки] и сначала настройте его. + Открыть приложение каталогов данных + Окрыть с... + Необязательно. + Создать новую страницу + Закладка + Закрыть вкладку + Закрыть другие вкладки + Закрыть вкладки справа + Копировать путь репозитория + Репозитории + Вставить + {0} дней назад + 1 час назад + {0} часов назад + Сейчас + Последний месяц + В прошлом году + {0} минут назад + {0} месяцев назад + {0} лет назад + Вчера + Параметры + ОТКРЫТЬ ИИ + Запрос на анализ сравнения + Ключ API + Создать запрос на тему + Модель + Имя: + Сервер + Разрешить потоковую передачу + ВИД + Шрифт по умолчанию + Редактировать ширину вкладки + Размер шрифта + По умолчанию + Редактор + Моноширный шрифт + В текстовом редакторе используется только моноширный шрифт + Тема + Переопределение темы + Использовать фиксированную ширину табуляции в строке заголовка. + Использовать системное окно + ИНСТРУМЕНТ СРАВНЕНИЙ/СЛИЯНИЯ + Путь установки + Введите путь для инструмента сравнения/слияния + Инструмент + ОСНОВНЫЕ + Проверить обновления при старте + Формат даты + Язык + Максимальная длина истории + Показывать время автора вместо времени ревизии на графике + Показать наследника в деталях комментария + Показывать метки на графике + Длина темы ревизии + GIT + Включить автозавершение CRLF + Каталог клонирования по умолчанию + Электроная почта пользователя + Общая электроная почта пользователя git + Разрешить (--prune) при скачивании + Разрешить (--ignore-cr-at-eol) в сравнении + Для работы программы требуется версия Git (>= 2.25.1) + Путь установки + Разрешить верификацию HTTP SSL + Имя пользователя + Общее имя пользователя git + Версия Git + GPG ПОДПИСЬ + GPG подпись ревизии + Формат GPG + Путь установки программы + Введите путь для установленной программы GPG + GPG подпись метки + Ключ подписи пользователя + Ключ GPG подписи пользователя + ВНЕДРЕНИЕ + ОБОЛОЧКА/ТЕРМИНАЛ + Путь + Оболочка/Терминал + Удалить внешний репозиторий + Цель: + Удалить рабочий каталог + Информация об обрезке рабочего каталога в «$GIT_COMMON_DIR/worktrees» + Загрузить + Ветка внешнего репозитория: + В: + Локальные изменения: + Отклонить + Отложить и применить повторно + Обновить все подмодули + Внешний репозиторий: + Загрузить (Получить и слить) + Использовать перемещение вместо слияния + Выложить + Убедитесь, что подмодули были вставлены + Принудительно выложить + Локальная ветка: + Внешний репозиторий: + Выложить изменения на внешний репозиторий + Ветка внешнего репозитория: + Отслеживать ветку + Выложить все метки + Выложить метку на внешний репозиторий + Выложить на все внешние репозитории + Внешний репозиторий: + Метка: + Выйти + Перемещение текущей ветки + Отложить и применить повторно локальные изменения + На: + Переместить: + Добавить внешний репозиторий + Редактировать внешний репозиторий + Имя: + Имя внешнего репозитория + Адрес: + Адрес внешнего репозитория git + Копировать адрес + Удалить... + Редактировать... + Извлечь + Открыть в браузере + Удалить + Подтвердить удаление рабочего каталога + Включить опцию (--force) + Цель: + Переименовать ветку + Новое имя: + Уникальное имя для данной ветки + Ветка: + Отказ + Автоматическое извлечение изменений с внешних репозиторий... + Сортировать + По дате ревизора (исполнителя) + По имени + Очистить (Сбор мусора и удаление) + Запустить команду (git gc) для данного репозитория. + Очистить всё + Очистить + Настройка репозитория + ПРОДОЛЖИТЬ + Изменить действия + Не изменять действия + Отклонить все изменения. + Разрешить опцию --reflog + Открыть в файловом менеджере + Поиск веток, меток и подмодулей + Видимость на графике + Не установлен (по умолчанию) + Скрыть в графе ревизии + Фильтр в графе ревизии + Включить опцию (--first-parent) + РАСПОЛОЖЕНИЕ + Горизонтально + Вертикально + ЗАПРОС РЕВИЗИЙ + Дата ревизии + Топологически + ЛОКАЛЬНЫЕ ВЕТКИ + Навигация по ГОЛОВЕ (HEAD) + Создать ветку + ОЧИСТКА УВЕДОМЛЕНИЙ + Выделять только текущую ветку на графике + Открыть в {0} + Открыть в расширенном инструменте + Обновить + ВНЕШНИЕ РЕПОЗИТОРИИ + ДОБАВИТЬ ВНЕШНИЙ РЕПОЗИТОРИЙ + Поиск ревизии + Автор + Ревизор + Содержимое + Файл + Сообщение + SHA + Текущая ветка + Показывать подмодули как дерево + Показывать метки как катлог + ПРОПУСТИТЬ + Статистикa + ПОДМОДУЛИ + ДОБАВИТЬ ПОДМОДУЛЬ + ОБНОВИТЬ ПОДМОДУЛЬ + МЕТКИ + НОВАЯ МЕТКА + По дате создания + По имени + Сортировать + Открыть в терминале + Использовать относительное время в историях + Просмотр журналов + Посетить '{0}' в браузере + РАБОЧИЕ КАТАЛОГИ + ДОБАВИТЬ РАБОЧИЙ КАТАЛОГ + ОБРЕЗАТЬ + Адрес репозитория Git + Сбросить текущую ветку до версии + Режим сброса: + Переместить в: + Текущая ветка: + Сброс ветки (без переключения) + Переместить в: + Ветка: + Открыть в файловом менеджере + Отменить ревизию + Ревизия: + Отмена ревизии + Изменить комментарий ревизии + Используйте «Shift+Enter» для ввода новой строки. «Enter» - это горячая клавиша кнопки «OK» + Запуск. Подождите пожалуйста... + СОХРАНИТЬ + Сохранить как... + Заплатка успешно сохранена! + Сканирование репозиторий + Корневой каталог: + Проверка для обновления... + Доступна новая версия программного обеспечения: + Не удалось проверить наличие обновлений! + Загрузка + Пропустить эту версию + Обновление ПО + В настоящее время обновления недоступны. + Отслеживать ветку + Ветка: + Снять основную ветку + Основная ветка: + Копировать SHA + Перейти + Втиснуть ревизии + В: + Приватный ключ SSH: + Путь хранения приватного ключа SSH + ЗАПУСК + Отложить + Автоматически восстанавливать после откладывания + Ваши рабочие файлы остаются неизменными, но отложенные сохранятся. + Включить неотслеживаемые файлы + Хранить отложенные файлы + Сообщение: + Имя тайника (необязательно) + Только сформированные изменения + Сформированные так и несформированные изменения выбранных файлов будут сохранены!!! + Отложить локальные изменения + Принять + Отбросить + Сохранить как заплатку... + Отбросить тайник + Отбросить: + Отложенные + ИЗМЕНЕНИЯ + ОТЛОЖЕННЫЕ + Статистика + РЕВИЗИИ + РЕВИЗОРЫ (ИСПОЛНИТЕЛИ) + ОБЗОР + МЕСЯЦ + НЕДЕЛЯ + АВТОРЫ: + РЕВИЗИИ: + ПОДМОДУЛИ + Добавить подмодули + Копировать относительный путь + Удалить подмодуль + Извлечение вложенных подмодулей + Открыть подмодуль репозитория + Каталог: + Относительный путь для хранения подмодуля. + Удалить подмодуль + СОСТОЯНИЕ + изменён + не создан + ревизия изменена + не слита + URL-адрес + ОК + Копировать имя метки + Копировать сообщение с метки + Удалить ${0}$... + Влить ${0}$ в ${1}$... + Выложить ${0}$... + Обновление подмодулей + Все подмодули + Создавать по необходимости + Рекурсивно + Подмодуль: + Использовать опцию (--remote) + Сетевой адрес: + Журналы + ОЧИСТИТЬ ВСЁ + Копировать + Удалить + Предупреждение + Приветствие + Создать группу + Создать подгруппу + Клонировать репозиторий + Удалить + ПОДДЕРЖИВАЕТСЯ: ПЕРЕТАСКИВАНИЕ КАТАЛОГОВ, ПОЛЬЗОВАТЕЛЬСКАЯ ГРУППИРОВКА. + Редактировать + Перейти в другую группу + Открыть все репозитории + Открыть репозиторий + Открыть терминал + Повторное сканирование репозиториев в каталоге клонирования по умолчанию + Поиск репозиториев... + Сортировка + Изменения + Игнорировать Git + Игнорировать все *{0} файлы + Игнорировать *{0} файлы в том же каталоге + Игнорировать файлы в том же каталоге + Игнорировать только эти файлы + Изменить + Теперь вы можете сформировать этот файл. + ЗАФИКСИРОВАТЬ + ЗАФИКСИРОВАТЬ и ОТПРАВИТЬ + Шаблон/Истории + Запустить событие щелчка + Зафиксировать (Редактировать) + Сформировать все изменения и зафиксировать + Вы сформировали {0} файл(ов), но отображается только {1} файл(ов) ({2} файл(ов) отфильтровано). Вы хотите продолжить? + ОБНАРУЖЕНЫ КОНФЛИКТЫ + ОТКРЫТЬ ВНЕШНИЙ ИНСТРУМЕНТ СЛИЯНИЯ + ОТКРЫТЬ ВСЕ КОНФЛИКТЫ ВО ВНЕШНЕМ ИНСТРУМЕНТЕ СЛИЯНИЯ + КОНФЛИКТЫ ФАЙЛОВ РАЗРЕШЕНЫ + ИСПОЛЬЗОВАТЬ МОИ + ИСПОЛЬЗОВАТЬ ИХ + ВКЛЮЧИТЬ НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ + НЕТ ПОСЛЕДНИХ ВХОДНЫХ СООБЩЕНИЙ + НЕТ ШАБЛОНОВ РЕВИЗИИ + Сбросить автора + Щёлкните правой кнопкой мыши выбранный файл(ы) и разрешите конфликты. + Завершение работы + СФОРМИРОВАННЫЕ + РАСФОРМИРОВАТЬ + РАСФОРМИРОВАТЬ ВСЁ + НЕСФОРМИРОВАННЫЕ + СФОРМИРОВАТЬ + СФОРМИРОВАТЬ ВСЁ + ОТКРЫТЬ СПИСОК НЕОТСЛЕЖИВАЕМЫХ ФАЙЛОВ + Шаблон: ${0}$ + РАБОЧЕЕ ПРОСТРАНСТВО: + Настройка рабочего пространства... + РАБОЧИЙ КАТАЛОГ + Копировать путь + Заблокировать + Удалить + Разблокировать + diff --git a/src/Resources/Locales/ta_IN.axaml b/src/Resources/Locales/ta_IN.axaml new file mode 100644 index 00000000..abe53252 --- /dev/null +++ b/src/Resources/Locales/ta_IN.axaml @@ -0,0 +1,744 @@ + + + + + + பற்றி + மூலஅறிவிலி பற்றி + திறந்தமூல & கட்டற்ற அறிவிலி இடைமுக வாடிக்கயாளர் + பணிமரத்தைச் சேர் + இடம்: + இந்த பணிமரத்திற்கான பாதை. தொடர்புடைய பாதை ஆதரிக்கப்படுகிறது. + கிளை பெயர்: + விருப்பத்தேர்வு. இயல்புநிலை இலக்கு கோப்புறை பெயர். + கிளை கண்காணி: + தொலை கிளையைக் கண்காணித்தல் + என்ன சரிபார்க்க வேண்டும்: + புதிய கிளையை உருவாக்கு + ஏற்கனவே உள்ள கிளை + செநு உதவியாளர் + மறு-உருவாக்கு + உறுதிமொழி செய்தியை உருவாக்க செநுவைப் பயன்படுத்து + உறுதிமொழி செய்தி என இடு + ஒட்டு + ஒட்டு கோப்பு: + .ஒட்டு இடுவதற்கு கோப்பைத் தேர்ந்தெடு + வெள்ளைவெளி மாற்றங்களைப் புறக்கணி + ஒட்டு இடு + வெள்ளைவெளி: + பதுக்கிவைத்ததை இடு + பயன்படுத்திய பின் நீக்கு + குறியீட்டின் மாற்றங்களை மீண்டும் நிறுவு + பதுக்கிவை: + காப்பகம்... + இதற்கு காப்பகத்தை சேமி: + காப்பகக் கோப்பு பாதையைத் தேர்ந்தெடு + திருத்தம்: + காப்பகம் + மூலஅறிவிலி கடவுகேள் + கோப்புகள் மாற்றப்படவில்லை எனக் கருதப்படுகிறது + எந்த கோப்புகளும் மாற்றப்படவில்லை எனக் கருதப்படுகிறது + நீக்கு + புதுப்பி + இருமம் கோப்பு ஆதரிக்கப்படவில்லை!!! + குற்றச்சாட்டு + இந்த கோப்பில் குற்றம் சாட்ட ஆதரிக்கப்படவில்லை!!! + ${0}$ சரிபார்... + பணிமரத்துடன் ஒப்பிடுக + கிளை பெயரை நகலெடு + தனிப்பயன் செயல் + ${0}$ ஐ நீக்கு... + தேர்ந்தெடுக்கப்பட்ட {0} கிளைகளை நீக்கு + ${0}$ இதற்கு வேகமாக முன்னோக்கிச் செல் + ${0}$ ஐ ${1}$இல் பெறு... + அறிவிலி ஓட்டம் - முடி ${0}$ + ${0}$ ஐ ${1}$இல் இணை... + தேர்ந்தெடுக்கப்பட்ட {0} கிளைகளை தற்பொதையதில் இணை + இழு ${0}$ + இழு ${0}$ஐ ${1}$-க்குள்... + தள்ளு ${0}$ + மறுதளம் ${0}$ இதன்மேல் ${1}$... + மறுபெயரிடு ${0}$... + கண்காணிப்பு கிளையை அமை... + கிளை ஒப்பிடு + தவறான மேல்ஓடை! + எண்மங்கள் + விடு + பெற்றோர் திருத்தத்திற்கு மீட்டமை + இந்த திருத்தத்திற்கு மீட்டமை + உறுதிமொழி செய்தி உருவாக்கு + காட்சி பயன்முறையை மாற்று + கோப்பு மற்றும் கோப்புறை பட்டியலாக காட்டு + பாதை பட்டியலாகக் காட்டு + கோப்பு முறைமை மரமாகக் காட்டு + கிளை சரிபார் + உறுதிமொழி சரிபார் + உறுதிமொழி: + முன்னறிவிப்பு: ஒரு உறுதிமொழி சரிபார்பதன் மூலம், உங்கள் தலை பிரிக்கப்படும் + உள்ளக மாற்றங்கள்: + நிராகரி + பதுக்கிவை & மீண்டும் இடு + கிளை: + கனி பறி + உறுதிமொழி செய்திக்கு மூலத்தைச் சேர் + உறுதிமொழி(கள்): + அனைத்து மாற்றங்களையும் உறுதிமொழி + முதன்மைகோடு: + பொதுவாக நீங்கள் ஒரு ஒன்றிணையை கனி-பறிக்க முடியாது, ஏனெனில் இணைப்பின் எந்தப் பக்கத்தை முதன்மையாகக் கருத வேண்டும் என்பது உங்களுக்குத் தெரியாது. இந்த விருப்பம் குறிப்பிட்ட பெற்றோருடன் தொடர்புடைய மாற்றத்தை மீண்டும் இயக்க கனி-பறி அனுமதிக்கிறது. + பதுக்கிவைத்தையும் அழி + நீங்கள் அனைத்து பதுக்கிவைத்தையும் அழிக்க முயற்சிக்கிறீர்கள் தொடர விரும்புகிறீர்களா? + நகலி தொலை களஞ்சியம் + கூடுதல் அளவுருக்கள்: + நகலி களஞ்சியத்திற்கான கூடுதல் வாதங்கள். விருப்பத்தேர்வு. + உள்ளக பெயர்: + களஞ்சியப் பெயர். விருப்பத்தேர்வு. + பெற்றோர் கோப்புறை: + துவக்கு & துணை தொகுதிகளைப் புதுப்பி + களஞ்சிய முகவரி: + மூடு + திருத்தி + உறுதிமொழி சரிபார் + கனி-பறி உறுதிமொழி + கனி-பறி ... + தலையுடன் ஒப்பிடுக + பணிமரத்துடன் ஒப்பிடுக + தகவலை + பாகொவ-வை + தனிப்பயன் செயல் + இங்கே ${0}$ ஐ ஊடாடும் வகையில் மறுதளம் + ${0}$ இதற்கு ஒன்றிணை + ஒன்றிணை ... + இங்கே ${0}$ ஐ மறுதளம் + ${0}$ ஐ இங்கே மீட்டமை + உறுதிமொழி திரும்பபெறு + வேறுமொழி + ஒட்டாக சேமி... + பெற்றோர்களில் நொறுக்கு + நொறுக்கு குழந்தைகள் இங்கே சேர் + மாற்றங்கள் + மாற்றங்களைத் தேடு... + கோப்புகள் + பெகோஅ கோப்பு + கோப்புகளைத் தேடு... + துணைத்தொகுதி + தகவல் + ஆசிரியர் + மாற்றப்பட்டது + குழந்தைகள் + உறுதிமொழியாளர் + இந்த உறுதிமொழிடைக் கொண்ட குறிப்புகளைச் சரிபார் + உறுதிமொழி இதில் உள்ளது + முதல் 100 மாற்றங்களை மட்டும் காட்டுகிறது மாற்றங்கள் தாவலில் அனைத்து மாற்றங்களையும் காண்க. + செய்தி + பெற்றோர்கள் + குறிகள் + பாகொவ + உலாவியில் திற + விளக்கம் + உறுதிமொழி பொருளை உள்ளிடவும் + களஞ்சியம் உள்ளமை + உறுதிமொழி வளர்புரு + வார்ப்புரு உள்ளடக்கம்: + வார்ப்புரு பெயர்: + தனிப்பயன் செயல் + வாதங்கள்: + ${களஞ்சிய} - களஞ்சியத்தின் பாதை; ${கிளை} - தேர்ந்தெடுக்கப்பட்ட கிளை; ${பாகொவ} - தேர்ந்தெடுக்கப்பட்ட உறுதிமொழிடியின் பாகொவ + இயக்கக்கூடிய கோப்பு: + பெயர்: + நோக்கம்: + கிளை + உறுதிமொழி + களஞ்சியம் + செயல்பாட்டிலிருந்து வெளியேற காத்திரு + மின்னஞ்சல் முகவரி + மின்னஞ்சல் முகவரி + அறிவிலி + தொலைகளை தானாக எடு + நிமையங்கள் + இயல்புநிலை தொலை + சிக்கல் கண்காணி + மாதிரி அசூர் வளர்பணிகள் விதியைச் சேர் + மாதிரி அறிவிலிஈ சிக்கலுக்கான விதியைச் சேர் + மாதிரி அறிவிலிஈ இழு கோரிக்கை விதியைச் சேர் + மாதிரி அறிவிலிமையம் விதியைச் சேர் + மாதிரி அறிவிலிஆய்வு சிக்கலுக்கான விதியைச் சேர் + மாதிரி அறிவிலிஆய்வு இணைப்பு கோரிக்கை விதியைச் சேர் + மாதிரி சீரா விதியைச் சேர் + புதிய விதி + வழக்கவெளி வெளிப்பாடு வெளியீடு: + விதியின் பெயர்: + முடிவு முகவரி: + வழக்கவெளி குழுக்கள் மதிப்புகளை அணுக $1, $2 ஐப் பயன்படுத்து + செநு + விருப்பமான சேவை: + 'விருப்பமான சேவை' அமைக்கப்பட்டிருந்தால், மூலஅறிவிலி இந்த களஞ்சியத்தில் மட்டுமே அதைப் பயன்படுத்தும். இல்லையெனில், ஒன்றுக்கு மேற்பட்ட சேவைகள் இருந்தால், அவற்றில் ஒன்றைத் தேர்ந்தெடுப்பதற்கான சூழல் பட்டயல் காண்பிக்கப்படும். + உஉபநெ பதிலாள் + இந்த களஞ்சியத்தால் பயன்படுத்தப்படும் உஉபநெ பதிலாள் + பயனர் பெயர் + இந்த களஞ்சியத்திற்கான பயனர் பெயர் + பணியிடங்கள் + நிறம் + பெயர் + தாவல்களை மீட்டமை + வழக்கமான உறுதிமொழி உதவியாளர் + உடைக்கும் மாற்றம்: + மூடப்பட்ட வெளியீடு சிக்கல்: + மாற்ற விவரங்கள்: + நோக்கம்: + குறுகிய விளக்கம்: + மாற்ற வகை: + நகல் + அனைத்து உரையையும் நகலெடு + முழு பாதையை நகலெடு + நகல் பாதை + கிளையை உருவாக்கு... + இதன் அடிப்படையில்: + உருவாக்கப்பட்ட கிளையைப் சரிபார் + உள்ளக மாற்றங்கள்: + நிராகரி + பதுக்கிவை & மீண்டும் இடு + புதிய கிளை பெயர்: + கிளை பெயரை உள்ளிடவும். + இடைவெளிகள் கோடுகளால் மாற்றப்படும். + உள்ளக கிளையை உருவாக்கு + குறிச்சொல்லை உருவாக்கு... + இங்கு புதிய குறிச்சொல்: + சீபிசீ கையொப்பமிடுதல் + குறிச்சொல் செய்தி: + விருப்பத்தேர்வு. + குறிச்சொல் பெயர்: + பரிந்துரைக்கப்பட்ட வடிவம்: ப1.0.0-ஆனா + உருவாக்கப்பட்ட பிறகு அனைத்து தொலைகளுக்கும் தள்ளு + புதிய குறிசொல் உருவாக்கு + வகை: + annotated + குறைந்தஎடை + நேரடியாகத் தொடங்க கட்டுப்பாட்டை அழுத்திப் பிடி + வெட்டு + கிளையை நீக்கு + கிளை: + நீங்கள் ஒரு தொலை கிளையை நீக்கப் போகிறீர்கள்!!! + தொலை ${0}$ கிளையையும் நீக்கு + பல கிளைகளை நீக்கு + நீங்கள் ஒரே நேரத்தில் பல கிளைகளை நீக்க முயற்சிக்கிறீர்கள் நடவடிக்கை எடுப்பதற்கு முன் மீண்டும் சரிபார்! + தொலையை நீக்கு + தொலை: + பாதை: + இலக்கு: + எல்லா குழந்தைகளும் பட்டியலிலிருந்து நீக்கப்படுவார்கள். + இது பட்டியலிலிருந்து மட்டுமே அகற்றும், வட்டிலிருந்து அல்ல! + குழுவை நீக்குவதை உறுதிப்படுத்து + களஞ்சியத்தை நீக்குவதை உறுதிப்படுத்து + துணைத்தொகுதியை நீக்கு + துணைத்தொகுதி பாதை: + குறிச்சொல்லை நீக்கு + குறிசொல்: + தொலை களஞ்சியங்களிலிருந்து நீக்கு + இருமம் வேறுபாடு + புதிய + பழைய + நகல் + கோப்பு முறை மாற்றப்பட்டது + முதல் வேறுபாடு + வெள்ளைவெளி மாற்றத்தை புறக்கணி + கடைசி வேறுபாடு + பெகோஅ பொருள் மாற்றம் + அடுத்த வேறுபாடு + மாற்றங்கள் இல்லை அல்லது வரிமுடிவு மாற்றங்கள் மட்டும் + முந்தைய வேறுபாடு + ஒட்டாகச் சேமி + மறைக்கப்பட்ட சின்னங்களைக் காட்டு + பக்கவாட்டு வேறுபாடு + துணைத் தொகுதி + புதிய + இடமாற்று + தொடரியல் சிறப்பம்சமாக்கல் + வரி சொல் மடக்கு + தடுப்பு-வழிசெலுத்தலை இயக்கு + ஒன்றிணை கருவியில் திற + அனைத்து வரிகளையும் காட்டு + தெரியும் வரிகளின் எண்ணிக்கையைக் குறை + தெரியும் வரிகளின் எண்ணிக்கையை அதிகரி + மாற்றங்களைக் காண கோப்பைத் தேர்ந்தெடு + ஒன்றிணை கருவியில் திற + மாற்றங்களை நிராகரி + செயல்படும் நகலில் உள்ள அனைத்து உள்ளக மாற்றங்கள். + மாற்றங்கள்: + புறக்கணிக்கப்பட்ட கோப்புகளைச் சேர் + {0} மாற்றங்கள் நிராகரிக்கப்படும் + இந்தச் செயலை நீங்கள் செயல்தவிர்க்க முடியாது!!! + புத்தகக்குறி: + புதிய பெயர்: + இலக்கு: + தேர்ந்தெடுக்கப்பட்ட குழுவைத் திருத்து + தேர்ந்தெடுக்கப்பட்ட களஞ்சியத்தைத் திருத்து + தனிப்பயன் செயலை இயக்கு + செயல் பெயர்: + பெறு + எல்லா தொலைகளையும் பெறு + உள்ளக குறிப்புகளை கட்டாயமாக மீறு + குறிச்சொற்கள் இல்லாமல் பெறு + தொலை: + தொலை மாற்றங்களைப் பெறு + மாறாமல் என கருது + நிராகரி... + {0} கோப்புகளை நிராகரி... + தேர்ந்தெடுக்கப்பட்ட வரிகளில் மாற்றங்களை நிராகரி + வெளிப்புற இணைப்பு கருவியைத் திற + ${0}$ஐப் பயன்படுத்தி தீர் + ஒட்டு என சேமி... + நிலைபடுத்து + {0} fகோப்புகள் நிலைபடுத்து + தேர்ந்தெடுக்கப்பட்ட வரிகளில் மாற்றங்களை நிலைபடுத்து + பதுக்கிவை... + {0} கோப்புகள் பதுக்கிவை... + நிலைநீக்கு + நிலைநீக்கு {0} கோப்புகள் + தேர்ந்தெடுக்கப்பட்ட வரிகளில் மாற்றங்களை நிலைநீக்கு + என்னுடையதைப் பயன்படுத்து (சரிபார் --நமது) + அவர்களுடையதைப் பயன்படுத்து (சரிபார் --அவர்களது) + கோப்பு வரலாறு + மாற்றம் + உள்ளடக்கம் + அறிவிலி-ஓட்டம் + மேம்பாட்டு கிளை: + நற்பொருத்தம்: + நற்பொருத்தம் முன்னொட்டு: + ஓட்டம் - நற்பொருத்தம் முடி + ஓட்டம் - சூடானதிருத்தம் முடி + ஓட்டம் - வெளியீட்டை முடி + இலக்கு: + சூடானதிருத்தம்: + சூடானதிருத்தம் முன்னொட்டு: + அறிவிலி-ஓட்டம் துவக்கு + கிளையை வைத்திரு + உற்பத்தி கிளை: + வெளியீடு: + வெளியீடு முன்னொட்டு: + நற்பொருத்தம் தொடங்கு... + ஓட்டம் - நற்பொருத்தம் தொடங்கு + சூடானதிருத்தம் தொடங்கு... + ஓட்டம் - சூடானதிருத்தம் தொடங்கு + பெயரை உள்ளிடு + வெளியீட்டைத் தொடங்கு... + ஓட்டம் - வெளியீட்டைத் தொடங்கு + பதிப்பு குறிச்சொல் முன்னொட்டு: + அறிவிலி பெகோஅ + அறிவிலி கண்காணி வடிவத்தைச் சேர்... + வடிவம் என்பது கோப்பு பெயர் + தனிப்பயன் வடிவம்: + அறிவிலி பெகோஅ இல் கண்காணி வடிவங்களைச் சேர் + பெறு + அறிவிலி பெகோஅ பொருள்களைப் பதிவிறக்க `அறிவிலி பெகோஅ பெறு` ஐ இயக்கவும் இது செயல்படும் நகலை புதுப்பிக்காது. + அறிவிலி பெகோஅ பொருள்களைப் பெறு + அறிவிலி பெகோஅ கொக்கிகளை நிறுவு + பூட்டுகளைக் காட்டு + பூட்டப்பட்ட கோப்புகள் இல்லை + பூட்டு + எனது பூட்டுகளை மட்டும் காட்டு + பெகோஅ பூட்டுகள் + திற + கட்டாயம் திற + கத்தரி + உள்ளக சேமிப்பகத்திலிருந்து பழைய பெகோஅ கோப்புகளை நீக்க `அறிவிலி பெகோஅ கத்தரி` ஐ இயக்கு + இழு + தற்போதைய குறிக்கு அனைத்து அறிவிலி பெகோஅ கோப்புகளையும் பதிவிறக்கி சரிபார்க்க `அறிவிலி பெகோஅ இழு`ஐ இயக்கு + பெகோஅ பொருள்களை இழு + தள்ளு + வரிசைப்படுத்தப்பட்ட பெரிய கோப்புகளை அறிவிலி பெகோஅ முடிவுபுள்ளிக்கு தள்ளு + பெகோஅ பொருள்கள் தள்ளு + தொலை: + '{0}' என பெயரிடப்பட்ட கோப்புகளைக் கண்காணி + அனைத்து *{0} கோப்புகளையும் கண்காணி + வரலாறு + ஆசிரியர் + ஆசிரியர் நேரம் + வரைபடம் & பொருள் + பாகொவ + உறுதிமொழி நேரம் + தேர்ந்தெடுக்கப்பட்ட {0} உறுதிமொழிகள் + பல உறுதிமொழிகளைத் தேர்ந்தெடுக்க 'கட்டுப்பாடு' அல்லது 'உயர்த்து'ஐ அழுத்திப் பிடி. + பல உறுதிமொழிகளைத் தேர்ந்தெடுக்க ⌘ அல்லது ⇧ ஐ அழுத்திப் பிடி. + குறிப்புகள்: + விசைப்பலகை குறுக்குவழிகள் குறிப்பு + உலகளாவிய + தற்போதைய மேல்தோன்றலை Cancel + புதிய களஞ்சியத்தை நகலி செய் + தற்போதைய பக்கத்தை மூடு + அடுத்த பக்கத்திற்குச் செல் + முந்தைய பக்கத்திற்குச் செல் + புதிய பக்கத்தை உருவாக்கு + விருப்பத்தேர்வுகள் உரையாடலைத் திற + களஞ்சியம் + நிலைபடுத்திய மாற்றங்களை உறுதிமொழி + நிலைபடுத்திய மாற்றங்களை உறுதிமொழி மற்றும் தள்ளு + அனைத்து மாற்றங்களையும் நிலைபடுத்தி உறுதிமொழி + தேர்ந்தெடுக்கப்பட்ட உறுதிமொழியின் அடிப்படையில் ஒரு புதிய கிளையை உருவாக்குகிறது + தேர்ந்தெடுக்கப்பட்ட மாற்றங்களை நிராகரி + எடு, நேரடியாகத் தொடங்குகிறது + முகப்பலகை பயன்முறை (இயல்புநிலை) + உறுதிமொழி தேடல் பயன்முறை + இழு, நேரடியாகத் தொடங்குகிறது + தள்ளு, நேரடியாகத் தொடங்குகிறது + இந்த களஞ்சியத்தை மீண்டும் ஏற்ற கட்டாயப்படுத்து + தேர்ந்தெடுக்கப்பட்ட மாற்றங்களை நிலைபடுத்து/நிலைநீக்கு + 'மாற்றங்கள்' என்பதற்கு மாறு + 'வரலாறுகள்' என்பதற்கு மாறு + 'பதுகிவைத்தவை' என்பதற்கு மாறு + உரை திருத்தி + தேடல் பலகத்தை மூடு + அடுத்த பொருத்தத்தைக் கண்டறி + முந்தைய பொருத்தத்தைக் கண்டறி + தேடல் பலகத்தைத் திற + நிராகரி + நிலைபடுத்து + நிலைநீக்கு + களஞ்சியத்தைத் துவக்கு + பாதை: + கனி-பறி செயல்பாட்டில் உள்ளது. + உறுதிமொழி செயலாக்குதல் + இணைத்தல் செயல்பாட்டில் உள்ளது. + இணைத்தல் + மறுதளம் செயல்பாட்டில் உள்ளது + இல் நிறுத்தப்பட்டது + திரும்ப்பெறும் செயல்பாட்டில் உள்ளது. + திரும்பபெறும் உறுதிமொழி + ஊடாடும் மறுதளம் + மேல்: + இலக்கு கிளை: + இணைப்பை நகலெடு + உலாவியில் திற + பிழை + அறிவிப்பு + கிளையை ஒன்றிணை + Into: + இணைப்பு விருப்பம்: + இதனுள்: + ஒன்றிணை (பல) + அனைத்து மாற்றங்களையும் உறுதிமொழி + சூழ்ச்சிமுறை: + இலக்குகள்: + களஞ்சிய முனையை நகர்த்து + இதற்கான பெற்றோர் முனையைத் தேர்ந்தெடு + பெயர்: + அறிவிலி உள்ளமைக்கப்படவில்லை. [விருப்பத்தேர்வுகள்]க்குச் சென்று முதலில் அதை உள்ளமை. + தரவு சேமிப்பக கோப்பகத்தைத் திற + இதனுடன் திற... + விருப்பத்தேர்வு. + புதிய பக்கத்தை உருவாக்கு + புத்தகக்குறி + மூடு தாவல் + பிற தாவல்களை மூடு + வலதுபுறத்தில் உள்ள தாவல்களை மூடு + களஞ்சிய பாதை நகலெடு + களஞ்சியங்கள் + ஒட்டு + {0} நாட்களுக்கு முன்பு + 1 மணி நேரத்திற்கு முன்பு + {0} மணி நேரத்திற்கு முன்பு + சற்றுமுன் + கடந்த திங்கள் + கடந்த ஆண்டு + {0} நிமையங்களுக்கு முன்பு + {0} திங்களுக்கு முன்பு + {0} ஆண்டுகளுக்கு முன்பு + நேற்று + விருப்பத்தேர்வுகள் + செநு + வேறுபாடு உடனடியாக பகுப்பாய்வு செய் + பநிஇ திறவுகோல் + பொருள் உடனடியாக உருவாக்கு + மாதிரி + பெயர் + சேவையகம் + ஓடையை இயக்கு + தோற்றம் + இயல்புநிலை எழுத்துரு + திருத்தி தாவல் அகலம் + எழுத்துரு அளவு + இயல்புநிலை + திருத்தி + ஒற்றைவெளி எழுத்துரு + ஒற்றைவெளி எழுத்துருவை உரை திருத்தியில் மட்டும் பயன்படுத்து + கருப்பொருள் + கருப்பொருள் மேலெழுதப்படுகிறது + தலைப்புப்பட்டியில் நிலையான தாவல் அகலத்தைப் பயன்படுத்து + சொந்த சாளர சட்டத்தைப் பயன்படுத்து + வேறு/ஒன்றிணை கருவி + நிறுவல் பாதை + வேறு/ஒன்றிணை கருவிக்கான பாதை உள்ளிடு + கருவி + பொது + தொடக்கத்தில் புதுப்பிப்புகளைச் சரிபார் + தேதி வடிவம் + மொழி + வரலாற்று உறுதிமொழிகள் + வரைபடத்தில் உறுதிமொழி நேரத்திற்குப் பதிலாக ஆசிரியர் நேரத்தைக் காட்டு + உறுதிமொழி விவரங்களில் குழந்தைகளைக் காட்டு + உறுதிமொழி வரைபடத்தில் குறிச்சொற்களைக் காட்டு + பொருள் வழிகாட்டி நீளம் + அறிவிலி + தானியங்கி வரிமுடிவை இயக்கு + இயல்புநிலை நகலி அடைவு + பயனர் மின்னஞ்சல் + உலகளாவிய அறிவிலி பயனர் மின்னஞ்சல் + --prune எடுக்கும்போது இயக்கு + அறிவிலி (>= 2.25.1) இந்த பயன்பாட்டிற்கு தேவைப்படுகிறது + நிறுவல் பாதை + உஉபநெ பாகுஅ சரிபார்ப்பை இயக்கு + பயனர் பெயர் + உலகளாவிய அறிவிலி பயனர் பெயர் + அறிவிலி பதிப்பு + சிபிசி கையொப்பமிடுதல் + சிபிசி கையொப்பமிடுதல் உறுதிமொழி + சிபிசி வடிவம் + நிரல் நிறுவல் பாதை + நிறுவப்பட்ட சிபிசி நிரலுக்கான உள்ளீட்டு பாதை + சிபிசி கையொப்பமிடுதலை குறிச்சொலிடு + பயனர் கையொப்பமிடும் திறவுகோல் + பயனரின் கையொப்பமிடும் திறவுகோல் + ஒருங்கிணைப்பு + ஓடு/முனையம் + பாதை + ஓடு/முனையம் + தொலை கத்தரி + இலக்கு: + பணிமரங்கள் கத்தரி + `$GIT_COMMON_DIR/பணிமரங்கள்` இதில் பணிமரம் தகவலை கத்தரி + இழு + தொலை கிளை: + இதனுள்: + உள்ளக மாற்றங்கள்: + நிராகரி + பதுக்கிவை & மீண்டும் இடு + தொலை: + இழு (எடுத்து ஒன்றிணை) + ஒன்றிணை என்பதற்குப் பதிலாக மறுதளத்தைப் பயன்படுத்து + தள்ளு + துணைத் தொகுதிகள் தள்ளப்பட்டது என்பதை உறுதிசெய் + கட்டாயமாக தள்ளு + உள்ளக கிளை: + தொலை: + மாற்றங்களை தொலைக்கு தள்ளு + தொலை கிளை: + கண்காணிப்பு கிளையாக அமை + அனைத்து குறிச்சொற்களையும் தள்ளு + தொலைக்கு குறிச்சொல்லை தள்ளு + அனைத்து தொலைகளுக்கும் தள்ளு + தொலை: + குறிச்சொல்: + வெளியேறு + தற்போதைய கிளையை மறுதளம் செய் + உள்ளக மாற்றங்களை பதுக்கிவை & மீண்டும் இடு + மேல்: + மறுதளம்: + தொலையைச் சேர் + தொலையைத் திருத்து + பெயர்: + களஞ்சிய பெயர் + களஞ்சிய முகவரி: + தொலை அறிவிலி களஞ்சிய முகவரி: + முகவரியை நகலெடு + நீக்கு... + திருத்து... + பெறு + உலாவியில் திற + கத்தரித்தல் + பணிமரத்தை அகற்றுவதை உறுதிப்படுத்து + `--கட்டாயம்` விருப்பத்தை இயக்கு + இலக்கு: + கிளையை மறுபெயரிடு + புதிய பெயர்: + இந்தக் கிளைக்கான தனித்துவமான பெயர் + கிளை: + நிறுத்து + தொலைகளிலிருந்து மாற்றங்களைத் தானாகப் பெறுதல்... + சுத்தப்படுத்தல்(சீசி & கத்தரித்தல்) + இந்த களஞ்சியத்திற்கு `அறிவிலி சீசி` கட்டளையை இயக்கு. + அனைத்தையும் அழி + இந்த களஞ்சியத்தை உள்ளமை + தொடர்க + தனிப்பயன் செயல்கள் + தனிப்பயன் செயல்கள் இல்லை + எல்லா மாற்றங்களையும் நிராகரி + '--குறிபதிவு' விருப்பத்தை இயக்கு + கோப்பு உலாவியில் திற + கிளைகள்/குறிச்சொற்கள்/துணைத் தொகுதிகளைத் தேடு + வரைபடத்தில் தெரிவுநிலை + அமைவை நீக்கு + உறுதிமொழி வரைபடத்தில் மறை + உறுதிமொழி வரைபடத்தில் வடிகட்டு + '--first-parent' விருப்பம் இயக்கு + தளவமைப்பு + கிடைமட்டம் + செங்குத்து + உறுதிமொழி வரிசை + உறுதிமொழி தேதி + இடவியல் மூலமாக + உள்ளக கிளைகள் + தலைக்கு செல் + கிளையை உருவாக்கு + அறிவிப்புகளை அழி + வரைபடத்தில் தற்போதைய கிளையை மட்டும் முன்னிலை படுத்து + {0} இல் திற + வெளிப்புற கருவிகளில் திற + புதுப்பி + தொலைகள் + தொலையைச் சேர் + உறுதிமொழி தேடு + ஆசிரியர் + உறுதிமொழியாளர் + கோப்பு + செய்தி + பாகொவ + தற்போதைய கிளை + குறிச்சொற்களை மரமாகக் காட்டு + தவிர் + புள்ளிவிவரங்கள் + துணைத் தொகுதிகள் + துணைத் தொகுதியைச் சேர் + துணைத் தொகுதியைப் புதுப்பி + குறிசொற்கள் + புதிய குறிசொல் + படைப்பாளர் தேதியின்படி + பெயர் மூலம் + வரிசைப்படுத்து + முனையத்தில் திற + வரலாறுகளில் உறவு நேரத்தைப் பயன்படுத்து + பணிமரங்கள் + பணிமரத்தைச் சேர் + கத்தரித்தல் + அறிவிலி களஞ்சிய முகவரி + தற்போதைய கிளையை திருத்தத்திற்கு மீட்டமை + மீட்டமை பயன்முறை: + இதற்கு நகர்த்து: + தற்போதைய கிளை: + கோப்பு உலாவியில் வெளிப்படுத்து + பின்வாங்கு உறுதிமொழி + உறுதிமொழி: + பின்வாங்கு மாற்றங்களை உறுதிமொழி + மாறுசொல் உறுதிமொழி செய்தி + புதிய வரியை உள்ளிட 'உயர்த்து+நுழை' ஐப் பயன்படுத்தவும். 'நுழை' என்பது சரி பொத்தானின் சூடானவிசை ஆகும் + இயங்குகிறது. காத்திருக்கவும்... + சேமி + எனச் சேமி... + ஒட்டு வெற்றிகரமாக சேமிக்கப்பட்டது! + களஞ்சியங்களை வருடு + வேர் அடைவு: + புதுப்பிப்புகளைச் சரிபார்... + இந்த மென்பொருளின் புதிய பதிப்பு கிடைக்கிறது: + புதுப்பிப்புகளைச் சரிபார்க்க முடியவில்லை! + பதிவிறக்கம் + இந்தப் பதிப்பைத் தவிர் + மென்பொருள் புதுப்பி + தற்போது புதுப்பிப்புகள் எதுவும் கிடைக்கவில்லை. + கண்காணிப்பு கிளையை அமை + கிளை: + மேல்ஓடையை நீக்கு + மேல்ஓடை: + SHA ஐ நகலெடு + இதற்கு செல் + நொறுக்கு உறுதிமொழிகள் + இதில்: + பாஓடு தனியார் திறவுகோல்: + தனியார் பாஓடு திறவுகோல் கடை பாதை + தொடங்கு + பதுக்கிவை + பதுக்கிவைத்த பிறகு தானியங்கி மீட்டமை + உங்கள் செயல்படும் கோப்புகள் மாறாமல் இருக்கும், ஆனால் ஒரு பதுக்கிவைக்கப்படும். + கண்காணிக்கப்படாத கோப்புகளைச் சேர் + நிலைப்படுத்தப்பட்ட கோப்புகளை வைத்திரு + செய்தி: + விருப்பத்தேர்வு. இந்த பதுக்கலின் பெயர் + நிலைப்படுத்தப்பட்ட மாற்றங்கள் மட்டும் + தேர்ந்தெடுக்கப்பட்ட கோப்புகளின் நிலைப்படுத்தப்பட்ட மற்றும் நிலைப்படுத்தப்படாத மாற்றங்கள் இரண்டும் பதுக்கிவைக்கப்படும்!!! + உள்ளக மாற்றங்களை பதுக்கிவை + இடு + கைவிடு + ஒட்டாகச் சேமி... + பதுக்கிவைத்தவை கைவிடு + கைவிடு: + பதுக்கிவைத்தவைகள் + மாற்றங்கள் + பதுக்கிவைத்தவைகள் + புள்ளிவிவரங்கள் + உறுதிமொழிகள் + உறுதிமொழியாளர் + மேலோட்டப் பார்வை + திங்கள் + வாரம் + ஆசிரியர்கள்: + உறுதிமொழிகள்: + துணைத் தொகுதி + துணைத் தொகுதியைச் சேர் + உறவு பாதையை நகலெடு + உள்ளமைக்கப்பட்ட துணைத் தொகுதிகளை எடு + துணைத் தொகுதி களஞ்சியத்தைத் திற + உறவு பாதை: + இந்த தொகுதியை சேமிப்பதற்கான தொடர்புடைய கோப்புறை. + துணை தொகுதியை நீக்கு + சரி + குறிச்சொல் பெயரை நகலெடு + குறிச்சொல் செய்தியை நகலெடு + நீக்கு ${0}$... + ${0}$ இதை ${1}$ இல் இணை... + தள்ளு ${0}$... + துணைத்தொகுதிகளைப் புதுப்பி + அனைத்து துணைத்தொகுதிகள் + தேவைக்கேற்றப துவக்கு + சுழற்சி முறையில் + --தொலை விருப்பத்தைப் பயன்படுத்து + முகவரி: + முன்னறிவிப்பு + வரவேற்பு பக்கம் + குழுவை உருவாக்கு + துணைக் குழுவை உருவாக்கு + நகலி களஞ்சியம் + நீக்கு + கோப்புறையை இழுத்து & விடு ஆதரிக்கப்படுகிறது. தனிப்பயன் குழுவாக்க ஆதரவு. + திருத்து + வேறொரு குழுவிற்கு நகர்த்து + அனைத்து களஞ்சியங்களையும் திற + களஞ்சியத்தைத் திற + முனையத்தைத் திற + இயல்புநிலை நகலி அடைவில் களஞ்சியங்களை மீண்டும் வருடு + களஞ்சியங்களைத் தேடு... + வரிசைப்படுத்து + உள்ளக மாற்றங்கள் + அறிவிலி புறக்கணி + எல்லா *{0} கோப்புகளையும் புறக்கணி + ஒரே கோப்புறையில் *{0} கோப்புகளைப் புறக்கணி + ஒரே கோப்புறையில் கோப்புகளைப் புறக்கணி + இந்த கோப்பை மட்டும் புறக்கணி + பின்னொட்டு + இந்த கோப்பை இப்போது நீங்கள் நிலைப்படுத்தலாம். + உறுதிமொழி + உறுதிமொழி & தள்ளு + வளர்புரு/வரலாறுகள் + சொடுக்கு நிகழ்வைத் தூண்டு + உறுதிமொழி (திருத்து) + அனைத்து மாற்றங்களையும் நிலைப்படுத்தி உறுதிமொழி + நீங்கள் {0} கோப்புகளை நிலைப்படுத்தியுள்ளீர்கள், ஆனால் {1} கோப்புகள் மட்டுமே காட்டப்பட்டுள்ளன ({2} கோப்புகள் வடிகட்டப்பட்டுள்ளன). தொடர விரும்புகிறீர்களா? + மோதல்கள் கண்டறியப்பட்டது + கோப்பு மோதல்கள் தீர்க்கப்பட்டது + கண்காணிக்கப்படாத கோப்புகளைச் சேர் + அண்மைக் கால உள்ளீட்டு செய்திகள் இல்லை + உறுதிமொழி வளர்புருகள் இல்லை + தேர்ந்தெடுக்கப்பட்ட கோப்பு(களை) வலது சொடுக்கு செய்து, முரண்பாடுகளைத் தீர்க்க உங்கள் விருப்பத்தைத் தேர்ந்தெடு. + கையெழுத்திடு + நிலைபடுத்தியது + நிலைநீக்கு + அனைத்தும் நிலைநீக்கு + நிலைநீக்கு + நிலைபடுத்து + அனைத்தும் நிலைபடுத்து + மாறாதது எனநினைப்பதை பார் + வளர்புரு: ${0}$ + பணியிடம்: + பணியிடங்களை உள்ளமை... + பணிமரம் + பாதையை நகலெடு + பூட்டு + நீக்கு + திற + diff --git a/src/Resources/Locales/uk_UA.axaml b/src/Resources/Locales/uk_UA.axaml new file mode 100644 index 00000000..096b4398 --- /dev/null +++ b/src/Resources/Locales/uk_UA.axaml @@ -0,0 +1,754 @@ + + + + + + Про програму + Про SourceGit + Безкоштовний Git GUI клієнт з відкритим кодом + Додати робоче дерево + Розташування: + Шлях для цього робочого дерева. Відносний шлях підтримується. + Назва гілки: + Необов'язково. За замовчуванням — назва кінцевої папки. + Відстежувати гілку: + Відстежувати віддалену гілку + Що перемкнути: + Створити нову гілку + Наявна гілка + AI Асистент + ПЕРЕГЕНЕРУВАТИ + Використати AI для генерації повідомлення коміту + ЗАСТОСУВАТИ ЯК ПОВІДОМЛЕННЯ КОМІТУ + Застосувати + Файл патчу: + Виберіть файл .patch для застосування + Ігнорувати зміни пробілів + Застосувати Патч + Пробіли: + Застосувати схованку + Видалити після застосування + Відновити зміни індексу + Схованка: + Архівувати... + Зберегти архів у: + Виберіть шлях до файлу архіву + Ревізія: + Архівувати + SourceGit Askpass + ФАЙЛИ, ЩО ВВАЖАЮТЬСЯ НЕЗМІНЕНИМИ + НЕМАЄ ФАЙЛІВ, ЩО ВВАЖАЮТЬСЯ НЕЗМІНЕНИМИ + ВИДАЛИТИ + Оновити + БІНАРНИЙ ФАЙЛ НЕ ПІДТРИМУЄТЬСЯ!!! + Автор рядка + ПОШУК АВТОРА РЯДКА ДЛЯ ЦЬОГО ФАЙЛУ НЕ ПІДТРИМУЄТЬСЯ!!! + Перейти на ${0}$... + Порівняти з ${0}$ + Порівняти з робочим деревом + Копіювати назву гілки + Спеціальна дія + Видалити ${0}$... + Видалити вибрані {0} гілок + Перемотати до ${0}$ + Отримати ${0}$ в ${1}$... + Git Flow - Завершити ${0}$ + Злиття ${0}$ в ${1}$... + Злити вибрані {0} гілок в поточну + Витягти ${0}$ + Витягти ${0}$ в ${1}$... + Надіслати ${0}$ + Перебазувати ${0}$ на ${1}$... + Перейменувати ${0}$... + Встановити відстежувану гілку... + Порівняти гілки + Недійсний upstream! + Байтів + СКАСУВАТИ + Скинути до батьківської ревізії + Скинути до цієї ревізії + Згенерувати повідомлення коміту + ЗМІНИТИ РЕЖИМ ВІДОБРАЖЕННЯ + Показати як список файлів та тек + Показати як список шляхів + Показати як дерево файлової системи + Перейти на гілку + Перейти на коміт + Коміт: + Попередження: Перехід на коміт призведе до стану "від'єднаний HEAD" + Локальні зміни: + Скасувати + Сховати та Застосувати + Гілка: + Cherry-pick + Додати джерело до повідомлення коміту + Коміт(и): + Закомітити всі зміни + Батьківський коміт: + Зазвичай неможливо cherry-pick злиття, бо невідомо, яку сторону злиття вважати батьківською (mainline). Ця опція дозволяє відтворити зміни відносно вказаного батьківського коміту. + Очистити схованки + Ви намагаєтеся очистити всі схованки. Ви впевнені? + Клонувати віддалене сховище + Додаткові параметри: + Додаткові аргументи для клонування сховища. Необов'язково. + Локальна назва: + Назва сховища. Необов'язково. + Батьківська тека: + Ініціалізувати та оновити підмодулі + URL сховища: + ЗАКРИТИ + Редактор + Перейти на коміт + Cherry-pick коміт + Cherry-pick ... + Порівняти з HEAD + Порівняти з робочим деревом + Iнформацію + SHA + Спеціальна дія + Інтерактивно перебазувати ${0}$ сюди + Злиття в ${0}$ + Злити ... + Перебазувати ${0}$ сюди + Скинути ${0}$ сюди + Скасувати коміт + Змінити повідомлення + Зберегти як патч... + Склеїти з батьківським комітом + Склеїти дочірні коміти сюди + ЗМІНИ + Пошук змін... + ФАЙЛИ + LFS Файл + Пошук файлів... + Підмодуль + ІНФОРМАЦІЯ + АВТОР + ЗМІНЕНО + ДОЧІРНІ + КОМІТЕР + Перевірити посилання, що містять цей коміт + КОМІТ МІСТИТЬСЯ В + Показано лише перші 100 змін. Дивіться всі зміни на вкладці ЗМІНИ. + ПОВІДОМЛЕННЯ + БАТЬКІВСЬКІ + ПОСИЛАННЯ (Refs) + SHA + Відкрити в браузері + Опис + Введіть тему коміту + Налаштування сховища + ШАБЛОН КОМІТУ + Зміст шаблону: + Назва шаблону: + СПЕЦІАЛЬНА ДІЯ + Аргументи: + ${REPO} - Шлях до сховища; ${BRANCH} - Вибрана гілка; ${SHA} - SHA вибраного коміту + Виконуваний файл: + Назва: + Область застосування: + Гілка + Коміт + Репозиторій + Чекати завершення дії + Адреса Email + Адреса електронної пошти + GIT + Автоматично отримувати зміни з віддалених сховищ + хвилин(и) + Віддалене сховище за замовчуванням + Бажаний режим злиття + ТРЕКЕР ЗАВДАНЬ + Додати приклад правила для Azure DevOps + Додати приклад правила для Gitee Issue + Додати приклад правила для Gitee Pull Request + Додати приклад правила для Github + Додати приклад правила для GitLab Issue + Додати приклад правила для GitLab Merge Request + Додати приклад правила для Jira + Нове правило + Регулярний вираз для завдання: + Назва правила: + URL результату: + Використовуйте $1, $2 для доступу до значень груп регулярного виразу. + AI + Бажаний сервіс: + Якщо 'Бажаний сервіс' встановлено, SourceGit буде використовувати лише його у цьому сховищі. Інакше, якщо доступно більше одного сервісу, буде показано контекстне меню для вибору. + HTTP Проксі + HTTP проксі, що використовується цим сховищем + Ім'я користувача + Ім'я користувача для цього сховища + Робочі простори + Колір + Відновлювати вкладки при запуску + ПРОДОВЖИТИ + Виявлено порожній коміт! Продовжити (--allow-empty)? + ІНДЕКСУВАТИ ВСЕ ТА ЗАКОМІТИТИ + Виявлено порожній коміт! Продовжити (--allow-empty) чи індексувати все та закомітити? + Допомога Conventional Commit + Зворотньо несумісні зміни: + Закрите завдання: + Детальні зміни: + Область застосування: + Короткий опис: + Тип зміни: + Копіювати + Копіювати весь текст + Копіювати повний шлях + Копіювати шлях + Створити гілку... + На основі: + Перейти на створену гілку + Локальні зміни: + Скасувати + Сховати та Застосувати + Назва нової гілки: + Введіть назву гілки. + Пробіли будуть замінені на тире. + Створити локальну гілку + Створити тег... + Новий тег для: + Підпис GPG + Повідомлення тегу: + Необов'язково. + Назва тегу: + Рекомендований формат: v1.0.0-alpha + Надіслати на всі віддалені сховища після створення + Створити Новий Тег + Тип: + анотований + легкий + Утримуйте Ctrl для запуску без діалогу + Вирізати + Видалити гілку + Гілка: + Ви збираєтеся видалити віддалену гілку!!! + Також видалити віддалену гілку ${0}$ + Видалити кілька гілок + Ви намагаєтеся видалити кілька гілок одночасно. Перевірте ще раз перед виконанням! + Видалити віддалене сховище + Віддалене сховище: + Шлях: + Ціль: + Усі дочірні елементи будуть видалені зі списку. + Це видалить сховище лише зі списку, а не з диска! + Підтвердити видалення групи + Підтвердити видалення сховища + Видалити підмодуль + Шлях до підмодуля: + Видалити тег + Тег: + Видалити з віддалених сховищ + РІЗНИЦЯ ДЛЯ БІНАРНИХ ФАЙЛІВ + НОВИЙ + СТАРИЙ + Копіювати + Змінено режим файлу + Перша відмінність + Ігнорувати зміни пробілів + Остання відмінність + ЗМІНА ОБ'ЄКТА LFS + Наступна відмінність + НЕМАЄ ЗМІН АБО ЛИШЕ ЗМІНИ КІНЦЯ РЯДКА + Попередня відмінність + Зберегти як патч + Показати приховані символи + Порівняння пліч-о-пліч + ПІДМОДУЛЬ + НОВИЙ + Поміняти місцями + Підсвітка синтаксису + Перенос слів + Увімкнути навігацію блоками + Відкрити в інструменті злиття + Показати всі рядки + Зменшити кількість видимих рядків + Збільшити кількість видимих рядків + ОБЕРІТЬ ФАЙЛ ДЛЯ ПЕРЕГЛЯДУ ЗМІН + Відкрити в інструменті злиття + Скасувати зміни + Усі локальні зміни в робочій копії. + Зміни: + Включити файли, які ігноруються + {0} змін будуть відхилені + Ви не можете скасувати цю дію!!! + Закладка: + Нова назва: + Ціль: + Редагувати вибрану групу + Редагувати вибраний репозиторій + Виконати спеціальну дію + Ім'я дії: + Витягти + Витягти всі віддалені сховища + Примусово перезаписати локальні refs + Витягти без тегів + Віддалений: + Витягти зміни з віддалених репозиторіїв + Вважати незмінними + Скасувати... + Скасувати {0} файлів... + Скасувати зміни в вибраних рядках + Відкрити зовнішній інструмент злиття + Розв'язати за допомогою ${0}$ + Зберегти як патч... + Стагнути + Стагнути {0} файлів + Стагнути зміни в вибраних рядках + Схованка... + Схованка {0} файлів... + Скинути стаг + Скинути {0} файлів + Скинути зміни в вибраних рядках + Використовувати Mine (checkout --ours) + Використовувати Theirs (checkout --theirs) + Історія файлу + ЗМІНА + ЗМІСТ + Git-Flow + Розробка гілки: + Функція: + Префікс функції: + FLOW - Завершити функцію + FLOW - Завершити гарячу поправку + FLOW - Завершити реліз + Ціль: + Гаряча поправка: + Префікс гарячої поправки: + Ініціалізувати Git-Flow + Залишити гілку + Гілка виробництва: + Реліз: + Префікс релізу: + Почати функцію... + FLOW - Почати функцію + Почати гарячу поправку... + FLOW - Почати гарячу поправку + Введіть назву + Почати реліз... + FLOW - Почати реліз + Тег версії Префікс: + Git LFS + Додати шаблон для відстеження... + Шаблон є ім'ям файлу + Спеціальний шаблон: + Додати шаблон для відстеження до Git LFS + Витягти + Запустіть `git lfs fetch`, щоб завантажити об'єкти Git LFS. Це не оновлює робочу копію. + Витягти об'єкти LFS + Встановити Git LFS hooks + Показати блокування + Немає заблокованих файлів + Заблокувати + Показати лише мої блокування + LFS блокування + Розблокувати + Примусово розблокувати + Принт + Запустіть `git lfs prune`, щоб видалити старі файли з локального сховища + Витягти + Запустіть `git lfs pull`, щоб завантажити всі файли Git LFS для поточної ref & checkout + Витягти об'єкти LFS + Надіслати + Надіслати чернетки великих файлів до кінця Git LFS + Надіслати об'єкти LFS + Віддалений: + Відстежувати файли, названі '{0}' + Відстежувати всі *{0} файли + ІСТОРІЯ + АВТОР + ЧАС АВТОРА + ГРАФ ТА ТЕМА + SHA + ЧАС КОМІТУ + ВИБРАНО {0} КОМІТІВ + Утримуйте 'Ctrl' або 'Shift' для вибору кількох комітів. + Утримуйте ⌘ або ⇧ для вибору кількох комітів. + ПОРАДИ: + Гарячі клавіші + ГЛОБАЛЬНІ + Скасувати поточне спливаюче вікно + Клонувати нове сховище + Закрити поточну вкладку + Перейти до наступної вкладки + Перейти до попередньої вкладки + Створити нову вкладку + Відкрити діалог Налаштування + СХОВИЩЕ + Закомітити проіндексовані зміни + Закомітити та надіслати проіндексовані зміни + Індексувати всі зміни та закомітити + Створити нову гілку на основі вибраного коміту + Скасувати вибрані зміни + Fetch, запускається без діалогу + Режим панелі керування (за замовчуванням) + Режим пошуку комітів + Pull, запускається без діалогу + Push, запускається без діалогу + Примусово перезавантажити це сховище + Індексувати/Видалити з індексу вибрані зміни + Перейти до 'Зміни' + Перейти до 'Історія' + Перейти до 'Схованки' + ТЕКСТОВИЙ РЕДАКТОР + Закрити панель пошуку + Знайти наступний збіг + Знайти попередній збіг + Відкрити панель пошуку + Скасувати + Індексувати + Видалити з індексу + Ініціалізувати сховище + Шлях: + Cherry-pick в процесі. + Обробка коміту + Злиття в процесі. + Виконується злиття + Перебазування в процесі. + Зупинено на + Скасування в процесі. + Скасування коміту + Інтерактивне перебазування + На: + Цільова гілка: + Копіювати посилання + Відкрити в браузері + ПОМИЛКА + ПОВІДОМЛЕННЯ + Злиття гілки + В: + Опція злиття: + Джерело: + Злиття (Кілька) + Закомітити всі зміни + Стратегія: + Цілі: + Перемістити вузол сховища + Виберіть батьківський вузол для: + Назва: + Git не налаштовано. Будь ласка, перейдіть до [Налаштування] та налаштуйте його. + Відкрити теку зберігання даних + Відкрити за допомогою... + Необов'язково. + Створити нову вкладку + Закладка + Закрити вкладку + Закрити інші вкладки + Закрити вкладки праворуч + Копіювати шлях до сховища + Сховища + Вставити + {0} днів тому + годину тому + {0} годин тому + Щойно + Минулого місяця + Минулого року + {0} хвилин тому + {0} місяців тому + {0} років тому + Вчора + Налаштування + AI + Промпт для аналізу різниці + Ключ API + Промпт для генерації теми + Модель + Назва + Сервер + Увімкнути потокове відтворення + ВИГЛЯД + Шрифт за замовчуванням + Ширина табуляції в редакторі + Розмір шрифту + За замовчуванням + Редактор + Моноширинний шрифт + Використовувати моноширинний шрифт лише в текстовому редакторі + Тема + Перевизначення теми + Використовувати фіксовану ширину вкладки в заголовку + Використовувати системну рамку вікна + ІНСТРУМЕНТ DIFF/MERGE + Шлях встановлення + Введіть шлях до інструменту diff/merge + Інструмент + ЗАГАЛЬНІ + Перевіряти оновлення при запуску + Формат дати + Мова + Кількість комітів в історії + Показувати час автора замість часу коміту в графі + Показувати дочірні коміти в деталях + Показувати теги в графі комітів + Довжина лінії-орієнтира для теми + GIT + Увімкнути авто-CRLF + Тека клонування за замовчуванням + Email користувача + Глобальний email користувача git + Увімкнути --prune при fetch + Git (>= 2.25.1) є обов'язковим для цієї програми + Шлях встановлення + Увімкнути перевірку HTTP SSL + Ім'я користувача + Глобальне ім'я користувача git + Версія Git + ПІДПИС GPG + Підпис GPG для комітів + Формат GPG + Шлях встановлення програми + Введіть шлях до встановленої програми GPG + Підпис GPG для тегів + Ключ підпису користувача + Ключ підпису GPG користувача + ІНТЕГРАЦІЯ + КОНСОЛЬ/ТЕРМІНАЛ + Шлях + Консоль/Термінал + Prune для віддаленого сховища + Ціль: + Prune для робочих дерев + Видалити застарілу інформацію про робочі дерева в `$GIT_COMMON_DIR/worktrees` + Pull (Витягти) + Віддалена гілка: + В: + Локальні зміни: + Скасувати + Сховати та Застосувати + Віддалене сховище: + Pull (Fetch & Merge) + Використовувати rebase замість merge + Push (Надіслати) + Переконатися, що підмодулі надіслано + Примусовий push + Локальна гілка: + Віддалене сховище: + Надіслати зміни на віддалене сховище + Віддалена гілка: + Встановити як відстежувану гілку + Надіслати всі теги + Надіслати тег на віддалене сховище + Надіслати на всі віддалені сховища + Віддалене сховище: + Тег: + Вийти + Перебазувати поточну гілку + Сховати та застосувати локальні зміни + На: + Перебазувати: + Додати віддалене сховище + Редагувати віддалене сховище + Назва: + Назва віддаленого сховища + URL сховища: + URL віддаленого git сховища + Копіювати URL + Видалити... + Редагувати... + Fetch (Отримати) + Відкрити у браузері + Prune (Очистити) + Підтвердити видалення робочого дерева + Увімкнути опцію `--force` + Ціль: + Перейменувати гілку + Нова назва: + Унікальна назва для цієї гілки + Гілка: + ПЕРЕРВАТИ + Автоматичне отримання змін з віддалених сховищ... + Очистка (GC & Prune) + Виконати команду `git gc` для цього сховища. + Очистити все + Налаштувати це сховище + ПРОДОВЖИТИ + Спеціальні дії + Немає спеціальних дій + Скасувати всі зміни + Увімкнути опцію '--reflog' + Відкрити у файловому менеджері + Пошук гілок/тегів/підмодулів + Видимість у графі + Не встановлено + Приховати в графі комітів + Фільтрувати в графі комітів + Увімкнути опцію '--first-parent' + РОЗТАШУВАННЯ + Горизонтальне + Вертикальне + ПОРЯДОК КОМІТІВ + За датою коміту + Топологічний + ЛОКАЛЬНІ ГІЛКИ + Перейти до HEAD + Створити гілку + ОЧИСТИТИ СПОВІЩЕННЯ + Виділяти лише поточну гілку в графі + Відкрити в {0} + Відкрити в зовнішніх інструментах + Оновити + ВІДДАЛЕНІ СХОВИЩА + ДОДАТИ ВІДДАЛЕНЕ СХОВИЩЕ + Пошук коміту + Автор + Комітер + Файл + Повідомлення + SHA + Поточна гілка + Показати теги як дерево + ПРОПУСТИТИ + Статистика + ПІДМОДУЛІ + ДОДАТИ ПІДМОДУЛЬ + ОНОВИТИ ПІДМОДУЛЬ + ТЕГИ + НОВИЙ ТЕГ + За датою створення + За назвою + Сортувати + Відкрити в терміналі + Використовувати відносний час в історії + РОБОЧІ ДЕРЕВА + ДОДАТИ РОБОЧЕ ДЕРЕВО + PRUNE (ОЧИСТИТИ) + URL Git сховища + Скинути поточну гілку до ревізії + Режим скидання: + Перемістити до: + Поточна гілка: + Показати у файловому менеджері + Revert (Скасувати коміт) + Коміт: + Закомітити зміни скасування + Змінити повідомлення коміту + Використовуйте 'Shift+Enter' для введення нового рядка. 'Enter' - гаряча клавіша кнопки OK + Виконується. Будь ласка, зачекайте... + ЗБЕРЕГТИ + Зберегти як... + Патч успішно збережено! + Сканувати сховища + Коренева тека: + Перевірити оновлення... + Доступна нова версія програми: + Не вдалося перевірити оновлення! + Завантажити + Пропустити цю версію + Оновлення програми + У вас встановлена остання версія. + Встановити відстежувану гілку + Гілка: + Скасувати upstream + Upstream: + Копіювати SHA + Перейти до + Squash (Склеїти коміти) + В: + Приватний ключ SSH: + Шлях до сховища приватного ключа SSH + ПОЧАТИ + Stash (Сховати) + Автоматично відновити після схову + Ваші робочі файли залишаться без змін, але буде збережено схованку. + Включити невідстежувані файли + Зберегти проіндексовані файли + Повідомлення: + Необов'язково. Назва цієї схованки + Лише проіндексовані зміни + Будуть сховані як проіндексовані, так і не проіндексовані зміни вибраних файлів!!! + Сховати локальні зміни + Застосувати + Видалити + Зберегти як патч... + Видалити схованку + Видалити: + СХОВАНКИ + ЗМІНИ + СХОВАНКИ + Статистика + КОМІТИ + КОМІТЕР + ОГЛЯД + МІСЯЦЬ + ТИЖДЕНЬ + АВТОРІВ: + КОМІТІВ: + ПІДМОДУЛІ + Додати підмодуль + Копіювати відносний шлях + Отримати вкладені підмодулі + Відкрити сховище підмодуля + Відносний шлях: + Відносна тека для зберігання цього модуля. + Видалити підмодуль + OK + Копіювати назву тегу + Копіювати повідомлення тегу + Видалити ${0}$... + Злиття ${0}$ в ${1}$... + Надіслати ${0}$... + Оновити підмодулі + Усі підмодулі + Ініціалізувати за потреби + Рекурсивно + Підмодуль: + Використовувати опцію --remote + URL: + Попередження + Вітальна сторінка + Створити групу + Створити підгрупу + Клонувати сховище + Видалити + ПІДТРИМУЄТЬСЯ ПЕРЕТЯГУВАННЯ ТЕК. МОЖЛИВЕ ГРУПУВАННЯ. + Редагувати + Перемістити до іншої групи + Відкрити всі сховища + Відкрити сховище + Відкрити термінал + Пересканувати сховища у теці клонування за замовчуванням + Пошук сховищ... + Сортувати + ЛОКАЛЬНІ ЗМІНИ + Git Ignore + Ігнорувати всі файли *{0} + Ігнорувати файли *{0} у цій же теці + Ігнорувати файли у цій же теці + Ігнорувати лише цей файл + Amend (Доповнити) + Тепер ви можете проіндексувати цей файл. + КОМІТ + КОМІТ ТА PUSH + Шаблон/Історії + Викликати подію кліку + Коміт (Редагувати) + Індексувати всі зміни та закомітити + Ви проіндексували {0} файл(ів), але відображено лише {1} ({2} файлів відфільтровано). Продовжити? + ВИЯВЛЕНО КОНФЛІКТИ + ВІДКРИТИ ЗОВНІШНІЙ ІНСТРУМЕНТ ЗЛИТТЯ + ВІДКРИТИ ВСІ КОНФЛІКТИ В ЗОВНІШНЬОМУ ІНСТРУМЕНТІ ЗЛИТТЯ + КОНФЛІКТИ ФАЙЛІВ ВИРІШЕНО + ВИКОРИСТАТИ МОЮ ВЕРСІЮ + ВИКОРИСТАТИ ЇХНЮ ВЕРСІЮ + ВКЛЮЧИТИ НЕВІДСТЕЖУВАНІ ФАЙЛИ + НЕМАЄ ОСТАННІХ ПОВІДОМЛЕНЬ + НЕМАЄ ШАБЛОНІВ КОМІТІВ + Клацніть правою кнопкою миші на вибраних файлах та оберіть спосіб вирішення конфліктів. + Підпис + ПРОІНДЕКСОВАНІ + ВИДАЛИТИ З ІНДЕКСУ + ВИДАЛИТИ ВСЕ З ІНДЕКСУ + НЕПРОІНДЕКСОВАНІ + ІНДЕКСУВАТИ + ІНДЕКСУВАТИ ВСЕ + ПЕРЕГЛЯНУТИ ФАЙЛИ, ЩО ВВАЖАЮТЬСЯ НЕЗМІНЕНИМИ + Шаблон: ${0}$ + РОБОЧИЙ ПРОСТІР: + Налаштувати робочі простори... + РОБОЧЕ ДЕРЕВО + Копіювати шлях + Заблокувати + Видалити + Розблокувати + diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml new file mode 100644 index 00000000..2a117fb8 --- /dev/null +++ b/src/Resources/Locales/zh_CN.axaml @@ -0,0 +1,809 @@ + + + + + + 关于软件 + 关于本软件 + 开源免费的Git客户端 + 新增工作树 + 工作树路径 : + 填写该工作树的路径。支持相对路径。 + 分支名 : + 选填。默认使用目标文件夹名称。 + 跟踪分支 + 设置上游跟踪分支 + 检出分支方式 : + 创建新分支 + 已有分支 + AI助手 + 重新生成 + 使用AI助手生成提交信息 + 应用本次生成 + 应用补丁(apply) + 补丁文件 : + 选择补丁文件 + 忽略空白符号 + 应用补丁 + 空白符号处理 : + 应用贮藏 + 在成功应用后丢弃该贮藏 + 恢复索引中已暂存的变化 + 已选贮藏 : + 存档(archive) ... + 存档文件路径: + 选择存档文件的存放路径 + 指定的提交: + 存档 + SourceGit Askpass + 不跟踪更改的文件 + 没有不跟踪更改的文件 + 移除 + 加载本地图片 + 重新加载 + 二进制文件不支持该操作!!! + 二分定位(bisect) + 终止 + 标记错误 + 二分定位进行中。当前提交是 '正确' 还是 '错误' ? + 标记正确 + 无法判定 + 二分定位进行中。请标记当前的提交是 '正确' 还是 '错误',然后检出另一个提交。 + 逐行追溯(blame) + 选中文件不支持该操作!!! + 检出(checkout) ${0}$... + 与当前 ${0}$ 比较 + 与本地工作树比较 + 复制分支名 + 自定义操作 + 删除 ${0}$... + 删除选中的 {0} 个分支 + 快进(fast-forward)到 ${0}$ + 拉取(fetch) ${0}$ 至 ${1}$... + GIT工作流 - 完成 ${0}$ + 合并 ${0}$ 到 ${1}$... + 合并 {0} 个分支到当前分支 + 拉回(pull) ${0}$ + 拉回(pull) ${0}$ 内容至 ${1}$... + 推送(push)${0}$ + 变基(rebase) ${0}$ 至 ${1}$... + 重命名 ${0}$... + 重置 ${0}$ 到 ${1}$... + 切换上游分支 ... + 分支比较 + 跟踪的上游分支不存在或已删除! + 字节 + 取 消 + 重置文件到上一版本 + 重置文件到该版本 + 生成提交信息 + 切换变更显示模式 + 文件名+路径列表模式 + 全路径列表模式 + 文件目录树形结构模式 + 检出(checkout)分支 + 检出(checkout)提交 + 提交 : + 注意:执行该操作后,当前HEAD会变为游离(detached)状态! + 未提交更改 : + 丢弃更改 + 贮藏并自动恢复 + 同时更新所有子模块 + 目标分支 : + 检出分支并快进 + 上游分支 : + 挑选提交 + 提交信息中追加来源信息 + 提交列表 : + 提交变化 + 对比的父提交 : + 通常你不能对一个合并进行挑选,因为你不知道合并的哪一边应该被视为主线。这个选项指定了作为主线的父提交,允许挑选相对于该提交的修改。 + 丢弃贮藏确认 + 您正在丢弃所有的贮藏,一经操作,无法回退,是否继续? + 克隆远程仓库 + 额外参数 : + 其他克隆参数,选填。 + 本地仓库名 : + 本地仓库目录的名字,选填。 + 父级目录 : + 初始化并更新子模块 + 远程仓库 : + 关闭 + 提交信息编辑器 + 检出此提交 + 挑选(cherry-pick)此提交 + 挑选(cherry-pick)... + 与当前HEAD比较 + 与本地工作树比较 + 作者 + 提交者 + 简要信息 + 提交指纹 + 主题 + 自定义操作 + 交互式变基(rebase -i) ${0}$ 到此处 + 合并(merge)此提交至 ${0}$ + 合并(merge)... + 变基(rebase) ${0}$ 到此处 + 重置(reset) ${0}$ 到此处 + 回滚此提交 + 编辑提交信息 + 另存为补丁 ... + 合并此提交到上一个提交 + 合并之后的提交到此处 + 变更对比 + 个文件发生变更 + 查找变更... + 文件列表 + LFS文件 + 查找文件... + 子模块 + 基本信息 + 修改者 + 变更列表 + 子提交 + 提交者 + 查看包含此提交的分支/标签 + 本提交已被以下分支/标签包含 + 仅显示前100项变更。请前往【变更对比】页面查看全部。 + 提交信息 + 父提交 + 相关引用 + 提交指纹 + 浏览器中查看 + 详细描述 + 主题 + 填写提交信息主题 + 仓库配置 + 提交信息模板 + 模板内容 : + 模板名 : + 自定义操作 + 命令行参数 : + 请使用${REPO}代替仓库路径,${BRANCH}代替选中的分支,${SHA}代替提交哈希 + 可执行文件路径 : + 名称 : + 作用目标 : + 选中的分支 + 选中的提交 + 仓库 + 等待操作执行完成 + 电子邮箱 + 邮箱地址 + GIT配置 + 启用定时自动拉取远程更新 + 分钟 + 默认远程 + 默认合并方式 + ISSUE追踪 + 新增匹配Azure DevOps规则 + 新增匹配Gitee议题规则 + 新增匹配Gitee合并请求规则 + 新增匹配Github Issue规则 + 新增匹配GitLab议题规则 + 新增匹配GitLab合并请求规则 + 新增匹配Jira规则 + 新增自定义规则 + 匹配ISSUE的正则表达式 : + 规则名 : + 为ISSUE生成的URL链接 : + 可在URL中使用$1,$2等变量填入正则表达式匹配的内容 + AI + 启用特定服务 : + 当【启用特定服务】被设置时,SourceGit将在本仓库中仅使用该服务。否则将弹出可用的AI服务列表供用户选择。 + HTTP代理 + HTTP网络代理 + 用户名 + 应用于本仓库的用户名 + 工作区 + 颜色 + 名称 + 启动时恢复打开的仓库 + 确认继续 + 提交未包含变更文件!是否继续(--allow-empty)? + 自动暂存并提交 + 提交未包含变更文件!是否继续(--allow-empty)或是自动暂存所有变更并提交? + 规范化提交信息生成 + 破坏性更新: + 关闭的ISSUE: + 详细说明: + 模块: + 简述: + 类型: + 复制 + 复制全部文本 + 复制完整路径 + 复制路径 + 新建分支 ... + 新分支基于 : + 完成后切换到新分支 + 未提交更改 : + 丢弃更改 + 贮藏并自动恢复 + 新分支名 : + 填写分支名称。 + 空格将被替换为'-'符号 + 创建本地分支 + 允许重置已存在的分支 + 新建标签 ... + 标签位于 : + 使用GPG签名 + 标签描述 : + 选填。 + 标签名 : + 推荐格式 :v1.0.0-alpha + 推送到所有远程仓库 + 新建标签 + 类型 : + 附注标签 + 轻量标签 + 按住Ctrl键点击将以默认参数运行 + 剪切 + 取消初始化子模块 + 强制取消,即使包含本地变更 + 子模块 : + 删除分支确认 + 分支名 : + 您正在删除远程上的分支,请务必小心!!! + 同时删除远程分支 ${0}$ + 删除多个分支 + 您正在尝试一次性删除多个分支,请务必仔细检查后再执行操作! + 删除远程确认 + 远程名 : + 路径 : + 目标 : + 所有子节点将被同时从列表中移除。 + 仅从列表中移除,不会删除硬盘中的文件! + 删除分组确认 + 删除仓库确认 + 删除子模块确认 + 子模块路径 : + 删除标签确认 + 标签名 : + 同时删除远程仓库中的此标签 + 二进制文件 + 当前大小 + 原始大小 + 复制 + 文件权限已变化 + 首个差异 + 忽略空白符号变化 + 最后一个差异 + LFS对象变更 + 下一个差异 + 没有变更或仅有换行符差异 + 上一个差异 + 保存为补丁文件 + 显示隐藏符号 + 分列对比 + 子模块 + 删除 + 新增 + 交换比对双方 + 语法高亮 + 自动换行 + 启用基于变更块的跳转 + 使用外部合并工具查看 + 显示完整文件 + 减少可见的行数 + 增加可见的行数 + 请选择需要对比的文件 + 使用外部比对工具查看 + 放弃更改确认 + 所有本地址未提交的修改。 + 变更 : + 包括所有已忽略的文件 + 总计{0}项选中更改 + 本操作不支持回退,请确认后继续!!! + 书签 : + 名称 : + 目标 : + 编辑分组 + 编辑仓库 + 执行自定义操作 + 自定义操作 : + 拉取(fetch) + 拉取所有的远程仓库 + 强制覆盖本地REFs + 不拉取远程标签 + 远程仓库 : + 拉取远程仓库内容 + 不跟踪此文件的更改 + 放弃更改... + 放弃 {0} 个文件的更改... + 放弃选中的更改 + 使用外部合并工具打开 + 应用 ${0}$ + 另存为补丁... + 暂存(add) + 暂存(add){0} 个文件 + 暂存选中的更改 + 贮藏(stash)... + 贮藏(stash)选中的 {0} 个文件... + 从暂存中移除 + 从暂存中移除 {0} 个文件 + 从暂存中移除选中的更改 + 使用 MINE (checkout --ours) + 使用 THEIRS (checkout --theirs) + 文件历史 + 文件变更 + 文件内容 + GIT工作流 + 开发分支 : + 特性分支 : + 特性分支名前缀 : + 结束特性分支 + 结束修复分支 + 结束版本分支 + 目标分支 : + 完成后自动推送 + 压缩变更为单一提交后合并分支 + 修复分支 : + 修复分支名前缀 : + 初始化GIT工作流 + 保留分支 + 发布分支 : + 版本分支 : + 版本分支名前缀 : + 开始特性分支... + 开始特性分支 + 开始修复分支... + 开始修复分支 + 输入分支名 + 开始版本分支... + 开始版本分支 + 版本标签前缀 : + Git LFS + 添加追踪文件规则... + 匹配完整文件名 + 规则 : + 添加LFS追踪文件规则 + 拉取LFS对象 (fetch) + 执行`git lfs prune`命令,下载远程LFS对象,但不会更新工作副本。 + 拉取LFS对象 + 启用Git LFS支持 + 显示LFS对象锁 + 没有锁定的LFS文件 + 锁定 + 仅显示被我锁定的文件 + LFS对象锁状态 + 解锁 + 强制解锁 + 精简本地LFS对象存储 + 运行`git lfs prune`命令,从本地存储中精简当前版本不需要的LFS对象 + 拉回LFS对象 (pull) + 运行`git lfs pull`命令,下载远程LFS对象并更新工作副本。 + 拉回LFS对象 + 推送 + 将排队的大文件推送到Git LFS远程服务 + 推送LFS对象 + 远程 : + 跟踪名为'{0}'的文件 + 跟踪所有 *{0} 文件 + 历史记录 + 作者 + 修改时间 + 路线图与主题 + 提交指纹 + 提交时间 + 已选中 {0} 项提交 + 可以按住 Ctrl 或 Shift 键选择多个提交 + 可以按住 ⌘ 或 ⇧ 键选择多个提交 + 小提示: + 快捷键参考 + 全局快捷键 + 取消弹出面板 + 克隆远程仓库 + 关闭当前页面 + 切换到下一个页面 + 切换到上一个页面 + 新建页面 + 打开偏好设置面板 + 切换工作区 + 切换显示页面 + 仓库页面快捷键 + 提交暂存区更改 + 提交暂存区更改并推送 + 自动暂存全部变更并提交 + 基于选中提交创建新分支 + 丢弃选中的更改 + 拉取 (fetch) 远程变更 + 切换左边栏为分支/标签等显示模式(默认) + 切换左边栏为提交搜索模式 + 拉回 (pull) 远程变更 + 推送本地变更到远程 + 重新加载仓库状态 + 将选中的变更暂存或从暂存列表中移除 + 显示本地更改 + 显示历史记录 + 显示贮藏列表 + 文本编辑器 + 关闭搜索 + 定位到下一个匹配搜索的位置 + 定位到上一个匹配搜索的位置 + 使用外部比对工具查看 + 打开搜索 + 丢弃 + 暂存 + 移出暂存区 + 初始化新仓库 + 路径 : + 挑选(Cherry-Pick)操作进行中。 + 正在处理提交 + 合并操作进行中。 + 正在处理 + 变基(Rebase)操作进行中。 + 当前停止于 + 回滚提交操作进行中。 + 正在回滚提交 + 交互式变基 + 起始提交 : + 目标分支 : + 复制链接地址 + 在浏览器中访问 + 出错了 + 系统提示 + 工作区列表 + 页面列表 + 合并分支 + 目标分支 : + 合并方式 : + 合并目标 : + 合并(多目标) + 提交变化 + 合并策略 : + 目标列表 : + 调整仓库分组 + 请选择目标分组: + 名称 : + GIT尚未配置。请打开【偏好设置】配置GIT路径。 + 浏览应用数据目录 + 打开文件... + 选填。 + 新建空白页 + 设置书签 + 关闭标签页 + 关闭其他标签页 + 关闭右侧标签页 + 复制仓库路径 + 新标签页 + 粘贴 + {0}天前 + 1小时前 + {0}小时前 + 刚刚 + 上个月 + 一年前 + {0}分钟前 + {0}个月前 + {0}年前 + 昨天 + 偏好设置 + AI + Analyze Diff Prompt + API密钥 + Generate Subject Prompt + 模型 + 配置名称 + 服务地址 + 启用流式输出 + 外观配置 + 缺省字体 + 编辑器制表符宽度 + 字体大小 + 默认 + 代码编辑器 + 等宽字体 + 仅在文本编辑器中使用等宽字体 + 主题 + 主题自定义 + 主标签使用固定宽度 + 使用系统默认窗体样式 + 对比/合并工具 + 安装路径 + 填写工具可执行文件所在位置 + 工具 + 通用配置 + 启动时检测软件更新 + 日期时间格式 + 显示语言 + 最大历史提交数 + 在提交路线图中显示修改时间而非提交时间 + 在提交详情页中显示子提交列表 + 在提交路线图中显示标签 + SUBJECT字数检测 + GIT配置 + 自动换行转换 + 默认克隆路径 + 邮箱 + 默认GIT用户邮箱 + 拉取更新时启用修剪(--prune) + 对比文件时,默认忽略换行符变更 (--ignore-cr-at-eol) + 本软件要求GIT最低版本为2.25.1 + 安装路径 + 启用HTTP SSL验证 + 用户名 + 默认GIT用户名 + Git 版本 + GPG签名 + 启用提交签名 + 签名格式 + 签名程序位置 + 签名程序所在路径 + 启用标签签名 + 用户签名KEY + 输入签名提交所使用的KEY + 第三方工具集成 + 终端/SHELL + 安装路径 + 终端/SHELL + 清理远程已删除分支 + 目标 : + 清理工作树 + 清理在`$GIT_COMMON_DIR/worktrees`中的无效工作树信息 + 拉回(pull) + 拉取分支 : + 本地分支 : + 未提交更改 : + 丢弃更改 + 贮藏并自动恢复 + 同时更新所有子模块 + 远程 : + 拉回(拉取并合并) + 使用变基方式合并分支 + 推送(push) + 确保子模块变更已推送 + 启用强制推送 + 本地分支 : + 远程仓库 : + 推送到远程仓库 + 远程分支 : + 跟踪远程分支 + 同时推送标签 + 推送标签到远程仓库 + 推送到所有远程仓库 + 远程仓库 : + 标签 : + 退出 + 变基(rebase)操作 + 自动贮藏并恢复本地变更 + 目标提交 : + 分支 : + 添加远程仓库 + 编辑远程仓库 + 远程名 : + 唯一远程名 + 仓库地址 : + 远程仓库的地址 + 复制远程地址 + 删除 ... + 编辑 ... + 拉取(fetch)更新 + 在浏览器中打开 + 清理远程已删除分支 + 移除工作树操作确认 + 启用`--force`选项 + 目标工作树 : + 分支重命名 + 新的名称 : + 新的分支名不能与现有分支名相同 + 分支 : + 终止合并 + 自动拉取远端变更中... + 排序方式 + 按提交时间 + 按名称 + 清理本仓库(GC) + 本操作将执行`git gc`命令。 + 清空过滤规则 + 清空 + 配置本仓库 + 下一步 + 自定义操作 + 自定义操作未设置 + 放弃所有更改 + 启用 --reflog 选项 + 在文件浏览器中打开 + 快速查找分支/标签/子模块 + 设置在列表中的可见性 + 不指定 + 在提交列表中隐藏 + 使用其对提交列表过滤 + 启用 --first-parent 过滤选项 + 布局方式 + 水平排布 + 竖直排布 + 提交列表排序规则 + 按提交时间 + 按拓扑排序 + 本地分支 + 定位HEAD + 新建分支 + 清空通知列表 + 提交路线图中仅高亮显示当前分支 + 在 {0} 中打开 + 使用外部工具打开 + 重新加载 + 远程列表 + 添加远程 + 查找提交 + 作者 + 提交者 + 变更内容 + 文件 + 提交信息 + 提交指纹 + 仅在当前分支中查找 + 以树型结构展示 + 以树型结构展示 + 跳过此提交 + 提交统计 + 子模块列表 + 添加子模块 + 更新子模块 + 标签列表 + 新建标签 + 按创建时间 + 按名称 + 排序 + 在终端中打开 + 在提交列表中使用相对时间 + 查看命令日志 + 访问远程仓库 '{0}' + 工作树列表 + 新增工作树 + 清理 + 远程仓库地址 + 重置(reset)当前分支到指定版本 + 重置模式 : + 提交 : + 当前分支 : + 重置所选分支(非当前分支) + 重置点 : + 操作分支 : + 在文件浏览器中查看 + 回滚操作确认 + 目标提交 : + 回滚后提交更改 + 编辑提交信息 + 请使用Shift+Enter换行。Enter键已被【确 定】按钮占用。 + 执行操作中,请耐心等待... + 保 存 + 另存为... + 补丁已成功保存! + 扫描仓库 + 根路径 : + 检测更新... + 检测到软件有版本更新: + 获取最新版本信息失败! + 下 载 + 忽略此版本 + 软件更新 + 当前已是最新版本。 + 切换上游分支 + 本地分支 : + 取消追踪 + 上游分支 : + 复制提交指纹 + 跳转到提交 + 压缩为单个提交 + 合并入: + SSH密钥 : + SSH密钥文件 + 开 始 + 贮藏(stash) + 贮藏后自动恢复工作区 + 工作区文件保持未修改状态,但贮藏内容已保存。 + 包含未跟踪的文件 + 保留暂存区文件 + 信息 : + 选填,用于命名此贮藏 + 仅贮藏暂存区的变更 + 选中文件的所有变更均会被贮藏! + 贮藏本地变更 + 应用(apply) + 删除(drop) + 另存为补丁... + 丢弃贮藏确认 + 丢弃贮藏 : + 贮藏列表 + 查看变更 + 贮藏列表 + 提交统计 + 提交次数 + 提交者 + 总览 + 本月 + 本周 + 贡献者人数: + 提交次数: + 子模块 + 添加子模块 + 复制路径 + 取消初始化 + 拉取子孙模块 + 打开仓库 + 相对仓库路径 : + 本地存放的相对路径。 + 删除子模块 + 状态 + 未提交修改 + 未初始化 + SHA变更 + 未解决冲突 + 仓库 + 确 定 + 复制标签名 + 复制标签信息 + 删除 ${0}$... + 合并 ${0}$ 到 ${1}$... + 推送 ${0}$... + 更新子模块 + 更新所有子模块 + 启用 '--init' + 启用 '--recursive' + 子模块 : + 启用 '--remote' + 仓库地址 : + 日志列表 + 清空日志 + 复制 + 删除 + 警告 + 起始页 + 新建分组 + 新建子分组 + 克隆远程仓库 + 删除 + 支持拖放目录添加。支持自定义分组。 + 编辑 + 调整分组 + 打开所有包含仓库 + 打开本地仓库 + 打开终端 + 重新扫描默认克隆路径下的仓库 + 快速查找仓库... + 排序 + 本地更改 + 添加至 .gitignore 忽略列表 + 忽略所有 *{0} 文件 + 忽略同目录下所有 *{0} 文件 + 忽略同目录下所有文件 + 忽略本文件 + 修补 + 现在您已可将其加入暂存区中 + 提交 + 提交并推送 + 历史输入/模板 + 触发点击事件 + 提交(修改原始提交) + 自动暂存所有变更并提交 + 当前有 {0} 个文件在暂存区中,但仅显示了 {1} 个文件({2} 个文件被过滤掉了),是否继续提交? + 检测到冲突 + 打开合并工具 + 打开合并工具解决冲突 + 文件冲突已解决 + 使用 MINE + 使用 THEIRS + 显示未跟踪文件 + 没有提交信息记录 + 没有可应用的提交信息模板 + 重置提交者 + 请选中冲突文件,打开右键菜单,选择合适的解决方式 + 署名 + 已暂存 + 从暂存区移除选中 + 从暂存区移除所有 + 未暂存 + 暂存选中 + 暂存所有 + 查看忽略变更文件 + 模板:${0}$ + 工作区: + 配置工作区... + 本地工作树 + 复制工作树路径 + 锁定工作树 + 移除工作树 + 解除工作树锁定 + diff --git a/src/Resources/Locales/zh_CN.xaml b/src/Resources/Locales/zh_CN.xaml deleted file mode 100644 index e2eedd24..00000000 --- a/src/Resources/Locales/zh_CN.xaml +++ /dev/null @@ -1,545 +0,0 @@ - - 开 始 - 确 定 - 保 存 - 关闭 - 取 消 - 点击前往 - 在文件浏览器中查看 - 另存为... - 复制路径 - {0} 字节 - 过滤 - 选填 - 选择文件夹 - 系统提示 - - 仓库地址 : - 远程仓库地址 - 本地目录 : - 本地存放的父级目录,选填. - - SSH密钥 : - SSH密钥文件 - - 关于软件 - SourceGit - 开源Git图形客户端 - - 补丁 - 应用补丁 - 补丁文件 : - 选择补丁文件 - 空白符号处理 : - 忽略空白符号 - 忽略 - 关闭所有警告 - 警告 - 应用补丁,输出关于空白符的警告 - 错误 - 输出错误,并终止应用补丁 - 更多错误 - 与【错误】级别相似,但输出内容更多 - - 存档 ... - 存档 - 指定的提交: - 存档文件路径: - 选择存档文件的存放路径 - - 逐行追溯 - - 子模块 - 添加子模块 - 拉取子孙模块 - 打开仓库 - 复制路径 - 删除子模块 - - 挑选此提交 - 挑选提交 - 提交ID : - 提交变化 - - 克隆远程仓库 - 远程仓库 : - 远程仓库地址 - 父级目录 : - 选择存放本仓库的父级文件夹路径 - 本地仓库名 : - 本地仓库目录的名字,选填 - 远程名 : - 远程的名字,选填 - 额外参数 : - 其他克隆参数,选填 - - 基本信息 - 修改者 - 提交者 - 提交指纹 - 父提交 - 相关引用 - 提交信息 - 变更列表 - 变更对比 - 查找文件... - 文件列表 - - 仓库配置 - 用户名 - 应用于本仓库的用户名 - 电子邮箱 - 邮箱地址 - HTTP代理 - HTTP网络代理 - - 新建分支 - 创建本地分支 - 新分支基于 : - 新分支名 : - 填写分支名称 - 未提交更改 : - 贮藏并自动恢复 - 忽略 - 完成后切换到新分支 - 对于空仓库,只有提交一次有效数据,Git 才会创建第一个分支 - - 新建标签 - 标签位于 : - 标签名 : - 推荐格式 :v1.0.0-alpha - 标签描述 : - 选填 - - 在文件浏览器中打开 - 在Visual Studio Code中打开 - 在GIT终端中打开 - 重新加载 - 查找提交 - 提交统计 - 清理本仓库(GC) - 配置本仓库 - 工作区 - 本地分支 - 新建分支 - 远程列表 - 添加远程 - 标签列表 - 新建标签 - 子模块列表 - 添加子模块 - 更新子模块 - 子树列表 - 添加子树 - 解决冲突 - 下一步 - 终止冲突解决 - - GIT工作流 - 初始化GIT工作流 - 发布分支 : - 开发分支 : - 特性分支 : - 版本分支 : - 修复分支 : - 特性分支名前缀 : - 版本分支名前缀 : - 修复分支名前缀 : - 版本标签前缀 : - 开始特性分支... - 开始版本分支... - 开始修复分支... - 开始特性分支 - 开始版本分支 - 开始修复分支 - 输入分支名 - 结束特性分支 - 结束版本分支 - 结束修复分支 - {0}分支名未填写! - {0}分支名包含非法字符! - {0}前缀未填写! - {0}前缀包含非法字符! - 开发分支与发布分支不可相同! - 保留分支 - - 书签 - 打开 - 在浏览器中查看 - - 推送 '{0}' - 放弃所有更改 - 快进到 '{0}' - 拉回 '{0}' - 拉回 '{0}' 内容至 '{1}' - 检出 '{0}' - 合并 '{0}' 到 '{1}' - 变基 '{0}' 分支至 '{1}' - GIT工作流 - 完成 '{0}' - 重命名 '{0}' - 删除 '{0}' - 切换上游分支... - 复制分支名 - 取消追踪 - - 拉取更新 ... - 清理远程已删除分支 - 编辑 ... - 删除 ... - 复制远程地址 - - 重置 '{0}' 到此处 - 变基 '{0}' 到此处 - 挑选此提交 - 编辑提交信息 - 合并此提交到上一个提交 - 回滚此提交 - 另存为补丁 ... - 复制提交指纹 - 复制提交信息 - - 推送 '{0}' - 删除 '{0}' - 复制标签名 - - 应用 - 应用并删除 - 删除 - - 从暂存中移除 - 暂存... - 放弃更改... - 贮藏... - 从暂存中移除 {0} 个文件 - 暂存 {0} 个文件... - 放弃 {0} 个文件的更改... - 贮藏选中的 {0} 个文件... - 另存为补丁... - 不跟踪此文件的更改 - - 确定要删除此分支吗? - 分支名 : - - 确定要移除该远程吗? - 远程名 : - - 确定要移除该标签吗? - 标签名 : - 同时删除远程仓库中的此标签 - - 确定要移除该子模块吗? - 子模块路径 : - - 下一个差异 - 上一个差异 - 切换显示模式 - 使用外部合并工具查看 - 请选择需要对比的文件 - 没有变更或仅有换行符差异 - 二进制文件 - 原始大小 : - 当前大小 : - LFS对象变更 - 复制 - - 放弃更改确认 - 需要放弃的变更 : - 本操作不支持回退,请确认后继续!!! - 所有本地址未提交的修改 - 总计{0}项选中更改 - - 拉取 - 拉取远程仓库内容 - 远程仓库 : - 拉取所有的远程仓库 - 自动清理远程已删除分支 - - 文件历史 - 使用该版本 - - 切换变更显示模式 - 网格模式 - 列表模式 - 树形模式 - - 选择目录... - 当前选择 : - - 历史记录 - 查询提交指纹、信息、作者。回车键开始,ESC键取消 - 清空 - 切换曲线/折线显示 - 切换横向/纵向显示 - 已选中 {0} 项提交 - - 初始化新仓库 - 路径 : - 点击【确定】将在此目录执行`git init`操作 - - Source Git - 主菜单 - 出错了 - - 新建空白页 - 新标签页 - 起始页 - 关闭标签页 - 关闭其他标签页 - 关闭右侧标签页 - 设置书签 - 复制仓库路径 - - 合并分支 - 合并分支 : - 目标分支 : - 合并方式 : - - 打开本地仓库 - 打开GIT终端 - 克隆远程仓库 - 仓库列表 - 删除 - 快速查找仓库 - 排序 - 支持拖放目录添加 - - 拉回 - 拉回(拉取并合并) - 远程 : - 拉取分支 : - 本地分支 : - 使用变基方式合并分支 - 自动贮藏并恢复本地变更 - - 推送 - 推送到远程仓库 - 本地分支 : - 远程仓库 : - 远程分支 : - 同时推送标签 - 启用强制推送 - - 推送标签到远程仓库 - 标签 : - 远程仓库 : - - 变基操作 - 分支 : - 目标提交 : - 自动贮藏并恢复本地变更 - - 添加远程仓库 - 编辑远程仓库 - 远程名 : - 唯一远程名 - 仓库地址 : - 远程仓库的地址 - - 分支重命名 - 分支 : - 新的名称 : - 新的分支名不能与现有分支名相同 - - 重置当前分支到指定版本 - 当前分支 : - 提交 : - 重置模式 : - - 确定要回滚吗? - 目标提交 : - 回滚后提交更改 - - 偏好设置 - 通用配置 - 显示语言 - 系统字体 - 文本字体 - 启用暗色主题 - 启动时恢复上次打开的仓库 - 最大历史提交数 - GIT配置 - 安装路径 - 填写git.exe所在位置 - Git 版本 - 默认克隆路径 - 默认的仓库本地存放位置 - 用户名 - 默认GIT用户名 - 邮箱 - 默认GIT用户邮箱 - 自动换行转换 - 启用定时自动拉取远程更新(重启生效) - 外部合并工具 - 工具 - 安装路径 - 填写工具可执行文件所在位置 - 选择git.exe所在位置 - 选择{0}所在位置 - - 贮藏 - 贮藏本地变更 - 信息 : - 选填,用于命名此贮藏 - 包含未跟踪的文件 - - 贮藏列表 - 贮藏列表 - 查看变更 - - 丢弃贮藏确认 - 丢弃贮藏 : - - 对比提交 : {0} -> {1} - - 本地更改 - 未暂存 - 查看忽略变更文件 - 暂存选中 - 暂存所有 - 已暂存 - 从暂存区移除选中 - 从暂存区移除所有 - 检测到冲突 - 使用THEIRS - 使用MINE - 打开合并工具 - 填写提交信息 - 历史提交信息 - 修补 - 提交 - CTRL + Enter - 提交并推送 - 没有提交信息记录 - 最近输入的提交信息 - 显示未跟踪文件 - - 检测到挑选提交冲突! - 检测到变基冲突! - 检测到回滚提交冲突! - 检测到分支合并冲突! - - 系统提示 - 本次配置变更需要在重启后生效,是否立即重启? - - 添加子树 - 远程地址: - 分支或提交ID: - 本地相对路径: - 合并提交为单一提交 - - 编辑子树信息 - 远程地址: - 本地相对路径: - - 删除子树 - 本地相对路径: - 本操作仅将子树信息删除,相关文件及提交不会更改 - - 拉取子树更新 - 推送子树更新到远程 - 本地相对路径: - 远程地址: - 远程分支: - 合并提交为单一提交 - - 编辑子树 ... - 删除子树 ... - 拉取子树更新 - 推送子树变更 - - 快捷键 - 快捷键 - 功能说明 - 新建标签页 - 关闭当前浏览标签页 - 切换到下一个标签页 - 切换到指定位置的标签页 - 打开/隐藏搜索框(仅在仓库页起效) - 重新加载当前仓库信息(仅在仓库页起效) - 暂存或从暂存中移除当前选中 - 关闭当前弹出面板 - - 编辑提交信息 - 提交: - 提交信息: - - 合并HEAD到上一个提交 - 当前提交 : - 合并到 : - 修改提交信息: - - 提交统计 - 本周 - 本月 - 本年 - 提交者人数:{0} - 总计提交次数:{0} - 提交者 - 提交次数 - - 不跟踪更改的文件 - 移除 - 没有不跟踪更改的文件 - - 星期日 - 星期一 - 星期二 - 星期三 - 星期四 - 星期五 - 星期六 - - 1月 - 2月 - 3月 - 4月 - 5月 - 6月 - 7月 - 8月 - 9月 - 10月 - 11月 - 12月 - - 按名称升序 - 按最近访问 - 按书签颜色 - - GPG签名 - 启用提交签名 - 可执行文件位置 - gpg.exe所在路径 - 用户签名KEY - 输入签名提交所使用的KEY - - GIT尚未配置。请打开【偏好设置】配置GIT路径。 - 路径({0})不存在或不可读取! - 无法找到bash.exe,请确保其在git.exe同目录中! - 二进制文件不支持该操作!!! - 选中文件不支持该操作!!! - 获取仓库GIT_DIR失败! - 初始化GIT FLOW失败! - 不支持的GIT FLOW分支! - 目录不存在或不可写!!! - 非法的远程仓库地址! - 非法的本地仓库地址! - 远程仓库地址不可为空 - 远程仓库地址包含非法字符!仅支持字母、数字、下划线、横线或英文点号! - 远程仓库名已存在! - 分支名不可为空 - 分支名包含非法字符!仅支持字母、数字、下划线、横线或英文点号! - 分支名已存在! - 标签名不可为空! - 标签名包含非法字符!仅支持字母、数字、下划线、横线或英文点号! - 标签名已存在! - 提交信息未填写! - 补丁文件不存在或不可访问! - 非法的子路径! - 非法的存档文件路径! - 内容未填写! - 正在将 '{0}' 从列表中移除,是否要继续? - 您正在丢弃所有的贮藏,一经操作,无法回退,是否继续? - 补丁已成功保存! - \ No newline at end of file diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml new file mode 100644 index 00000000..29a5346d --- /dev/null +++ b/src/Resources/Locales/zh_TW.axaml @@ -0,0 +1,809 @@ + + + + + + 關於 + 關於 SourceGit + 開源免費的 Git 客戶端 + 新增工作區 + 工作區路徑: + 填寫該工作區的路徑。支援相對路徑。 + 分支名稱: + 選填。預設使用目標資料夾名稱。 + 追蹤分支 + 設定遠端追蹤分支 + 簽出分支方式: + 建立新分支 + 已有分支 + AI 助理 + 重新產生 + 使用 AI 產生提交訊息 + 套用為提交訊息 + 套用修補檔 (apply patch) + 修補檔: + 選擇修補檔 + 忽略空白符號 + 套用修補檔 + 空白字元處理: + 套用擱置變更 + 套用擱置變更後刪除 + 還原索引中已暫存的變更 (--index) + 已選擇擱置變更: + 封存 (archive)... + 封存檔案路徑: + 選擇封存檔案的儲存路徑 + 指定的提交: + 封存 + SourceGit Askpass + 不追蹤變更的檔案 + 沒有不追蹤變更的檔案 + 移除 + 載入本機圖片... + 重新載入 + 二進位檔案不支援該操作! + 二分搜尋 (bisect) + 中止 + 標記為錯誤 + 二分搜尋進行中。目前的提交是「良好」是「錯誤」? + 標記為良好 + 無法確認 + 二分搜尋進行中。請標記目前的提交為「良好」或「錯誤」,然後簽出另一個提交。 + 逐行溯源 (blame) + 所選擇的檔案不支援該操作! + 簽出 (checkout) ${0}$... + 與目前 ${0}$ 比較 + 與本機工作區比較 + 複製分支名稱 + 自訂動作 + 刪除 ${0}$... + 刪除所選的 {0} 個分支 + 快轉 (fast-forward) 到 ${0}$ + 提取 (fetch) ${0}$ 到 ${1}$... + Git 工作流 - 完成 ${0}$ + 合併 ${0}$ 到 ${1}$... + 合併 {0} 個分支到目前分支 + 拉取 (pull) ${0}$ + 拉取 (pull) ${0}$ 內容至 ${1}$... + 推送 (push) ${0}$ + 重定基底 (rebase) ${0}$ 分支至 ${1}$... + 重新命名 ${0}$... + 重設 ${0}$ 至 ${1}$... + 切換上游分支... + 分支比較 + 追蹤上游分支不存在或已刪除! + 位元組 + 取 消 + 重設檔案到上一版本 + 重設檔案為此版本 + 產生提交訊息 + 切換變更顯示模式 + 檔案名稱 + 路徑列表模式 + 全路徑列表模式 + 檔案目錄樹狀結構模式 + 簽出 (checkout) 分支 + 簽出 (checkout) 提交 + 提交: + 注意: 執行該操作後,目前 HEAD 會變為分離 (detached) 狀態! + 未提交變更: + 捨棄變更 + 擱置變更並自動復原 + 同時更新所有子模組 + 目標分支: + 簽出分支並快轉 + 上游分支 : + 揀選提交 + 提交資訊中追加來源資訊 + 提交列表: + 提交變更 + 對比的父提交: + 通常您不能對一個合併進行揀選 (cherry-pick),因為您不知道合併的哪一邊應該被視為主線。這個選項指定了作為主線的父提交,允許揀選相對於該提交的修改。 + 捨棄擱置變更確認 + 您正在捨棄所有的擱置變更,一經操作便無法復原,是否繼續? + 複製 (clone) 遠端存放庫 + 額外參數: + 其他複製參數,選填。 + 本機存放庫名稱: + 本機存放庫目錄的名稱,選填。 + 父級目錄: + 初始化並更新子模組 + 遠端存放庫: + 關閉 + 提交訊息編輯器 + 簽出 (checkout) 此提交 + 揀選 (cherry-pick) 此提交 + 揀選 (cherry-pick)... + 與目前 HEAD 比較 + 與本機工作區比較 + 作者 + 提交者 + 摘要資訊 + 提交編號 + 標題 + 自訂動作 + 互動式重定基底 (rebase -i) ${0}$ 到此處 + 合併 (merge) 此提交到 ${0}$ + 合併 (merge)... + 重定基底 (rebase) ${0}$ 到此處 + 重設 (reset) ${0}$ 到此處 + 復原此提交 + 編輯提交訊息 + 另存為修補檔 (patch)... + 合併此提交到上一個提交 + 合併之後的提交到此處 + 變更對比 + 個檔案已變更 + 搜尋變更... + 檔案列表 + LFS 檔案 + 搜尋檔案... + 子模組 + 基本資訊 + 作者 + 變更列表 + 後續提交 + 提交者 + 檢視包含此提交的分支或標籤 + 本提交包含於以下分支或標籤 + 僅顯示前 100 項變更。請前往 [變更對比] 頁面以瀏覽所有變更。 + 提交訊息 + 前次提交 + 相關參照 + 提交編號 + 在瀏覽器中檢視 + 詳細描述 + 標題 + 填寫提交訊息標題 + 存放庫設定 + 提交訊息範本 + 範本內容: + 範本名稱: + 自訂動作 + 指令參數: + 使用 ${REPO} 表示存放庫路徑、${BRANCH} 表示所選的分支、${SHA} 表示所選的提交編號 + 可執行檔案路徑: + 名稱: + 執行範圍: + 選取的分支 + 選取的提交 + 存放庫 + 等待自訂動作執行結束 + 電子郵件 + 電子郵件地址 + Git 設定 + 啟用定時自動提取 (fetch) 遠端更新 + 分鐘 + 預設遠端存放庫 + 預設合併模式 + Issue 追蹤 + 新增符合 Azure DevOps 規則 + 新增符合 Gitee 議題規則 + 新增符合 Gitee 合併請求規則 + 新增符合 GitHub Issue 規則 + 新增符合 GitLab 議題規則 + 新增符合 GitLab 合併請求規則 + 新增符合 Jira 規則 + 新增自訂規則 + 符合 Issue 的正規表達式: + 規則名稱: + 為 Issue 產生的網址連結: + 可在網址中使用 $1、$2 等變數填入正規表達式相符的內容 + AI + 偏好服務: + 設定 [偏好服務] 後,SourceGit 將於此存放庫中使用該服務,否則會顯示 AI 服務列表供使用者選擇。 + HTTP 代理 + HTTP 網路代理 + 使用者名稱 + 用於本存放庫的使用者名稱 + 工作區 + 顏色 + 名稱 + 啟動時還原上次開啟的存放庫 + 確認繼續 + 未包含任何檔案變更! 您是否仍要提交 (--allow-empty)? + 自動暫存並提交 + 未包含任何檔案變更! 您是否仍要提交 (--allow-empty) 或者自動暫存全部變更並提交? + 產生約定式提交訊息 + 破壞性變更: + 關閉的 Issue: + 詳細資訊: + 模組: + 簡述: + 類型: + 複製 + 複製全部內容 + 複製完整路徑 + 複製路徑 + 新增分支... + 新分支基於: + 完成後切換到新分支 + 未提交變更: + 捨棄變更 + 擱置變更並自動復原 + 新分支名稱: + 輸入分支名稱。 + 空格將以英文破折號取代 + 建立本機分支 + 允許覆寫現有分支 + 新增標籤... + 標籤位於: + 使用 GPG 簽章 + 標籤描述: + 選填。 + 標籤名稱: + 建議格式: v1.0.0-alpha + 推送到所有遠端存放庫 + 新增標籤 + 類型: + 附註標籤 + 輕量標籤 + 按住 Ctrl 鍵將直接以預設參數執行 + 剪下 + 取消初始化子模組 + 強制取消,即使它包含本地變更 + 子模組 : + 刪除分支確認 + 分支名稱: + 您正在刪除遠端上的分支,請務必小心! + 同時刪除遠端分支 ${0}$ + 刪除多個分支 + 您正在嘗試一次性刪除多個分支,請務必仔細檢查後再刪除! + 刪除遠端確認 + 遠端名稱: + 路徑: + 目標: + 所有子節點都會從清單中移除。 + 只會從清單中移除,而不會刪除磁碟中的檔案! + 刪除群組確認 + 刪除存放庫確認 + 刪除子模組確認 + 子模組路徑: + 刪除標籤確認 + 標籤名稱: + 同時刪除遠端存放庫中的此標籤 + 二進位檔案 + 目前大小 + 原始大小 + 複製 + 檔案權限已變更 + 第一個差異 + 忽略空白符號變化 + 最後一個差異 + LFS 物件變更 + 下一個差異 + 沒有變更或僅有換行字元差異 + 上一個差異 + 另存為修補檔 (patch) + 顯示隱藏符號 + 並排對比 + 子模組 + 已刪除 + 新增 + 交換比對雙方 + 語法上色 + 自動換行 + 區塊切換上/下一個差異 + 使用外部合併工具檢視 + 顯示檔案的全部內容 + 減少可見的行數 + 增加可見的行數 + 請選擇需要對比的檔案 + 使用外部比對工具檢視 + 捨棄變更 + 所有本機未提交的變更。 + 變更: + 包括所有已忽略的檔案 + 將捨棄總計 {0} 項已選取的變更 + 您無法復原此操作,請確認後再繼續! + 書籤: + 名稱: + 目標: + 編輯群組 + 編輯存放庫 + 執行自訂動作 + 自訂動作: + 提取 (fetch) + 提取所有的遠端存放庫 + 強制覆寫本機 REFs + 不提取遠端標籤 + 遠端存放庫: + 提取遠端存放庫內容 + 不追蹤此檔案的變更 + 捨棄變更... + 捨棄已選的 {0} 個檔案變更... + 捨棄選取的變更 + 使用外部合併工具開啟 + 使用 ${0}$ + 另存為修補檔 (patch)... + 暫存 (add) + 暫存 (add) 已選的 {0} 個檔案 + 暫存選取的變更 + 擱置變更 (stash)... + 擱置變更 (stash) 所選的 {0} 個檔案... + 取消暫存 + 從暫存中移除 {0} 個檔案 + 取消暫存選取的變更 + 使用我方版本 (ours) + 使用對方版本 (theirs) + 檔案歷史 + 檔案變更 + 檔案内容 + Git 工作流 + 開發分支: + 功能分支: + 功能分支前置詞: + 完成功能分支 + 完成修復分支 + 完成發行分支 + 目標分支: + 完成後自動推送 + 壓縮為單一提交後合併 + 修復分支: + 修復分支前置詞: + 初始化 Git 工作流 + 保留分支 + 發行分支: + 版本分支: + 發行分支前置詞: + 開始功能分支... + 開始功能分支 + 開始修復分支... + 開始修復分支 + 輸入分支名稱 + 開始發行分支... + 開始發行分支 + 版本標籤前置詞: + Git LFS + 加入追蹤檔案規則... + 符合完整檔案名稱 + 規則: + 加入 LFS 追蹤檔案規則 + 提取 (fetch) + 執行 `git lfs fetch` 以下載遠端 LFS 物件,但不會更新工作副本。 + 提取 LFS 物件 + 啟用 Git LFS 支援 + 顯示 LFS 物件鎖 + 沒有鎖定的 LFS 物件 + 鎖定 + 僅顯示被我鎖定的檔案 + LFS 物件鎖 + 解鎖 + 強制解鎖 + 清理 (prune) + 執行 `git lfs prune` 以從本機中清理目前版本不需要的 LFS 物件 + 拉取 (pull) + 執行 `git lfs pull` 以下載遠端 LFS 物件並更新工作副本。 + 拉取 LFS 物件 + 推送 (push) + 將大型檔案推送到 Git LFS 遠端服務 + 推送 LFS 物件 + 遠端存放庫: + 追蹤名稱為「{0}」的檔案 + 追蹤所有 *{0} 檔案 + 歷史記錄 + 作者 + 修改時間 + 路線圖與訊息標題 + 提交編號 + 提交時間 + 已選取 {0} 項提交 + 可以按住 Ctrl 或 Shift 鍵選擇多個提交 + 可以按住 ⌘ 或 ⇧ 鍵選擇多個提交 + 小提示: + 快速鍵參考 + 全域快速鍵 + 取消彈出面板 + 複製 (clone) 遠端存放庫 + 關閉目前頁面 + 切換到下一個頁面 + 切換到上一個頁面 + 新增頁面 + 開啟偏好設定面板 + 切換工作區 + 切換目前頁面 + 存放庫頁面快速鍵 + 提交暫存區變更 + 提交暫存區變更並推送 + 自動暫存全部變更並提交 + 基於選取的提交建立新分支 + 捨棄選取的變更 + 提取 (fetch) 遠端的變更 + 切換左邊欄為分支/標籤等顯示模式 (預設) + 切換左邊欄為歷史搜尋模式 + 拉取 (pull) 遠端的變更 + 推送 (push) 本機變更到遠端存放庫 + 強制重新載入存放庫 + 暫存或取消暫存選取的變更 + 顯示本機變更 + 顯示歷史記錄 + 顯示擱置變更列表 + 文字編輯器快速鍵 + 關閉搜尋面板 + 前往下一個搜尋相符的位置 + 前往上一個搜尋相符的位置 + 使用外部比對工具檢視 + 開啟搜尋面板 + 捨棄 + 暫存 + 取消暫存 + 初始化存放庫 + 路徑: + 揀選 (cherry-pick) 操作進行中。 + 正在處理提交 + 合併操作進行中。 + 正在處理 + 重定基底 (rebase) 操作進行中。 + 目前停止於 + 復原提交操作進行中。 + 正在復原提交 + 互動式重定基底 + 起始提交: + 目標分支: + 複製連結 + 在瀏覽器中開啟連結 + 發生錯誤 + 系統提示 + 工作區列表 + 頁面列表 + 合併分支 + 目標分支: + 合併方式: + 合併來源: + 合併 (多個來源) + 提交變更 + 合併策略: + 目標列表: + 調整存放庫分組 + 請選擇目標分組: + 名稱: + 尚未設定 Git。請開啟 [偏好設定] 以設定 Git 路徑。 + 瀏覽程式資料目錄 + 開啟檔案... + 選填。 + 新增分頁 + 設定書籤 + 關閉分頁 + 關閉其他分頁 + 關閉右側分頁 + 複製存放庫路徑 + 新分頁 + 貼上 + {0} 天前 + 1 小時前 + {0} 小時前 + 剛剛 + 上個月 + 一年前 + {0} 分鐘前 + {0} 個月前 + {0} 年前 + 昨天 + 偏好設定 + AI + 分析變更差異提示詞 + API 金鑰 + 產生提交訊息提示詞 + 模型 + 名稱 + 伺服器 + 啟用串流輸出 + 外觀設定 + 預設字型 + 編輯器 Tab 寬度 + 字型大小 + 預設 + 程式碼 + 等寬字型 + 僅在文字編輯器中使用等寬字型 + 佈景主題 + 自訂主題 + 使用固定寬度的分頁標籤 + 使用系統原生預設視窗樣式 + 對比/合併工具 + 安裝路徑 + 填寫可執行檔案所在路徑 + 工具 + 一般設定 + 啟動時檢查軟體更新 + 日期時間格式 + 顯示語言 + 最大歷史提交數 + 在提交路線圖中顯示修改時間而非提交時間 + 在提交詳細資訊中顯示後續提交 + 在路線圖中顯示標籤 + 提交標題字數偵測 + Git 設定 + 自動換行轉換 + 預設複製 (clone) 路徑 + 電子郵件 + 預設 Git 使用者電子郵件 + 拉取變更時進行清理 (--prune) + 對比檔案時,預設忽略行尾的 CR 變更 (--ignore-cr-at-eol) + 本軟體要求 Git 最低版本為 2.25.1 + 安裝路徑 + 啟用 HTTP SSL 驗證 + 使用者名稱 + 預設 Git 使用者名稱 + Git 版本 + GPG 簽章 + 啟用提交簽章 + GPG 簽章格式 + 可執行檔案路徑 + 填寫 gpg.exe 所在路徑 + 啟用標籤簽章 + 使用者簽章金鑰 + 填寫簽章提交所使用的金鑰 + 第三方工具整合 + 終端機/Shell + 安裝路徑 + 終端機/Shell + 清理遠端已刪除分支 + 目標: + 清理工作區 + 清理在 `$GIT_COMMON_DIR/worktrees` 中的無效工作區資訊 + 拉取 (pull) + 拉取分支: + 本機分支: + 未提交變更: + 捨棄變更 + 擱置變更並自動復原 + 同時更新所有子模組 + 遠端: + 拉取 (提取並合併) + 使用重定基底 (rebase) 合併分支 + 推送 (push) + 確保已推送子模組 + 啟用強制推送 + 本機分支: + 遠端存放庫: + 推送到遠端存放庫 + 遠端分支: + 追蹤遠端分支 + 同時推送標籤 + 推送標籤到遠端存放庫 + 推送到所有遠端存放庫 + 遠端存放庫: + 標籤: + 結束 + 重定基底 (rebase) 操作 + 自動擱置變更並復原本機變更 + 目標提交: + 分支: + 新增遠端存放庫 + 編輯遠端存放庫 + 遠端名稱: + 唯一遠端名稱 + 存放庫網址: + 遠端存放庫的網址 + 複製遠端網址 + 刪除... + 編輯... + 提取 (fetch) 更新 + 在瀏覽器中存取網址 + 清理遠端已刪除分支 + 刪除工作區操作確認 + 啟用 [--force] 選項 + 目標工作區: + 分支重新命名 + 新名稱: + 新的分支名稱不能與現有分支名稱相同 + 分支: + 中止 + 自動提取遠端變更中... + 排序 + 依建立時間 + 依名稱升序 + 清理本存放庫 (GC) + 本操作將執行 `git gc` 命令。 + 清空篩選規則 + 清空 + 設定本存放庫 + 下一步 + 自訂動作 + 沒有自訂的動作 + 捨棄所有變更 + 啟用 [--reflog] 選項 + 在檔案瀏覽器中開啟 + 快速搜尋分支/標籤/子模組 + 篩選以顯示或隱藏 + 取消指定 + 在提交列表中隱藏 + 以其篩選提交列表 + 啟用 [--first-parent] 選項 + 版面配置 + 橫向顯示 + 縱向顯示 + 提交顯示順序 + 依時間排序 + 依拓撲排序 + 本機分支 + 回到 HEAD + 新增分支 + 清除所有通知 + 在提交路線圖中僅對目前分支上色 + 在 {0} 中開啟 + 使用外部工具開啟 + 重新載入 + 遠端列表 + 新增遠端 + 搜尋提交 + 作者 + 提交者 + 變更內容 + 檔案 + 提交訊息 + 提交編號 + 僅搜尋目前分支 + 以樹型結構展示 + 以樹型結構展示 + 跳過此提交 + 提交統計 + 子模組列表 + 新增子模組 + 更新子模組 + 標籤列表 + 新增標籤 + 依建立時間 + 依名稱 + 排序 + 在終端機中開啟 + 在提交列表中使用相對時間 + 檢視 Git 指令記錄 + 檢視遠端存放庫 '{0}' + 工作區列表 + 新增工作區 + 清理 + 遠端存放庫網址 + 重設目前分支到指定版本 + 重設模式: + 移至提交: + 目前分支: + 重設選取的分支(非目前分支) + 重設位置 : + 選取分支 : + 在檔案瀏覽器中檢視 + 復原操作確認 + 目標提交: + 復原後提交變更 + 編輯提交訊息 + 請使用 Shift + Enter 換行。Enter 鍵已被 [確定] 按鈕佔用。 + 執行操作中,請耐心等待... + 儲存 + 另存新檔... + 修補檔已成功儲存! + 掃描存放庫 + 頂層目錄: + 檢查更新... + 軟體有版本更新: + 取得最新版本資訊失敗! + 下載 + 忽略此版本 + 軟體更新 + 目前已是最新版本。 + 切換上游分支 + 本機分支: + 取消設定上游分支 + 上游分支: + 複製提交編號 + 前往此提交 + 壓縮為單個提交 + 合併入: + SSH 金鑰: + SSH 金鑰檔案 + 開 始 + 擱置變更 (stash) + 擱置變更後自動復原工作區 + 工作區檔案保持未修改,但擱置內容已儲存。 + 包含未追蹤的檔案 + 保留已暫存的變更 + 擱置變更訊息: + 選填,用於命名此擱置變更 + 僅擱置已暫存的變更 + 已選取的檔案中的變更均會被擱置! + 擱置本機變更 + 套用 (apply) + 刪除 (drop) + 另存為修補檔 (patch)... + 捨棄擱置變更確認 + 捨棄擱置變更: + 擱置變更 + 檢視變更 + 擱置變更列表 + 提交統計 + 提交次數 + 提交者 + 總覽 + 本月 + 本週 + 貢獻者人數: + 提交次數: + 子模組 + 新增子模組 + 複製路徑 + 取消初始化 + 提取子模組 + 開啟存放庫 + 相對存放庫路徑: + 本機存放的相對路徑。 + 刪除子模組 + 狀態 + 未提交變更 + 未初始化 + SHA 變更 + 未解決的衝突 + 存放庫 + 確 定 + 複製標籤名稱 + 複製標籤訊息 + 刪除 ${0}$... + 合併 ${0}$ 到 ${1}$... + 推送 ${0}$... + 更新子模組 + 更新所有子模組 + 啟用 [--init] 選項 + 啟用 [--recursive] 選項 + 子模組: + 啟用 [--remote] 選項 + 存放庫網址: + 記錄 + 清除所有記錄 + 複製 + 刪除 + 警告 + 起始頁 + 新增群組 + 新增子群組 + 複製 (clone) 遠端存放庫 + 刪除 + 支援拖放以新增目錄與自訂群組。 + 編輯 + 調整存放庫分組 + 開啟所有包含存放庫 + 開啟本機存放庫 + 開啟終端機 + 重新掃描預設複製 (clone) 目錄下的存放庫 + 快速搜尋存放庫... + 排序 + 本機變更 + 加入至 .gitignore 忽略清單 + 忽略所有 *{0} 檔案 + 忽略同路徑下所有 *{0} 檔案 + 忽略同路徑下所有檔案 + 忽略本檔案 + 修補 + 現在您已可將其加入暫存區中 + 提 交 + 提交並推送 + 歷史輸入/範本 + 觸發點擊事件 + 提交 (修改原始提交) + 自動暫存全部變更並提交 + 您已暫存 {0} 個檔案,但只顯示 {1} 個檔案 ({2} 個檔案被篩選器隱藏)。您確定要繼續提交嗎? + 偵測到衝突 + 使用外部合併工具開啟 + 使用外部合併工具開啟 + 檔案衝突已解決 + 使用我方版本 (ours) + 使用對方版本 (theirs) + 顯示未追蹤檔案 + 沒有提交訊息記錄 + 沒有可套用的提交訊息範本 + 重設作者 + 請選擇發生衝突的檔案,開啟右鍵選單,選擇合適的解決方式 + 署名 + 已暫存 + 取消暫存選取的檔案 + 取消暫存所有檔案 + 未暫存 + 暫存選取的檔案 + 暫存所有檔案 + 檢視不追蹤變更的檔案 + 範本: ${0}$ + 工作區: + 設定工作區... + 本機工作區 + 複製工作區路徑 + 鎖定工作區 + 移除工作區 + 解除鎖定工作區 + diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml new file mode 100644 index 00000000..15704775 --- /dev/null +++ b/src/Resources/Styles.axaml @@ -0,0 +1,1339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/Styles/Button.xaml b/src/Resources/Styles/Button.xaml deleted file mode 100644 index 76aabb6e..00000000 --- a/src/Resources/Styles/Button.xaml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/ComboBox.xaml b/src/Resources/Styles/ComboBox.xaml deleted file mode 100644 index b2f36a3f..00000000 --- a/src/Resources/Styles/ComboBox.xaml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/ContextMenu.xaml b/src/Resources/Styles/ContextMenu.xaml deleted file mode 100644 index 2c5be7b9..00000000 --- a/src/Resources/Styles/ContextMenu.xaml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/DataGrid.xaml b/src/Resources/Styles/DataGrid.xaml deleted file mode 100644 index 1ca58ae3..00000000 --- a/src/Resources/Styles/DataGrid.xaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/HyperLink.xaml b/src/Resources/Styles/HyperLink.xaml deleted file mode 100644 index fcba9d56..00000000 --- a/src/Resources/Styles/HyperLink.xaml +++ /dev/null @@ -1,12 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/IconButton.xaml b/src/Resources/Styles/IconButton.xaml deleted file mode 100644 index f530df8f..00000000 --- a/src/Resources/Styles/IconButton.xaml +++ /dev/null @@ -1,46 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ListBox.xaml b/src/Resources/Styles/ListBox.xaml deleted file mode 100644 index 2fda7c79..00000000 --- a/src/Resources/Styles/ListBox.xaml +++ /dev/null @@ -1,26 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ListView.xaml b/src/Resources/Styles/ListView.xaml deleted file mode 100644 index 3c81d5af..00000000 --- a/src/Resources/Styles/ListView.xaml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/Path.xaml b/src/Resources/Styles/Path.xaml deleted file mode 100644 index a91dee33..00000000 --- a/src/Resources/Styles/Path.xaml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/RadioButton.xaml b/src/Resources/Styles/RadioButton.xaml deleted file mode 100644 index f935b69f..00000000 --- a/src/Resources/Styles/RadioButton.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ScrollBar.xaml b/src/Resources/Styles/ScrollBar.xaml deleted file mode 100644 index 464ac563..00000000 --- a/src/Resources/Styles/ScrollBar.xaml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/ScrollViewer.xaml b/src/Resources/Styles/ScrollViewer.xaml deleted file mode 100644 index b1d1aecb..00000000 --- a/src/Resources/Styles/ScrollViewer.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/Slider.xaml b/src/Resources/Styles/Slider.xaml deleted file mode 100644 index c54077e5..00000000 --- a/src/Resources/Styles/Slider.xaml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/TabControl.xaml b/src/Resources/Styles/TabControl.xaml deleted file mode 100644 index 69cc26a8..00000000 --- a/src/Resources/Styles/TabControl.xaml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/TextBlock.xaml b/src/Resources/Styles/TextBlock.xaml deleted file mode 100644 index b2540c91..00000000 --- a/src/Resources/Styles/TextBlock.xaml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/TextBox.xaml b/src/Resources/Styles/TextBox.xaml deleted file mode 100644 index 3d95136d..00000000 --- a/src/Resources/Styles/TextBox.xaml +++ /dev/null @@ -1,107 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/ToggleButton.xaml b/src/Resources/Styles/ToggleButton.xaml deleted file mode 100644 index f284536a..00000000 --- a/src/Resources/Styles/ToggleButton.xaml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/Tooltip.xaml b/src/Resources/Styles/Tooltip.xaml deleted file mode 100644 index 4ce7cf0a..00000000 --- a/src/Resources/Styles/Tooltip.xaml +++ /dev/null @@ -1,22 +0,0 @@ - - - \ No newline at end of file diff --git a/src/Resources/Styles/Tree.xaml b/src/Resources/Styles/Tree.xaml deleted file mode 100644 index d0980c64..00000000 --- a/src/Resources/Styles/Tree.xaml +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Styles/Window.xaml b/src/Resources/Styles/Window.xaml deleted file mode 100644 index 710e1f58..00000000 --- a/src/Resources/Styles/Window.xaml +++ /dev/null @@ -1,40 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml new file mode 100644 index 00000000..3b463733 --- /dev/null +++ b/src/Resources/Themes.axaml @@ -0,0 +1,89 @@ + + + + #FFF0F5F9 + #00000000 + #FFCFDEEA + #FFF0F5F9 + #FFF8F8F8 + #FFFAFAFA + #FFB0CEE8 + #FF1F1F1F + #FF836C2E + #FFFFFFFF + #FFCFCFCF + #FF898989 + #FFCFCFCF + #FFF8F8F8 + White + #FF1F1F1F + #FF6F6F6F + #10000000 + #80BFE6C1 + #80FF9797 + #A7E1A7 + #F19B9D + #0000EE + #FFE4E4E4 + + + + #FF252525 + #FF444444 + #FF1F1F1F + #FF2C2C2C + #FF2B2B2B + #FF1C1C1C + #FF8F8F8F + #FFDDDDDD + #FFFAFAD2 + #FF252525 + #FF181818 + #FF7C7C7C + #FF404040 + #FF303030 + #FF333333 + #FFDDDDDD + #40F1F1F1 + #3C000000 + #C03A5C3F + #C0633F3E + #A0308D3C + #A09F4247 + #4DAAFC + #FF383838 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fonts:Inter#Inter + fonts:SourceGit#JetBrains Mono + fonts:SourceGit#JetBrains Mono + diff --git a/src/Resources/Themes/Dark.xaml b/src/Resources/Themes/Dark.xaml deleted file mode 100644 index 0e26e7dc..00000000 --- a/src/Resources/Themes/Dark.xaml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - Transparent - - - - - - - - - - \ No newline at end of file diff --git a/src/Resources/Themes/Light.xaml b/src/Resources/Themes/Light.xaml deleted file mode 100644 index 9982c328..00000000 --- a/src/Resources/Themes/Light.xaml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - #FFCFCFCF - - - - - - - - - - \ No newline at end of file diff --git a/src/SourceGit.csproj b/src/SourceGit.csproj index f0808ae0..5205ae4f 100644 --- a/src/SourceGit.csproj +++ b/src/SourceGit.csproj @@ -1,30 +1,67 @@ - - - net48 - WinExe - true - true - App.ico - sourcegit - OpenSource GIT client for Windows - Copyright © sourcegit 2023. All rights reserved. - App.manifest - 6.8 - MIT - SourceGit.App - https://github.com/sourcegit-scm/sourcegit.git - https://github.com/sourcegit-scm/sourcegit.git - Public - true - none - false - false - - - - - - - - - \ No newline at end of file + + + WinExe + net9.0 + App.manifest + App.ico + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)\\..\\VERSION")) + true + true + + SourceGit + OpenSource GIT client + sourcegit-scm + Copyright © $([System.DateTime]::Now.Year) sourcegit-scm. + MIT + https://github.com/sourcegit-scm/sourcegit.git + https://github.com/sourcegit-scm/sourcegit.git + Public + + + + true + true + link + + + + $(DefineConstants);DISABLE_UPDATE_DETECTION + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ViewModels/AIAssistant.cs b/src/ViewModels/AIAssistant.cs new file mode 100644 index 00000000..8756a30b --- /dev/null +++ b/src/ViewModels/AIAssistant.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class AIAssistant : ObservableObject + { + public bool IsGenerating + { + get => _isGenerating; + private set => SetProperty(ref _isGenerating, value); + } + + public string Text + { + get => _text; + private set => SetProperty(ref _text, value); + } + + public AIAssistant(Repository repo, Models.OpenAIService service, List changes, Action onApply) + { + _repo = repo; + _service = service; + _changes = changes; + _onApply = onApply; + _cancel = new CancellationTokenSource(); + + Gen(); + } + + public void Regen() + { + if (_cancel is { IsCancellationRequested: false }) + _cancel.Cancel(); + + Gen(); + } + + public void Apply() + { + _onApply?.Invoke(Text); + } + + public void Cancel() + { + _cancel?.Cancel(); + } + + private void Gen() + { + Text = string.Empty; + IsGenerating = true; + + _cancel = new CancellationTokenSource(); + Task.Run(() => + { + new Commands.GenerateCommitMessage(_service, _repo.FullPath, _changes, _cancel.Token, message => + { + Dispatcher.UIThread.Invoke(() => Text = message); + }).Exec(); + + Dispatcher.UIThread.Invoke(() => IsGenerating = false); + }, _cancel.Token); + } + + private readonly Repository _repo = null; + private Models.OpenAIService _service = null; + private List _changes = null; + private Action _onApply = null; + private CancellationTokenSource _cancel = null; + private bool _isGenerating = false; + private string _text = string.Empty; + } +} diff --git a/src/ViewModels/AddRemote.cs b/src/ViewModels/AddRemote.cs new file mode 100644 index 00000000..37bbc43a --- /dev/null +++ b/src/ViewModels/AddRemote.cs @@ -0,0 +1,124 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class AddRemote : Popup + { + [Required(ErrorMessage = "Remote name is required!!!")] + [RegularExpression(@"^[\w\-\.]+$", ErrorMessage = "Bad remote name format!!!")] + [CustomValidation(typeof(AddRemote), nameof(ValidateRemoteName))] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + [Required(ErrorMessage = "Remote URL is required!!!")] + [CustomValidation(typeof(AddRemote), nameof(ValidateRemoteURL))] + public string Url + { + get => _url; + set + { + if (SetProperty(ref _url, value, true)) + UseSSH = Models.Remote.IsSSH(value); + } + } + + public bool UseSSH + { + get => _useSSH; + set + { + if (SetProperty(ref _useSSH, value)) + ValidateProperty(_sshkey, nameof(SSHKey)); + } + } + + [CustomValidation(typeof(AddRemote), nameof(ValidateSSHKey))] + public string SSHKey + { + get => _sshkey; + set => SetProperty(ref _sshkey, value, true); + } + + public AddRemote(Repository repo) + { + _repo = repo; + } + + public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is AddRemote add) + { + var exists = add._repo.Remotes.Find(x => x.Name == name); + if (exists != null) + return new ValidationResult("A remote with given name already exists!!!"); + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateRemoteURL(string url, ValidationContext ctx) + { + if (ctx.ObjectInstance is AddRemote add) + { + if (!Models.Remote.IsValidURL(url)) + return new ValidationResult("Bad remote URL format!!!"); + + var exists = add._repo.Remotes.Find(x => x.URL == url); + if (exists != null) + return new ValidationResult("A remote with the same url already exists!!!"); + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateSSHKey(string sshkey, ValidationContext ctx) + { + if (ctx.ObjectInstance is AddRemote { _useSSH: true } && !string.IsNullOrEmpty(sshkey)) + { + if (!File.Exists(sshkey)) + return new ValidationResult("Given SSH private key can NOT be found!"); + } + + return ValidationResult.Success; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Adding remote ..."; + + var log = _repo.CreateLog("Add Remote"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Remote(_repo.FullPath).Use(log).Add(_name, _url); + if (succ) + { + new Commands.Config(_repo.FullPath).Use(log).Set($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); + new Commands.Fetch(_repo.FullPath, _name, false, false).Use(log).Exec(); + } + + log.Complete(); + CallUIThread(() => + { + _repo.MarkFetched(); + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + return succ; + }); + } + + private readonly Repository _repo = null; + private string _name = string.Empty; + private string _url = string.Empty; + private bool _useSSH = false; + private string _sshkey = string.Empty; + } +} diff --git a/src/ViewModels/AddSubmodule.cs b/src/ViewModels/AddSubmodule.cs new file mode 100644 index 00000000..82a1f62a --- /dev/null +++ b/src/ViewModels/AddSubmodule.cs @@ -0,0 +1,76 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class AddSubmodule : Popup + { + [Required(ErrorMessage = "Url is required!!!")] + [CustomValidation(typeof(AddSubmodule), nameof(ValidateURL))] + public string Url + { + get => _url; + set => SetProperty(ref _url, value, true); + } + + public string RelativePath + { + get => _relativePath; + set => SetProperty(ref _relativePath, value); + } + + public bool Recursive + { + get; + set; + } + + public AddSubmodule(Repository repo) + { + _repo = repo; + } + + public static ValidationResult ValidateURL(string url, ValidationContext ctx) + { + if (!Models.Remote.IsValidURL(url)) + return new ValidationResult("Invalid repository URL format"); + + return ValidationResult.Success; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Adding submodule..."; + + var log = _repo.CreateLog("Add Submodule"); + Use(log); + + var relativePath = _relativePath; + if (string.IsNullOrEmpty(relativePath)) + { + if (_url.EndsWith("/.git", StringComparison.Ordinal)) + relativePath = Path.GetFileName(Path.GetDirectoryName(_url)); + else if (_url.EndsWith(".git", StringComparison.Ordinal)) + relativePath = Path.GetFileNameWithoutExtension(_url); + else + relativePath = Path.GetFileName(_url); + } + + return Task.Run(() => + { + var succ = new Commands.Submodule(_repo.FullPath).Use(log).Add(_url, relativePath, Recursive); + log.Complete(); + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + private string _url = string.Empty; + private string _relativePath = string.Empty; + } +} diff --git a/src/ViewModels/AddWorktree.cs b/src/ViewModels/AddWorktree.cs new file mode 100644 index 00000000..a089a391 --- /dev/null +++ b/src/ViewModels/AddWorktree.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class AddWorktree : Popup + { + [Required(ErrorMessage = "Worktree path is required!")] + [CustomValidation(typeof(AddWorktree), nameof(ValidateWorktreePath))] + public string Path + { + get => _path; + set => SetProperty(ref _path, value, true); + } + + public bool CreateNewBranch + { + get => _createNewBranch; + set + { + if (SetProperty(ref _createNewBranch, value, true)) + { + if (value) + SelectedBranch = string.Empty; + else + SelectedBranch = LocalBranches.Count > 0 ? LocalBranches[0] : string.Empty; + } + } + } + + public List LocalBranches + { + get; + private set; + } + + public List RemoteBranches + { + get; + private set; + } + + public string SelectedBranch + { + get => _selectedBranch; + set => SetProperty(ref _selectedBranch, value); + } + + public bool SetTrackingBranch + { + get => _setTrackingBranch; + set => SetProperty(ref _setTrackingBranch, value); + } + + public string SelectedTrackingBranch + { + get; + set; + } + + public AddWorktree(Repository repo) + { + _repo = repo; + + LocalBranches = new List(); + RemoteBranches = new List(); + foreach (var branch in repo.Branches) + { + if (branch.IsLocal) + LocalBranches.Add(branch.Name); + else + RemoteBranches.Add(branch.FriendlyName); + } + + if (RemoteBranches.Count > 0) + SelectedTrackingBranch = RemoteBranches[0]; + else + SelectedTrackingBranch = string.Empty; + } + + public static ValidationResult ValidateWorktreePath(string path, ValidationContext ctx) + { + var creator = ctx.ObjectInstance as AddWorktree; + if (creator == null) + return new ValidationResult("Missing runtime context to create branch!"); + + if (string.IsNullOrEmpty(path)) + return new ValidationResult("Worktree path is required!"); + + var fullPath = System.IO.Path.IsPathRooted(path) ? path : System.IO.Path.Combine(creator._repo.FullPath, path); + var info = new DirectoryInfo(fullPath); + if (info.Exists) + { + var files = info.GetFiles(); + if (files.Length > 0) + return new ValidationResult("Given path is not empty!!!"); + + var folders = info.GetDirectories(); + if (folders.Length > 0) + return new ValidationResult("Given path is not empty!!!"); + } + + return ValidationResult.Success; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Adding worktree ..."; + + var branchName = _selectedBranch; + var tracking = _setTrackingBranch ? SelectedTrackingBranch : string.Empty; + var log = _repo.CreateLog("Add Worktree"); + + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Worktree(_repo.FullPath).Use(log).Add(_path, branchName, _createNewBranch, tracking); + log.Complete(); + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo = null; + private string _path = string.Empty; + private bool _createNewBranch = true; + private string _selectedBranch = string.Empty; + private bool _setTrackingBranch = false; + } +} diff --git a/src/ViewModels/Apply.cs b/src/ViewModels/Apply.cs new file mode 100644 index 00000000..c7f3c185 --- /dev/null +++ b/src/ViewModels/Apply.cs @@ -0,0 +1,64 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Apply : Popup + { + [Required(ErrorMessage = "Patch file is required!!!")] + [CustomValidation(typeof(Apply), nameof(ValidatePatchFile))] + public string PatchFile + { + get => _patchFile; + set => SetProperty(ref _patchFile, value, true); + } + + public bool IgnoreWhiteSpace + { + get => _ignoreWhiteSpace; + set => SetProperty(ref _ignoreWhiteSpace, value); + } + + public Models.ApplyWhiteSpaceMode SelectedWhiteSpaceMode + { + get; + set; + } + + public Apply(Repository repo) + { + _repo = repo; + + SelectedWhiteSpaceMode = Models.ApplyWhiteSpaceMode.Supported[0]; + } + + public static ValidationResult ValidatePatchFile(string file, ValidationContext _) + { + if (File.Exists(file)) + return ValidationResult.Success; + + return new ValidationResult($"File '{file}' can NOT be found!!!"); + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Apply patch..."; + + var log = _repo.CreateLog("Apply Patch"); + return Task.Run(() => + { + var succ = new Commands.Apply(_repo.FullPath, _patchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg, null).Use(log).Exec(); + log.Complete(); + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + private string _patchFile = string.Empty; + private bool _ignoreWhiteSpace = true; + } +} diff --git a/src/ViewModels/ApplyStash.cs b/src/ViewModels/ApplyStash.cs new file mode 100644 index 00000000..e7cd64e9 --- /dev/null +++ b/src/ViewModels/ApplyStash.cs @@ -0,0 +1,49 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ApplyStash : Popup + { + public Models.Stash Stash + { + get; + private set; + } + + public bool RestoreIndex + { + get; + set; + } = true; + + public bool DropAfterApply + { + get; + set; + } = false; + + public ApplyStash(Repository repo, Models.Stash stash) + { + _repo = repo; + Stash = stash; + } + + public override Task Sure() + { + ProgressDescription = $"Applying stash: {Stash.Name}"; + + var log = _repo.CreateLog("Apply Stash"); + return Task.Run(() => + { + var succ = new Commands.Stash(_repo.FullPath).Use(log).Apply(Stash.Name, RestoreIndex); + if (succ && DropAfterApply) + new Commands.Stash(_repo.FullPath).Use(log).Drop(Stash.Name); + + log.Complete(); + return true; + }); + } + + private readonly Repository _repo; + } +} diff --git a/src/ViewModels/Archive.cs b/src/ViewModels/Archive.cs new file mode 100644 index 00000000..a4a4f6eb --- /dev/null +++ b/src/ViewModels/Archive.cs @@ -0,0 +1,75 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Archive : Popup + { + [Required(ErrorMessage = "Output file name is required")] + public string SaveFile + { + get => _saveFile; + set => SetProperty(ref _saveFile, value, true); + } + + public object BasedOn + { + get; + private set; + } + + public Archive(Repository repo, Models.Branch branch) + { + _repo = repo; + _revision = branch.Head; + _saveFile = $"archive-{Path.GetFileName(branch.Name)}.zip"; + BasedOn = branch; + } + + public Archive(Repository repo, Models.Commit commit) + { + _repo = repo; + _revision = commit.SHA; + _saveFile = $"archive-{commit.SHA.AsSpan(0, 10)}.zip"; + BasedOn = commit; + } + + public Archive(Repository repo, Models.Tag tag) + { + _repo = repo; + _revision = tag.SHA; + _saveFile = $"archive-{Path.GetFileName(tag.Name)}.zip"; + BasedOn = tag; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Archiving ..."; + + var log = _repo.CreateLog("Archive"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Archive(_repo.FullPath, _revision, _saveFile).Use(log).Exec(); + log.Complete(); + + CallUIThread(() => + { + _repo.SetWatcherEnabled(true); + if (succ) + App.SendNotification(_repo.FullPath, $"Save archive to : {_saveFile}"); + }); + + return succ; + }); + } + + private readonly Repository _repo = null; + private string _saveFile; + private readonly string _revision; + } +} diff --git a/src/ViewModels/AssumeUnchangedManager.cs b/src/ViewModels/AssumeUnchangedManager.cs new file mode 100644 index 00000000..68151448 --- /dev/null +++ b/src/ViewModels/AssumeUnchangedManager.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; + +using Avalonia.Collections; +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class AssumeUnchangedManager + { + public AvaloniaList Files { get; private set; } + + public AssumeUnchangedManager(Repository repo) + { + _repo = repo; + Files = new AvaloniaList(); + + Task.Run(() => + { + var collect = new Commands.QueryAssumeUnchangedFiles(_repo.FullPath).Result(); + Dispatcher.UIThread.Invoke(() => Files.AddRange(collect)); + }); + } + + public void Remove(string file) + { + if (!string.IsNullOrEmpty(file)) + { + var log = _repo.CreateLog("Remove Assume Unchanged File"); + new Commands.AssumeUnchanged(_repo.FullPath, file, false).Use(log).Exec(); + log.Complete(); + Files.Remove(file); + } + } + + private readonly Repository _repo; + } +} diff --git a/src/ViewModels/Blame.cs b/src/ViewModels/Blame.cs new file mode 100644 index 00000000..a189215a --- /dev/null +++ b/src/ViewModels/Blame.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Blame : ObservableObject + { + public string FilePath + { + get; + } + + public Models.Commit Revision + { + get => _revision; + private set => SetProperty(ref _revision, value); + } + + public Models.BlameData Data + { + get => _data; + private set + { + if (SetProperty(ref _data, value)) + OnPropertyChanged(nameof(IsBinary)); + } + } + + public bool IsBinary + { + get => _data != null && _data.IsBinary; + } + + public bool CanBack + { + get => _navigationActiveIndex > 0; + } + + public bool CanForward + { + get => _navigationActiveIndex < _navigationHistory.Count - 1; + } + + public Blame(string repo, string file, Models.Commit commit) + { + var sha = commit.SHA.Substring(0, 10); + + FilePath = file; + Revision = commit; + + _repo = repo; + _navigationHistory.Add(sha); + _commits.Add(sha, commit); + SetBlameData(sha); + } + + public string GetCommitMessage(string sha) + { + if (_commitMessages.TryGetValue(sha, out var msg)) + return msg; + + msg = new Commands.QueryCommitFullMessage(_repo, sha).Result(); + _commitMessages[sha] = msg; + return msg; + } + + public void Back() + { + if (_navigationActiveIndex <= 0) + return; + + _navigationActiveIndex--; + OnPropertyChanged(nameof(CanBack)); + OnPropertyChanged(nameof(CanForward)); + NavigateToCommit(_navigationHistory[_navigationActiveIndex]); + } + + public void Forward() + { + if (_navigationActiveIndex >= _navigationHistory.Count - 1) + return; + + _navigationActiveIndex++; + OnPropertyChanged(nameof(CanBack)); + OnPropertyChanged(nameof(CanForward)); + NavigateToCommit(_navigationHistory[_navigationActiveIndex]); + } + + public void NavigateToCommit(string commitSHA) + { + if (!_navigationHistory[_navigationActiveIndex].Equals(commitSHA, StringComparison.Ordinal)) + { + _navigationHistory.Add(commitSHA); + _navigationActiveIndex = _navigationHistory.Count - 1; + OnPropertyChanged(nameof(CanBack)); + OnPropertyChanged(nameof(CanForward)); + } + + if (!Revision.SHA.StartsWith(commitSHA, StringComparison.Ordinal)) + SetBlameData(commitSHA); + + if (App.GetLauncher() is { Pages: { } pages }) + { + foreach (var page in pages) + { + if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) + { + repo.NavigateToCommit(commitSHA); + break; + } + } + } + } + + private void SetBlameData(string commitSHA) + { + if (_cancellationSource is { IsCancellationRequested: false }) + _cancellationSource.Cancel(); + + _cancellationSource = new CancellationTokenSource(); + var token = _cancellationSource.Token; + + if (_commits.TryGetValue(commitSHA, out var c)) + { + Revision = c; + } + else + { + Task.Run(() => + { + var result = new Commands.QuerySingleCommit(_repo, commitSHA).Result(); + + Dispatcher.UIThread.Invoke(() => + { + if (!token.IsCancellationRequested) + { + _commits.Add(commitSHA, result); + Revision = result ?? new Models.Commit() { SHA = commitSHA }; + } + }); + }, token); + } + + Task.Run(() => + { + var result = new Commands.Blame(_repo, FilePath, commitSHA).Result(); + + Dispatcher.UIThread.Invoke(() => + { + if (!token.IsCancellationRequested) + Data = result; + }); + }, token); + } + + private string _repo; + private Models.Commit _revision; + private CancellationTokenSource _cancellationSource = null; + private int _navigationActiveIndex = 0; + private List _navigationHistory = []; + private Models.BlameData _data = null; + private Dictionary _commits = new(); + private Dictionary _commitMessages = new(); + } +} diff --git a/src/ViewModels/BlockNavigation.cs b/src/ViewModels/BlockNavigation.cs new file mode 100644 index 00000000..9a5a926c --- /dev/null +++ b/src/ViewModels/BlockNavigation.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class BlockNavigation : ObservableObject + { + public class Block + { + public int Start { get; set; } = 0; + public int End { get; set; } = 0; + + public Block(int start, int end) + { + Start = start; + End = end; + } + + public bool IsInRange(int line) + { + return line >= Start && line <= End; + } + } + + public AvaloniaList Blocks + { + get; + } = []; + + public int Current + { + get => _current; + private set => SetProperty(ref _current, value); + } + + public string Indicator + { + get + { + if (Blocks.Count == 0) + return "-/-"; + + if (_current >= 0 && _current < Blocks.Count) + return $"{_current + 1}/{Blocks.Count}"; + + return $"-/{Blocks.Count}"; + } + } + + public BlockNavigation(object context) + { + Blocks.Clear(); + Current = -1; + + var lines = new List(); + if (context is Models.TextDiff combined) + lines = combined.Lines; + else if (context is TwoSideTextDiff twoSide) + lines = twoSide.Old; + + if (lines.Count == 0) + return; + + var lineIdx = 0; + var blockStartIdx = 0; + var isNewBlock = true; + var blocks = new List(); + + foreach (var line in lines) + { + lineIdx++; + if (line.Type == Models.TextDiffLineType.Added || + line.Type == Models.TextDiffLineType.Deleted || + line.Type == Models.TextDiffLineType.None) + { + if (isNewBlock) + { + isNewBlock = false; + blockStartIdx = lineIdx; + } + } + else + { + if (!isNewBlock) + { + blocks.Add(new Block(blockStartIdx, lineIdx - 1)); + isNewBlock = true; + } + } + } + + if (!isNewBlock) + blocks.Add(new Block(blockStartIdx, lines.Count - 1)); + + Blocks.AddRange(blocks); + } + + public Block GetCurrentBlock() + { + return (_current >= 0 && _current < Blocks.Count) ? Blocks[_current] : null; + } + + public Block GotoFirst() + { + if (Blocks.Count == 0) + return null; + + Current = 0; + return Blocks[_current]; + } + + public Block GotoPrev() + { + if (Blocks.Count == 0) + return null; + + if (_current == -1) + Current = 0; + else if (_current > 0) + Current = _current - 1; + return Blocks[_current]; + } + + public Block GotoNext() + { + if (Blocks.Count == 0) + return null; + + if (_current < Blocks.Count - 1) + Current = _current + 1; + return Blocks[_current]; + } + + public Block GotoLast() + { + if (Blocks.Count == 0) + return null; + + Current = Blocks.Count - 1; + return Blocks[_current]; + } + + private int _current = -1; + } +} diff --git a/src/ViewModels/BranchCompare.cs b/src/ViewModels/BranchCompare.cs new file mode 100644 index 00000000..f56cfd76 --- /dev/null +++ b/src/ViewModels/BranchCompare.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class BranchCompare : ObservableObject + { + public Models.Branch Base + { + get => _based; + private set => SetProperty(ref _based, value); + } + + public Models.Branch To + { + get => _to; + private set => SetProperty(ref _to, value); + } + + public Models.Commit BaseHead + { + get => _baseHead; + private set => SetProperty(ref _baseHead, value); + } + + public Models.Commit ToHead + { + get => _toHead; + private set => SetProperty(ref _toHead, value); + } + + public List VisibleChanges + { + get => _visibleChanges; + private set => SetProperty(ref _visibleChanges, value); + } + + public List SelectedChanges + { + get => _selectedChanges; + set + { + if (SetProperty(ref _selectedChanges, value)) + { + if (value != null && value.Count == 1) + DiffContext = new DiffContext(_repo, new Models.DiffOption(_based.Head, _to.Head, value[0]), _diffContext); + else + DiffContext = null; + } + } + } + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + { + RefreshVisible(); + } + } + } + + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public BranchCompare(string repo, Models.Branch baseBranch, Models.Branch toBranch) + { + _repo = repo; + _based = baseBranch; + _to = toBranch; + + Refresh(); + } + + public void NavigateTo(string commitSHA) + { + var launcher = App.GetLauncher(); + if (launcher == null) + return; + + foreach (var page in launcher.Pages) + { + if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) + { + repo.NavigateToCommit(commitSHA); + break; + } + } + } + + public void Swap() + { + (Base, To) = (_to, _based); + SelectedChanges = []; + + if (_baseHead != null) + (BaseHead, ToHead) = (_toHead, _baseHead); + + Refresh(); + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; + } + + public ContextMenu CreateChangeContextMenu() + { + if (_selectedChanges == null || _selectedChanges.Count != 1) + return null; + + var change = _selectedChanges[0]; + var menu = new ContextMenu(); + + var diffWithMerger = new MenuItem(); + diffWithMerger.Header = App.Text("DiffWithMerger"); + diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); + diffWithMerger.Click += (_, ev) => + { + var toolType = Preferences.Instance.ExternalMergeToolType; + var toolPath = Preferences.Instance.ExternalMergeToolPath; + var opt = new Models.DiffOption(_based.Head, _to.Head, change); + + Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, toolType, toolPath, opt)); + ev.Handled = true; + }; + menu.Items.Add(diffWithMerger); + + if (change.Index != Models.ChangeState.Deleted) + { + var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(full); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(full, true); + ev.Handled = true; + }; + menu.Items.Add(explore); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, ev) => + { + App.CopyText(change.Path); + ev.Handled = true; + }; + menu.Items.Add(copyPath); + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyFullPath.Click += (_, e) => + { + App.CopyText(Native.OS.GetAbsPath(_repo, change.Path)); + e.Handled = true; + }; + menu.Items.Add(copyFullPath); + + return menu; + } + + private void Refresh() + { + Task.Run(() => + { + if (_baseHead == null) + { + var baseHead = new Commands.QuerySingleCommit(_repo, _based.Head).Result(); + var toHead = new Commands.QuerySingleCommit(_repo, _to.Head).Result(); + Dispatcher.UIThread.Invoke(() => + { + BaseHead = baseHead; + ToHead = toHead; + }); + } + + _changes = new Commands.CompareRevisions(_repo, _based.Head, _to.Head).Result(); + + var visible = _changes; + if (!string.IsNullOrWhiteSpace(_searchFilter)) + { + visible = new List(); + foreach (var c in _changes) + { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + } + + Dispatcher.UIThread.Invoke(() => VisibleChanges = visible); + }); + } + + private void RefreshVisible() + { + if (_changes == null) + return; + + if (string.IsNullOrEmpty(_searchFilter)) + { + VisibleChanges = _changes; + } + else + { + var visible = new List(); + foreach (var c in _changes) + { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + + VisibleChanges = visible; + } + } + + private string _repo; + private Models.Branch _based = null; + private Models.Branch _to = null; + private Models.Commit _baseHead = null; + private Models.Commit _toHead = null; + private List _changes = null; + private List _visibleChanges = null; + private List _selectedChanges = null; + private string _searchFilter = string.Empty; + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/BranchTreeNode.cs b/src/ViewModels/BranchTreeNode.cs new file mode 100644 index 00000000..130893ec --- /dev/null +++ b/src/ViewModels/BranchTreeNode.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class BranchTreeNode : ObservableObject + { + public string Name { get; private set; } = string.Empty; + public string Path { get; private set; } = string.Empty; + public object Backend { get; private set; } = null; + public ulong TimeToSort { get; private set; } = 0; + public int Depth { get; set; } = 0; + public bool IsSelected { get; set; } = false; + public List Children { get; private set; } = new List(); + public int Counter { get; set; } = 0; + + public Models.FilterMode FilterMode + { + get => _filterMode; + set => SetProperty(ref _filterMode, value); + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public CornerRadius CornerRadius + { + get => _cornerRadius; + set => SetProperty(ref _cornerRadius, value); + } + + public bool IsBranch + { + get => Backend is Models.Branch; + } + + public bool IsCurrent + { + get => Backend is Models.Branch { IsCurrent: true }; + } + + public bool ShowUpstreamGoneTip + { + get => Backend is Models.Branch { IsUpstreamGone: true }; + } + + public string BranchesCount + { + get => Counter > 0 ? $"({Counter})" : string.Empty; + } + + public string Tooltip + { + get + { + if (Backend is Models.Branch b) + return b.FriendlyName; + + if (Backend is Models.Remote r) + return r.URL; + + return null; + } + } + + private Models.FilterMode _filterMode = Models.FilterMode.None; + private bool _isExpanded = false; + private CornerRadius _cornerRadius = new CornerRadius(4); + + public class Builder + { + public List Locals => _locals; + public List Remotes => _remotes; + public List InvalidExpandedNodes => _invalidExpandedNodes; + + public Builder(Models.BranchSortMode localSortMode, Models.BranchSortMode remoteSortMode) + { + _localSortMode = localSortMode; + _remoteSortMode = remoteSortMode; + } + + public void SetExpandedNodes(List expanded) + { + foreach (var node in expanded) + _expanded.Add(node); + } + + public void Run(List branches, List remotes, bool bForceExpanded) + { + var folders = new Dictionary(); + + var fakeRemoteTime = (ulong)remotes.Count; + foreach (var remote in remotes) + { + var path = $"refs/remotes/{remote.Name}"; + var node = new BranchTreeNode() + { + Name = remote.Name, + Path = path, + Backend = remote, + IsExpanded = bForceExpanded || _expanded.Contains(path), + TimeToSort = fakeRemoteTime, + }; + + fakeRemoteTime--; + folders.Add(path, node); + _remotes.Add(node); + } + + foreach (var branch in branches) + { + if (branch.IsLocal) + { + MakeBranchNode(branch, _locals, folders, "refs/heads", bForceExpanded); + continue; + } + + var rk = $"refs/remotes/{branch.Remote}"; + if (folders.TryGetValue(rk, out var remote)) + { + remote.Counter++; + MakeBranchNode(branch, remote.Children, folders, rk, bForceExpanded); + } + } + + foreach (var path in _expanded) + { + if (!folders.ContainsKey(path)) + _invalidExpandedNodes.Add(path); + } + + folders.Clear(); + + if (_localSortMode == Models.BranchSortMode.Name) + SortNodesByName(_locals); + else + SortNodesByTime(_locals); + + if (_remoteSortMode == Models.BranchSortMode.Name) + SortNodesByName(_remotes); + else + SortNodesByTime(_remotes); + } + + private void MakeBranchNode(Models.Branch branch, List roots, Dictionary folders, string prefix, bool bForceExpanded) + { + var time = branch.CommitterDate; + var fullpath = $"{prefix}/{branch.Name}"; + var sepIdx = branch.Name.IndexOf('/', StringComparison.Ordinal); + if (sepIdx == -1 || branch.IsDetachedHead) + { + roots.Add(new BranchTreeNode() + { + Name = branch.Name, + Path = fullpath, + Backend = branch, + IsExpanded = false, + TimeToSort = time, + }); + return; + } + + var lastFolder = null as BranchTreeNode; + var start = 0; + + while (sepIdx != -1) + { + var folder = string.Concat(prefix, "/", branch.Name.Substring(0, sepIdx)); + var name = branch.Name.Substring(start, sepIdx - start); + if (folders.TryGetValue(folder, out var val)) + { + lastFolder = val; + lastFolder.Counter++; + lastFolder.TimeToSort = Math.Max(lastFolder.TimeToSort, time); + if (!lastFolder.IsExpanded) + lastFolder.IsExpanded |= (branch.IsCurrent || _expanded.Contains(folder)); + } + else if (lastFolder == null) + { + lastFolder = new BranchTreeNode() + { + Name = name, + Path = folder, + IsExpanded = bForceExpanded || branch.IsCurrent || _expanded.Contains(folder), + TimeToSort = time, + Counter = 1, + }; + roots.Add(lastFolder); + folders.Add(folder, lastFolder); + } + else + { + var cur = new BranchTreeNode() + { + Name = name, + Path = folder, + IsExpanded = bForceExpanded || branch.IsCurrent || _expanded.Contains(folder), + TimeToSort = time, + Counter = 1, + }; + lastFolder.Children.Add(cur); + folders.Add(folder, cur); + lastFolder = cur; + } + + start = sepIdx + 1; + sepIdx = branch.Name.IndexOf('/', start); + } + + lastFolder?.Children.Add(new BranchTreeNode() + { + Name = System.IO.Path.GetFileName(branch.Name), + Path = fullpath, + Backend = branch, + IsExpanded = false, + TimeToSort = time, + }); + } + + private void SortNodesByName(List nodes) + { + nodes.Sort((l, r) => + { + if (l.Backend is Models.Branch { IsDetachedHead: true }) + return -1; + + if (l.Backend is Models.Branch) + return r.Backend is Models.Branch ? Models.NumericSort.Compare(l.Name, r.Name) : 1; + + return r.Backend is Models.Branch ? -1 : Models.NumericSort.Compare(l.Name, r.Name); + }); + + foreach (var node in nodes) + SortNodesByName(node.Children); + } + + private void SortNodesByTime(List nodes) + { + nodes.Sort((l, r) => + { + if (l.Backend is Models.Branch { IsDetachedHead: true }) + return -1; + + if (l.Backend is Models.Branch) + { + if (r.Backend is Models.Branch) + return r.TimeToSort == l.TimeToSort ? Models.NumericSort.Compare(l.Name, r.Name) : r.TimeToSort.CompareTo(l.TimeToSort); + else + return 1; + } + + if (r.Backend is Models.Branch) + return -1; + + if (r.TimeToSort == l.TimeToSort) + return Models.NumericSort.Compare(l.Name, r.Name); + + return r.TimeToSort.CompareTo(l.TimeToSort); + }); + + foreach (var node in nodes) + SortNodesByTime(node.Children); + } + + private readonly Models.BranchSortMode _localSortMode = Models.BranchSortMode.Name; + private readonly Models.BranchSortMode _remoteSortMode = Models.BranchSortMode.Name; + private readonly List _locals = new List(); + private readonly List _remotes = new List(); + private readonly List _invalidExpandedNodes = new List(); + private readonly HashSet _expanded = new HashSet(); + } + } +} diff --git a/src/ViewModels/ChangeCollection.cs b/src/ViewModels/ChangeCollection.cs new file mode 100644 index 00000000..5de9f4fd --- /dev/null +++ b/src/ViewModels/ChangeCollection.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +using Avalonia.Collections; + +namespace SourceGit.ViewModels +{ + public class ChangeCollectionAsTree + { + public List Tree { get; set; } = new List(); + public AvaloniaList Rows { get; set; } = new AvaloniaList(); + public AvaloniaList SelectedRows { get; set; } = new AvaloniaList(); + } + + public class ChangeCollectionAsGrid + { + public AvaloniaList Changes { get; set; } = new AvaloniaList(); + public AvaloniaList SelectedChanges { get; set; } = new AvaloniaList(); + } + + public class ChangeCollectionAsList + { + public AvaloniaList Changes { get; set; } = new AvaloniaList(); + public AvaloniaList SelectedChanges { get; set; } = new AvaloniaList(); + } +} diff --git a/src/ViewModels/ChangeTreeNode.cs b/src/ViewModels/ChangeTreeNode.cs new file mode 100644 index 00000000..6c061f65 --- /dev/null +++ b/src/ViewModels/ChangeTreeNode.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ChangeTreeNode : ObservableObject + { + public string FullPath { get; set; } + public int Depth { get; private set; } = 0; + public Models.Change Change { get; set; } = null; + public List Children { get; set; } = new List(); + + public bool IsFolder + { + get => Change == null; + } + + public bool ShowConflictMarker + { + get => Change is { IsConflicted: true }; + } + + public string ConflictMarker + { + get => Change?.ConflictMarker ?? string.Empty; + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public ChangeTreeNode(Models.Change c, int depth) + { + FullPath = c.Path; + Depth = depth; + Change = c; + IsExpanded = false; + } + + public ChangeTreeNode(string path, bool isExpanded, int depth) + { + FullPath = path; + Depth = depth; + IsExpanded = isExpanded; + } + + public static List Build(IList changes, HashSet folded) + { + var nodes = new List(); + var folders = new Dictionary(); + + foreach (var c in changes) + { + var sepIdx = c.Path.IndexOf('/', StringComparison.Ordinal); + if (sepIdx == -1) + { + nodes.Add(new ChangeTreeNode(c, 0)); + } + else + { + ChangeTreeNode lastFolder = null; + int depth = 0; + + while (sepIdx != -1) + { + var folder = c.Path.Substring(0, sepIdx); + if (folders.TryGetValue(folder, out var value)) + { + lastFolder = value; + } + else if (lastFolder == null) + { + lastFolder = new ChangeTreeNode(folder, !folded.Contains(folder), depth); + folders.Add(folder, lastFolder); + InsertFolder(nodes, lastFolder); + } + else + { + var cur = new ChangeTreeNode(folder, !folded.Contains(folder), depth); + folders.Add(folder, cur); + InsertFolder(lastFolder.Children, cur); + lastFolder = cur; + } + + depth++; + sepIdx = c.Path.IndexOf('/', sepIdx + 1); + } + + lastFolder?.Children.Add(new ChangeTreeNode(c, depth)); + } + } + + Sort(nodes); + + folders.Clear(); + return nodes; + } + + private static void InsertFolder(List collection, ChangeTreeNode subFolder) + { + for (int i = 0; i < collection.Count; i++) + { + if (!collection[i].IsFolder) + { + collection.Insert(i, subFolder); + return; + } + } + + collection.Add(subFolder); + } + + private static void Sort(List nodes) + { + foreach (var node in nodes) + { + if (node.IsFolder) + Sort(node.Children); + } + + nodes.Sort((l, r) => + { + if (l.IsFolder == r.IsFolder) + return Models.NumericSort.Compare(l.FullPath, r.FullPath); + return l.IsFolder ? -1 : 1; + }); + } + + private bool _isExpanded = true; + } +} diff --git a/src/ViewModels/Checkout.cs b/src/ViewModels/Checkout.cs new file mode 100644 index 00000000..630a97a5 --- /dev/null +++ b/src/ViewModels/Checkout.cs @@ -0,0 +1,107 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Checkout : Popup + { + public string Branch + { + get; + } + + public bool DiscardLocalChanges + { + get; + set; + } + + public bool IsRecurseSubmoduleVisible + { + get => _repo.Submodules.Count > 0; + } + + public bool RecurseSubmodules + { + get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; + set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + } + + public Checkout(Repository repo, string branch) + { + _repo = repo; + Branch = branch; + DiscardLocalChanges = false; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Checkout '{Branch}' ..."; + + var log = _repo.CreateLog($"Checkout '{Branch}'"); + Use(log); + + var updateSubmodules = IsRecurseSubmoduleVisible && RecurseSubmodules; + return Task.Run(() => + { + var succ = false; + var needPopStash = false; + + if (DiscardLocalChanges) + { + succ = new Commands.Checkout(_repo.FullPath).Use(log).Branch(Branch, true); + } + else + { + var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); + if (changes > 0) + { + succ = new Commands.Stash(_repo.FullPath).Use(log).Push("CHECKOUT_AUTO_STASH"); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + needPopStash = true; + } + + succ = new Commands.Checkout(_repo.FullPath).Use(log).Branch(Branch, false); + } + + if (succ) + { + if (updateSubmodules) + { + var submodules = new Commands.QueryUpdatableSubmodules(_repo.FullPath).Result(); + if (submodules.Count > 0) + new Commands.Submodule(_repo.FullPath).Use(log).Update(submodules, true, true); + } + + if (needPopStash) + new Commands.Stash(_repo.FullPath).Use(log).Pop("stash@{0}"); + } + + log.Complete(); + + CallUIThread(() => + { + ProgressDescription = "Waiting for branch updated..."; + + var b = _repo.Branches.Find(x => x.IsLocal && x.Name == Branch); + if (b != null && _repo.HistoriesFilterMode == Models.FilterMode.Included) + _repo.SetBranchFilterMode(b, Models.FilterMode.Included, true, false); + + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + + Task.Delay(400).Wait(); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/CheckoutAndFastForward.cs b/src/ViewModels/CheckoutAndFastForward.cs new file mode 100644 index 00000000..97ab86d7 --- /dev/null +++ b/src/ViewModels/CheckoutAndFastForward.cs @@ -0,0 +1,111 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CheckoutAndFastForward : Popup + { + public Models.Branch LocalBranch + { + get; + } + + public Models.Branch RemoteBranch + { + get; + } + + public bool DiscardLocalChanges + { + get; + set; + } + + public bool IsRecurseSubmoduleVisible + { + get => _repo.Submodules.Count > 0; + } + + public bool RecurseSubmodules + { + get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; + set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + } + + public CheckoutAndFastForward(Repository repo, Models.Branch localBranch, Models.Branch remoteBranch) + { + _repo = repo; + LocalBranch = localBranch; + RemoteBranch = remoteBranch; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Checkout and Fast-Forward '{LocalBranch.Name}' ..."; + + var log = _repo.CreateLog($"Checkout and Fast-Forward '{LocalBranch.Name}' ..."); + Use(log); + + var updateSubmodules = IsRecurseSubmoduleVisible && RecurseSubmodules; + return Task.Run(() => + { + var succ = false; + var needPopStash = false; + + if (DiscardLocalChanges) + { + succ = new Commands.Checkout(_repo.FullPath).Use(log).Branch(LocalBranch.Name, RemoteBranch.Head, true, true); + } + else + { + var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); + if (changes > 0) + { + succ = new Commands.Stash(_repo.FullPath).Use(log).Push("CHECKOUT_AND_FASTFORWARD_AUTO_STASH"); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + needPopStash = true; + } + + succ = new Commands.Checkout(_repo.FullPath).Use(log).Branch(LocalBranch.Name, RemoteBranch.Head, false, true); + } + + if (succ) + { + if (updateSubmodules) + { + var submodules = new Commands.QueryUpdatableSubmodules(_repo.FullPath).Result(); + if (submodules.Count > 0) + new Commands.Submodule(_repo.FullPath).Use(log).Update(submodules, true, true); + } + + if (needPopStash) + new Commands.Stash(_repo.FullPath).Use(log).Pop("stash@{0}"); + } + + log.Complete(); + + CallUIThread(() => + { + ProgressDescription = "Waiting for branch updated..."; + + if (_repo.HistoriesFilterMode == Models.FilterMode.Included) + _repo.SetBranchFilterMode(LocalBranch, Models.FilterMode.Included, true, false); + + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + + Task.Delay(400).Wait(); + return succ; + }); + } + + private Repository _repo; + } +} diff --git a/src/ViewModels/CheckoutCommit.cs b/src/ViewModels/CheckoutCommit.cs new file mode 100644 index 00000000..c41fd2ce --- /dev/null +++ b/src/ViewModels/CheckoutCommit.cs @@ -0,0 +1,94 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CheckoutCommit : Popup + { + public Models.Commit Commit + { + get; + } + + public bool DiscardLocalChanges + { + get; + set; + } + + public bool IsRecurseSubmoduleVisible + { + get => _repo.Submodules.Count > 0; + } + + public bool RecurseSubmodules + { + get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; + set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + } + + public CheckoutCommit(Repository repo, Models.Commit commit) + { + _repo = repo; + Commit = commit; + DiscardLocalChanges = false; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Checkout Commit '{Commit.SHA}' ..."; + + var log = _repo.CreateLog("Checkout Commit"); + Use(log); + + var updateSubmodules = IsRecurseSubmoduleVisible && RecurseSubmodules; + return Task.Run(() => + { + var succ = false; + var needPop = false; + + if (DiscardLocalChanges) + { + succ = new Commands.Checkout(_repo.FullPath).Use(log).Commit(Commit.SHA, true); + } + else + { + var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); + if (changes > 0) + { + succ = new Commands.Stash(_repo.FullPath).Use(log).Push("CHECKOUT_AUTO_STASH"); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + needPop = true; + } + + succ = new Commands.Checkout(_repo.FullPath).Use(log).Commit(Commit.SHA, false); + } + + if (succ) + { + if (updateSubmodules) + { + var submodules = new Commands.QueryUpdatableSubmodules(_repo.FullPath).Result(); + if (submodules.Count > 0) + new Commands.Submodule(_repo.FullPath).Use(log).Update(submodules, true, true); + } + + if (needPop) + new Commands.Stash(_repo.FullPath).Use(log).Pop("stash@{0}"); + } + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/CherryPick.cs b/src/ViewModels/CherryPick.cs new file mode 100644 index 00000000..d5238bb3 --- /dev/null +++ b/src/ViewModels/CherryPick.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CherryPick : Popup + { + public List Targets + { + get; + private set; + } + + public bool IsMergeCommit + { + get; + private set; + } + + public List ParentsForMergeCommit + { + get; + private set; + } + + public int MainlineForMergeCommit + { + get; + set; + } + + public bool AppendSourceToMessage + { + get; + set; + } + + public bool AutoCommit + { + get; + set; + } + + public CherryPick(Repository repo, List targets) + { + _repo = repo; + Targets = targets; + IsMergeCommit = false; + ParentsForMergeCommit = []; + MainlineForMergeCommit = 0; + AppendSourceToMessage = true; + AutoCommit = true; + } + + public CherryPick(Repository repo, Models.Commit merge, List parents) + { + _repo = repo; + Targets = [merge]; + IsMergeCommit = true; + ParentsForMergeCommit = parents; + MainlineForMergeCommit = 0; + AppendSourceToMessage = true; + AutoCommit = true; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + _repo.ClearCommitMessage(); + ProgressDescription = $"Cherry-Pick commit(s) ..."; + + var log = _repo.CreateLog("Cherry-Pick"); + Use(log); + + return Task.Run(() => + { + if (IsMergeCommit) + { + new Commands.CherryPick( + _repo.FullPath, + Targets[0].SHA, + !AutoCommit, + AppendSourceToMessage, + $"-m {MainlineForMergeCommit + 1}").Use(log).Exec(); + } + else + { + new Commands.CherryPick( + _repo.FullPath, + string.Join(' ', Targets.ConvertAll(c => c.SHA)), + !AutoCommit, + AppendSourceToMessage, + string.Empty).Use(log).Exec(); + } + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/Cleanup.cs b/src/ViewModels/Cleanup.cs new file mode 100644 index 00000000..1fc39cb5 --- /dev/null +++ b/src/ViewModels/Cleanup.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Cleanup : Popup + { + public Cleanup(Repository repo) + { + _repo = repo; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Cleanup (GC & prune) ..."; + + var log = _repo.CreateLog("Cleanup (GC & prune)"); + Use(log); + + return Task.Run(() => + { + new Commands.GC(_repo.FullPath).Use(log).Exec(); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/ClearStashes.cs b/src/ViewModels/ClearStashes.cs new file mode 100644 index 00000000..d71bab31 --- /dev/null +++ b/src/ViewModels/ClearStashes.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ClearStashes : Popup + { + public ClearStashes(Repository repo) + { + _repo = repo; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Clear all stashes..."; + + var log = _repo.CreateLog("Clear Stashes"); + Use(log); + + return Task.Run(() => + { + new Commands.Stash(_repo.FullPath).Use(log).Clear(); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs new file mode 100644 index 00000000..94a74893 --- /dev/null +++ b/src/ViewModels/Clone.cs @@ -0,0 +1,180 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class Clone : Popup + { + [Required(ErrorMessage = "Remote URL is required")] + [CustomValidation(typeof(Clone), nameof(ValidateRemote))] + public string Remote + { + get => _remote; + set + { + if (SetProperty(ref _remote, value, true)) + UseSSH = Models.Remote.IsSSH(value); + } + } + + public bool UseSSH + { + get => _useSSH; + set => SetProperty(ref _useSSH, value); + } + + public string SSHKey + { + get => _sshKey; + set => SetProperty(ref _sshKey, value); + } + + [Required(ErrorMessage = "Parent folder is required")] + [CustomValidation(typeof(Clone), nameof(ValidateParentFolder))] + public string ParentFolder + { + get => _parentFolder; + set => SetProperty(ref _parentFolder, value, true); + } + + public string Local + { + get => _local; + set => SetProperty(ref _local, value); + } + + public string ExtraArgs + { + get => _extraArgs; + set => SetProperty(ref _extraArgs, value); + } + + public bool InitAndUpdateSubmodules + { + get; + set; + } = true; + + public Clone(string pageId) + { + _pageId = pageId; + + var activeWorkspace = Preferences.Instance.GetActiveWorkspace(); + _parentFolder = activeWorkspace?.DefaultCloneDir; + if (string.IsNullOrEmpty(ParentFolder)) + _parentFolder = Preferences.Instance.GitDefaultCloneDir; + + Task.Run(async () => + { + try + { + var text = await App.GetClipboardTextAsync(); + if (Models.Remote.IsValidURL(text)) + Dispatcher.UIThread.Invoke(() => Remote = text); + } + catch + { + // ignore + } + }); + } + + public static ValidationResult ValidateRemote(string remote, ValidationContext _) + { + if (!Models.Remote.IsValidURL(remote)) + return new ValidationResult("Invalid remote repository URL format"); + return ValidationResult.Success; + } + + public static ValidationResult ValidateParentFolder(string folder, ValidationContext _) + { + if (!Directory.Exists(folder)) + return new ValidationResult("Given path can NOT be found"); + return ValidationResult.Success; + } + + public override Task Sure() + { + ProgressDescription = "Clone ..."; + + var log = new CommandLog("Clone"); + Use(log); + + return Task.Run(() => + { + var cmd = new Commands.Clone(_pageId, _parentFolder, _remote, _local, _useSSH ? _sshKey : "", _extraArgs).Use(log); + if (!cmd.Exec()) + return false; + + var path = _parentFolder; + if (!string.IsNullOrEmpty(_local)) + { + path = Path.GetFullPath(Path.Combine(path, _local)); + } + else + { + var name = Path.GetFileName(_remote)!; + if (name.EndsWith(".git", StringComparison.Ordinal)) + name = name.Substring(0, name.Length - 4); + path = Path.GetFullPath(Path.Combine(path, name)); + } + + if (!Directory.Exists(path)) + { + CallUIThread(() => + { + App.RaiseException(_pageId, $"Folder '{path}' can NOT be found"); + }); + return false; + } + + if (_useSSH && !string.IsNullOrEmpty(_sshKey)) + { + var config = new Commands.Config(path); + config.Set("remote.origin.sshkey", _sshKey); + } + + if (InitAndUpdateSubmodules) + { + var submodules = new Commands.QueryUpdatableSubmodules(path).Result(); + if (submodules.Count > 0) + new Commands.Submodule(path).Use(log).Update(submodules, true, true); + } + + log.Complete(); + + CallUIThread(() => + { + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(path, null, true); + var launcher = App.GetLauncher(); + var page = null as LauncherPage; + foreach (var one in launcher.Pages) + { + if (one.Node.Id == _pageId) + { + page = one; + break; + } + } + + Welcome.Instance.Refresh(); + launcher.OpenRepositoryInTab(node, page); + }); + + return true; + }); + } + + private string _pageId = string.Empty; + private string _remote = string.Empty; + private bool _useSSH = false; + private string _sshKey = string.Empty; + private string _parentFolder = string.Empty; + private string _local = string.Empty; + private string _extraArgs = string.Empty; + } +} diff --git a/src/ViewModels/CommandLog.cs b/src/ViewModels/CommandLog.cs new file mode 100644 index 00000000..e73bc1ec --- /dev/null +++ b/src/ViewModels/CommandLog.cs @@ -0,0 +1,99 @@ +using System; +using System.Text; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class CommandLog : ObservableObject, Models.ICommandLog + { + public string Name + { + get; + private set; + } = string.Empty; + + public DateTime StartTime + { + get; + } = DateTime.Now; + + public DateTime EndTime + { + get; + private set; + } = DateTime.Now; + + public bool IsComplete + { + get; + private set; + } = false; + + public string Content + { + get + { + return IsComplete ? _content : _builder.ToString(); + } + } + + public CommandLog(string name) + { + Name = name; + } + + public void Register(Action handler) + { + if (!IsComplete) + _onNewLineReceived += handler; + } + + public void AppendLine(string line = null) + { + var newline = line ?? string.Empty; + + Dispatcher.UIThread.Invoke(() => + { + _builder.AppendLine(newline); + _onNewLineReceived?.Invoke(newline); + }); + } + + public void Complete() + { + IsComplete = true; + + Dispatcher.UIThread.Invoke(() => + { + _content = _builder.ToString(); + _builder.Clear(); + _builder = null; + + EndTime = DateTime.Now; + + OnPropertyChanged(nameof(IsComplete)); + + if (_onNewLineReceived != null) + { + var dumpHandlers = _onNewLineReceived.GetInvocationList(); + foreach (var d in dumpHandlers) + _onNewLineReceived -= (Action)d; + } + }); + } + + private string _content = string.Empty; + private StringBuilder _builder = new StringBuilder(); + private event Action _onNewLineReceived; + } + + public static class CommandExtensions + { + public static T Use(this T cmd, CommandLog log) where T : Commands.Command + { + cmd.Log = log; + return cmd; + } + } +} diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs new file mode 100644 index 00000000..fbecf30e --- /dev/null +++ b/src/ViewModels/CommitDetail.cs @@ -0,0 +1,891 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public partial class CommitDetail : ObservableObject, IDisposable + { + public int ActivePageIndex + { + get => _repo.CommitDetailActivePageIndex; + set + { + if (_repo.CommitDetailActivePageIndex != value) + { + _repo.CommitDetailActivePageIndex = value; + OnPropertyChanged(); + } + } + } + + public Models.Commit Commit + { + get => _commit; + set + { + if (SetProperty(ref _commit, value)) + Refresh(); + } + } + + public Models.CommitFullMessage FullMessage + { + get => _fullMessage; + private set => SetProperty(ref _fullMessage, value); + } + + public Models.CommitSignInfo SignInfo + { + get => _signInfo; + private set => SetProperty(ref _signInfo, value); + } + + public List WebLinks + { + get; + private set; + } + + public List Children + { + get => _children; + private set => SetProperty(ref _children, value); + } + + public List Changes + { + get => _changes; + set => SetProperty(ref _changes, value); + } + + public List VisibleChanges + { + get => _visibleChanges; + set => SetProperty(ref _visibleChanges, value); + } + + public List SelectedChanges + { + get => _selectedChanges; + set + { + if (SetProperty(ref _selectedChanges, value)) + { + if (value == null || value.Count != 1) + DiffContext = null; + else + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, value[0]), _diffContext); + } + } + } + + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public string SearchChangeFilter + { + get => _searchChangeFilter; + set + { + if (SetProperty(ref _searchChangeFilter, value)) + RefreshVisibleChanges(); + } + } + + public string ViewRevisionFilePath + { + get => _viewRevisionFilePath; + private set => SetProperty(ref _viewRevisionFilePath, value); + } + + public object ViewRevisionFileContent + { + get => _viewRevisionFileContent; + private set => SetProperty(ref _viewRevisionFileContent, value); + } + + public string RevisionFileSearchFilter + { + get => _revisionFileSearchFilter; + set + { + if (SetProperty(ref _revisionFileSearchFilter, value)) + RefreshRevisionSearchSuggestion(); + } + } + + public List RevisionFileSearchSuggestion + { + get => _revisionFileSearchSuggestion; + private set => SetProperty(ref _revisionFileSearchSuggestion, value); + } + + public CommitDetail(Repository repo) + { + _repo = repo; + WebLinks = Models.CommitLink.Get(repo.Remotes); + } + + public void Dispose() + { + _repo = null; + _commit = null; + _changes = null; + _visibleChanges = null; + _selectedChanges = null; + _signInfo = null; + _searchChangeFilter = null; + _diffContext = null; + _viewRevisionFileContent = null; + _cancellationSource = null; + _requestingRevisionFiles = false; + _revisionFiles = null; + _revisionFileSearchSuggestion = null; + } + + public void NavigateTo(string commitSHA) + { + _repo?.NavigateToCommit(commitSHA); + } + + public List GetRefsContainsThisCommit() + { + return new Commands.QueryRefsContainsCommit(_repo.FullPath, _commit.SHA).Result(); + } + + public void ClearSearchChangeFilter() + { + SearchChangeFilter = string.Empty; + } + + public void ClearRevisionFileSearchFilter() + { + RevisionFileSearchFilter = string.Empty; + } + + public void CancelRevisionFileSuggestions() + { + RevisionFileSearchSuggestion = null; + } + + public Models.Commit GetParent(string sha) + { + return new Commands.QuerySingleCommit(_repo.FullPath, sha).Result(); + } + + public List GetRevisionFilesUnderFolder(string parentFolder) + { + return new Commands.QueryRevisionObjects(_repo.FullPath, _commit.SHA, parentFolder).Result(); + } + + public void ViewRevisionFile(Models.Object file) + { + if (file == null) + { + ViewRevisionFilePath = string.Empty; + ViewRevisionFileContent = null; + return; + } + + ViewRevisionFilePath = file.Path; + + switch (file.Type) + { + case Models.ObjectType.Blob: + Task.Run(() => + { + var isBinary = new Commands.IsBinary(_repo.FullPath, _commit.SHA, file.Path).Result(); + if (isBinary) + { + var imgDecoder = ImageSource.GetDecoder(file.Path); + if (imgDecoder != Models.ImageDecoder.None) + { + var source = ImageSource.FromRevision(_repo.FullPath, _commit.SHA, file.Path, imgDecoder); + var image = new Models.RevisionImageFile(file.Path, source.Bitmap, source.Size); + Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = image); + } + else + { + var size = new Commands.QueryFileSize(_repo.FullPath, file.Path, _commit.SHA).Result(); + var binary = new Models.RevisionBinaryFile() { Size = size }; + Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = binary); + } + + return; + } + + var contentStream = Commands.QueryFileContent.Run(_repo.FullPath, _commit.SHA, file.Path); + var content = new StreamReader(contentStream).ReadToEnd(); + var lfs = Models.LFSObject.Parse(content); + if (lfs != null) + { + var imgDecoder = ImageSource.GetDecoder(file.Path); + if (imgDecoder != Models.ImageDecoder.None) + { + var combined = new RevisionLFSImage(_repo.FullPath, file.Path, lfs, imgDecoder); + Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = combined); + } + else + { + var rlfs = new Models.RevisionLFSObject() { Object = lfs }; + Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = rlfs); + } + } + else + { + var txt = new Models.RevisionTextFile() { FileName = file.Path, Content = content }; + Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = txt); + } + }); + break; + case Models.ObjectType.Commit: + Task.Run(() => + { + var submoduleRoot = Path.Combine(_repo.FullPath, file.Path).Replace('\\', '/').Trim('/'); + var commit = new Commands.QuerySingleCommit(submoduleRoot, file.SHA).Result(); + var message = commit != null ? new Commands.QueryCommitFullMessage(submoduleRoot, file.SHA).Result() : null; + var module = new Models.RevisionSubmodule() + { + Commit = commit ?? new Models.Commit() { SHA = _commit.SHA }, + FullMessage = new Models.CommitFullMessage { Message = message } + }; + + Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = module); + }); + break; + default: + ViewRevisionFileContent = null; + break; + } + } + + public ContextMenu CreateChangeContextMenu(Models.Change change) + { + var diffWithMerger = new MenuItem(); + diffWithMerger.Header = App.Text("DiffWithMerger"); + diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); + diffWithMerger.Click += (_, ev) => + { + var toolType = Preferences.Instance.ExternalMergeToolType; + var toolPath = Preferences.Instance.ExternalMergeToolPath; + var opt = new Models.DiffOption(_commit, change); + + Task.Run(() => Commands.MergeTool.OpenForDiff(_repo.FullPath, toolType, toolPath, opt)); + ev.Handled = true; + }; + + var fullPath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(fullPath); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(fullPath, true); + ev.Handled = true; + }; + + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = App.CreateMenuIcon("Icons.Histories"); + history.Click += (_, ev) => + { + App.ShowWindow(new FileHistories(_repo, change.Path, _commit.SHA), false); + ev.Handled = true; + }; + + var blame = new MenuItem(); + blame.Header = App.Text("Blame"); + blame.Icon = App.CreateMenuIcon("Icons.Blame"); + blame.IsEnabled = change.Index != Models.ChangeState.Deleted; + blame.Click += (_, ev) => + { + App.ShowWindow(new Blame(_repo.FullPath, change.Path, _commit), false); + ev.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var baseRevision = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessRevisionCompareChanges(_repo.FullPath, [change], baseRevision, _commit.SHA, saveTo)); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(diffWithMerger); + menu.Items.Add(explore); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(history); + menu.Items.Add(blame); + menu.Items.Add(patch); + menu.Items.Add(new MenuItem { Header = "-" }); + + if (!_repo.IsBare) + { + var resetToThisRevision = new MenuItem(); + resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); + resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); + resetToThisRevision.Click += async (_, ev) => + { + await ResetToThisRevision(change.Path); + ev.Handled = true; + }; + + var resetToFirstParent = new MenuItem(); + resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); + resetToFirstParent.Icon = App.CreateMenuIcon("Icons.File.Checkout"); + resetToFirstParent.IsEnabled = _commit.Parents.Count > 0; + resetToFirstParent.Click += async (_, ev) => + { + await ResetToParentRevision(change); + ev.Handled = true; + }; + + menu.Items.Add(resetToThisRevision); + menu.Items.Add(resetToFirstParent); + menu.Items.Add(new MenuItem { Header = "-" }); + + TryToAddContextMenuItemsForGitLFS(menu, fullPath, change.Path); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, ev) => + { + App.CopyText(change.Path); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyFullPath.Click += (_, e) => + { + App.CopyText(fullPath); + e.Handled = true; + }; + + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + return menu; + } + + public ContextMenu CreateRevisionFileContextMenu(Models.Object file) + { + var menu = new ContextMenu(); + var fullPath = Native.OS.GetAbsPath(_repo.FullPath, file.Path); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(fullPath); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(fullPath, file.Type == Models.ObjectType.Blob); + ev.Handled = true; + }; + + var openWith = new MenuItem(); + openWith.Header = App.Text("OpenWith"); + openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); + openWith.Click += async (_, ev) => + { + var fileName = Path.GetFileNameWithoutExtension(fullPath) ?? ""; + var fileExt = Path.GetExtension(fullPath) ?? ""; + var tmpFile = Path.Combine(Path.GetTempPath(), $"{fileName}~{_commit.SHA.Substring(0, 10)}{fileExt}"); + await Task.Run(() => Commands.SaveRevisionFile.Run(_repo.FullPath, _commit.SHA, file.Path, tmpFile)); + Native.OS.OpenWithDefaultEditor(tmpFile); + ev.Handled = true; + }; + + var saveAs = new MenuItem(); + saveAs.Header = App.Text("SaveAs"); + saveAs.Icon = App.CreateMenuIcon("Icons.Save"); + saveAs.IsEnabled = file.Type == Models.ObjectType.Blob; + saveAs.Click += async (_, ev) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + try + { + var selected = await storageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString(); + var saveTo = Path.Combine(folderPath, Path.GetFileName(file.Path)!); + await Task.Run(() => Commands.SaveRevisionFile.Run(_repo.FullPath, _commit.SHA, file.Path, saveTo)); + } + } + catch (Exception e) + { + App.RaiseException(_repo.FullPath, $"Failed to save file: {e.Message}"); + } + + ev.Handled = true; + }; + + menu.Items.Add(explore); + menu.Items.Add(openWith); + menu.Items.Add(saveAs); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = App.CreateMenuIcon("Icons.Histories"); + history.Click += (_, ev) => + { + App.ShowWindow(new FileHistories(_repo, file.Path, _commit.SHA), false); + ev.Handled = true; + }; + + var blame = new MenuItem(); + blame.Header = App.Text("Blame"); + blame.Icon = App.CreateMenuIcon("Icons.Blame"); + blame.IsEnabled = file.Type == Models.ObjectType.Blob; + blame.Click += (_, ev) => + { + App.ShowWindow(new Blame(_repo.FullPath, file.Path, _commit), false); + ev.Handled = true; + }; + + menu.Items.Add(history); + menu.Items.Add(blame); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (!_repo.IsBare) + { + var resetToThisRevision = new MenuItem(); + resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); + resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); + resetToThisRevision.Click += async (_, ev) => + { + await ResetToThisRevision(file.Path); + ev.Handled = true; + }; + + var change = _changes.Find(x => x.Path == file.Path) ?? new Models.Change() { Index = Models.ChangeState.None, Path = file.Path }; + var resetToFirstParent = new MenuItem(); + resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); + resetToFirstParent.Icon = App.CreateMenuIcon("Icons.File.Checkout"); + resetToFirstParent.IsEnabled = _commit.Parents.Count > 0; + resetToFirstParent.Click += async (_, ev) => + { + await ResetToParentRevision(change); + ev.Handled = true; + }; + + menu.Items.Add(resetToThisRevision); + menu.Items.Add(resetToFirstParent); + menu.Items.Add(new MenuItem() { Header = "-" }); + + TryToAddContextMenuItemsForGitLFS(menu, fullPath, file.Path); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, ev) => + { + App.CopyText(file.Path); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyFullPath.Click += (_, e) => + { + App.CopyText(fullPath); + e.Handled = true; + }; + + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + return menu; + } + + private void Refresh() + { + _changes = null; + _requestingRevisionFiles = false; + _revisionFiles = null; + + SignInfo = null; + ViewRevisionFileContent = null; + Children = null; + RevisionFileSearchFilter = string.Empty; + RevisionFileSearchSuggestion = null; + + if (_commit == null) + return; + + if (_cancellationSource is { IsCancellationRequested: false }) + _cancellationSource.Cancel(); + + _cancellationSource = new CancellationTokenSource(); + var token = _cancellationSource.Token; + + Task.Run(() => + { + var message = new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA).Result(); + var inlines = ParseInlinesInMessage(message); + + if (!token.IsCancellationRequested) + Dispatcher.UIThread.Invoke(() => FullMessage = new Models.CommitFullMessage { Message = message, Inlines = inlines }); + }, token); + + Task.Run(() => + { + var signInfo = new Commands.QueryCommitSignInfo(_repo.FullPath, _commit.SHA, !_repo.HasAllowedSignersFile).Result(); + if (!token.IsCancellationRequested) + Dispatcher.UIThread.Invoke(() => SignInfo = signInfo); + }, token); + + if (Preferences.Instance.ShowChildren) + { + Task.Run(() => + { + var max = Preferences.Instance.MaxHistoryCommits; + var cmd = new Commands.QueryCommitChildren(_repo.FullPath, _commit.SHA, max) { CancellationToken = token }; + var children = cmd.Result(); + if (!token.IsCancellationRequested) + Dispatcher.UIThread.Post(() => Children = children); + }, token); + } + + Task.Run(() => + { + var parent = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; + var cmd = new Commands.CompareRevisions(_repo.FullPath, parent, _commit.SHA) { CancellationToken = token }; + var changes = cmd.Result(); + var visible = changes; + if (!string.IsNullOrWhiteSpace(_searchChangeFilter)) + { + visible = new List(); + foreach (var c in changes) + { + if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + } + + if (!token.IsCancellationRequested) + { + Dispatcher.UIThread.Post(() => + { + Changes = changes; + VisibleChanges = visible; + + if (visible.Count == 0) + SelectedChanges = null; + }); + } + }, token); + } + + private Models.InlineElementCollector ParseInlinesInMessage(string message) + { + var inlines = new Models.InlineElementCollector(); + if (_repo.Settings.IssueTrackerRules is { Count: > 0 } rules) + { + foreach (var rule in rules) + rule.Matches(inlines, message); + } + + var urlMatches = REG_URL_FORMAT().Matches(message); + for (int i = 0; i < urlMatches.Count; i++) + { + var match = urlMatches[i]; + if (!match.Success) + continue; + + var start = match.Index; + var len = match.Length; + if (inlines.Intersect(start, len) != null) + continue; + + var url = message.Substring(start, len); + if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) + inlines.Add(new Models.InlineElement(Models.InlineElementType.Link, start, len, url)); + } + + var shaMatches = REG_SHA_FORMAT().Matches(message); + for (int i = 0; i < shaMatches.Count; i++) + { + var match = shaMatches[i]; + if (!match.Success) + continue; + + var start = match.Index; + var len = match.Length; + if (inlines.Intersect(start, len) != null) + continue; + + var sha = match.Groups[1].Value; + var isCommitSHA = new Commands.IsCommitSHA(_repo.FullPath, sha).Result(); + if (isCommitSHA) + inlines.Add(new Models.InlineElement(Models.InlineElementType.CommitSHA, start, len, sha)); + } + + inlines.Sort(); + return inlines; + } + + private void RefreshVisibleChanges() + { + if (_changes == null) + return; + + if (string.IsNullOrEmpty(_searchChangeFilter)) + { + VisibleChanges = _changes; + } + else + { + var visible = new List(); + foreach (var c in _changes) + { + if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + + VisibleChanges = visible; + } + } + + private void TryToAddContextMenuItemsForGitLFS(ContextMenu menu, string fullPath, string path) + { + if (_repo.Remotes.Count == 0 || !File.Exists(fullPath)) + return; + + var lfsEnabled = new Commands.LFS(_repo.FullPath).IsEnabled(); + if (!lfsEnabled) + return; + + var lfs = new MenuItem(); + lfs.Header = App.Text("GitLFS"); + lfs.Icon = App.CreateMenuIcon("Icons.LFS"); + + var lfsLock = new MenuItem(); + lfsLock.Header = App.Text("GitLFS.Locks.Lock"); + lfsLock.Icon = App.CreateMenuIcon("Icons.Lock"); + if (_repo.Remotes.Count == 1) + { + lfsLock.Click += async (_, e) => + { + var log = _repo.CreateLog("Lock LFS file"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(_repo.Remotes[0].Name, path, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Lock file \"{path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + } + else + { + foreach (var remote in _repo.Remotes) + { + var remoteName = remote.Name; + var lockRemote = new MenuItem(); + lockRemote.Header = remoteName; + lockRemote.Click += async (_, e) => + { + var log = _repo.CreateLog("Lock LFS file"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(remoteName, path, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Lock file \"{path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfsLock.Items.Add(lockRemote); + } + } + lfs.Items.Add(lfsLock); + + var lfsUnlock = new MenuItem(); + lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); + lfsUnlock.Icon = App.CreateMenuIcon("Icons.Unlock"); + if (_repo.Remotes.Count == 1) + { + lfsUnlock.Click += async (_, e) => + { + var log = _repo.CreateLog("Unlock LFS file"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(_repo.Remotes[0].Name, path, false, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Unlock file \"{path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + } + else + { + foreach (var remote in _repo.Remotes) + { + var remoteName = remote.Name; + var unlockRemote = new MenuItem(); + unlockRemote.Header = remoteName; + unlockRemote.Click += async (_, e) => + { + var log = _repo.CreateLog("Unlock LFS file"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(remoteName, path, false, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Unlock file \"{path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfsUnlock.Items.Add(unlockRemote); + } + } + lfs.Items.Add(lfsUnlock); + + menu.Items.Add(lfs); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + private void RefreshRevisionSearchSuggestion() + { + if (!string.IsNullOrEmpty(_revisionFileSearchFilter)) + { + if (_revisionFiles == null) + { + if (_requestingRevisionFiles) + return; + + var sha = Commit.SHA; + _requestingRevisionFiles = true; + + Task.Run(() => + { + var files = new Commands.QueryRevisionFileNames(_repo.FullPath, sha).Result(); + Dispatcher.UIThread.Invoke(() => + { + if (sha == Commit.SHA && _requestingRevisionFiles) + { + _revisionFiles = files; + _requestingRevisionFiles = false; + + if (!string.IsNullOrEmpty(_revisionFileSearchFilter)) + CalcRevisionFileSearchSuggestion(); + } + }); + }); + } + else + { + CalcRevisionFileSearchSuggestion(); + } + } + else + { + RevisionFileSearchSuggestion = null; + GC.Collect(); + } + } + + private void CalcRevisionFileSearchSuggestion() + { + var suggestion = new List(); + foreach (var file in _revisionFiles) + { + if (file.Contains(_revisionFileSearchFilter, StringComparison.OrdinalIgnoreCase) && + file.Length != _revisionFileSearchFilter.Length) + suggestion.Add(file); + + if (suggestion.Count >= 100) + break; + } + + RevisionFileSearchSuggestion = suggestion; + } + + private Task ResetToThisRevision(string path) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); + + return Task.Run(() => + { + new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevision(path, $"{_commit.SHA}"); + log.Complete(); + }); + } + + private Task ResetToParentRevision(Models.Change change) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}~1'"); + + return Task.Run(() => + { + if (change.Index == Models.ChangeState.Renamed) + new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevision(change.OriginalPath, $"{_commit.SHA}~1"); + + new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevision(change.Path, $"{_commit.SHA}~1"); + log.Complete(); + }); + } + + [GeneratedRegex(@"\b(https?://|ftp://)[\w\d\._/\-~%@()+:?&=#!]*[\w\d/]")] + private static partial Regex REG_URL_FORMAT(); + + [GeneratedRegex(@"\b([0-9a-fA-F]{6,40})\b")] + private static partial Regex REG_SHA_FORMAT(); + + private Repository _repo = null; + private Models.Commit _commit = null; + private Models.CommitFullMessage _fullMessage = null; + private Models.CommitSignInfo _signInfo = null; + private List _children = null; + private List _changes = null; + private List _visibleChanges = null; + private List _selectedChanges = null; + private string _searchChangeFilter = string.Empty; + private DiffContext _diffContext = null; + private string _viewRevisionFilePath = string.Empty; + private object _viewRevisionFileContent = null; + private CancellationTokenSource _cancellationSource = null; + private bool _requestingRevisionFiles = false; + private List _revisionFiles = null; + private string _revisionFileSearchFilter = string.Empty; + private List _revisionFileSearchSuggestion = null; + } +} diff --git a/src/ViewModels/ConfigureWorkspace.cs b/src/ViewModels/ConfigureWorkspace.cs new file mode 100644 index 00000000..5be066ae --- /dev/null +++ b/src/ViewModels/ConfigureWorkspace.cs @@ -0,0 +1,85 @@ +using System; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ConfigureWorkspace : ObservableObject + { + public AvaloniaList Workspaces + { + get; + } + + public Workspace Selected + { + get => _selected; + set + { + if (SetProperty(ref _selected, value)) + CanDeleteSelected = value != null && !value.IsActive; + } + } + + public bool CanDeleteSelected + { + get => _canDeleteSelected; + private set => SetProperty(ref _canDeleteSelected, value); + } + + public ConfigureWorkspace() + { + Workspaces = new(Preferences.Instance.Workspaces); + } + + public void Add() + { + var workspace = new Workspace() { Name = $"Unnamed {DateTime.Now:yyyy-MM-dd HH:mm:ss}" }; + Preferences.Instance.Workspaces.Add(workspace); + Workspaces.Add(workspace); + Selected = workspace; + } + + public void Delete() + { + if (_selected == null || _selected.IsActive) + return; + + Preferences.Instance.Workspaces.Remove(_selected); + Workspaces.Remove(_selected); + } + + public void MoveSelectedUp() + { + if (_selected == null) + return; + + var idx = Workspaces.IndexOf(_selected); + if (idx == 0) + return; + + Workspaces.Move(idx - 1, idx); + + Preferences.Instance.Workspaces.RemoveAt(idx); + Preferences.Instance.Workspaces.Insert(idx - 1, _selected); + } + + public void MoveSelectedDown() + { + if (_selected == null) + return; + + var idx = Workspaces.IndexOf(_selected); + if (idx == Workspaces.Count - 1) + return; + + Workspaces.Move(idx + 1, idx); + + Preferences.Instance.Workspaces.RemoveAt(idx); + Preferences.Instance.Workspaces.Insert(idx + 1, _selected); + } + + private Workspace _selected = null; + private bool _canDeleteSelected = false; + } +} diff --git a/src/ViewModels/ConfirmCommit.cs b/src/ViewModels/ConfirmCommit.cs new file mode 100644 index 00000000..cea56948 --- /dev/null +++ b/src/ViewModels/ConfirmCommit.cs @@ -0,0 +1,26 @@ +using System; + +namespace SourceGit.ViewModels +{ + public class ConfirmCommit + { + public string Message + { + get; + private set; + } + + public ConfirmCommit(string message, Action onSure) + { + Message = message; + _onSure = onSure; + } + + public void Continue() + { + _onSure?.Invoke(); + } + + private Action _onSure; + } +} diff --git a/src/ViewModels/ConfirmEmptyCommit.cs b/src/ViewModels/ConfirmEmptyCommit.cs new file mode 100644 index 00000000..87178b75 --- /dev/null +++ b/src/ViewModels/ConfirmEmptyCommit.cs @@ -0,0 +1,38 @@ +using System; + +namespace SourceGit.ViewModels +{ + public class ConfirmEmptyCommit + { + public bool HasLocalChanges + { + get; + private set; + } + + public string Message + { + get; + private set; + } + + public ConfirmEmptyCommit(bool hasLocalChanges, Action onSure) + { + HasLocalChanges = hasLocalChanges; + Message = App.Text(hasLocalChanges ? "ConfirmEmptyCommit.WithLocalChanges" : "ConfirmEmptyCommit.NoLocalChanges"); + _onSure = onSure; + } + + public void StageAllThenCommit() + { + _onSure?.Invoke(true); + } + + public void Continue() + { + _onSure?.Invoke(false); + } + + private Action _onSure; + } +} diff --git a/src/ViewModels/Conflict.cs b/src/ViewModels/Conflict.cs new file mode 100644 index 00000000..bf93b5bc --- /dev/null +++ b/src/ViewModels/Conflict.cs @@ -0,0 +1,125 @@ +using System; + +namespace SourceGit.ViewModels +{ + public class ConflictSourceBranch + { + public string Name { get; private set; } + public string Head { get; private set; } + public Models.Commit Revision { get; private set; } + + public ConflictSourceBranch(string name, string head, Models.Commit revision) + { + Name = name; + Head = head; + Revision = revision; + } + + public ConflictSourceBranch(Repository repo, Models.Branch branch) + { + Name = branch.Name; + Head = branch.Head; + Revision = new Commands.QuerySingleCommit(repo.FullPath, branch.Head).Result() ?? new Models.Commit() { SHA = branch.Head }; + } + } + + public class Conflict + { + public string Marker + { + get => _change.ConflictMarker; + } + + public string Description + { + get => _change.ConflictDesc; + } + + public object Theirs + { + get; + private set; + } + + public object Mine + { + get; + private set; + } + + public bool IsResolved + { + get; + private set; + } = false; + + public bool CanUseExternalMergeTool + { + get; + private set; + } = false; + + public Conflict(Repository repo, WorkingCopy wc, Models.Change change) + { + _wc = wc; + _change = change; + + var isSubmodule = repo.Submodules.Find(x => x.Path.Equals(change.Path, StringComparison.Ordinal)) != null; + if (!isSubmodule && (_change.ConflictReason == Models.ConflictReason.BothAdded || _change.ConflictReason == Models.ConflictReason.BothModified)) + { + CanUseExternalMergeTool = true; + IsResolved = new Commands.IsConflictResolved(repo.FullPath, change).Result(); + } + + var context = wc.InProgressContext; + if (context is CherryPickInProgress cherryPick) + { + Theirs = cherryPick.Head; + Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); + } + else if (context is RebaseInProgress rebase) + { + var b = repo.Branches.Find(x => x.IsLocal && x.Name == rebase.HeadName); + if (b != null) + Theirs = new ConflictSourceBranch(b.Name, b.Head, rebase.StoppedAt); + else + Theirs = new ConflictSourceBranch(rebase.HeadName, rebase.StoppedAt?.SHA ?? "----------", rebase.StoppedAt); + + Mine = rebase.Onto; + } + else if (context is RevertInProgress revert) + { + Theirs = revert.Head; + Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); + } + else if (context is MergeInProgress merge) + { + Theirs = merge.Source; + Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); + } + else + { + Theirs = "Stash or Patch"; + Mine = new ConflictSourceBranch(repo, repo.CurrentBranch); + } + } + + public void UseTheirs() + { + _wc.UseTheirs([_change]); + } + + public void UseMine() + { + _wc.UseMine([_change]); + } + + public void OpenExternalMergeTool() + { + _wc.UseExternalMergeTool(_change); + } + + private WorkingCopy _wc = null; + private Models.Change _change = null; + } +} diff --git a/src/ViewModels/ConventionalCommitMessageBuilder.cs b/src/ViewModels/ConventionalCommitMessageBuilder.cs new file mode 100644 index 00000000..8faabe86 --- /dev/null +++ b/src/ViewModels/ConventionalCommitMessageBuilder.cs @@ -0,0 +1,114 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ConventionalCommitMessageBuilder : ObservableValidator + { + [Required(ErrorMessage = "Type of changes can not be null")] + public Models.ConventionalCommitType Type + { + get => _type; + set => SetProperty(ref _type, value, true); + } + + public string Scope + { + get => _scope; + set => SetProperty(ref _scope, value); + } + + [Required(ErrorMessage = "Short description can not be empty")] + public string Description + { + get => _description; + set => SetProperty(ref _description, value, true); + } + + public string Detail + { + get => _detail; + set => SetProperty(ref _detail, value); + } + + public string BreakingChanges + { + get => _breakingChanges; + set => SetProperty(ref _breakingChanges, value); + } + + public string ClosedIssue + { + get => _closedIssue; + set => SetProperty(ref _closedIssue, value); + } + + public ConventionalCommitMessageBuilder(Action onApply) + { + _onApply = onApply; + } + + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode")] + public bool Apply() + { + if (HasErrors) + return false; + + ValidateAllProperties(); + if (HasErrors) + return false; + + var builder = new StringBuilder(); + builder.Append(_type.Type); + + if (!string.IsNullOrEmpty(_scope)) + { + builder.Append("("); + builder.Append(_scope); + builder.Append(")"); + } + + if (string.IsNullOrEmpty(_breakingChanges)) + builder.Append(": "); + else + builder.Append("!: "); + + builder.Append(_description); + builder.Append("\n\n"); + + if (!string.IsNullOrEmpty(_detail)) + { + builder.Append(_detail); + builder.Append("\n\n"); + } + + if (!string.IsNullOrEmpty(_breakingChanges)) + { + builder.Append("BREAKING CHANGE: "); + builder.Append(_breakingChanges); + builder.Append("\n\n"); + } + + if (!string.IsNullOrEmpty(_closedIssue)) + { + builder.Append("Closed "); + builder.Append(_closedIssue); + } + + _onApply?.Invoke(builder.ToString()); + return true; + } + + private Action _onApply = null; + private Models.ConventionalCommitType _type = Models.ConventionalCommitType.Supported[0]; + private string _scope = string.Empty; + private string _description = string.Empty; + private string _detail = string.Empty; + private string _breakingChanges = string.Empty; + private string _closedIssue = string.Empty; + } +} diff --git a/src/ViewModels/CreateBranch.cs b/src/ViewModels/CreateBranch.cs new file mode 100644 index 00000000..e518e977 --- /dev/null +++ b/src/ViewModels/CreateBranch.cs @@ -0,0 +1,222 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CreateBranch : Popup + { + [Required(ErrorMessage = "Branch name is required!")] + [RegularExpression(@"^[\w \-/\.#\+]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(CreateBranch), nameof(ValidateBranchName))] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public object BasedOn + { + get; + } + + public bool DiscardLocalChanges + { + get; + set; + } + + public bool CheckoutAfterCreated + { + get => _repo.Settings.CheckoutBranchOnCreateBranch; + set + { + if (_repo.Settings.CheckoutBranchOnCreateBranch != value) + { + _repo.Settings.CheckoutBranchOnCreateBranch = value; + OnPropertyChanged(); + } + } + } + + public bool IsBareRepository + { + get => _repo.IsBare; + } + + public bool AllowOverwrite + { + get => _allowOverwrite; + set + { + if (SetProperty(ref _allowOverwrite, value)) + ValidateProperty(_name, nameof(Name)); + } + } + + public bool IsRecurseSubmoduleVisible + { + get => _repo.Submodules.Count > 0; + } + + public bool RecurseSubmodules + { + get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; + set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + } + + public CreateBranch(Repository repo, Models.Branch branch) + { + _repo = repo; + _baseOnRevision = branch.IsDetachedHead ? branch.Head : branch.FullName; + + if (!branch.IsLocal && repo.Branches.Find(x => x.IsLocal && x.Name == branch.Name) == null) + { + Name = branch.Name; + } + + BasedOn = branch; + DiscardLocalChanges = false; + } + + public CreateBranch(Repository repo, Models.Commit commit) + { + _repo = repo; + _baseOnRevision = commit.SHA; + + BasedOn = commit; + DiscardLocalChanges = false; + } + + public CreateBranch(Repository repo, Models.Tag tag) + { + _repo = repo; + _baseOnRevision = tag.SHA; + + BasedOn = tag; + DiscardLocalChanges = false; + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is CreateBranch creator) + { + if (!creator._allowOverwrite) + { + var fixedName = creator.FixName(name); + foreach (var b in creator._repo.Branches) + { + if (b.FriendlyName == fixedName) + return new ValidationResult("A branch with same name already exists!"); + } + } + + return ValidationResult.Success; + } + else + { + return new ValidationResult("Missing runtime context to create branch!"); + } + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + + var fixedName = FixName(_name); + var log = _repo.CreateLog($"Create Branch '{fixedName}'"); + Use(log); + + var updateSubmodules = IsRecurseSubmoduleVisible && RecurseSubmodules; + return Task.Run(() => + { + bool succ = false; + if (CheckoutAfterCreated && !_repo.IsBare) + { + var needPopStash = false; + if (DiscardLocalChanges) + { + succ = new Commands.Checkout(_repo.FullPath).Use(log).Branch(fixedName, _baseOnRevision, true, _allowOverwrite); + } + else + { + var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); + if (changes > 0) + { + succ = new Commands.Stash(_repo.FullPath).Use(log).Push("CREATE_BRANCH_AUTO_STASH"); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + needPopStash = true; + } + + succ = new Commands.Checkout(_repo.FullPath).Use(log).Branch(fixedName, _baseOnRevision, false, _allowOverwrite); + } + + if (succ) + { + if (updateSubmodules) + { + var submodules = new Commands.QueryUpdatableSubmodules(_repo.FullPath).Result(); + if (submodules.Count > 0) + new Commands.Submodule(_repo.FullPath).Use(log).Update(submodules, true, true); + } + + if (needPopStash) + new Commands.Stash(_repo.FullPath).Use(log).Pop("stash@{0}"); + } + } + else + { + succ = Commands.Branch.Create(_repo.FullPath, fixedName, _baseOnRevision, _allowOverwrite, log); + } + + log.Complete(); + + CallUIThread(() => + { + if (succ && CheckoutAfterCreated) + { + var fake = new Models.Branch() { IsLocal = true, FullName = $"refs/heads/{fixedName}" }; + if (BasedOn is Models.Branch based && !based.IsLocal) + fake.Upstream = based.FullName; + + var folderEndIdx = fake.FullName.LastIndexOf('/'); + if (folderEndIdx > 10) + _repo.Settings.ExpandedBranchNodesInSideBar.Add(fake.FullName.Substring(0, folderEndIdx)); + + if (_repo.HistoriesFilterMode == Models.FilterMode.Included) + _repo.SetBranchFilterMode(fake, Models.FilterMode.Included, true, false); + + ProgressDescription = "Waiting for branch updated..."; + } + + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + + if (CheckoutAfterCreated) + Task.Delay(400).Wait(); + + return true; + }); + } + + private string FixName(string name) + { + if (!name.Contains(' ')) + return name; + + var parts = name.Split(' ', System.StringSplitOptions.RemoveEmptyEntries); + return string.Join("-", parts); + } + + private readonly Repository _repo = null; + private string _name = null; + private readonly string _baseOnRevision = null; + private bool _allowOverwrite = false; + } +} diff --git a/src/ViewModels/CreateGroup.cs b/src/ViewModels/CreateGroup.cs new file mode 100644 index 00000000..fb0218f0 --- /dev/null +++ b/src/ViewModels/CreateGroup.cs @@ -0,0 +1,38 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CreateGroup : Popup + { + [Required(ErrorMessage = "Group name is required!")] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public CreateGroup(RepositoryNode parent) + { + _parent = parent; + } + + public override Task Sure() + { + Preferences.Instance.AddNode(new RepositoryNode() + { + Id = Guid.NewGuid().ToString(), + Name = _name, + IsRepository = false, + IsExpanded = false, + }, _parent, true); + + Welcome.Instance.Refresh(); + return null; + } + + private readonly RepositoryNode _parent = null; + private string _name = string.Empty; + } +} diff --git a/src/ViewModels/CreateTag.cs b/src/ViewModels/CreateTag.cs new file mode 100644 index 00000000..d3cd512b --- /dev/null +++ b/src/ViewModels/CreateTag.cs @@ -0,0 +1,111 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CreateTag : Popup + { + public object BasedOn + { + get; + private set; + } + + [Required(ErrorMessage = "Tag name is required!")] + [RegularExpression(@"^(?!\.)(?!/)(?!.*\.$)(?!.*/$)(?!.*\.\.)[\w\-\./]+$", ErrorMessage = "Bad tag name format!")] + [CustomValidation(typeof(CreateTag), nameof(ValidateTagName))] + public string TagName + { + get => _tagName; + set => SetProperty(ref _tagName, value, true); + } + + public string Message + { + get; + set; + } + + public bool Annotated + { + get => _annotated; + set => SetProperty(ref _annotated, value); + } + + public bool SignTag + { + get; + set; + } = false; + + public bool PushToRemotes + { + get => _repo.Settings.PushToRemoteWhenCreateTag; + set => _repo.Settings.PushToRemoteWhenCreateTag = value; + } + + public CreateTag(Repository repo, Models.Branch branch) + { + _repo = repo; + _basedOn = branch.Head; + + BasedOn = branch; + SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); + } + + public CreateTag(Repository repo, Models.Commit commit) + { + _repo = repo; + _basedOn = commit.SHA; + + BasedOn = commit; + SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); + } + + public static ValidationResult ValidateTagName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is CreateTag creator) + { + var found = creator._repo.Tags.Find(x => x.Name == name); + if (found != null) + return new ValidationResult("A tag with same name already exists!"); + } + return ValidationResult.Success; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Create tag..."; + + var remotes = PushToRemotes ? _repo.Remotes : null; + var log = _repo.CreateLog("Create Tag"); + Use(log); + + return Task.Run(() => + { + bool succ; + if (_annotated) + succ = Commands.Tag.Add(_repo.FullPath, _tagName, _basedOn, Message, SignTag, log); + else + succ = Commands.Tag.Add(_repo.FullPath, _tagName, _basedOn, log); + + if (succ && remotes != null) + { + foreach (var remote in remotes) + new Commands.Push(_repo.FullPath, remote.Name, $"refs/tags/{_tagName}", false).Use(log).Exec(); + } + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + private string _tagName = string.Empty; + private bool _annotated = true; + private readonly string _basedOn; + } +} diff --git a/src/ViewModels/DeinitSubmodule.cs b/src/ViewModels/DeinitSubmodule.cs new file mode 100644 index 00000000..a96a65d0 --- /dev/null +++ b/src/ViewModels/DeinitSubmodule.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeinitSubmodule : Popup + { + public string Submodule + { + get; + private set; + } + + public bool Force + { + get; + set; + } + + public DeinitSubmodule(Repository repo, string submodule) + { + _repo = repo; + Submodule = submodule; + Force = false; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "De-initialize Submodule"; + + var log = _repo.CreateLog("De-initialize Submodule"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Submodule(_repo.FullPath).Use(log).Deinit(Submodule, false); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private Repository _repo; + } +} diff --git a/src/ViewModels/DeleteBranch.cs b/src/ViewModels/DeleteBranch.cs new file mode 100644 index 00000000..4decdb40 --- /dev/null +++ b/src/ViewModels/DeleteBranch.cs @@ -0,0 +1,78 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteBranch : Popup + { + public Models.Branch Target + { + get; + } + + public Models.Branch TrackingRemoteBranch + { + get; + } + + public string DeleteTrackingRemoteTip + { + get; + private set; + } + + public bool AlsoDeleteTrackingRemote + { + get => _alsoDeleteTrackingRemote; + set => SetProperty(ref _alsoDeleteTrackingRemote, value); + } + + public DeleteBranch(Repository repo, Models.Branch branch) + { + _repo = repo; + Target = branch; + + if (branch.IsLocal && !string.IsNullOrEmpty(branch.Upstream)) + { + TrackingRemoteBranch = repo.Branches.Find(x => x.FullName == branch.Upstream); + if (TrackingRemoteBranch != null) + DeleteTrackingRemoteTip = App.Text("DeleteBranch.WithTrackingRemote", TrackingRemoteBranch.FriendlyName); + } + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Deleting branch..."; + + var log = _repo.CreateLog("Delete Branch"); + Use(log); + + return Task.Run(() => + { + if (Target.IsLocal) + { + Commands.Branch.DeleteLocal(_repo.FullPath, Target.Name, log); + + if (_alsoDeleteTrackingRemote && TrackingRemoteBranch != null) + Commands.Branch.DeleteRemote(_repo.FullPath, TrackingRemoteBranch.Remote, TrackingRemoteBranch.Name, log); + } + else + { + Commands.Branch.DeleteRemote(_repo.FullPath, Target.Remote, Target.Name, log); + } + + log.Complete(); + + CallUIThread(() => + { + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + return true; + }); + } + + private readonly Repository _repo = null; + private bool _alsoDeleteTrackingRemote = false; + } +} diff --git a/src/ViewModels/DeleteMultipleBranches.cs b/src/ViewModels/DeleteMultipleBranches.cs new file mode 100644 index 00000000..b40ff223 --- /dev/null +++ b/src/ViewModels/DeleteMultipleBranches.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteMultipleBranches : Popup + { + public List Targets + { + get; + } + + public DeleteMultipleBranches(Repository repo, List branches, bool isLocal) + { + _repo = repo; + _isLocal = isLocal; + Targets = branches; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Deleting multiple branches..."; + + var log = _repo.CreateLog("Delete Multiple Branches"); + Use(log); + + return Task.Run(() => + { + if (_isLocal) + { + foreach (var target in Targets) + Commands.Branch.DeleteLocal(_repo.FullPath, target.Name, log); + } + else + { + foreach (var target in Targets) + Commands.Branch.DeleteRemote(_repo.FullPath, target.Remote, target.Name, log); + } + + log.Complete(); + + CallUIThread(() => + { + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + + return true; + }); + } + + private Repository _repo = null; + private bool _isLocal = false; + } +} diff --git a/src/ViewModels/DeleteRemote.cs b/src/ViewModels/DeleteRemote.cs new file mode 100644 index 00000000..faf7c8a9 --- /dev/null +++ b/src/ViewModels/DeleteRemote.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteRemote : Popup + { + public Models.Remote Remote + { + get; + private set; + } + + public DeleteRemote(Repository repo, Models.Remote remote) + { + _repo = repo; + Remote = remote; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Deleting remote ..."; + + var log = _repo.CreateLog("Delete Remote"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Remote(_repo.FullPath).Use(log).Delete(Remote.Name); + log.Complete(); + + CallUIThread(() => + { + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/DeleteRepositoryNode.cs b/src/ViewModels/DeleteRepositoryNode.cs new file mode 100644 index 00000000..38e03d9f --- /dev/null +++ b/src/ViewModels/DeleteRepositoryNode.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteRepositoryNode : Popup + { + public RepositoryNode Node + { + get; + } + + public DeleteRepositoryNode(RepositoryNode node) + { + Node = node; + } + + public override Task Sure() + { + Preferences.Instance.RemoveNode(Node, true); + Welcome.Instance.Refresh(); + return null; + } + } +} diff --git a/src/ViewModels/DeleteSubmodule.cs b/src/ViewModels/DeleteSubmodule.cs new file mode 100644 index 00000000..239c7d31 --- /dev/null +++ b/src/ViewModels/DeleteSubmodule.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteSubmodule : Popup + { + public string Submodule + { + get; + private set; + } + + public DeleteSubmodule(Repository repo, string submodule) + { + _repo = repo; + Submodule = submodule; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Deleting submodule ..."; + + var log = _repo.CreateLog("Delete Submodule"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Submodule(_repo.FullPath).Use(log).Delete(Submodule); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/DeleteTag.cs b/src/ViewModels/DeleteTag.cs new file mode 100644 index 00000000..f7de6341 --- /dev/null +++ b/src/ViewModels/DeleteTag.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteTag : Popup + { + public Models.Tag Target + { + get; + private set; + } + + public bool PushToRemotes + { + get => _repo.Settings.PushToRemoteWhenDeleteTag; + set => _repo.Settings.PushToRemoteWhenDeleteTag = value; + } + + public DeleteTag(Repository repo, Models.Tag tag) + { + _repo = repo; + Target = tag; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Deleting tag '{Target.Name}' ..."; + + var remotes = PushToRemotes ? _repo.Remotes : []; + var log = _repo.CreateLog("Delete Tag"); + Use(log); + + return Task.Run(() => + { + var succ = Commands.Tag.Delete(_repo.FullPath, Target.Name, log); + if (succ) + { + foreach (var r in remotes) + new Commands.Push(_repo.FullPath, r.Name, $"refs/tags/{Target.Name}", true).Use(log).Exec(); + } + + log.Complete(); + + CallUIThread(() => + { + _repo.MarkTagsDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs new file mode 100644 index 00000000..9bb3c710 --- /dev/null +++ b/src/ViewModels/DiffContext.cs @@ -0,0 +1,288 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class DiffContext : ObservableObject + { + public string Title + { + get; + } + + public bool IgnoreWhitespace + { + get => Preferences.Instance.IgnoreWhitespaceChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreWhitespaceChangesInDiff) + { + Preferences.Instance.IgnoreWhitespaceChangesInDiff = value; + OnPropertyChanged(); + LoadDiffContent(); + } + } + } + + public string FileModeChange + { + get => _fileModeChange; + private set => SetProperty(ref _fileModeChange, value); + } + + public bool IsTextDiff + { + get => _isTextDiff; + private set => SetProperty(ref _isTextDiff, value); + } + + public object Content + { + get => _content; + private set => SetProperty(ref _content, value); + } + + public int UnifiedLines + { + get => _unifiedLines; + private set => SetProperty(ref _unifiedLines, value); + } + + public DiffContext(string repo, Models.DiffOption option, DiffContext previous = null) + { + _repo = repo; + _option = option; + + if (previous != null) + { + _isTextDiff = previous._isTextDiff; + _content = previous._content; + _fileModeChange = previous._fileModeChange; + _unifiedLines = previous._unifiedLines; + _info = previous._info; + } + + if (string.IsNullOrEmpty(_option.OrgPath) || _option.OrgPath == "/dev/null") + Title = _option.Path; + else + Title = $"{_option.OrgPath} → {_option.Path}"; + + LoadDiffContent(); + } + + public void ToggleFullTextDiff() + { + Preferences.Instance.UseFullTextDiff = !Preferences.Instance.UseFullTextDiff; + LoadDiffContent(); + } + + public void IncrUnified() + { + UnifiedLines = _unifiedLines + 1; + LoadDiffContent(); + } + + public void DecrUnified() + { + UnifiedLines = Math.Max(4, _unifiedLines - 1); + LoadDiffContent(); + } + + public void OpenExternalMergeTool() + { + var toolType = Preferences.Instance.ExternalMergeToolType; + var toolPath = Preferences.Instance.ExternalMergeToolPath; + Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, toolType, toolPath, _option)); + } + + private void LoadDiffContent() + { + if (_option.Path.EndsWith('/')) + { + Content = null; + IsTextDiff = false; + return; + } + + Task.Run(() => + { + var numLines = Preferences.Instance.UseFullTextDiff ? 999999999 : _unifiedLines; + var ignoreWhitespace = Preferences.Instance.IgnoreWhitespaceChangesInDiff; + var latest = new Commands.Diff(_repo, _option, numLines, ignoreWhitespace).Result(); + var info = new Info(_option, numLines, ignoreWhitespace, latest); + if (_info != null && info.IsSame(_info)) + return; + + _info = info; + + var rs = null as object; + if (latest.TextDiff != null) + { + var count = latest.TextDiff.Lines.Count; + var isSubmodule = false; + if (count <= 3) + { + var submoduleDiff = new Models.SubmoduleDiff(); + var submoduleRoot = $"{_repo}/{_option.Path}".Replace('\\', '/').TrimEnd('/'); + isSubmodule = true; + for (int i = 1; i < count; i++) + { + var line = latest.TextDiff.Lines[i]; + if (!line.Content.StartsWith("Subproject commit ", StringComparison.Ordinal)) + { + isSubmodule = false; + break; + } + + var sha = line.Content.Substring(18); + if (line.Type == Models.TextDiffLineType.Added) + submoduleDiff.New = QuerySubmoduleRevision(submoduleRoot, sha); + else if (line.Type == Models.TextDiffLineType.Deleted) + submoduleDiff.Old = QuerySubmoduleRevision(submoduleRoot, sha); + } + + if (isSubmodule) + rs = submoduleDiff; + } + + if (!isSubmodule) + { + latest.TextDiff.File = _option.Path; + rs = latest.TextDiff; + } + } + else if (latest.IsBinary) + { + var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; + var imgDecoder = ImageSource.GetDecoder(_option.Path); + + if (imgDecoder != Models.ImageDecoder.None) + { + var imgDiff = new Models.ImageDiff(); + + if (_option.Revisions.Count == 2) + { + var oldImage = ImageSource.FromRevision(_repo, _option.Revisions[0], oldPath, imgDecoder); + var newImage = ImageSource.FromRevision(_repo, _option.Revisions[1], _option.Path, imgDecoder); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + imgDiff.New = newImage.Bitmap; + imgDiff.NewFileSize = newImage.Size; + } + else + { + if (!oldPath.Equals("/dev/null", StringComparison.Ordinal)) + { + var oldImage = ImageSource.FromRevision(_repo, "HEAD", oldPath, imgDecoder); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + } + + var fullPath = Path.Combine(_repo, _option.Path); + if (File.Exists(fullPath)) + { + var newImage = ImageSource.FromFile(fullPath, imgDecoder); + imgDiff.New = newImage.Bitmap; + imgDiff.NewFileSize = newImage.Size; + } + } + + rs = imgDiff; + } + else + { + var binaryDiff = new Models.BinaryDiff(); + if (_option.Revisions.Count == 2) + { + binaryDiff.OldSize = new Commands.QueryFileSize(_repo, oldPath, _option.Revisions[0]).Result(); + binaryDiff.NewSize = new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).Result(); + } + else + { + var fullPath = Path.Combine(_repo, _option.Path); + binaryDiff.OldSize = new Commands.QueryFileSize(_repo, oldPath, "HEAD").Result(); + binaryDiff.NewSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; + } + rs = binaryDiff; + } + } + else if (latest.IsLFS) + { + var imgDecoder = ImageSource.GetDecoder(_option.Path); + if (imgDecoder != Models.ImageDecoder.None) + rs = new LFSImageDiff(_repo, latest.LFSDiff, imgDecoder); + else + rs = latest.LFSDiff; + } + else + { + rs = new Models.NoOrEOLChange(); + } + + Dispatcher.UIThread.Post(() => + { + if (_content is Models.TextDiff old && rs is Models.TextDiff cur && old.File == cur.File) + cur.ScrollOffset = old.ScrollOffset; + + FileModeChange = latest.FileModeChange; + Content = rs; + IsTextDiff = rs is Models.TextDiff; + }); + }); + } + + private Models.RevisionSubmodule QuerySubmoduleRevision(string repo, string sha) + { + var commit = new Commands.QuerySingleCommit(repo, sha).Result(); + if (commit == null) + return new Models.RevisionSubmodule() { Commit = new Models.Commit() { SHA = sha } }; + + var body = new Commands.QueryCommitFullMessage(repo, sha).Result(); + return new Models.RevisionSubmodule() + { + Commit = commit, + FullMessage = new Models.CommitFullMessage { Message = body } + }; + } + + private class Info + { + public string Argument { get; } + public int UnifiedLines { get; } + public bool IgnoreWhitespace { get; } + public string OldHash { get; } + public string NewHash { get; } + + public Info(Models.DiffOption option, int unifiedLines, bool ignoreWhitespace, Models.DiffResult result) + { + Argument = option.ToString(); + UnifiedLines = unifiedLines; + IgnoreWhitespace = ignoreWhitespace; + OldHash = result.OldHash; + NewHash = result.NewHash; + } + + public bool IsSame(Info other) + { + return Argument.Equals(other.Argument, StringComparison.Ordinal) && + UnifiedLines == other.UnifiedLines && + IgnoreWhitespace == other.IgnoreWhitespace && + OldHash.Equals(other.OldHash, StringComparison.Ordinal) && + NewHash.Equals(other.NewHash, StringComparison.Ordinal); + } + } + + private readonly string _repo; + private readonly Models.DiffOption _option = null; + private string _fileModeChange = string.Empty; + private int _unifiedLines = 4; + private bool _isTextDiff = false; + private object _content = null; + private Info _info = null; + } +} diff --git a/src/ViewModels/Discard.cs b/src/ViewModels/Discard.cs new file mode 100644 index 00000000..7619635c --- /dev/null +++ b/src/ViewModels/Discard.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DiscardAllMode + { + public bool IncludeIgnored + { + get; + set; + } = false; + } + + public class DiscardSingleFile + { + public string Path + { + get; + set; + } = string.Empty; + } + + public class DiscardMultipleFiles + { + public int Count + { + get; + set; + } = 0; + } + + public class Discard : Popup + { + public object Mode + { + get; + } + + public Discard(Repository repo) + { + _repo = repo; + Mode = new DiscardAllMode(); + } + + public Discard(Repository repo, List changes) + { + _repo = repo; + _changes = changes; + + if (_changes == null) + Mode = new DiscardAllMode(); + else if (_changes.Count == 1) + Mode = new DiscardSingleFile() { Path = _changes[0].Path }; + else + Mode = new DiscardMultipleFiles() { Count = _changes.Count }; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = _changes == null ? "Discard all local changes ..." : $"Discard total {_changes.Count} changes ..."; + + var log = _repo.CreateLog("Discard all"); + Use(log); + + return Task.Run(() => + { + if (Mode is DiscardAllMode all) + Commands.Discard.All(_repo.FullPath, all.IncludeIgnored, log); + else + Commands.Discard.Changes(_repo.FullPath, _changes, log); + + log.Complete(); + + CallUIThread(() => + { + _repo.MarkWorkingCopyDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + + return true; + }); + } + + private readonly Repository _repo = null; + private readonly List _changes = null; + } +} diff --git a/src/ViewModels/DropStash.cs b/src/ViewModels/DropStash.cs new file mode 100644 index 00000000..545da010 --- /dev/null +++ b/src/ViewModels/DropStash.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DropStash : Popup + { + public Models.Stash Stash { get; } + + public DropStash(Repository repo, Models.Stash stash) + { + _repo = repo; + Stash = stash; + } + + public override Task Sure() + { + ProgressDescription = $"Dropping stash: {Stash.Name}"; + + var log = _repo.CreateLog("Drop Stash"); + Use(log); + + return Task.Run(() => + { + new Commands.Stash(_repo.FullPath).Use(log).Drop(Stash.Name); + log.Complete(); + return true; + }); + } + + private readonly Repository _repo; + } +} diff --git a/src/ViewModels/EditRemote.cs b/src/ViewModels/EditRemote.cs new file mode 100644 index 00000000..763c8ce1 --- /dev/null +++ b/src/ViewModels/EditRemote.cs @@ -0,0 +1,142 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class EditRemote : Popup + { + [Required(ErrorMessage = "Remote name is required!!!")] + [RegularExpression(@"^[\w\-\.]+$", ErrorMessage = "Bad remote name format!!!")] + [CustomValidation(typeof(EditRemote), nameof(ValidateRemoteName))] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + [Required(ErrorMessage = "Remote URL is required!!!")] + [CustomValidation(typeof(EditRemote), nameof(ValidateRemoteURL))] + public string Url + { + get => _url; + set + { + if (SetProperty(ref _url, value, true)) + UseSSH = Models.Remote.IsSSH(value); + } + } + + public bool UseSSH + { + get => _useSSH; + set + { + if (SetProperty(ref _useSSH, value)) + ValidateProperty(_sshkey, nameof(SSHKey)); + } + } + + [CustomValidation(typeof(EditRemote), nameof(ValidateSSHKey))] + public string SSHKey + { + get => _sshkey; + set => SetProperty(ref _sshkey, value, true); + } + + public EditRemote(Repository repo, Models.Remote remote) + { + _repo = repo; + _remote = remote; + _name = remote.Name; + _url = remote.URL; + _useSSH = Models.Remote.IsSSH(remote.URL); + + if (_useSSH) + { + SSHKey = new Commands.Config(repo.FullPath).Get($"remote.{remote.Name}.sshkey"); + } + } + + public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is EditRemote edit) + { + foreach (var remote in edit._repo.Remotes) + { + if (remote != edit._remote && name == remote.Name) + return new ValidationResult("A remote with given name already exists!!!"); + } + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateRemoteURL(string url, ValidationContext ctx) + { + if (ctx.ObjectInstance is EditRemote edit) + { + if (!Models.Remote.IsValidURL(url)) + return new ValidationResult("Bad remote URL format!!!"); + + foreach (var remote in edit._repo.Remotes) + { + if (remote != edit._remote && url == remote.URL) + return new ValidationResult("A remote with the same url already exists!!!"); + } + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateSSHKey(string sshkey, ValidationContext ctx) + { + if (ctx.ObjectInstance is EditRemote { _useSSH: true } && !string.IsNullOrEmpty(sshkey)) + { + if (!File.Exists(sshkey)) + return new ValidationResult("Given SSH private key can NOT be found!"); + } + + return ValidationResult.Success; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Editing remote '{_remote.Name}' ..."; + + return Task.Run(() => + { + if (_remote.Name != _name) + { + var succ = new Commands.Remote(_repo.FullPath).Rename(_remote.Name, _name); + if (succ) + _remote.Name = _name; + } + + if (_remote.URL != _url) + { + var succ = new Commands.Remote(_repo.FullPath).SetURL(_name, _url, false); + if (succ) + _remote.URL = _url; + } + + var pushURL = new Commands.Remote(_repo.FullPath).GetURL(_name, true); + if (pushURL != _url) + new Commands.Remote(_repo.FullPath).SetURL(_name, _url, true); + + new Commands.Config(_repo.FullPath).Set($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); + + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + private readonly Models.Remote _remote = null; + private string _name = null; + private string _url = null; + private bool _useSSH = false; + private string _sshkey = string.Empty; + } +} diff --git a/src/ViewModels/EditRepositoryNode.cs b/src/ViewModels/EditRepositoryNode.cs new file mode 100644 index 00000000..599b7f63 --- /dev/null +++ b/src/ViewModels/EditRepositoryNode.cs @@ -0,0 +1,63 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class EditRepositoryNode : Popup + { + public string Id + { + get => _id; + set => SetProperty(ref _id, value); + } + + [Required(ErrorMessage = "Name is required!")] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public int Bookmark + { + get => _bookmark; + set => SetProperty(ref _bookmark, value); + } + + public bool IsRepository + { + get => _isRepository; + set => SetProperty(ref _isRepository, value); + } + + public EditRepositoryNode(RepositoryNode node) + { + _node = node; + _id = node.Id; + _name = node.Name; + _isRepository = node.IsRepository; + _bookmark = node.Bookmark; + } + + public override Task Sure() + { + bool needSort = _node.Name != _name; + _node.Name = _name; + _node.Bookmark = _bookmark; + + if (needSort) + { + Preferences.Instance.SortByRenamedNode(_node); + Welcome.Instance.Refresh(); + } + + return null; + } + + private RepositoryNode _node = null; + private string _id = null; + private string _name = null; + private bool _isRepository = false; + private int _bookmark = 0; + } +} diff --git a/src/ViewModels/ExecuteCustomAction.cs b/src/ViewModels/ExecuteCustomAction.cs new file mode 100644 index 00000000..72570bf0 --- /dev/null +++ b/src/ViewModels/ExecuteCustomAction.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ExecuteCustomAction : Popup + { + public Models.CustomAction CustomAction + { + get; + } + + public ExecuteCustomAction(Repository repo, Models.CustomAction action) + { + _repo = repo; + _args = action.Arguments.Replace("${REPO}", GetWorkdir()); + CustomAction = action; + } + + public ExecuteCustomAction(Repository repo, Models.CustomAction action, Models.Branch branch) + { + _repo = repo; + _args = action.Arguments.Replace("${REPO}", GetWorkdir()).Replace("${BRANCH}", branch.FriendlyName); + CustomAction = action; + } + + public ExecuteCustomAction(Repository repo, Models.CustomAction action, Models.Commit commit) + { + _repo = repo; + _args = action.Arguments.Replace("${REPO}", GetWorkdir()).Replace("${SHA}", commit.SHA); + CustomAction = action; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Run custom action ..."; + + var log = _repo.CreateLog(CustomAction.Name); + Use(log); + + return Task.Run(() => + { + if (CustomAction.WaitForExit) + Commands.ExecuteCustomAction.RunAndWait(_repo.FullPath, CustomAction.Executable, _args, log); + else + Commands.ExecuteCustomAction.Run(_repo.FullPath, CustomAction.Executable, _args); + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private string GetWorkdir() + { + return OperatingSystem.IsWindows() ? _repo.FullPath.Replace("/", "\\") : _repo.FullPath; + } + + private readonly Repository _repo = null; + private readonly string _args; + } +} diff --git a/src/ViewModels/Fetch.cs b/src/ViewModels/Fetch.cs new file mode 100644 index 00000000..2fa4dae2 --- /dev/null +++ b/src/ViewModels/Fetch.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Fetch : Popup + { + public List Remotes + { + get => _repo.Remotes; + } + + public bool FetchAllRemotes + { + get => _fetchAllRemotes; + set => SetProperty(ref _fetchAllRemotes, value); + } + + public Models.Remote SelectedRemote + { + get; + set; + } + + public bool NoTags + { + get => _repo.Settings.FetchWithoutTags; + set => _repo.Settings.FetchWithoutTags = value; + } + + public bool Force + { + get => _repo.Settings.EnableForceOnFetch; + set => _repo.Settings.EnableForceOnFetch = value; + } + + public Fetch(Repository repo, Models.Remote preferredRemote = null) + { + _repo = repo; + _fetchAllRemotes = preferredRemote == null; + + if (preferredRemote != null) + { + SelectedRemote = preferredRemote; + } + else if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + { + var def = _repo.Remotes.Find(r => r.Name == _repo.Settings.DefaultRemote); + if (def != null) + SelectedRemote = def; + else + SelectedRemote = _repo.Remotes[0]; + } + else + { + SelectedRemote = _repo.Remotes[0]; + } + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + + var notags = _repo.Settings.FetchWithoutTags; + var force = _repo.Settings.EnableForceOnFetch; + var log = _repo.CreateLog("Fetch"); + Use(log); + + return Task.Run(() => + { + if (FetchAllRemotes) + { + foreach (var remote in _repo.Remotes) + new Commands.Fetch(_repo.FullPath, remote.Name, notags, force).Use(log).Exec(); + } + else + { + new Commands.Fetch(_repo.FullPath, SelectedRemote.Name, notags, force).Use(log).Exec(); + } + + log.Complete(); + + var upstream = _repo.CurrentBranch?.Upstream; + var upstreamHead = string.Empty; + if (!string.IsNullOrEmpty(upstream)) + upstreamHead = new Commands.QueryRevisionByRefName(_repo.FullPath, upstream.Substring(13)).Result(); + + CallUIThread(() => + { + if (!string.IsNullOrEmpty(upstreamHead)) + _repo.NavigateToCommit(upstreamHead, true); + + _repo.MarkFetched(); + _repo.SetWatcherEnabled(true); + }); + + return true; + }); + } + + private readonly Repository _repo = null; + private bool _fetchAllRemotes; + } +} diff --git a/src/ViewModels/FetchInto.cs b/src/ViewModels/FetchInto.cs new file mode 100644 index 00000000..c681b637 --- /dev/null +++ b/src/ViewModels/FetchInto.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class FetchInto : Popup + { + public Models.Branch Local + { + get; + } + + public Models.Branch Upstream + { + get; + } + + public FetchInto(Repository repo, Models.Branch local, Models.Branch upstream) + { + _repo = repo; + Local = local; + Upstream = upstream; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Fast-Forward ..."; + + var log = _repo.CreateLog($"Fetch Into '{Local.FriendlyName}'"); + Use(log); + + return Task.Run(() => + { + new Commands.Fetch(_repo.FullPath, Local, Upstream).Use(log).Exec(); + log.Complete(); + + var changedLocalBranchHead = new Commands.QueryRevisionByRefName(_repo.FullPath, Local.Name).Result(); + CallUIThread(() => + { + _repo.NavigateToCommit(changedLocalBranchHead, true); + _repo.SetWatcherEnabled(true); + }); + + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/FileHistories.cs b/src/ViewModels/FileHistories.cs new file mode 100644 index 00000000..c3e5aac1 --- /dev/null +++ b/src/ViewModels/FileHistories.cs @@ -0,0 +1,307 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Collections; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class FileHistoriesRevisionFile(string path, object content) + { + public string Path { get; set; } = path; + public object Content { get; set; } = content; + } + + public class FileHistoriesSingleRevision : ObservableObject + { + public bool IsDiffMode + { + get => _isDiffMode; + set + { + if (SetProperty(ref _isDiffMode, value)) + RefreshViewContent(); + } + } + + public object ViewContent + { + get => _viewContent; + set => SetProperty(ref _viewContent, value); + } + + public FileHistoriesSingleRevision(Repository repo, string file, Models.Commit revision, bool prevIsDiffMode) + { + _repo = repo; + _file = file; + _revision = revision; + _isDiffMode = prevIsDiffMode; + _viewContent = null; + + RefreshViewContent(); + } + + public Task ResetToSelectedRevision() + { + return Task.Run(() => new Commands.Checkout(_repo.FullPath).FileWithRevision(_file, $"{_revision.SHA}")); + } + + private void RefreshViewContent() + { + if (_isDiffMode) + SetViewContentAsDiff(); + else + SetViewContentAsRevisionFile(); + } + + private void SetViewContentAsRevisionFile() + { + var objs = new Commands.QueryRevisionObjects(_repo.FullPath, _revision.SHA, _file).Result(); + if (objs.Count == 0) + { + ViewContent = new FileHistoriesRevisionFile(_file, null); + return; + } + + var obj = objs[0]; + switch (obj.Type) + { + case Models.ObjectType.Blob: + Task.Run(() => + { + var isBinary = new Commands.IsBinary(_repo.FullPath, _revision.SHA, _file).Result(); + if (isBinary) + { + var imgDecoder = ImageSource.GetDecoder(_file); + if (imgDecoder != Models.ImageDecoder.None) + { + var source = ImageSource.FromRevision(_repo.FullPath, _revision.SHA, _file, imgDecoder); + var image = new Models.RevisionImageFile(_file, source.Bitmap, source.Size); + Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, image)); + } + else + { + var size = new Commands.QueryFileSize(_repo.FullPath, _file, _revision.SHA).Result(); + var binaryFile = new Models.RevisionBinaryFile() { Size = size }; + Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, binaryFile)); + } + + return; + } + + var contentStream = Commands.QueryFileContent.Run(_repo.FullPath, _revision.SHA, _file); + var content = new StreamReader(contentStream).ReadToEnd(); + var lfs = Models.LFSObject.Parse(content); + if (lfs != null) + { + var imgDecoder = ImageSource.GetDecoder(_file); + if (imgDecoder != Models.ImageDecoder.None) + { + var combined = new RevisionLFSImage(_repo.FullPath, _file, lfs, imgDecoder); + Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, combined)); + } + else + { + var rlfs = new Models.RevisionLFSObject() { Object = lfs }; + Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, rlfs)); + } + } + else + { + var txt = new Models.RevisionTextFile() { FileName = obj.Path, Content = content }; + Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, txt)); + } + }); + break; + case Models.ObjectType.Commit: + Task.Run(() => + { + var submoduleRoot = Path.Combine(_repo.FullPath, _file); + var commit = new Commands.QuerySingleCommit(submoduleRoot, obj.SHA).Result(); + var message = commit != null ? new Commands.QueryCommitFullMessage(submoduleRoot, obj.SHA).Result() : null; + var module = new Models.RevisionSubmodule() + { + Commit = commit ?? new Models.Commit() { SHA = obj.SHA }, + FullMessage = new Models.CommitFullMessage { Message = message } + }; + + Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, module)); + }); + break; + default: + ViewContent = new FileHistoriesRevisionFile(_file, null); + break; + } + } + + private void SetViewContentAsDiff() + { + var option = new Models.DiffOption(_revision, _file); + ViewContent = new DiffContext(_repo.FullPath, option, _viewContent as DiffContext); + } + + private Repository _repo = null; + private string _file = null; + private Models.Commit _revision = null; + private bool _isDiffMode = false; + private object _viewContent = null; + } + + public class FileHistoriesCompareRevisions : ObservableObject + { + public Models.Commit StartPoint + { + get => _startPoint; + set => SetProperty(ref _startPoint, value); + } + + public Models.Commit EndPoint + { + get => _endPoint; + set => SetProperty(ref _endPoint, value); + } + + public DiffContext ViewContent + { + get => _viewContent; + set => SetProperty(ref _viewContent, value); + } + + public FileHistoriesCompareRevisions(Repository repo, string file, Models.Commit start, Models.Commit end) + { + _repo = repo; + _file = file; + _startPoint = start; + _endPoint = end; + RefreshViewContent(); + } + + public void Swap() + { + (StartPoint, EndPoint) = (_endPoint, _startPoint); + RefreshViewContent(); + } + + public Task SaveAsPatch(string saveTo) + { + return Task.Run(() => + { + Commands.SaveChangesAsPatch.ProcessRevisionCompareChanges(_repo.FullPath, _changes, _startPoint.SHA, _endPoint.SHA, saveTo); + return true; + }); + } + + private void RefreshViewContent() + { + Task.Run(() => + { + _changes = new Commands.CompareRevisions(_repo.FullPath, _startPoint.SHA, _endPoint.SHA, _file).Result(); + if (_changes.Count == 0) + { + Dispatcher.UIThread.Invoke(() => ViewContent = null); + return; + } + + var option = new Models.DiffOption(_startPoint.SHA, _endPoint.SHA, _changes[0]); + Dispatcher.UIThread.Invoke(() => ViewContent = new DiffContext(_repo.FullPath, option, _viewContent)); + }); + } + + private Repository _repo = null; + private string _file = null; + private Models.Commit _startPoint = null; + private Models.Commit _endPoint = null; + private List _changes = []; + private DiffContext _viewContent = null; + } + + public class FileHistories : ObservableObject + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List Commits + { + get => _commits; + set => SetProperty(ref _commits, value); + } + + public AvaloniaList SelectedCommits + { + get; + set; + } = []; + + public object ViewContent + { + get => _viewContent; + private set => SetProperty(ref _viewContent, value); + } + + public FileHistories(Repository repo, string file, string commit = null) + { + _repo = repo; + + Task.Run(() => + { + var based = commit ?? string.Empty; + var commits = new Commands.QueryCommits(_repo.FullPath, $"--date-order -n 10000 {based} -- \"{file}\"", false).Result(); + Dispatcher.UIThread.Invoke(() => + { + IsLoading = false; + Commits = commits; + if (Commits.Count > 0) + SelectedCommits.Add(Commits[0]); + }); + }); + + SelectedCommits.CollectionChanged += (_, _) => + { + if (_viewContent is FileHistoriesSingleRevision singleRevision) + _prevIsDiffMode = singleRevision.IsDiffMode; + + switch (SelectedCommits.Count) + { + case 1: + ViewContent = new FileHistoriesSingleRevision(_repo, file, SelectedCommits[0], _prevIsDiffMode); + break; + case 2: + ViewContent = new FileHistoriesCompareRevisions(_repo, file, SelectedCommits[0], SelectedCommits[1]); + break; + default: + ViewContent = SelectedCommits.Count; + break; + } + }; + } + + public void NavigateToCommit(Models.Commit commit) + { + _repo.NavigateToCommit(commit.SHA); + } + + public string GetCommitFullMessage(Models.Commit commit) + { + var sha = commit.SHA; + if (_fullCommitMessages.TryGetValue(sha, out var msg)) + return msg; + + msg = new Commands.QueryCommitFullMessage(_repo.FullPath, sha).Result(); + _fullCommitMessages[sha] = msg; + return msg; + } + + private readonly Repository _repo = null; + private bool _isLoading = true; + private bool _prevIsDiffMode = true; + private List _commits = null; + private Dictionary _fullCommitMessages = new(); + private object _viewContent = null; + } +} diff --git a/src/ViewModels/FilterModeInGraph.cs b/src/ViewModels/FilterModeInGraph.cs new file mode 100644 index 00000000..9930b816 --- /dev/null +++ b/src/ViewModels/FilterModeInGraph.cs @@ -0,0 +1,62 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class FilterModeInGraph : ObservableObject + { + public bool IsFiltered + { + get => _mode == Models.FilterMode.Included; + set => SetFilterMode(value ? Models.FilterMode.Included : Models.FilterMode.None); + } + + public bool IsExcluded + { + get => _mode == Models.FilterMode.Excluded; + set => SetFilterMode(value ? Models.FilterMode.Excluded : Models.FilterMode.None); + } + + public FilterModeInGraph(Repository repo, object target) + { + _repo = repo; + _target = target; + + if (_target is Models.Branch b) + _mode = GetFilterMode(b.FullName); + else if (_target is Models.Tag t) + _mode = GetFilterMode(t.Name); + } + + private Models.FilterMode GetFilterMode(string pattern) + { + foreach (var filter in _repo.Settings.HistoriesFilters) + { + if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + return filter.Mode; + } + + return Models.FilterMode.None; + } + + private void SetFilterMode(Models.FilterMode mode) + { + if (_mode != mode) + { + _mode = mode; + + if (_target is Models.Branch branch) + _repo.SetBranchFilterMode(branch, _mode, false, true); + else if (_target is Models.Tag tag) + _repo.SetTagFilterMode(tag, _mode); + + OnPropertyChanged(nameof(IsFiltered)); + OnPropertyChanged(nameof(IsExcluded)); + } + } + + private Repository _repo = null; + private object _target = null; + private Models.FilterMode _mode = Models.FilterMode.None; + } +} diff --git a/src/ViewModels/GitFlowFinish.cs b/src/ViewModels/GitFlowFinish.cs new file mode 100644 index 00000000..bef4c2d9 --- /dev/null +++ b/src/ViewModels/GitFlowFinish.cs @@ -0,0 +1,65 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class GitFlowFinish : Popup + { + public Models.Branch Branch + { + get; + } + + public Models.GitFlowBranchType Type + { + get; + private set; + } + + public bool Squash + { + get; + set; + } = false; + + public bool AutoPush + { + get; + set; + } = false; + + public bool KeepBranch + { + get; + set; + } = false; + + public GitFlowFinish(Repository repo, Models.Branch branch, Models.GitFlowBranchType type) + { + _repo = repo; + Branch = branch; + Type = type; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Git Flow - Finish {Branch.Name} ..."; + + var log = _repo.CreateLog("GitFlow - Finish"); + Use(log); + + var prefix = _repo.GitFlow.GetPrefix(Type); + var name = Branch.Name.StartsWith(prefix) ? Branch.Name.Substring(prefix.Length) : Branch.Name; + + return Task.Run(() => + { + var succ = Commands.GitFlow.Finish(_repo.FullPath, Type, name, Squash, AutoPush, KeepBranch, log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo; + } +} diff --git a/src/ViewModels/GitFlowStart.cs b/src/ViewModels/GitFlowStart.cs new file mode 100644 index 00000000..3ecba883 --- /dev/null +++ b/src/ViewModels/GitFlowStart.cs @@ -0,0 +1,72 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class GitFlowStart : Popup + { + public Models.GitFlowBranchType Type + { + get; + private set; + } + + public string Prefix + { + get; + private set; + } + + [Required(ErrorMessage = "Name is required!!!")] + [RegularExpression(@"^[\w\-/\.#]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(GitFlowStart), nameof(ValidateBranchName))] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public GitFlowStart(Repository repo, Models.GitFlowBranchType type) + { + _repo = repo; + + Type = type; + Prefix = _repo.GitFlow.GetPrefix(type); + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is GitFlowStart starter) + { + var check = $"{starter.Prefix}{name}"; + foreach (var b in starter._repo.Branches) + { + if (b.FriendlyName == check) + return new ValidationResult("A branch with same name already exists!"); + } + } + + return ValidationResult.Success; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Git Flow - Start {Prefix}{_name} ..."; + + var log = _repo.CreateLog("GitFlow - Start"); + Use(log); + + return Task.Run(() => + { + var succ = Commands.GitFlow.Start(_repo.FullPath, Type, _name, log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo; + private string _name = null; + } +} diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs new file mode 100644 index 00000000..db368d80 --- /dev/null +++ b/src/ViewModels/Histories.cs @@ -0,0 +1,1188 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Platform.Storage; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Histories : ObservableObject, IDisposable + { + public Repository Repo + { + get => _repo; + } + + public bool IsLoading + { + get => _isLoading; + set => SetProperty(ref _isLoading, value); + } + + public List Commits + { + get => _commits; + set + { + var lastSelected = AutoSelectedCommit; + if (SetProperty(ref _commits, value)) + { + if (value.Count > 0 && lastSelected != null) + AutoSelectedCommit = value.Find(x => x.SHA == lastSelected.SHA); + } + } + } + + public Models.CommitGraph Graph + { + get => _graph; + set => SetProperty(ref _graph, value); + } + + public Models.Commit AutoSelectedCommit + { + get => _autoSelectedCommit; + set => SetProperty(ref _autoSelectedCommit, value); + } + + public long NavigationId + { + get => _navigationId; + private set => SetProperty(ref _navigationId, value); + } + + public IDisposable DetailContext + { + get => _detailContext; + set => SetProperty(ref _detailContext, value); + } + + public Models.Bisect Bisect + { + get => _bisect; + private set => SetProperty(ref _bisect, value); + } + + public GridLength LeftArea + { + get => _leftArea; + set => SetProperty(ref _leftArea, value); + } + + public GridLength RightArea + { + get => _rightArea; + set => SetProperty(ref _rightArea, value); + } + + public GridLength TopArea + { + get => _topArea; + set => SetProperty(ref _topArea, value); + } + + public GridLength BottomArea + { + get => _bottomArea; + set => SetProperty(ref _bottomArea, value); + } + + public Histories(Repository repo) + { + _repo = repo; + } + + public void Dispose() + { + Commits = []; + _repo = null; + _graph = null; + _autoSelectedCommit = null; + _detailContext?.Dispose(); + _detailContext = null; + } + + public Models.BisectState UpdateBisectInfo() + { + var test = Path.Combine(_repo.GitDir, "BISECT_START"); + if (!File.Exists(test)) + { + Bisect = null; + return Models.BisectState.None; + } + + var info = new Models.Bisect(); + var dir = Path.Combine(_repo.GitDir, "refs", "bisect"); + if (Directory.Exists(dir)) + { + var files = new DirectoryInfo(dir).GetFiles(); + foreach (var file in files) + { + if (file.Name.StartsWith("bad")) + info.Bads.Add(File.ReadAllText(file.FullName).Trim()); + else if (file.Name.StartsWith("good")) + info.Goods.Add(File.ReadAllText(file.FullName).Trim()); + } + } + + Bisect = info; + + if (info.Bads.Count == 0 || info.Goods.Count == 0) + return Models.BisectState.WaitingForRange; + else + return Models.BisectState.Detecting; + } + + public void NavigateTo(string commitSHA) + { + var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); + if (commit == null) + { + AutoSelectedCommit = null; + commit = new Commands.QuerySingleCommit(_repo.FullPath, commitSHA).Result(); + } + else + { + AutoSelectedCommit = commit; + NavigationId = _navigationId + 1; + } + + if (commit != null) + { + if (_detailContext is CommitDetail detail) + { + detail.Commit = commit; + } + else + { + var commitDetail = new CommitDetail(_repo); + commitDetail.Commit = commit; + DetailContext = commitDetail; + } + } + else + { + DetailContext = null; + } + } + + public void Select(IList commits) + { + if (commits.Count == 0) + { + _repo.SelectedSearchedCommit = null; + DetailContext = null; + } + else if (commits.Count == 1) + { + var commit = (commits[0] as Models.Commit)!; + if (_repo.SelectedSearchedCommit == null || _repo.SelectedSearchedCommit.SHA != commit.SHA) + _repo.SelectedSearchedCommit = _repo.SearchedCommits.Find(x => x.SHA == commit.SHA); + + AutoSelectedCommit = commit; + NavigationId = _navigationId + 1; + + if (_detailContext is CommitDetail detail) + { + detail.Commit = commit; + } + else + { + var commitDetail = new CommitDetail(_repo); + commitDetail.Commit = commit; + DetailContext = commitDetail; + } + } + else if (commits.Count == 2) + { + _repo.SelectedSearchedCommit = null; + + var end = commits[0] as Models.Commit; + var start = commits[1] as Models.Commit; + DetailContext = new RevisionCompare(_repo.FullPath, start, end); + } + else + { + _repo.SelectedSearchedCommit = null; + DetailContext = new Models.Count(commits.Count); + } + } + + public void DoubleTapped(Models.Commit commit) + { + if (commit == null || commit.IsCurrentHead) + return; + + var firstRemoteBranch = null as Models.Branch; + foreach (var d in commit.Decorators) + { + if (d.Type == Models.DecoratorType.LocalBranchHead) + { + var b = _repo.Branches.Find(x => x.FriendlyName == d.Name); + if (b != null) + { + _repo.CheckoutBranch(b); + return; + } + } + else if (d.Type == Models.DecoratorType.RemoteBranchHead) + { + var remoteBranch = _repo.Branches.Find(x => x.FriendlyName == d.Name); + if (remoteBranch != null) + { + var localBranch = _repo.Branches.Find(x => x.IsLocal && x.Upstream == remoteBranch.FullName); + if (localBranch is { TrackStatus.Ahead.Count: 0 }) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutAndFastForward(_repo, localBranch, remoteBranch)); + return; + } + } + + if (firstRemoteBranch == null) + firstRemoteBranch = remoteBranch; + } + } + + if (_repo.CanCreatePopup()) + { + if (firstRemoteBranch != null) + _repo.ShowPopup(new CreateBranch(_repo, firstRemoteBranch)); + else if (!_repo.IsBare) + _repo.ShowPopup(new CheckoutCommit(_repo, commit)); + } + } + + public ContextMenu MakeContextMenu(ListBox list) + { + var current = _repo.CurrentBranch; + if (current == null || list.SelectedItems == null) + return null; + + if (list.SelectedItems.Count > 1) + { + var selected = new List(); + var canCherryPick = true; + var canMerge = true; + + foreach (var item in list.SelectedItems) + { + if (item is Models.Commit c) + { + selected.Add(c); + + if (c.IsMerged) + { + canMerge = false; + canCherryPick = false; + } + else if (c.Parents.Count > 1) + { + canCherryPick = false; + } + } + } + + // Sort selected commits in order. + selected.Sort((l, r) => _commits.IndexOf(r) - _commits.IndexOf(l)); + + var multipleMenu = new ContextMenu(); + + if (!_repo.IsBare) + { + if (canCherryPick) + { + var cherryPickMultiple = new MenuItem(); + cherryPickMultiple.Header = App.Text("CommitCM.CherryPickMultiple"); + cherryPickMultiple.Icon = App.CreateMenuIcon("Icons.CherryPick"); + cherryPickMultiple.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CherryPick(_repo, selected)); + e.Handled = true; + }; + multipleMenu.Items.Add(cherryPickMultiple); + } + + if (canMerge) + { + var mergeMultiple = new MenuItem(); + mergeMultiple.Header = App.Text("CommitCM.MergeMultiple"); + mergeMultiple.Icon = App.CreateMenuIcon("Icons.Merge"); + mergeMultiple.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new MergeMultiple(_repo, selected)); + e.Handled = true; + }; + multipleMenu.Items.Add(mergeMultiple); + } + + if (canCherryPick || canMerge) + multipleMenu.Items.Add(new MenuItem() { Header = "-" }); + } + + var saveToPatchMultiple = new MenuItem(); + saveToPatchMultiple.Icon = App.CreateMenuIcon("Icons.Diff"); + saveToPatchMultiple.Header = App.Text("CommitCM.SaveAsPatch"); + saveToPatchMultiple.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + var log = null as CommandLog; + try + { + var picker = await storageProvider.OpenFolderPickerAsync(options); + if (picker.Count == 1) + { + log = _repo.CreateLog("Save as Patch"); + + var folder = picker[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString(); + var succ = false; + for (var i = 0; i < selected.Count; i++) + { + var saveTo = GetPatchFileName(folderPath, selected[i], i); + succ = await Task.Run(() => new Commands.FormatPatch(_repo.FullPath, selected[i].SHA, saveTo).Use(log).Exec()); + if (!succ) + break; + } + + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + } + catch (Exception exception) + { + App.RaiseException(_repo.FullPath, $"Failed to save as patch: {exception.Message}"); + } + + log?.Complete(); + e.Handled = true; + }; + multipleMenu.Items.Add(saveToPatchMultiple); + multipleMenu.Items.Add(new MenuItem() { Header = "-" }); + + var copyMultipleSHAs = new MenuItem(); + copyMultipleSHAs.Header = App.Text("CommitCM.CopySHA"); + copyMultipleSHAs.Icon = App.CreateMenuIcon("Icons.Fingerprint"); + copyMultipleSHAs.Click += (_, e) => + { + var builder = new StringBuilder(); + foreach (var c in selected) + builder.AppendLine(c.SHA); + + App.CopyText(builder.ToString()); + e.Handled = true; + }; + + var copyMultipleInfo = new MenuItem(); + copyMultipleInfo.Header = App.Text("CommitCM.CopyInfo"); + copyMultipleInfo.Icon = App.CreateMenuIcon("Icons.Info"); + copyMultipleInfo.Click += (_, e) => + { + var builder = new StringBuilder(); + foreach (var c in selected) + builder.AppendLine($"{c.SHA.AsSpan(0, 10)} - {c.Subject}"); + + App.CopyText(builder.ToString()); + e.Handled = true; + }; + + var copyMultiple = new MenuItem(); + copyMultiple.Header = App.Text("Copy"); + copyMultiple.Icon = App.CreateMenuIcon("Icons.Copy"); + copyMultiple.Items.Add(copyMultipleSHAs); + copyMultiple.Items.Add(copyMultipleInfo); + multipleMenu.Items.Add(copyMultiple); + + return multipleMenu; + } + + var commit = (list.SelectedItem as Models.Commit)!; + var menu = new ContextMenu(); + var tags = new List(); + + if (commit.HasDecorators) + { + foreach (var d in commit.Decorators) + { + if (d.Type == Models.DecoratorType.CurrentBranchHead) + { + FillCurrentBranchMenu(menu, current); + } + else if (d.Type == Models.DecoratorType.LocalBranchHead) + { + var b = _repo.Branches.Find(x => x.IsLocal && d.Name == x.Name); + FillOtherLocalBranchMenu(menu, b, current, commit.IsMerged); + } + else if (d.Type == Models.DecoratorType.RemoteBranchHead) + { + var b = _repo.Branches.Find(x => !x.IsLocal && d.Name == x.FriendlyName); + FillRemoteBranchMenu(menu, b, current, commit.IsMerged); + } + else if (d.Type == Models.DecoratorType.Tag) + { + var t = _repo.Tags.Find(x => x.Name == d.Name); + if (t != null) + tags.Add(t); + } + } + + if (menu.Items.Count > 0) + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (tags.Count > 0) + { + foreach (var tag in tags) + FillTagMenu(menu, tag, current, commit.IsMerged); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (!_repo.IsBare) + { + if (current.Head != commit.SHA) + { + var reset = new MenuItem(); + reset.Header = App.Text("CommitCM.Reset", current.Name); + reset.Icon = App.CreateMenuIcon("Icons.Reset"); + reset.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Reset(_repo, current, commit)); + e.Handled = true; + }; + menu.Items.Add(reset); + + if (commit.IsMerged) + { + var squash = new MenuItem(); + squash.Header = App.Text("CommitCM.SquashCommitsSinceThis"); + squash.Icon = App.CreateMenuIcon("Icons.SquashIntoParent"); + squash.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Squash(_repo, commit, commit.SHA)); + + e.Handled = true; + }; + menu.Items.Add(squash); + } + } + else + { + var reword = new MenuItem(); + reword.Header = App.Text("CommitCM.Reword"); + reword.Icon = App.CreateMenuIcon("Icons.Edit"); + reword.Click += (_, e) => + { + if (_repo.LocalChangesCount > 0) + { + App.RaiseException(_repo.FullPath, "You have local changes. Please run stash or discard first."); + return; + } + + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Reword(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(reword); + + var squash = new MenuItem(); + squash.Header = App.Text("CommitCM.Squash"); + squash.Icon = App.CreateMenuIcon("Icons.SquashIntoParent"); + squash.IsEnabled = commit.Parents.Count == 1; + squash.Click += (_, e) => + { + if (commit.Parents.Count == 1) + { + var parent = _commits.Find(x => x.SHA == commit.Parents[0]); + if (parent != null && _repo.CanCreatePopup()) + _repo.ShowPopup(new Squash(_repo, parent, commit.SHA)); + } + + e.Handled = true; + }; + menu.Items.Add(squash); + } + + if (!commit.IsMerged) + { + var rebase = new MenuItem(); + rebase.Header = App.Text("CommitCM.Rebase", current.Name); + rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); + rebase.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Rebase(_repo, current, commit)); + e.Handled = true; + }; + menu.Items.Add(rebase); + + if (!commit.HasDecorators) + { + var merge = new MenuItem(); + merge.Header = App.Text("CommitCM.Merge", current.Name); + merge.Icon = App.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Merge(_repo, commit, current.Name)); + + e.Handled = true; + }; + menu.Items.Add(merge); + } + + var cherryPick = new MenuItem(); + cherryPick.Header = App.Text("CommitCM.CherryPick"); + cherryPick.Icon = App.CreateMenuIcon("Icons.CherryPick"); + cherryPick.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + { + if (commit.Parents.Count <= 1) + { + _repo.ShowPopup(new CherryPick(_repo, [commit])); + } + else + { + var parents = new List(); + foreach (var sha in commit.Parents) + { + var parent = _commits.Find(x => x.SHA == sha); + if (parent == null) + parent = new Commands.QuerySingleCommit(_repo.FullPath, sha).Result(); + + if (parent != null) + parents.Add(parent); + } + + _repo.ShowPopup(new CherryPick(_repo, commit, parents)); + } + } + + e.Handled = true; + }; + menu.Items.Add(cherryPick); + } + else + { + var revert = new MenuItem(); + revert.Header = App.Text("CommitCM.Revert"); + revert.Icon = App.CreateMenuIcon("Icons.Undo"); + revert.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Revert(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(revert); + } + + if (current.Head != commit.SHA) + { + var checkoutCommit = new MenuItem(); + checkoutCommit.Header = App.Text("CommitCM.Checkout"); + checkoutCommit.Icon = App.CreateMenuIcon("Icons.Detached"); + checkoutCommit.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutCommit(_repo, commit)); + e.Handled = true; + }; + + var interactiveRebase = new MenuItem(); + interactiveRebase.Header = App.Text("CommitCM.InteractiveRebase", current.Name); + interactiveRebase.Icon = App.CreateMenuIcon("Icons.InteractiveRebase"); + interactiveRebase.Click += (_, e) => + { + if (_repo.LocalChangesCount > 0) + { + App.RaiseException(_repo.FullPath, "You have local changes. Please run stash or discard first."); + return; + } + + App.ShowWindow(new InteractiveRebase(_repo, current, commit), true); + e.Handled = true; + }; + + menu.Items.Add(checkoutCommit); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(interactiveRebase); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (current.Head != commit.SHA) + { + var compareWithHead = new MenuItem(); + compareWithHead.Header = App.Text("CommitCM.CompareWithHead"); + compareWithHead.Icon = App.CreateMenuIcon("Icons.Compare"); + compareWithHead.Click += (_, e) => + { + var head = _commits.Find(x => x.SHA == current.Head); + if (head == null) + { + _repo.SelectedSearchedCommit = null; + head = new Commands.QuerySingleCommit(_repo.FullPath, current.Head).Result(); + if (head != null) + DetailContext = new RevisionCompare(_repo.FullPath, commit, head); + } + else + { + list.SelectedItems.Add(head); + } + + e.Handled = true; + }; + menu.Items.Add(compareWithHead); + + if (_repo.LocalChangesCount > 0) + { + var compareWithWorktree = new MenuItem(); + compareWithWorktree.Header = App.Text("CommitCM.CompareWithWorktree"); + compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); + compareWithWorktree.Click += (_, e) => + { + DetailContext = new RevisionCompare(_repo.FullPath, commit, null); + e.Handled = true; + }; + menu.Items.Add(compareWithWorktree); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var createBranch = new MenuItem(); + createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CreateBranch(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(createBranch); + + var createTag = new MenuItem(); + createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CreateTag(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(createTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var saveToPatch = new MenuItem(); + saveToPatch.Icon = App.CreateMenuIcon("Icons.Diff"); + saveToPatch.Header = App.Text("CommitCM.SaveAsPatch"); + saveToPatch.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + var log = null as CommandLog; + try + { + var selected = await storageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + log = _repo.CreateLog("Save as Patch"); + + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString(); + var saveTo = GetPatchFileName(folderPath, commit); + var succ = await Task.Run(() => new Commands.FormatPatch(_repo.FullPath, commit.SHA, saveTo).Use(log).Exec()); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + } + catch (Exception exception) + { + App.RaiseException(_repo.FullPath, $"Failed to save as patch: {exception.Message}"); + } + + log?.Complete(); + e.Handled = true; + }; + menu.Items.Add(saveToPatch); + + var archive = new MenuItem(); + archive.Icon = App.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Archive(_repo, commit)); + e.Handled = true; + }; + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var actions = _repo.GetCustomActions(Models.CustomActionScope.Commit); + if (actions.Count > 0) + { + var custom = new MenuItem(); + custom.Header = App.Text("CommitCM.CustomAction"); + custom.Icon = App.CreateMenuIcon("Icons.Action"); + + foreach (var action in actions) + { + var dup = action; + var item = new MenuItem(); + item.Icon = App.CreateMenuIcon("Icons.Action"); + item.Header = dup.Name; + item.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowAndStartPopup(new ExecuteCustomAction(_repo, dup, commit)); + + e.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var copySHA = new MenuItem(); + copySHA.Header = App.Text("CommitCM.CopySHA"); + copySHA.Icon = App.CreateMenuIcon("Icons.Fingerprint"); + copySHA.Click += (_, e) => + { + App.CopyText(commit.SHA); + e.Handled = true; + }; + + var copySubject = new MenuItem(); + copySubject.Header = App.Text("CommitCM.CopySubject"); + copySubject.Icon = App.CreateMenuIcon("Icons.Subject"); + copySubject.Click += (_, e) => + { + App.CopyText(commit.Subject); + e.Handled = true; + }; + + var copyInfo = new MenuItem(); + copyInfo.Header = App.Text("CommitCM.CopyInfo"); + copyInfo.Icon = App.CreateMenuIcon("Icons.Info"); + copyInfo.Click += (_, e) => + { + App.CopyText($"{commit.SHA.AsSpan(0, 10)} - {commit.Subject}"); + e.Handled = true; + }; + + var copyAuthor = new MenuItem(); + copyAuthor.Header = App.Text("CommitCM.CopyAuthor"); + copyAuthor.Icon = App.CreateMenuIcon("Icons.User"); + copyAuthor.Click += (_, e) => + { + App.CopyText(commit.Author.ToString()); + e.Handled = true; + }; + + var copyCommitter = new MenuItem(); + copyCommitter.Header = App.Text("CommitCM.CopyCommitter"); + copyCommitter.Icon = App.CreateMenuIcon("Icons.User"); + copyCommitter.Click += (_, e) => + { + App.CopyText(commit.Committer.ToString()); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Items.Add(copySHA); + copy.Items.Add(copySubject); + copy.Items.Add(copyInfo); + copy.Items.Add(copyAuthor); + copy.Items.Add(copyCommitter); + menu.Items.Add(copy); + + return menu; + } + + private void FillCurrentBranchMenu(ContextMenu menu, Models.Branch current) + { + var submenu = new MenuItem(); + submenu.Icon = App.CreateMenuIcon("Icons.Branch"); + submenu.Header = current.Name; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new FilterModeInGraph(_repo, current); + submenu.Items.Add(visibility); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + if (!string.IsNullOrEmpty(current.Upstream)) + { + var upstream = current.Upstream.Substring(13); + + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", upstream); + fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = current.TrackStatus.Ahead.Count == 0; + fastForward.Click += (_, e) => + { + var b = _repo.Branches.Find(x => x.FriendlyName == upstream); + if (b == null) + return; + + if (_repo.CanCreatePopup()) + _repo.ShowAndStartPopup(new Merge(_repo, b, current.Name, true)); + + e.Handled = true; + }; + submenu.Items.Add(fastForward); + + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.Pull", upstream); + pull.Icon = App.CreateMenuIcon("Icons.Pull"); + pull.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Pull(_repo, null)); + e.Handled = true; + }; + submenu.Items.Add(pull); + } + + var push = new MenuItem(); + push.Header = App.Text("BranchCM.Push", current.Name); + push.Icon = App.CreateMenuIcon("Icons.Push"); + push.IsEnabled = _repo.Remotes.Count > 0; + push.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Push(_repo, current)); + e.Handled = true; + }; + submenu.Items.Add(push); + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", current.Name); + rename.Icon = App.CreateMenuIcon("Icons.Rename"); + rename.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new RenameBranch(_repo, current)); + e.Handled = true; + }; + submenu.Items.Add(rename); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + if (!_repo.IsBare) + { + var type = _repo.GetGitFlowType(current); + if (type != Models.GitFlowBranchType.None) + { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", current.Name); + finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); + finish.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new GitFlowFinish(_repo, current, type)); + e.Handled = true; + }; + submenu.Items.Add(finish); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + } + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(current.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); + } + + private void FillOtherLocalBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) + { + var submenu = new MenuItem(); + submenu.Icon = App.CreateMenuIcon("Icons.Branch"); + submenu.Header = branch.Name; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new FilterModeInGraph(_repo, branch); + submenu.Items.Add(visibility); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + if (!_repo.IsBare) + { + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", branch.Name); + checkout.Icon = App.CreateMenuIcon("Icons.Check"); + checkout.Click += (_, e) => + { + _repo.CheckoutBranch(branch); + e.Handled = true; + }; + submenu.Items.Add(checkout); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", branch.Name, current.Name); + merge.Icon = App.CreateMenuIcon("Icons.Merge"); + merge.IsEnabled = !merged; + merge.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Merge(_repo, branch, current.Name, false)); + e.Handled = true; + }; + submenu.Items.Add(merge); + } + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", branch.Name); + rename.Icon = App.CreateMenuIcon("Icons.Rename"); + rename.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new RenameBranch(_repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(rename); + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", branch.Name); + delete.Icon = App.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new DeleteBranch(_repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(delete); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + if (!_repo.IsBare) + { + var type = _repo.GetGitFlowType(branch); + if (type != Models.GitFlowBranchType.None) + { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", branch.Name); + finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); + finish.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new GitFlowFinish(_repo, branch, type)); + e.Handled = true; + }; + submenu.Items.Add(finish); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + } + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(branch.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); + } + + private void FillRemoteBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) + { + var name = branch.FriendlyName; + + var submenu = new MenuItem(); + submenu.Icon = App.CreateMenuIcon("Icons.Branch"); + submenu.Header = name; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new FilterModeInGraph(_repo, branch); + submenu.Items.Add(visibility); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", name); + checkout.Icon = App.CreateMenuIcon("Icons.Check"); + checkout.Click += (_, e) => + { + _repo.CheckoutBranch(branch); + e.Handled = true; + }; + submenu.Items.Add(checkout); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", name, current.Name); + merge.Icon = App.CreateMenuIcon("Icons.Merge"); + merge.IsEnabled = !merged; + merge.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Merge(_repo, branch, current.Name, false)); + e.Handled = true; + }; + + submenu.Items.Add(merge); + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", name); + delete.Icon = App.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new DeleteBranch(_repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(delete); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); + } + + private void FillTagMenu(ContextMenu menu, Models.Tag tag, Models.Branch current, bool merged) + { + var submenu = new MenuItem(); + submenu.Header = tag.Name; + submenu.Icon = App.CreateMenuIcon("Icons.Tag"); + submenu.MinWidth = 200; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new FilterModeInGraph(_repo, tag); + submenu.Items.Add(visibility); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var push = new MenuItem(); + push.Header = App.Text("TagCM.Push", tag.Name); + push.Icon = App.CreateMenuIcon("Icons.Push"); + push.IsEnabled = _repo.Remotes.Count > 0; + push.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new PushTag(_repo, tag)); + e.Handled = true; + }; + submenu.Items.Add(push); + + if (!_repo.IsBare && !merged) + { + var merge = new MenuItem(); + merge.Header = App.Text("TagCM.Merge", tag.Name, current.Name); + merge.Icon = App.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Merge(_repo, tag, current.Name)); + e.Handled = true; + }; + submenu.Items.Add(merge); + } + + var delete = new MenuItem(); + delete.Header = App.Text("TagCM.Delete", tag.Name); + delete.Icon = App.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new DeleteTag(_repo, tag)); + e.Handled = true; + }; + submenu.Items.Add(delete); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var copy = new MenuItem(); + copy.Header = App.Text("TagCM.Copy"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(tag.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); + } + + private string GetPatchFileName(string dir, Models.Commit commit, int index = 0) + { + var ignore_chars = new HashSet { '/', '\\', ':', ',', '*', '?', '\"', '<', '>', '|', '`', '$', '^', '%', '[', ']', '+', '-' }; + var builder = new StringBuilder(); + builder.Append(index.ToString("D4")); + builder.Append('-'); + + var chars = commit.Subject.ToCharArray(); + var len = 0; + foreach (var c in chars) + { + if (!ignore_chars.Contains(c)) + { + if (c == ' ' || c == '\t') + builder.Append('-'); + else + builder.Append(c); + + len++; + + if (len >= 48) + break; + } + } + builder.Append(".patch"); + + return Path.Combine(dir, builder.ToString()); + } + + private Repository _repo = null; + private bool _isLoading = true; + private List _commits = new List(); + private Models.CommitGraph _graph = null; + private Models.Commit _autoSelectedCommit = null; + private long _navigationId = 0; + private IDisposable _detailContext = null; + + private Models.Bisect _bisect = null; + + private GridLength _leftArea = new GridLength(1, GridUnitType.Star); + private GridLength _rightArea = new GridLength(1, GridUnitType.Star); + private GridLength _topArea = new GridLength(1, GridUnitType.Star); + private GridLength _bottomArea = new GridLength(1, GridUnitType.Star); + } +} diff --git a/src/ViewModels/ImageSource.cs b/src/ViewModels/ImageSource.cs new file mode 100644 index 00000000..f94b0c95 --- /dev/null +++ b/src/ViewModels/ImageSource.cs @@ -0,0 +1,180 @@ +using System; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; + +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +using BitMiracle.LibTiff.Classic; +using Pfim; + +namespace SourceGit.ViewModels +{ + public class ImageSource + { + public Bitmap Bitmap { get; } + public long Size { get; } + + public ImageSource(Bitmap bitmap, long size) + { + Bitmap = bitmap; + Size = size; + } + + public static Models.ImageDecoder GetDecoder(string file) + { + var ext = (Path.GetExtension(file) ?? ".invalid_img").ToLower(CultureInfo.CurrentCulture); + + switch (ext) + { + case ".ico": + case ".bmp": + case ".gif": + case ".jpg": + case ".jpeg": + case ".png": + case ".webp": + return Models.ImageDecoder.Builtin; + case ".tga": + case ".dds": + return Models.ImageDecoder.Pfim; + case ".tif": + case ".tiff": + return Models.ImageDecoder.Tiff; + default: + return Models.ImageDecoder.None; + } + } + + public static ImageSource FromFile(string fullpath, Models.ImageDecoder decoder) + { + using (var stream = File.OpenRead(fullpath)) + return LoadFromStream(stream, decoder); + } + + public static ImageSource FromRevision(string repo, string revision, string file, Models.ImageDecoder decoder) + { + var stream = Commands.QueryFileContent.Run(repo, revision, file); + return LoadFromStream(stream, decoder); + } + + public static ImageSource FromLFSObject(string repo, Models.LFSObject lfs, Models.ImageDecoder decoder) + { + if (string.IsNullOrEmpty(lfs.Oid) || lfs.Size == 0) + return new ImageSource(null, 0); + + var stream = Commands.QueryFileContent.FromLFS(repo, lfs.Oid, lfs.Size); + return LoadFromStream(stream, decoder); + } + + private static ImageSource LoadFromStream(Stream stream, Models.ImageDecoder decoder) + { + var size = stream.Length; + if (size > 0) + { + try + { + switch (decoder) + { + case Models.ImageDecoder.Builtin: + return DecodeWithAvalonia(stream, size); + case Models.ImageDecoder.Pfim: + return DecodeWithPfim(stream, size); + case Models.ImageDecoder.Tiff: + return DecodeWithTiff(stream, size); + } + } + catch (Exception e) + { + Console.Out.WriteLine(e.Message); + } + } + + return new ImageSource(null, 0); + } + + private static ImageSource DecodeWithAvalonia(Stream stream, long size) + { + var bitmap = new Bitmap(stream); + return new ImageSource(bitmap, size); + } + + private static ImageSource DecodeWithPfim(Stream stream, long size) + { + using (var pfiImage = Pfimage.FromStream(stream)) + { + var data = pfiImage.Data; + var stride = pfiImage.Stride; + + var pixelFormat = PixelFormats.Bgra8888; + var alphaFormat = AlphaFormat.Opaque; + switch (pfiImage.Format) + { + case ImageFormat.Rgb8: + pixelFormat = PixelFormats.Gray8; + break; + case ImageFormat.R5g5b5: + case ImageFormat.R5g5b5a1: + pixelFormat = PixelFormats.Bgr555; + break; + case ImageFormat.R5g6b5: + pixelFormat = PixelFormats.Bgr565; + break; + case ImageFormat.Rgb24: + pixelFormat = PixelFormats.Bgr24; + break; + case ImageFormat.Rgba16: + var pixels2 = pfiImage.DataLen / 2; + data = new byte[pixels2 * 4]; + stride = pfiImage.Width * 4; + for (var i = 0; i < pixels2; i++) + { + var src = BitConverter.ToUInt16(pfiImage.Data, i * 2); + data[i * 4 + 0] = (byte)Math.Round((src & 0x0F) / 15F * 255); // B + data[i * 4 + 1] = (byte)Math.Round(((src >> 4) & 0x0F) / 15F * 255); // G + data[i * 4 + 2] = (byte)Math.Round(((src >> 8) & 0x0F) / 15F * 255); // R + data[i * 4 + 3] = (byte)Math.Round(((src >> 12) & 0x0F) / 15F * 255); // A + } + + alphaFormat = AlphaFormat.Premul; + break; + case ImageFormat.Rgba32: + alphaFormat = AlphaFormat.Premul; + break; + default: + return new ImageSource(null, 0); + } + + var ptr = Marshal.UnsafeAddrOfPinnedArrayElement(data, 0); + var pixelSize = new PixelSize(pfiImage.Width, pfiImage.Height); + var dpi = new Vector(96, 96); + var bitmap = new Bitmap(pixelFormat, alphaFormat, ptr, pixelSize, dpi, stride); + return new ImageSource(bitmap, size); + } + } + + private static ImageSource DecodeWithTiff(Stream stream, long size) + { + using (var tiff = Tiff.ClientOpen($"{Guid.NewGuid()}.tif", "r", stream, new TiffStream())) + { + if (tiff == null) + return new ImageSource(null, 0); + + var width = tiff.GetField(TiffTag.IMAGEWIDTH)[0].ToInt(); + var height = tiff.GetField(TiffTag.IMAGELENGTH)[0].ToInt(); + var pixels = new int[width * height]; + + // Currently only supports image when its `BITSPERSAMPLE` is one in [1,2,4,8,16] + tiff.ReadRGBAImageOriented(width, height, pixels, Orientation.TOPLEFT); + + var ptr = Marshal.UnsafeAddrOfPinnedArrayElement(pixels, 0); + var pixelSize = new PixelSize(width, height); + var dpi = new Vector(96, 96); + var bitmap = new Bitmap(PixelFormats.Rgba8888, AlphaFormat.Premul, ptr, pixelSize, dpi, width * 4); + return new ImageSource(bitmap, size); + } + } + } +} diff --git a/src/ViewModels/InProgressContexts.cs b/src/ViewModels/InProgressContexts.cs new file mode 100644 index 00000000..a1a87a42 --- /dev/null +++ b/src/ViewModels/InProgressContexts.cs @@ -0,0 +1,187 @@ +using System.IO; + +namespace SourceGit.ViewModels +{ + public abstract class InProgressContext + { + protected InProgressContext(string repo, string cmd) + { + _repo = repo; + _cmd = cmd; + } + + public bool Abort() + { + return new Commands.Command() + { + WorkingDirectory = _repo, + Context = _repo, + Args = $"{_cmd} --abort", + }.Exec(); + } + + public virtual bool Skip() + { + return new Commands.Command() + { + WorkingDirectory = _repo, + Context = _repo, + Args = $"{_cmd} --skip", + }.Exec(); + } + + public virtual bool Continue() + { + return new Commands.Command() + { + WorkingDirectory = _repo, + Context = _repo, + Editor = Commands.Command.EditorType.None, + Args = $"{_cmd} --continue", + }.Exec(); + } + + protected string GetFriendlyNameOfCommit(Models.Commit commit) + { + var branchDecorator = commit.Decorators.Find(x => x.Type == Models.DecoratorType.LocalBranchHead || x.Type == Models.DecoratorType.RemoteBranchHead); + if (branchDecorator != null) + return branchDecorator.Name; + + var tagDecorator = commit.Decorators.Find(x => x.Type == Models.DecoratorType.Tag); + if (tagDecorator != null) + return tagDecorator.Name; + + return commit.SHA.Substring(0, 10); + } + + protected string _repo = string.Empty; + protected string _cmd = string.Empty; + } + + public class CherryPickInProgress : InProgressContext + { + public Models.Commit Head + { + get; + private set; + } + + public string HeadName + { + get => GetFriendlyNameOfCommit(Head); + } + + public CherryPickInProgress(Repository repo) : base(repo.FullPath, "cherry-pick") + { + var headSHA = File.ReadAllText(Path.Combine(repo.GitDir, "CHERRY_PICK_HEAD")).Trim(); + Head = new Commands.QuerySingleCommit(repo.FullPath, headSHA).Result() ?? new Models.Commit() { SHA = headSHA }; + } + } + + public class RebaseInProgress : InProgressContext + { + public string HeadName + { + get; + private set; + } + + public string BaseName + { + get => GetFriendlyNameOfCommit(Onto); + } + + public Models.Commit StoppedAt + { + get; + private set; + } + + public Models.Commit Onto + { + get; + private set; + } + + public RebaseInProgress(Repository repo) : base(repo.FullPath, "rebase") + { + HeadName = File.ReadAllText(Path.Combine(repo.GitDir, "rebase-merge", "head-name")).Trim(); + if (HeadName.StartsWith("refs/heads/")) + HeadName = HeadName.Substring(11); + else if (HeadName.StartsWith("refs/tags/")) + HeadName = HeadName.Substring(10); + + var stoppedSHAPath = Path.Combine(repo.GitDir, "rebase-merge", "stopped-sha"); + var stoppedSHA = string.Empty; + if (File.Exists(stoppedSHAPath)) + stoppedSHA = File.ReadAllText(stoppedSHAPath).Trim(); + else + stoppedSHA = new Commands.QueryRevisionByRefName(repo.FullPath, HeadName).Result(); + + if (!string.IsNullOrEmpty(stoppedSHA)) + StoppedAt = new Commands.QuerySingleCommit(repo.FullPath, stoppedSHA).Result() ?? new Models.Commit() { SHA = stoppedSHA }; + + var ontoSHA = File.ReadAllText(Path.Combine(repo.GitDir, "rebase-merge", "onto")).Trim(); + Onto = new Commands.QuerySingleCommit(repo.FullPath, ontoSHA).Result() ?? new Models.Commit() { SHA = ontoSHA }; + } + + public override bool Continue() + { + return new Commands.Command() + { + WorkingDirectory = _repo, + Context = _repo, + Editor = Commands.Command.EditorType.RebaseEditor, + Args = $"rebase --continue", + }.Exec(); + } + } + + public class RevertInProgress : InProgressContext + { + public Models.Commit Head + { + get; + private set; + } + + public RevertInProgress(Repository repo) : base(repo.FullPath, "revert") + { + var headSHA = File.ReadAllText(Path.Combine(repo.GitDir, "REVERT_HEAD")).Trim(); + Head = new Commands.QuerySingleCommit(repo.FullPath, headSHA).Result() ?? new Models.Commit() { SHA = headSHA }; + } + } + + public class MergeInProgress : InProgressContext + { + public string Current + { + get; + private set; + } + + public Models.Commit Source + { + get; + private set; + } + + public string SourceName + { + get => GetFriendlyNameOfCommit(Source); + } + + public MergeInProgress(Repository repo) : base(repo.FullPath, "merge") + { + Current = Commands.Branch.ShowCurrent(repo.FullPath); + + var sourceSHA = File.ReadAllText(Path.Combine(repo.GitDir, "MERGE_HEAD")).Trim(); + Source = new Commands.QuerySingleCommit(repo.FullPath, sourceSHA).Result() ?? new Models.Commit() { SHA = sourceSHA }; + } + + public override bool Skip() + { + return true; + } + } +} diff --git a/src/ViewModels/Init.cs b/src/ViewModels/Init.cs new file mode 100644 index 00000000..b47ba663 --- /dev/null +++ b/src/ViewModels/Init.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Init : Popup + { + public string TargetPath + { + get => _targetPath; + set => SetProperty(ref _targetPath, value); + } + + public string Reason + { + get; + private set; + } + + public Init(string pageId, string path, RepositoryNode parent, string reason) + { + _pageId = pageId; + _targetPath = path; + _parentNode = parent; + Reason = string.IsNullOrEmpty(reason) ? "Invalid repository detected!" : reason; + } + + public override Task Sure() + { + ProgressDescription = $"Initialize git repository at: '{_targetPath}'"; + + var log = new CommandLog("Initialize"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Init(_pageId, _targetPath).Use(log).Exec(); + log.Complete(); + + if (succ) + { + CallUIThread(() => + { + Preferences.Instance.FindOrAddNodeByRepositoryPath(_targetPath, _parentNode, true); + Welcome.Instance.Refresh(); + }); + } + + return succ; + }); + } + + private readonly string _pageId = null; + private string _targetPath = null; + private readonly RepositoryNode _parentNode = null; + } +} diff --git a/src/ViewModels/InitGitFlow.cs b/src/ViewModels/InitGitFlow.cs new file mode 100644 index 00000000..265a73d3 --- /dev/null +++ b/src/ViewModels/InitGitFlow.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public partial class InitGitFlow : Popup + { + [GeneratedRegex(@"^[\w\-/\.]+$")] + private static partial Regex TAG_PREFIX(); + + [Required(ErrorMessage = "Master branch name is required!!!")] + [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))] + public string Master + { + get => _master; + set => SetProperty(ref _master, value, true); + } + + [Required(ErrorMessage = "Develop branch name is required!!!")] + [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))] + public string Develop + { + get => _develop; + set => SetProperty(ref _develop, value, true); + } + + [Required(ErrorMessage = "Feature prefix is required!!!")] + [RegularExpression(@"^[\w\-\.]+/$", ErrorMessage = "Bad feature prefix format!")] + public string FeaturePrefix + { + get => _featurePrefix; + set => SetProperty(ref _featurePrefix, value, true); + } + + [Required(ErrorMessage = "Release prefix is required!!!")] + [RegularExpression(@"^[\w\-\.]+/$", ErrorMessage = "Bad release prefix format!")] + public string ReleasePrefix + { + get => _releasePrefix; + set => SetProperty(ref _releasePrefix, value, true); + } + + [Required(ErrorMessage = "Hotfix prefix is required!!!")] + [RegularExpression(@"^[\w\-\.]+/$", ErrorMessage = "Bad hotfix prefix format!")] + public string HotfixPrefix + { + get => _hotfixPrefix; + set => SetProperty(ref _hotfixPrefix, value, true); + } + + [CustomValidation(typeof(InitGitFlow), nameof(ValidateTagPrefix))] + public string TagPrefix + { + get => _tagPrefix; + set => SetProperty(ref _tagPrefix, value, true); + } + + public InitGitFlow(Repository repo) + { + _repo = repo; + + var localBranches = new List(); + foreach (var branch in repo.Branches) + { + if (branch.IsLocal) + localBranches.Add(branch.Name); + } + + if (localBranches.Contains("master")) + _master = "master"; + else if (localBranches.Contains("main")) + _master = "main"; + else if (localBranches.Count > 0) + _master = localBranches[0]; + else + _master = "master"; + } + + public static ValidationResult ValidateBaseBranch(string _, ValidationContext ctx) + { + if (ctx.ObjectInstance is InitGitFlow initializer) + { + if (initializer._master == initializer._develop) + return new ValidationResult("Develop branch has the same name with master branch!"); + } + + return ValidationResult.Success; + } + + public static ValidationResult ValidateTagPrefix(string tagPrefix, ValidationContext ctx) + { + if (!string.IsNullOrWhiteSpace(tagPrefix) && !TAG_PREFIX().IsMatch(tagPrefix)) + return new ValidationResult("Bad tag prefix format!"); + + return ValidationResult.Success; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Init git-flow ..."; + + var log = _repo.CreateLog("Gitflow - Init"); + Use(log); + + return Task.Run(() => + { + var succ = false; + var current = _repo.CurrentBranch; + + var masterBranch = _repo.Branches.Find(x => x.IsLocal && x.Name.Equals(_master, StringComparison.Ordinal)); + if (masterBranch == null) + { + succ = Commands.Branch.Create(_repo.FullPath, _master, current.Head, true, log); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + } + + var developBranch = _repo.Branches.Find(x => x.IsLocal && x.Name.Equals(_develop, StringComparison.Ordinal)); + if (developBranch == null) + { + succ = Commands.Branch.Create(_repo.FullPath, _develop, current.Head, true, log); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + } + + succ = Commands.GitFlow.Init( + _repo.FullPath, + _master, + _develop, + _featurePrefix, + _releasePrefix, + _hotfixPrefix, + _tagPrefix, + log); + + log.Complete(); + + CallUIThread(() => + { + if (succ) + { + var gitflow = new Models.GitFlow(); + gitflow.Master = _master; + gitflow.Develop = _develop; + gitflow.FeaturePrefix = _featurePrefix; + gitflow.ReleasePrefix = _releasePrefix; + gitflow.HotfixPrefix = _hotfixPrefix; + _repo.GitFlow = gitflow; + } + + _repo.SetWatcherEnabled(true); + }); + + return succ; + }); + } + + private readonly Repository _repo; + private string _master; + private string _develop = "develop"; + private string _featurePrefix = "feature/"; + private string _releasePrefix = "release/"; + private string _hotfixPrefix = "hotfix/"; + private string _tagPrefix = string.Empty; + } +} diff --git a/src/ViewModels/InteractiveRebase.cs b/src/ViewModels/InteractiveRebase.cs new file mode 100644 index 00000000..01e38e41 --- /dev/null +++ b/src/ViewModels/InteractiveRebase.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; + +using Avalonia.Collections; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class InteractiveRebaseItem : ObservableObject + { + public Models.Commit Commit + { + get; + private set; + } + + public bool CanSquashOrFixup + { + get => _canSquashOrFixup; + set + { + if (SetProperty(ref _canSquashOrFixup, value)) + { + if (_action == Models.InteractiveRebaseAction.Squash || _action == Models.InteractiveRebaseAction.Fixup) + Action = Models.InteractiveRebaseAction.Pick; + } + } + } + + public Models.InteractiveRebaseAction Action + { + get => _action; + set => SetProperty(ref _action, value); + } + + public string Subject + { + get => _subject; + private set => SetProperty(ref _subject, value); + } + + public string FullMessage + { + get => _fullMessage; + set + { + if (SetProperty(ref _fullMessage, value)) + { + var normalized = value.ReplaceLineEndings("\n"); + var idx = normalized.IndexOf("\n\n", StringComparison.Ordinal); + if (idx > 0) + Subject = normalized.Substring(0, idx).ReplaceLineEndings(" "); + else + Subject = value.ReplaceLineEndings(" "); + } + } + } + + public InteractiveRebaseItem(Models.Commit c, string message, bool canSquashOrFixup) + { + Commit = c; + FullMessage = message; + CanSquashOrFixup = canSquashOrFixup; + } + + private Models.InteractiveRebaseAction _action = Models.InteractiveRebaseAction.Pick; + private string _subject; + private string _fullMessage; + private bool _canSquashOrFixup = true; + } + + public class InteractiveRebase : ObservableObject + { + public Models.Branch Current + { + get; + private set; + } + + public Models.Commit On + { + get; + private set; + } + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public AvaloniaList Items + { + get; + private set; + } = []; + + public InteractiveRebaseItem SelectedItem + { + get => _selectedItem; + set + { + if (SetProperty(ref _selectedItem, value)) + DetailContext.Commit = value?.Commit; + } + } + + public CommitDetail DetailContext + { + get; + private set; + } + + public InteractiveRebase(Repository repo, Models.Branch current, Models.Commit on) + { + var repoPath = repo.FullPath; + _repo = repo; + + Current = current; + On = on; + IsLoading = true; + DetailContext = new CommitDetail(repo); + + Task.Run(() => + { + var commits = new Commands.QueryCommitsForInteractiveRebase(repoPath, on.SHA).Result(); + var list = new List(); + + for (var i = 0; i < commits.Count; i++) + { + var c = commits[i]; + list.Add(new InteractiveRebaseItem(c.Commit, c.Message, i < commits.Count - 1)); + } + + Dispatcher.UIThread.Invoke(() => + { + Items.AddRange(list); + IsLoading = false; + }); + }); + } + + public void MoveItemUp(InteractiveRebaseItem item) + { + var idx = Items.IndexOf(item); + if (idx > 0) + { + var prev = Items[idx - 1]; + Items.RemoveAt(idx - 1); + Items.Insert(idx, prev); + SelectedItem = item; + UpdateItems(); + } + } + + public void MoveItemDown(InteractiveRebaseItem item) + { + var idx = Items.IndexOf(item); + if (idx < Items.Count - 1) + { + var next = Items[idx + 1]; + Items.RemoveAt(idx + 1); + Items.Insert(idx, next); + SelectedItem = item; + UpdateItems(); + } + } + + public void ChangeAction(InteractiveRebaseItem item, Models.InteractiveRebaseAction action) + { + if (!item.CanSquashOrFixup) + { + if (action == Models.InteractiveRebaseAction.Squash || action == Models.InteractiveRebaseAction.Fixup) + return; + } + + item.Action = action; + UpdateItems(); + } + + public Task Start() + { + _repo.SetWatcherEnabled(false); + + var saveFile = Path.Combine(_repo.GitDir, "sourcegit_rebase_jobs.json"); + var collection = new Models.InteractiveRebaseJobCollection(); + collection.OrigHead = _repo.CurrentBranch.Head; + collection.Onto = On.SHA; + for (int i = Items.Count - 1; i >= 0; i--) + { + var item = Items[i]; + collection.Jobs.Add(new Models.InteractiveRebaseJob() + { + SHA = item.Commit.SHA, + Action = item.Action, + Message = item.FullMessage, + }); + } + File.WriteAllText(saveFile, JsonSerializer.Serialize(collection, JsonCodeGen.Default.InteractiveRebaseJobCollection)); + + var log = _repo.CreateLog("Interactive Rebase"); + return Task.Run(() => + { + var succ = new Commands.InteractiveRebase(_repo.FullPath, On.SHA).Use(log).Exec(); + log.Complete(); + Dispatcher.UIThread.Invoke(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private void UpdateItems() + { + if (Items.Count == 0) + return; + + var hasValidParent = false; + for (var i = Items.Count - 1; i >= 0; i--) + { + var item = Items[i]; + if (hasValidParent) + { + item.CanSquashOrFixup = true; + } + else + { + item.CanSquashOrFixup = false; + hasValidParent = item.Action != Models.InteractiveRebaseAction.Drop; + } + } + } + + private Repository _repo = null; + private bool _isLoading = false; + private InteractiveRebaseItem _selectedItem = null; + } +} diff --git a/src/ViewModels/LFSFetch.cs b/src/ViewModels/LFSFetch.cs new file mode 100644 index 00000000..43ca88fb --- /dev/null +++ b/src/ViewModels/LFSFetch.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class LFSFetch : Popup + { + public List Remotes => _repo.Remotes; + + public Models.Remote SelectedRemote + { + get; + set; + } + + public LFSFetch(Repository repo) + { + _repo = repo; + SelectedRemote = _repo.Remotes[0]; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Fetching LFS objects from remote ..."; + + var log = _repo.CreateLog("LFS Fetch"); + Use(log); + + return Task.Run(() => + { + new Commands.LFS(_repo.FullPath).Fetch(SelectedRemote.Name, log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSImageDiff.cs b/src/ViewModels/LFSImageDiff.cs new file mode 100644 index 00000000..bb69fd65 --- /dev/null +++ b/src/ViewModels/LFSImageDiff.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class LFSImageDiff : ObservableObject + { + public Models.LFSDiff LFS + { + get; + } + + public Models.ImageDiff Image + { + get => _image; + private set => SetProperty(ref _image, value); + } + + public LFSImageDiff(string repo, Models.LFSDiff lfs, Models.ImageDecoder decoder) + { + LFS = lfs; + + Task.Run(() => + { + var oldImage = ImageSource.FromLFSObject(repo, lfs.Old, decoder); + var newImage = ImageSource.FromLFSObject(repo, lfs.New, decoder); + + var img = new Models.ImageDiff() + { + Old = oldImage.Bitmap, + OldFileSize = oldImage.Size, + New = newImage.Bitmap, + NewFileSize = newImage.Size + }; + + Dispatcher.UIThread.Invoke(() => Image = img); + }); + } + + private Models.ImageDiff _image; + } +} diff --git a/src/ViewModels/LFSLocks.cs b/src/ViewModels/LFSLocks.cs new file mode 100644 index 00000000..0ee149d0 --- /dev/null +++ b/src/ViewModels/LFSLocks.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class LFSLocks : ObservableObject + { + public bool HasValidUserName + { + get; + private set; + } = false; + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public bool ShowOnlyMyLocks + { + get => _showOnlyMyLocks; + set + { + if (SetProperty(ref _showOnlyMyLocks, value)) + UpdateVisibleLocks(); + } + } + + public List VisibleLocks + { + get => _visibleLocks; + private set => SetProperty(ref _visibleLocks, value); + } + + public LFSLocks(Repository repo, string remote) + { + _repo = repo; + _remote = remote; + _userName = new Commands.Config(repo.FullPath).Get("user.name"); + + HasValidUserName = !string.IsNullOrEmpty(_userName); + + Task.Run(() => + { + _cachedLocks = new Commands.LFS(_repo.FullPath).Locks(_remote); + Dispatcher.UIThread.Invoke(() => + { + UpdateVisibleLocks(); + IsLoading = false; + }); + }); + } + + public void Unlock(Models.LFSLock lfsLock, bool force) + { + if (_isLoading) + return; + + IsLoading = true; + + var log = _repo.CreateLog("Unlock LFS File"); + Task.Run(() => + { + var succ = new Commands.LFS(_repo.FullPath).Unlock(_remote, lfsLock.ID, force, log); + log.Complete(); + + Dispatcher.UIThread.Invoke(() => + { + if (succ) + { + _cachedLocks.Remove(lfsLock); + UpdateVisibleLocks(); + } + + IsLoading = false; + }); + }); + } + + private void UpdateVisibleLocks() + { + var visible = new List(); + + if (!_showOnlyMyLocks) + { + foreach (var lfsLock in _cachedLocks) + visible.Add(lfsLock); + } + else + { + foreach (var lfsLock in _cachedLocks) + { + if (lfsLock.User == _userName) + visible.Add(lfsLock); + } + } + + VisibleLocks = visible; + } + + private Repository _repo; + private string _remote; + private bool _isLoading = true; + private List _cachedLocks = []; + private List _visibleLocks = []; + private bool _showOnlyMyLocks = false; + private string _userName; + } +} diff --git a/src/ViewModels/LFSPrune.cs b/src/ViewModels/LFSPrune.cs new file mode 100644 index 00000000..9a6bd8a7 --- /dev/null +++ b/src/ViewModels/LFSPrune.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class LFSPrune : Popup + { + public LFSPrune(Repository repo) + { + _repo = repo; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "LFS prune ..."; + + var log = _repo.CreateLog("LFS Prune"); + Use(log); + + return Task.Run(() => + { + new Commands.LFS(_repo.FullPath).Prune(log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSPull.cs b/src/ViewModels/LFSPull.cs new file mode 100644 index 00000000..8bac8bbb --- /dev/null +++ b/src/ViewModels/LFSPull.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class LFSPull : Popup + { + public List Remotes => _repo.Remotes; + + public Models.Remote SelectedRemote + { + get; + set; + } + + public LFSPull(Repository repo) + { + _repo = repo; + SelectedRemote = _repo.Remotes[0]; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Pull LFS objects from remote ..."; + + var log = _repo.CreateLog("LFS Pull"); + Use(log); + + return Task.Run(() => + { + new Commands.LFS(_repo.FullPath).Pull(SelectedRemote.Name, log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSPush.cs b/src/ViewModels/LFSPush.cs new file mode 100644 index 00000000..0013ee29 --- /dev/null +++ b/src/ViewModels/LFSPush.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class LFSPush : Popup + { + public List Remotes => _repo.Remotes; + + public Models.Remote SelectedRemote + { + get; + set; + } + + public LFSPush(Repository repo) + { + _repo = repo; + SelectedRemote = _repo.Remotes[0]; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Push LFS objects to remote ..."; + + var log = _repo.CreateLog("LFS Push"); + Use(log); + + return Task.Run(() => + { + new Commands.LFS(_repo.FullPath).Push(SelectedRemote.Name, log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSTrackCustomPattern.cs b/src/ViewModels/LFSTrackCustomPattern.cs new file mode 100644 index 00000000..b69733e8 --- /dev/null +++ b/src/ViewModels/LFSTrackCustomPattern.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class LFSTrackCustomPattern : Popup + { + [Required(ErrorMessage = "LFS track pattern is required!!!")] + public string Pattern + { + get => _pattern; + set => SetProperty(ref _pattern, value, true); + } + + public bool IsFilename + { + get; + set; + } = false; + + public LFSTrackCustomPattern(Repository repo) + { + _repo = repo; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Adding custom LFS tracking pattern ..."; + + var log = _repo.CreateLog("LFS Add Custom Pattern"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.LFS(_repo.FullPath).Track(_pattern, IsFilename, log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + private string _pattern = string.Empty; + } +} diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs new file mode 100644 index 00000000..bb3efd51 --- /dev/null +++ b/src/ViewModels/Launcher.cs @@ -0,0 +1,622 @@ +using System; +using System.IO; + +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Input; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Launcher : ObservableObject + { + public string Title + { + get => _title; + private set => SetProperty(ref _title, value); + } + + public AvaloniaList Pages + { + get; + private set; + } + + public Workspace ActiveWorkspace + { + get => _activeWorkspace; + private set => SetProperty(ref _activeWorkspace, value); + } + + public LauncherPage ActivePage + { + get => _activePage; + set + { + if (SetProperty(ref _activePage, value)) + { + UpdateTitle(); + + if (!_ignoreIndexChange && value is { Data: Repository repo }) + _activeWorkspace.ActiveIdx = _activeWorkspace.Repositories.IndexOf(repo.FullPath); + } + } + } + + public IDisposable Switcher + { + get => _switcher; + private set => SetProperty(ref _switcher, value); + } + + public Launcher(string startupRepo) + { + _ignoreIndexChange = true; + + Pages = new AvaloniaList(); + AddNewTab(); + + var pref = Preferences.Instance; + if (string.IsNullOrEmpty(startupRepo)) + { + ActiveWorkspace = pref.GetActiveWorkspace(); + + var repos = ActiveWorkspace.Repositories.ToArray(); + foreach (var repo in repos) + { + var node = pref.FindNode(repo); + if (node == null) + { + node = new RepositoryNode() + { + Id = repo, + Name = Path.GetFileName(repo), + Bookmark = 0, + IsRepository = true, + }; + } + + OpenRepositoryInTab(node, null); + } + + var activeIdx = ActiveWorkspace.ActiveIdx; + if (activeIdx >= 0 && activeIdx < Pages.Count) + { + ActivePage = Pages[activeIdx]; + } + else + { + ActivePage = Pages[0]; + ActiveWorkspace.ActiveIdx = 0; + } + } + else + { + ActiveWorkspace = new Workspace() { Name = "Unnamed" }; + + foreach (var w in pref.Workspaces) + w.IsActive = false; + + var test = new Commands.QueryRepositoryRootPath(startupRepo).ReadToEnd(); + if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + { + Pages[0].Notifications.Add(new Models.Notification + { + IsError = true, + Message = $"Given path: '{startupRepo}' is NOT a valid repository!" + }); + } + else + { + var node = pref.FindOrAddNodeByRepositoryPath(test.StdOut.Trim(), null, false); + Welcome.Instance.Refresh(); + OpenRepositoryInTab(node, null); + } + } + + _ignoreIndexChange = false; + + if (string.IsNullOrEmpty(_title)) + UpdateTitle(); + } + + public void Quit(double width, double height) + { + var pref = Preferences.Instance; + pref.Layout.LauncherWidth = width; + pref.Layout.LauncherHeight = height; + pref.Save(); + + _ignoreIndexChange = true; + + foreach (var one in Pages) + CloseRepositoryInTab(one, false); + + _ignoreIndexChange = false; + } + + public void OpenWorkspaceSwitcher() + { + Switcher = new WorkspaceSwitcher(this); + } + + public void OpenTabSwitcher() + { + Switcher = new LauncherPageSwitcher(this); + } + + public void CancelSwitcher() + { + Switcher?.Dispose(); + Switcher = null; + } + + public void SwitchWorkspace(Workspace to) + { + if (to == null || to.IsActive) + return; + + foreach (var one in Pages) + { + if (!one.CanCreatePopup() || one.Data is Repository { IsAutoFetching: true }) + { + App.RaiseException(null, "You have unfinished task(s) in opened pages. Please wait!!!"); + return; + } + } + + _ignoreIndexChange = true; + + var pref = Preferences.Instance; + foreach (var w in pref.Workspaces) + w.IsActive = false; + + ActiveWorkspace = to; + to.IsActive = true; + + foreach (var one in Pages) + CloseRepositoryInTab(one, false); + + Pages.Clear(); + AddNewTab(); + + var repos = to.Repositories.ToArray(); + foreach (var repo in repos) + { + var node = pref.FindNode(repo); + if (node == null) + { + node = new RepositoryNode() + { + Id = repo, + Name = Path.GetFileName(repo), + Bookmark = 0, + IsRepository = true, + }; + } + + OpenRepositoryInTab(node, null); + } + + var activeIdx = to.ActiveIdx; + if (activeIdx >= 0 && activeIdx < Pages.Count) + { + ActivePage = Pages[activeIdx]; + } + else + { + ActivePage = Pages[0]; + to.ActiveIdx = 0; + } + + _ignoreIndexChange = false; + Preferences.Instance.Save(); + GC.Collect(); + } + + public void AddNewTab() + { + var page = new LauncherPage(); + Pages.Add(page); + ActivePage = page; + } + + public void MoveTab(LauncherPage from, LauncherPage to) + { + _ignoreIndexChange = true; + + var fromIdx = Pages.IndexOf(from); + var toIdx = Pages.IndexOf(to); + Pages.Move(fromIdx, toIdx); + ActivePage = from; + + ActiveWorkspace.Repositories.Clear(); + foreach (var p in Pages) + { + if (p.Data is Repository r) + ActiveWorkspace.Repositories.Add(r.FullPath); + } + ActiveWorkspace.ActiveIdx = ActiveWorkspace.Repositories.IndexOf(from.Node.Id); + + _ignoreIndexChange = false; + } + + public void GotoNextTab() + { + if (Pages.Count == 1) + return; + + var activeIdx = Pages.IndexOf(_activePage); + var nextIdx = (activeIdx + 1) % Pages.Count; + ActivePage = Pages[nextIdx]; + } + + public void GotoPrevTab() + { + if (Pages.Count == 1) + return; + + var activeIdx = Pages.IndexOf(_activePage); + var prevIdx = activeIdx == 0 ? Pages.Count - 1 : activeIdx - 1; + ActivePage = Pages[prevIdx]; + } + + public void CloseTab(LauncherPage page) + { + if (Pages.Count == 1) + { + var last = Pages[0]; + if (last.Data is Repository repo) + { + ActiveWorkspace.Repositories.Clear(); + ActiveWorkspace.ActiveIdx = 0; + + repo.Close(); + + Welcome.Instance.ClearSearchFilter(); + last.Node = new RepositoryNode() { Id = Guid.NewGuid().ToString() }; + last.Data = Welcome.Instance; + last.Popup = null; + UpdateTitle(); + + GC.Collect(); + } + else + { + App.Quit(0); + } + + return; + } + + if (page == null) + page = _activePage; + + var removeIdx = Pages.IndexOf(page); + var activeIdx = Pages.IndexOf(_activePage); + if (removeIdx == activeIdx) + ActivePage = Pages[removeIdx > 0 ? removeIdx - 1 : removeIdx + 1]; + + CloseRepositoryInTab(page); + Pages.RemoveAt(removeIdx); + GC.Collect(); + } + + public void CloseOtherTabs() + { + if (Pages.Count == 1) + return; + + _ignoreIndexChange = true; + + var id = ActivePage.Node.Id; + foreach (var one in Pages) + { + if (one.Node.Id != id) + CloseRepositoryInTab(one); + } + + Pages = new AvaloniaList { ActivePage }; + ActiveWorkspace.ActiveIdx = 0; + OnPropertyChanged(nameof(Pages)); + + _ignoreIndexChange = false; + GC.Collect(); + } + + public void CloseRightTabs() + { + _ignoreIndexChange = true; + + var endIdx = Pages.IndexOf(ActivePage); + for (var i = Pages.Count - 1; i > endIdx; i--) + { + CloseRepositoryInTab(Pages[i]); + Pages.Remove(Pages[i]); + } + + _ignoreIndexChange = false; + GC.Collect(); + } + + public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) + { + foreach (var one in Pages) + { + if (one.Node.Id == node.Id) + { + ActivePage = one; + return; + } + } + + if (!Path.Exists(node.Id)) + { + App.RaiseException(node.Id, "Repository does NOT exist any more. Please remove it."); + return; + } + + var isBare = new Commands.IsBareRepository(node.Id).Result(); + var gitDir = isBare ? node.Id : GetRepositoryGitDir(node.Id); + if (string.IsNullOrEmpty(gitDir)) + { + App.RaiseException(node.Id, "Given path is not a valid git repository!"); + return; + } + + var repo = new Repository(isBare, node.Id, gitDir); + repo.Open(); + + if (page == null) + { + if (ActivePage == null || ActivePage.Node.IsRepository) + { + page = new LauncherPage(node, repo); + Pages.Add(page); + } + else + { + page = ActivePage; + page.Node = node; + page.Data = repo; + } + } + else + { + page.Node = node; + page.Data = repo; + } + + if (page != _activePage) + ActivePage = page; + else + UpdateTitle(); + + ActiveWorkspace.Repositories.Clear(); + foreach (var p in Pages) + { + if (p.Data is Repository r) + ActiveWorkspace.Repositories.Add(r.FullPath); + } + + if (!_ignoreIndexChange) + ActiveWorkspace.ActiveIdx = ActiveWorkspace.Repositories.IndexOf(node.Id); + } + + public void DispatchNotification(string pageId, string message, bool isError) + { + var notification = new Models.Notification() + { + IsError = isError, + Message = message, + }; + + foreach (var page in Pages) + { + var id = page.Node.Id.Replace('\\', '/').TrimEnd('/'); + if (id == pageId) + { + page.Notifications.Add(notification); + return; + } + } + + if (_activePage != null) + _activePage.Notifications.Add(notification); + } + + public ContextMenu CreateContextForWorkspace() + { + var pref = Preferences.Instance; + var menu = new ContextMenu(); + + for (var i = 0; i < pref.Workspaces.Count; i++) + { + var workspace = pref.Workspaces[i]; + + var icon = App.CreateMenuIcon(workspace.IsActive ? "Icons.Check" : "Icons.Workspace"); + icon.Fill = workspace.Brush; + + var item = new MenuItem(); + item.Header = workspace.Name; + item.Icon = icon; + item.Click += (_, e) => + { + if (!workspace.IsActive) + SwitchWorkspace(workspace); + + e.Handled = true; + }; + + menu.Items.Add(item); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var configure = new MenuItem(); + configure.Header = App.Text("Workspace.Configure"); + configure.Click += (_, e) => + { + App.ShowWindow(new ConfigureWorkspace(), true); + e.Handled = true; + }; + menu.Items.Add(configure); + + return menu; + } + + public ContextMenu CreateContextForPageTab(LauncherPage page) + { + if (page == null) + return null; + + var menu = new ContextMenu(); + var close = new MenuItem(); + close.Header = App.Text("PageTabBar.Tab.Close"); + close.InputGesture = KeyGesture.Parse(OperatingSystem.IsMacOS() ? "⌘+W" : "Ctrl+W"); + close.Click += (_, e) => + { + CloseTab(page); + e.Handled = true; + }; + menu.Items.Add(close); + + var closeOthers = new MenuItem(); + closeOthers.Header = App.Text("PageTabBar.Tab.CloseOther"); + closeOthers.Click += (_, e) => + { + CloseOtherTabs(); + e.Handled = true; + }; + menu.Items.Add(closeOthers); + + var closeRight = new MenuItem(); + closeRight.Header = App.Text("PageTabBar.Tab.CloseRight"); + closeRight.Click += (_, e) => + { + CloseRightTabs(); + e.Handled = true; + }; + menu.Items.Add(closeRight); + + if (page.Node.IsRepository) + { + var bookmark = new MenuItem(); + bookmark.Header = App.Text("PageTabBar.Tab.Bookmark"); + bookmark.Icon = App.CreateMenuIcon("Icons.Bookmark"); + + for (int i = 0; i < Models.Bookmarks.Supported.Count; i++) + { + var icon = App.CreateMenuIcon("Icons.Bookmark"); + + if (i != 0) + icon.Fill = Models.Bookmarks.Brushes[i]; + + var dupIdx = i; + var setter = new MenuItem(); + setter.Header = icon; + setter.Click += (_, e) => + { + page.Node.Bookmark = dupIdx; + e.Handled = true; + }; + bookmark.Items.Add(setter); + } + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(bookmark); + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("PageTabBar.Tab.CopyPath"); + copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, e) => + { + page.CopyPath(); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copyPath); + } + + return menu; + } + + private string GetRepositoryGitDir(string repo) + { + var fullpath = Path.Combine(repo, ".git"); + if (Directory.Exists(fullpath)) + { + if (Directory.Exists(Path.Combine(fullpath, "refs")) && + Directory.Exists(Path.Combine(fullpath, "objects")) && + File.Exists(Path.Combine(fullpath, "HEAD"))) + return fullpath; + + return null; + } + + if (File.Exists(fullpath)) + { + var redirect = File.ReadAllText(fullpath).Trim(); + if (redirect.StartsWith("gitdir: ", StringComparison.Ordinal)) + redirect = redirect.Substring(8); + + if (!Path.IsPathRooted(redirect)) + redirect = Path.GetFullPath(Path.Combine(repo, redirect)); + + if (Directory.Exists(redirect)) + return redirect; + + return null; + } + + return new Commands.QueryGitDir(repo).Result(); + } + + private void CloseRepositoryInTab(LauncherPage page, bool removeFromWorkspace = true) + { + if (page.Data is Repository repo) + { + if (removeFromWorkspace) + ActiveWorkspace.Repositories.Remove(repo.FullPath); + + repo.Close(); + } + + page.Data = null; + } + + private void UpdateTitle() + { + if (_activeWorkspace == null) + return; + + var workspace = _activeWorkspace.Name; + if (_activePage is { Data: Repository }) + { + var node = _activePage.Node; + var name = node.Name; + var path = node.Id; + + if (!OperatingSystem.IsWindows()) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; + if (path.StartsWith(home, StringComparison.Ordinal)) + path = $"~{path.AsSpan(prefixLen)}"; + } + + Title = $"[{workspace}] {name} ({path})"; + } + else + { + Title = $"[{workspace}] Repositories"; + } + } + + private Workspace _activeWorkspace = null; + private LauncherPage _activePage = null; + private bool _ignoreIndexChange = false; + private string _title = string.Empty; + private IDisposable _switcher = null; + } +} diff --git a/src/ViewModels/LauncherPage.cs b/src/ViewModels/LauncherPage.cs new file mode 100644 index 00000000..8a59d246 --- /dev/null +++ b/src/ViewModels/LauncherPage.cs @@ -0,0 +1,149 @@ +using System; + +using Avalonia.Collections; +using Avalonia.Media; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class LauncherPage : ObservableObject + { + public RepositoryNode Node + { + get => _node; + set => SetProperty(ref _node, value); + } + + public object Data + { + get => _data; + set => SetProperty(ref _data, value); + } + + public IBrush DirtyBrush + { + get => _dirtyBrush; + private set => SetProperty(ref _dirtyBrush, value); + } + + public Popup Popup + { + get => _popup; + set => SetProperty(ref _popup, value); + } + + public AvaloniaList Notifications + { + get; + set; + } = new AvaloniaList(); + + public LauncherPage() + { + _node = new RepositoryNode() { Id = Guid.NewGuid().ToString() }; + _data = Welcome.Instance; + + // New welcome page will clear the search filter before. + Welcome.Instance.ClearSearchFilter(); + } + + public LauncherPage(RepositoryNode node, Repository repo) + { + _node = node; + _data = repo; + } + + public void ClearNotifications() + { + Notifications.Clear(); + } + + public void CopyPath() + { + if (_node.IsRepository) + App.CopyText(_node.Id); + } + + public void ChangeDirtyState(Models.DirtyState flag, bool remove) + { + if (remove) + { + if (_dirtyState.HasFlag(flag)) + _dirtyState -= flag; + } + else + { + _dirtyState |= flag; + } + + if (_dirtyState.HasFlag(Models.DirtyState.HasLocalChanges)) + DirtyBrush = Brushes.Gray; + else if (_dirtyState.HasFlag(Models.DirtyState.HasPendingPullOrPush)) + DirtyBrush = Brushes.RoyalBlue; + else + DirtyBrush = null; + } + + public bool CanCreatePopup() + { + return _popup == null || !_popup.InProgress; + } + + public void StartPopup(Popup popup) + { + Popup = popup; + + if (popup.CanStartDirectly()) + ProcessPopup(); + } + + public async void ProcessPopup() + { + if (_popup is { InProgress: false } dump) + { + if (!dump.Check()) + return; + + dump.InProgress = true; + var task = dump.Sure(); + var finished = false; + if (task != null) + { + try + { + finished = await task; + } + catch (Exception e) + { + App.LogException(e); + } + + dump.InProgress = false; + if (finished) + Popup = null; + } + else + { + dump.InProgress = false; + Popup = null; + } + } + } + + public void CancelPopup() + { + if (_popup == null) + return; + if (_popup.InProgress) + return; + Popup = null; + } + + private RepositoryNode _node = null; + private object _data = null; + private IBrush _dirtyBrush = null; + private Models.DirtyState _dirtyState = Models.DirtyState.None; + private Popup _popup = null; + } +} diff --git a/src/ViewModels/LauncherPageSwitcher.cs b/src/ViewModels/LauncherPageSwitcher.cs new file mode 100644 index 00000000..5f53021d --- /dev/null +++ b/src/ViewModels/LauncherPageSwitcher.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class LauncherPageSwitcher : ObservableObject, IDisposable + { + public List VisiblePages + { + get => _visiblePages; + private set => SetProperty(ref _visiblePages, value); + } + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + UpdateVisiblePages(); + } + } + + public LauncherPage SelectedPage + { + get => _selectedPage; + set => SetProperty(ref _selectedPage, value); + } + + public LauncherPageSwitcher(Launcher launcher) + { + _launcher = launcher; + UpdateVisiblePages(); + } + + public void ClearFilter() + { + SearchFilter = string.Empty; + } + + public void Switch() + { + _launcher.ActivePage = _selectedPage ?? _launcher.ActivePage; + _launcher.CancelSwitcher(); + } + + public void Dispose() + { + _visiblePages.Clear(); + _selectedPage = null; + _searchFilter = string.Empty; + } + + private void UpdateVisiblePages() + { + var visible = new List(); + if (string.IsNullOrEmpty(_searchFilter)) + { + visible.AddRange(_launcher.Pages); + } + else + { + foreach (var page in _launcher.Pages) + { + if (page.Node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase) || + (page.Node.IsRepository && page.Node.Id.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase))) + { + visible.Add(page); + } + } + } + + VisiblePages = visible; + SelectedPage = visible.Count > 0 ? visible[0] : null; + } + + private Launcher _launcher = null; + private List _visiblePages = []; + private string _searchFilter = string.Empty; + private LauncherPage _selectedPage = null; + } +} diff --git a/src/ViewModels/LayoutInfo.cs b/src/ViewModels/LayoutInfo.cs new file mode 100644 index 00000000..d5f7ec2b --- /dev/null +++ b/src/ViewModels/LayoutInfo.cs @@ -0,0 +1,70 @@ +using Avalonia.Controls; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class LayoutInfo : ObservableObject + { + public double LauncherWidth + { + get; + set; + } = 1280; + + public double LauncherHeight + { + get; + set; + } = 720; + + public WindowState LauncherWindowState + { + get; + set; + } = WindowState.Normal; + + public GridLength RepositorySidebarWidth + { + get => _repositorySidebarWidth; + set => SetProperty(ref _repositorySidebarWidth, value); + } + + public GridLength HistoriesAuthorColumnWidth + { + get => _historiesAuthorColumnWidth; + set => SetProperty(ref _historiesAuthorColumnWidth, value); + } + + public GridLength WorkingCopyLeftWidth + { + get => _workingCopyLeftWidth; + set => SetProperty(ref _workingCopyLeftWidth, value); + } + + public GridLength StashesLeftWidth + { + get => _stashesLeftWidth; + set => SetProperty(ref _stashesLeftWidth, value); + } + + public GridLength CommitDetailChangesLeftWidth + { + get => _commitDetailChangesLeftWidth; + set => SetProperty(ref _commitDetailChangesLeftWidth, value); + } + + public GridLength CommitDetailFilesLeftWidth + { + get => _commitDetailFilesLeftWidth; + set => SetProperty(ref _commitDetailFilesLeftWidth, value); + } + + private GridLength _repositorySidebarWidth = new GridLength(250, GridUnitType.Pixel); + private GridLength _historiesAuthorColumnWidth = new GridLength(120, GridUnitType.Pixel); + private GridLength _workingCopyLeftWidth = new GridLength(300, GridUnitType.Pixel); + private GridLength _stashesLeftWidth = new GridLength(300, GridUnitType.Pixel); + private GridLength _commitDetailChangesLeftWidth = new GridLength(256, GridUnitType.Pixel); + private GridLength _commitDetailFilesLeftWidth = new GridLength(256, GridUnitType.Pixel); + } +} diff --git a/src/ViewModels/Merge.cs b/src/ViewModels/Merge.cs new file mode 100644 index 00000000..eb54418c --- /dev/null +++ b/src/ViewModels/Merge.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Merge : Popup + { + public object Source + { + get; + } + + public string Into + { + get; + } + + public Models.MergeMode Mode + { + get; + set; + } + + public Merge(Repository repo, Models.Branch source, string into, bool forceFastForward) + { + _repo = repo; + _sourceName = source.FriendlyName; + + Source = source; + Into = into; + Mode = forceFastForward ? Models.MergeMode.Supported[1] : AutoSelectMergeMode(); + } + + public Merge(Repository repo, Models.Commit source, string into) + { + _repo = repo; + _sourceName = source.SHA; + + Source = source; + Into = into; + Mode = AutoSelectMergeMode(); + } + + public Merge(Repository repo, Models.Tag source, string into) + { + _repo = repo; + _sourceName = source.Name; + + Source = source; + Into = into; + Mode = AutoSelectMergeMode(); + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + _repo.ClearCommitMessage(); + ProgressDescription = $"Merging '{_sourceName}' into '{Into}' ..."; + + var log = _repo.CreateLog($"Merging '{_sourceName}' into '{Into}'"); + Use(log); + + return Task.Run(() => + { + new Commands.Merge(_repo.FullPath, _sourceName, Mode.Arg).Use(log).Exec(); + log.Complete(); + + var head = new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").Result(); + CallUIThread(() => + { + _repo.NavigateToCommit(head, true); + _repo.SetWatcherEnabled(true); + }); + return true; + }); + } + + private Models.MergeMode AutoSelectMergeMode() + { + var preferredMergeModeIdx = _repo.Settings.PreferredMergeMode; + if (preferredMergeModeIdx < 0 || preferredMergeModeIdx > Models.MergeMode.Supported.Length) + preferredMergeModeIdx = 0; + + var defaultMergeMode = Models.MergeMode.Supported[preferredMergeModeIdx]; + var config = new Commands.Config(_repo.FullPath).Get($"branch.{Into}.mergeoptions"); + if (string.IsNullOrEmpty(config)) + return defaultMergeMode; + if (config.Equals("--ff-only", StringComparison.Ordinal)) + return Models.MergeMode.Supported[1]; + if (config.Equals("--no-ff", StringComparison.Ordinal)) + return Models.MergeMode.Supported[2]; + if (config.Equals("--squash", StringComparison.Ordinal)) + return Models.MergeMode.Supported[3]; + if (config.Equals("--no-commit", StringComparison.Ordinal) || config.Equals("--no-ff --no-commit", StringComparison.Ordinal)) + return Models.MergeMode.Supported[4]; + + return defaultMergeMode; + } + + private readonly Repository _repo = null; + private readonly string _sourceName; + } +} diff --git a/src/ViewModels/MergeMultiple.cs b/src/ViewModels/MergeMultiple.cs new file mode 100644 index 00000000..aaa21cce --- /dev/null +++ b/src/ViewModels/MergeMultiple.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class MergeMultiple : Popup + { + public List Targets + { + get; + } = []; + + public bool AutoCommit + { + get; + set; + } + + public Models.MergeStrategy Strategy + { + get; + set; + } + + public MergeMultiple(Repository repo, List commits) + { + _repo = repo; + Targets.AddRange(commits); + AutoCommit = true; + Strategy = Models.MergeStrategy.ForMultiple[0]; + } + + public MergeMultiple(Repository repo, List branches) + { + _repo = repo; + Targets.AddRange(branches); + AutoCommit = true; + Strategy = Models.MergeStrategy.ForMultiple[0]; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + _repo.ClearCommitMessage(); + ProgressDescription = "Merge head(s) ..."; + + var log = _repo.CreateLog("Merge Multiple Heads"); + Use(log); + + return Task.Run(() => + { + new Commands.Merge( + _repo.FullPath, + ConvertTargetToMergeSources(), + AutoCommit, + Strategy.Arg).Use(log).Exec(); + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private List ConvertTargetToMergeSources() + { + var ret = new List(); + foreach (var t in Targets) + { + if (t is Models.Branch branch) + { + ret.Add(branch.FriendlyName); + } + else if (t is Models.Commit commit) + { + var d = commit.Decorators.Find(x => x.Type is + Models.DecoratorType.LocalBranchHead or + Models.DecoratorType.RemoteBranchHead or + Models.DecoratorType.Tag); + + if (d != null) + ret.Add(d.Name); + else + ret.Add(commit.SHA); + } + } + + return ret; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/MoveRepositoryNode.cs b/src/ViewModels/MoveRepositoryNode.cs new file mode 100644 index 00000000..51f2e974 --- /dev/null +++ b/src/ViewModels/MoveRepositoryNode.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class MoveRepositoryNode : Popup + { + public RepositoryNode Target + { + get; + } = null; + + public List Rows + { + get; + } = []; + + public RepositoryNode Selected + { + get => _selected; + set => SetProperty(ref _selected, value); + } + + public MoveRepositoryNode(RepositoryNode target) + { + Target = target; + Rows.Add(new RepositoryNode() + { + Name = "ROOT", + Depth = 0, + Id = Guid.NewGuid().ToString() + }); + MakeRows(Preferences.Instance.RepositoryNodes, 1); + } + + public override Task Sure() + { + if (_selected != null) + { + var node = Preferences.Instance.FindNode(_selected.Id); + Preferences.Instance.MoveNode(Target, node, true); + Welcome.Instance.Refresh(); + } + + return null; + } + + private void MakeRows(List collection, int depth) + { + foreach (var node in collection) + { + if (node.IsRepository || node.Id == Target.Id) + continue; + + var dump = new RepositoryNode() + { + Name = node.Name, + Depth = depth, + Id = node.Id + }; + Rows.Add(dump); + MakeRows(node.SubNodes, depth + 1); + } + } + + private RepositoryNode _selected = null; + } +} diff --git a/src/ViewModels/Popup.cs b/src/ViewModels/Popup.cs new file mode 100644 index 00000000..b3fe7e23 --- /dev/null +++ b/src/ViewModels/Popup.cs @@ -0,0 +1,64 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Popup : ObservableValidator + { + public bool InProgress + { + get => _inProgress; + set => SetProperty(ref _inProgress, value); + } + + public string ProgressDescription + { + get => _progressDescription; + set => SetProperty(ref _progressDescription, value); + } + + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode")] + public bool Check() + { + if (HasErrors) + return false; + ValidateAllProperties(); + return !HasErrors; + } + + public virtual bool CanStartDirectly() + { + return true; + } + + public virtual Task Sure() + { + return null; + } + + protected void CallUIThread(Action action) + { + Dispatcher.UIThread.Invoke(action); + } + + protected void Use(CommandLog log) + { + log.Register(SetDescription); + } + + private void SetDescription(string data) + { + var desc = data.Trim(); + if (!string.IsNullOrEmpty(desc)) + ProgressDescription = desc; + } + + private bool _inProgress = false; + private string _progressDescription = string.Empty; + } +} diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs new file mode 100644 index 00000000..665b7604 --- /dev/null +++ b/src/ViewModels/Preferences.cs @@ -0,0 +1,711 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Preferences : ObservableObject + { + [JsonIgnore] + public static Preferences Instance + { + get + { + if (_instance != null) + return _instance; + + _instance = Load(); + _instance._isLoading = false; + + _instance.PrepareGit(); + _instance.PrepareShellOrTerminal(); + _instance.PrepareWorkspaces(); + + return _instance; + } + } + + public string Locale + { + get => _locale; + set + { + if (SetProperty(ref _locale, value) && !_isLoading) + App.SetLocale(value); + } + } + + public string Theme + { + get => _theme; + set + { + if (SetProperty(ref _theme, value) && !_isLoading) + App.SetTheme(_theme, _themeOverrides); + } + } + + public string ThemeOverrides + { + get => _themeOverrides; + set + { + if (SetProperty(ref _themeOverrides, value) && !_isLoading) + App.SetTheme(_theme, value); + } + } + + public string DefaultFontFamily + { + get => _defaultFontFamily; + set + { + if (SetProperty(ref _defaultFontFamily, value) && !_isLoading) + App.SetFonts(value, _monospaceFontFamily, _onlyUseMonoFontInEditor); + } + } + + public string MonospaceFontFamily + { + get => _monospaceFontFamily; + set + { + if (SetProperty(ref _monospaceFontFamily, value) && !_isLoading) + App.SetFonts(_defaultFontFamily, value, _onlyUseMonoFontInEditor); + } + } + + public bool OnlyUseMonoFontInEditor + { + get => _onlyUseMonoFontInEditor; + set + { + if (SetProperty(ref _onlyUseMonoFontInEditor, value) && !_isLoading) + App.SetFonts(_defaultFontFamily, _monospaceFontFamily, _onlyUseMonoFontInEditor); + } + } + + public bool UseSystemWindowFrame + { + get => Native.OS.UseSystemWindowFrame; + set => Native.OS.UseSystemWindowFrame = value; + } + + public double DefaultFontSize + { + get => _defaultFontSize; + set => SetProperty(ref _defaultFontSize, value); + } + + public double EditorFontSize + { + get => _editorFontSize; + set => SetProperty(ref _editorFontSize, value); + } + + public int EditorTabWidth + { + get => _editorTabWidth; + set => SetProperty(ref _editorTabWidth, value); + } + + public LayoutInfo Layout + { + get => _layout; + set => SetProperty(ref _layout, value); + } + + public int MaxHistoryCommits + { + get => _maxHistoryCommits; + set => SetProperty(ref _maxHistoryCommits, value); + } + + public int SubjectGuideLength + { + get => _subjectGuideLength; + set => SetProperty(ref _subjectGuideLength, value); + } + + public int DateTimeFormat + { + get => Models.DateTimeFormat.ActiveIndex; + set + { + if (value != Models.DateTimeFormat.ActiveIndex && + value >= 0 && + value < Models.DateTimeFormat.Supported.Count) + { + Models.DateTimeFormat.ActiveIndex = value; + OnPropertyChanged(); + } + } + } + + public bool UseFixedTabWidth + { + get => _useFixedTabWidth; + set => SetProperty(ref _useFixedTabWidth, value); + } + + public bool Check4UpdatesOnStartup + { + get => _check4UpdatesOnStartup; + set => SetProperty(ref _check4UpdatesOnStartup, value); + } + + public bool ShowAuthorTimeInGraph + { + get => _showAuthorTimeInGraph; + set => SetProperty(ref _showAuthorTimeInGraph, value); + } + + public bool ShowChildren + { + get => _showChildren; + set => SetProperty(ref _showChildren, value); + } + + public string IgnoreUpdateTag + { + get => _ignoreUpdateTag; + set => SetProperty(ref _ignoreUpdateTag, value); + } + + public bool ShowTagsAsTree + { + get; + set; + } = false; + + public bool ShowTagsInGraph + { + get => _showTagsInGraph; + set => SetProperty(ref _showTagsInGraph, value); + } + + public bool ShowSubmodulesAsTree + { + get; + set; + } = false; + + public bool UseTwoColumnsLayoutInHistories + { + get => _useTwoColumnsLayoutInHistories; + set => SetProperty(ref _useTwoColumnsLayoutInHistories, value); + } + + public bool DisplayTimeAsPeriodInHistories + { + get => _displayTimeAsPeriodInHistories; + set => SetProperty(ref _displayTimeAsPeriodInHistories, value); + } + + public bool UseSideBySideDiff + { + get => _useSideBySideDiff; + set => SetProperty(ref _useSideBySideDiff, value); + } + + public bool UseSyntaxHighlighting + { + get => _useSyntaxHighlighting; + set => SetProperty(ref _useSyntaxHighlighting, value); + } + + public bool IgnoreCRAtEOLInDiff + { + get => Models.DiffOption.IgnoreCRAtEOL; + set + { + if (Models.DiffOption.IgnoreCRAtEOL != value) + { + Models.DiffOption.IgnoreCRAtEOL = value; + OnPropertyChanged(); + } + } + } + + public bool IgnoreWhitespaceChangesInDiff + { + get => _ignoreWhitespaceChangesInDiff; + set => SetProperty(ref _ignoreWhitespaceChangesInDiff, value); + } + + public bool EnableDiffViewWordWrap + { + get => _enableDiffViewWordWrap; + set => SetProperty(ref _enableDiffViewWordWrap, value); + } + + public bool ShowHiddenSymbolsInDiffView + { + get => _showHiddenSymbolsInDiffView; + set => SetProperty(ref _showHiddenSymbolsInDiffView, value); + } + + public bool UseFullTextDiff + { + get => _useFullTextDiff; + set => SetProperty(ref _useFullTextDiff, value); + } + + public bool UseBlockNavigationInDiffView + { + get => _useBlockNavigationInDiffView; + set => SetProperty(ref _useBlockNavigationInDiffView, value); + } + + public int LFSImageActiveIdx + { + get => _lfsImageActiveIdx; + set => SetProperty(ref _lfsImageActiveIdx, value); + } + + public Models.ChangeViewMode UnstagedChangeViewMode + { + get => _unstagedChangeViewMode; + set => SetProperty(ref _unstagedChangeViewMode, value); + } + + public Models.ChangeViewMode StagedChangeViewMode + { + get => _stagedChangeViewMode; + set => SetProperty(ref _stagedChangeViewMode, value); + } + + public Models.ChangeViewMode CommitChangeViewMode + { + get => _commitChangeViewMode; + set => SetProperty(ref _commitChangeViewMode, value); + } + + public string GitInstallPath + { + get => Native.OS.GitExecutable; + set + { + if (Native.OS.GitExecutable != value) + { + Native.OS.GitExecutable = value; + OnPropertyChanged(); + } + } + } + + public string GitDefaultCloneDir + { + get => _gitDefaultCloneDir; + set => SetProperty(ref _gitDefaultCloneDir, value); + } + + public int ShellOrTerminal + { + get => _shellOrTerminal; + set + { + if (SetProperty(ref _shellOrTerminal, value)) + { + if (value >= 0 && value < Models.ShellOrTerminal.Supported.Count) + Native.OS.SetShellOrTerminal(Models.ShellOrTerminal.Supported[value]); + else + Native.OS.SetShellOrTerminal(null); + + OnPropertyChanged(nameof(ShellOrTerminalPath)); + } + } + } + + public string ShellOrTerminalPath + { + get => Native.OS.ShellOrTerminal; + set + { + if (value != Native.OS.ShellOrTerminal) + { + Native.OS.ShellOrTerminal = value; + OnPropertyChanged(); + } + } + } + + public int ExternalMergeToolType + { + get => _externalMergeToolType; + set + { + var changed = SetProperty(ref _externalMergeToolType, value); + if (changed && !OperatingSystem.IsWindows() && value > 0 && value < Models.ExternalMerger.Supported.Count) + { + var tool = Models.ExternalMerger.Supported[value]; + if (File.Exists(tool.Exec)) + ExternalMergeToolPath = tool.Exec; + else + ExternalMergeToolPath = string.Empty; + } + } + } + + public string ExternalMergeToolPath + { + get => _externalMergeToolPath; + set => SetProperty(ref _externalMergeToolPath, value); + } + + public uint StatisticsSampleColor + { + get => _statisticsSampleColor; + set => SetProperty(ref _statisticsSampleColor, value); + } + + public List RepositoryNodes + { + get; + set; + } = []; + + public List Workspaces + { + get; + set; + } = []; + + public AvaloniaList CustomActions + { + get; + set; + } = []; + + public AvaloniaList OpenAIServices + { + get; + set; + } = []; + + public double LastCheckUpdateTime + { + get => _lastCheckUpdateTime; + set => SetProperty(ref _lastCheckUpdateTime, value); + } + + public void SetCanModify() + { + _isReadonly = false; + } + + public bool IsGitConfigured() + { + var path = GitInstallPath; + return !string.IsNullOrEmpty(path) && File.Exists(path); + } + + public bool ShouldCheck4UpdateOnStartup() + { + if (!_check4UpdatesOnStartup) + return false; + + var lastCheck = DateTime.UnixEpoch.AddSeconds(LastCheckUpdateTime).ToLocalTime(); + var now = DateTime.Now; + + if (lastCheck.Year == now.Year && lastCheck.Month == now.Month && lastCheck.Day == now.Day) + return false; + + LastCheckUpdateTime = now.Subtract(DateTime.UnixEpoch.ToLocalTime()).TotalSeconds; + return true; + } + + public Workspace GetActiveWorkspace() + { + foreach (var w in Workspaces) + { + if (w.IsActive) + return w; + } + + var first = Workspaces[0]; + first.IsActive = true; + return first; + } + + public void AddNode(RepositoryNode node, RepositoryNode to, bool save) + { + var collection = to == null ? RepositoryNodes : to.SubNodes; + collection.Add(node); + SortNodes(collection); + + if (save) + Save(); + } + + public void SortNodes(List collection) + { + collection?.Sort((l, r) => + { + if (l.IsRepository != r.IsRepository) + return l.IsRepository ? 1 : -1; + + return Models.NumericSort.Compare(l.Name, r.Name); + }); + } + + public RepositoryNode FindNode(string id) + { + return FindNodeRecursive(id, RepositoryNodes); + } + + public RepositoryNode FindOrAddNodeByRepositoryPath(string repo, RepositoryNode parent, bool shouldMoveNode, bool save = true) + { + var normalized = repo.Replace('\\', '/').TrimEnd('/'); + + var node = FindNodeRecursive(normalized, RepositoryNodes); + if (node == null) + { + node = new RepositoryNode() + { + Id = normalized, + Name = Path.GetFileName(normalized), + Bookmark = 0, + IsRepository = true, + }; + + AddNode(node, parent, save); + } + else if (shouldMoveNode) + { + MoveNode(node, parent, save); + } + + return node; + } + + public void MoveNode(RepositoryNode node, RepositoryNode to, bool save) + { + if (to == null && RepositoryNodes.Contains(node)) + return; + if (to != null && to.SubNodes.Contains(node)) + return; + + RemoveNode(node, false); + AddNode(node, to, false); + + if (save) + Save(); + } + + public void RemoveNode(RepositoryNode node, bool save) + { + RemoveNodeRecursive(node, RepositoryNodes); + + if (save) + Save(); + } + + public void SortByRenamedNode(RepositoryNode node) + { + var container = FindNodeContainer(node, RepositoryNodes); + SortNodes(container); + Save(); + } + + public void AutoRemoveInvalidNode() + { + RemoveInvalidRepositoriesRecursive(RepositoryNodes); + } + + public void Save() + { + if (_isLoading || _isReadonly) + return; + + var file = Path.Combine(Native.OS.DataDir, "preference.json"); + var data = JsonSerializer.Serialize(this, JsonCodeGen.Default.Preferences); + File.WriteAllText(file, data); + } + + private static Preferences Load() + { + var path = Path.Combine(Native.OS.DataDir, "preference.json"); + if (!File.Exists(path)) + return new Preferences(); + + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path), JsonCodeGen.Default.Preferences); + } + catch + { + return new Preferences(); + } + } + + private void PrepareGit() + { + var path = Native.OS.GitExecutable; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + GitInstallPath = Native.OS.FindGitExecutable(); + } + + private void PrepareShellOrTerminal() + { + if (_shellOrTerminal >= 0) + return; + + for (int i = 0; i < Models.ShellOrTerminal.Supported.Count; i++) + { + var shell = Models.ShellOrTerminal.Supported[i]; + if (Native.OS.TestShellOrTerminal(shell)) + { + ShellOrTerminal = i; + break; + } + } + } + + private void PrepareWorkspaces() + { + if (Workspaces.Count == 0) + { + Workspaces.Add(new Workspace() { Name = "Default" }); + return; + } + + foreach (var workspace in Workspaces) + { + if (!workspace.RestoreOnStartup) + { + workspace.Repositories.Clear(); + workspace.ActiveIdx = 0; + } + } + } + + private void SortNodesRecursive(List collection) + { + SortNodes(collection); + foreach (var node in collection) + SortNodesRecursive(node.SubNodes); + } + + private RepositoryNode FindNodeRecursive(string id, List collection) + { + foreach (var node in collection) + { + if (node.Id == id) + return node; + + var sub = FindNodeRecursive(id, node.SubNodes); + if (sub != null) + return sub; + } + + return null; + } + + private List FindNodeContainer(RepositoryNode node, List collection) + { + foreach (var sub in collection) + { + if (node == sub) + return collection; + + var subCollection = FindNodeContainer(node, sub.SubNodes); + if (subCollection != null) + return subCollection; + } + + return null; + } + + private bool RemoveNodeRecursive(RepositoryNode node, List collection) + { + if (collection.Contains(node)) + { + collection.Remove(node); + return true; + } + + foreach (var one in collection) + { + if (RemoveNodeRecursive(node, one.SubNodes)) + return true; + } + + return false; + } + + private bool RemoveInvalidRepositoriesRecursive(List collection) + { + bool changed = false; + + for (int i = collection.Count - 1; i >= 0; i--) + { + var node = collection[i]; + if (node.IsInvalid) + { + collection.RemoveAt(i); + changed = true; + } + else if (!node.IsRepository) + { + changed |= RemoveInvalidRepositoriesRecursive(node.SubNodes); + } + } + + return changed; + } + + private static Preferences _instance = null; + + private bool _isLoading = true; + private bool _isReadonly = true; + private string _locale = "en_US"; + private string _theme = "Default"; + private string _themeOverrides = string.Empty; + private string _defaultFontFamily = string.Empty; + private string _monospaceFontFamily = string.Empty; + private bool _onlyUseMonoFontInEditor = true; + private double _defaultFontSize = 13; + private double _editorFontSize = 13; + private int _editorTabWidth = 4; + private LayoutInfo _layout = new LayoutInfo(); + + private int _maxHistoryCommits = 20000; + private int _subjectGuideLength = 50; + private bool _useFixedTabWidth = true; + private bool _showAuthorTimeInGraph = false; + private bool _showChildren = false; + + private bool _check4UpdatesOnStartup = true; + private double _lastCheckUpdateTime = 0; + private string _ignoreUpdateTag = string.Empty; + + private bool _showTagsInGraph = true; + private bool _useTwoColumnsLayoutInHistories = false; + private bool _displayTimeAsPeriodInHistories = false; + private bool _useSideBySideDiff = false; + private bool _ignoreWhitespaceChangesInDiff = false; + private bool _useSyntaxHighlighting = false; + private bool _enableDiffViewWordWrap = false; + private bool _showHiddenSymbolsInDiffView = false; + private bool _useFullTextDiff = false; + private bool _useBlockNavigationInDiffView = false; + private int _lfsImageActiveIdx = 0; + + private Models.ChangeViewMode _unstagedChangeViewMode = Models.ChangeViewMode.List; + private Models.ChangeViewMode _stagedChangeViewMode = Models.ChangeViewMode.List; + private Models.ChangeViewMode _commitChangeViewMode = Models.ChangeViewMode.List; + + private string _gitDefaultCloneDir = string.Empty; + + private int _shellOrTerminal = -1; + private int _externalMergeToolType = 0; + private string _externalMergeToolPath = string.Empty; + + private uint _statisticsSampleColor = 0xFF00FF00; + } +} diff --git a/src/ViewModels/PruneRemote.cs b/src/ViewModels/PruneRemote.cs new file mode 100644 index 00000000..007bcab8 --- /dev/null +++ b/src/ViewModels/PruneRemote.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class PruneRemote : Popup + { + public Models.Remote Remote + { + get; + } + + public PruneRemote(Repository repo, Models.Remote remote) + { + _repo = repo; + Remote = remote; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Run `prune` on remote ..."; + + var log = _repo.CreateLog($"Prune Remote '{Remote.Name}'"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Remote(_repo.FullPath).Use(log).Prune(Remote.Name); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/PruneWorktrees.cs b/src/ViewModels/PruneWorktrees.cs new file mode 100644 index 00000000..3cb884dc --- /dev/null +++ b/src/ViewModels/PruneWorktrees.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class PruneWorktrees : Popup + { + public PruneWorktrees(Repository repo) + { + _repo = repo; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Prune worktrees ..."; + + var log = _repo.CreateLog("Prune Worktrees"); + Use(log); + + return Task.Run(() => + { + new Commands.Worktree(_repo.FullPath).Use(log).Prune(); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/Pull.cs b/src/ViewModels/Pull.cs new file mode 100644 index 00000000..1a6f2e01 --- /dev/null +++ b/src/ViewModels/Pull.cs @@ -0,0 +1,228 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Pull : Popup + { + public List Remotes => _repo.Remotes; + public Models.Branch Current => _current; + + public bool HasSpecifiedRemoteBranch + { + get; + private set; + } + + public Models.Remote SelectedRemote + { + get => _selectedRemote; + set + { + if (SetProperty(ref _selectedRemote, value)) + PostRemoteSelected(); + } + } + + public List RemoteBranches + { + get => _remoteBranches; + private set => SetProperty(ref _remoteBranches, value); + } + + [Required(ErrorMessage = "Remote branch to pull is required!!!")] + public Models.Branch SelectedBranch + { + get => _selectedBranch; + set => SetProperty(ref _selectedBranch, value, true); + } + + public bool DiscardLocalChanges + { + get; + set; + } = false; + + public bool UseRebase + { + get => _repo.Settings.PreferRebaseInsteadOfMerge; + set => _repo.Settings.PreferRebaseInsteadOfMerge = value; + } + + public bool IsRecurseSubmoduleVisible + { + get => _repo.Submodules.Count > 0; + } + + public bool RecurseSubmodules + { + get => _repo.Settings.UpdateSubmodulesOnCheckoutBranch; + set => _repo.Settings.UpdateSubmodulesOnCheckoutBranch = value; + } + + public Pull(Repository repo, Models.Branch specifiedRemoteBranch) + { + _repo = repo; + _current = repo.CurrentBranch; + + if (specifiedRemoteBranch != null) + { + _selectedRemote = repo.Remotes.Find(x => x.Name == specifiedRemoteBranch.Remote); + _selectedBranch = specifiedRemoteBranch; + + var branches = new List(); + foreach (var branch in _repo.Branches) + { + if (branch.Remote == specifiedRemoteBranch.Remote) + branches.Add(branch); + } + + _remoteBranches = branches; + HasSpecifiedRemoteBranch = true; + } + else + { + var autoSelectedRemote = null as Models.Remote; + if (!string.IsNullOrEmpty(_current.Upstream)) + { + var remoteNameEndIdx = _current.Upstream.IndexOf('/', 13); + if (remoteNameEndIdx > 0) + { + var remoteName = _current.Upstream.Substring(13, remoteNameEndIdx - 13); + autoSelectedRemote = _repo.Remotes.Find(x => x.Name == remoteName); + } + } + + if (autoSelectedRemote == null) + { + var remote = null as Models.Remote; + if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + remote = _repo.Remotes.Find(x => x.Name == _repo.Settings.DefaultRemote); + _selectedRemote = remote ?? _repo.Remotes[0]; + } + else + { + _selectedRemote = autoSelectedRemote; + } + + PostRemoteSelected(); + HasSpecifiedRemoteBranch = false; + } + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + + var log = _repo.CreateLog("Pull"); + Use(log); + + var updateSubmodules = IsRecurseSubmoduleVisible && RecurseSubmodules; + return Task.Run(() => + { + var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); + var needPopStash = false; + if (changes > 0) + { + if (DiscardLocalChanges) + { + Commands.Discard.All(_repo.FullPath, false, log); + } + else + { + var succ = new Commands.Stash(_repo.FullPath).Use(log).Push("PULL_AUTO_STASH"); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + needPopStash = true; + } + } + + bool rs = new Commands.Pull( + _repo.FullPath, + _selectedRemote.Name, + !string.IsNullOrEmpty(_current.Upstream) && _current.Upstream.Equals(_selectedBranch.FullName) ? string.Empty : _selectedBranch.Name, + UseRebase).Use(log).Exec(); + + if (rs) + { + if (updateSubmodules) + { + var submodules = new Commands.QueryUpdatableSubmodules(_repo.FullPath).Result(); + if (submodules.Count > 0) + new Commands.Submodule(_repo.FullPath).Use(log).Update(submodules, true, true); + } + + if (needPopStash) + new Commands.Stash(_repo.FullPath).Use(log).Pop("stash@{0}"); + } + + log.Complete(); + + var head = new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").Result(); + CallUIThread(() => + { + _repo.NavigateToCommit(head, true); + _repo.SetWatcherEnabled(true); + }); + + return rs; + }); + } + + private void PostRemoteSelected() + { + var remoteName = _selectedRemote.Name; + var branches = new List(); + foreach (var branch in _repo.Branches) + { + if (branch.Remote == remoteName) + branches.Add(branch); + } + + RemoteBranches = branches; + + var autoSelectedBranch = false; + if (!string.IsNullOrEmpty(_current.Upstream) && + _current.Upstream.StartsWith($"refs/remotes/{remoteName}/", System.StringComparison.Ordinal)) + { + foreach (var branch in branches) + { + if (_current.Upstream == branch.FullName) + { + SelectedBranch = branch; + autoSelectedBranch = true; + break; + } + } + } + + if (!autoSelectedBranch) + { + foreach (var branch in branches) + { + if (_current.Name == branch.Name) + { + SelectedBranch = branch; + autoSelectedBranch = true; + break; + } + } + } + + if (!autoSelectedBranch) + SelectedBranch = null; + } + + private readonly Repository _repo = null; + private readonly Models.Branch _current = null; + private Models.Remote _selectedRemote = null; + private List _remoteBranches = null; + private Models.Branch _selectedBranch = null; + } +} diff --git a/src/ViewModels/Push.cs b/src/ViewModels/Push.cs new file mode 100644 index 00000000..917935b0 --- /dev/null +++ b/src/ViewModels/Push.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Push : Popup + { + public bool HasSpecifiedLocalBranch + { + get; + private set; + } + + [Required(ErrorMessage = "Local branch is required!!!")] + public Models.Branch SelectedLocalBranch + { + get => _selectedLocalBranch; + set + { + if (SetProperty(ref _selectedLocalBranch, value, true)) + AutoSelectBranchByRemote(); + } + } + + public List LocalBranches + { + get; + } + + public List Remotes + { + get => _repo.Remotes; + } + + [Required(ErrorMessage = "Remote is required!!!")] + public Models.Remote SelectedRemote + { + get => _selectedRemote; + set + { + if (SetProperty(ref _selectedRemote, value, true)) + AutoSelectBranchByRemote(); + } + } + + public List RemoteBranches + { + get => _remoteBranches; + private set => SetProperty(ref _remoteBranches, value); + } + + [Required(ErrorMessage = "Remote branch is required!!!")] + public Models.Branch SelectedRemoteBranch + { + get => _selectedRemoteBranch; + set + { + if (SetProperty(ref _selectedRemoteBranch, value, true)) + IsSetTrackOptionVisible = value != null && _selectedLocalBranch.Upstream != value.FullName; + } + } + + public bool IsSetTrackOptionVisible + { + get => _isSetTrackOptionVisible; + private set => SetProperty(ref _isSetTrackOptionVisible, value); + } + + public bool Tracking + { + get; + set; + } = true; + + public bool IsCheckSubmodulesVisible + { + get => _repo.Submodules.Count > 0; + } + + public bool CheckSubmodules + { + get; + set; + } = true; + + public bool PushAllTags + { + get => _repo.Settings.PushAllTags; + set => _repo.Settings.PushAllTags = value; + } + + public bool ForcePush + { + get; + set; + } + + public Push(Repository repo, Models.Branch localBranch) + { + _repo = repo; + + // Gather all local branches and find current branch. + LocalBranches = new List(); + var current = null as Models.Branch; + foreach (var branch in _repo.Branches) + { + if (branch.IsLocal) + LocalBranches.Add(branch); + if (branch.IsCurrent) + current = branch; + } + + // Set default selected local branch. + if (localBranch != null) + { + if (LocalBranches.Count == 0) + LocalBranches.Add(localBranch); + + _selectedLocalBranch = localBranch; + HasSpecifiedLocalBranch = true; + } + else + { + _selectedLocalBranch = current; + HasSpecifiedLocalBranch = false; + } + + // Find preferred remote if selected local branch has upstream. + if (!string.IsNullOrEmpty(_selectedLocalBranch?.Upstream)) + { + foreach (var branch in repo.Branches) + { + if (!branch.IsLocal && _selectedLocalBranch.Upstream == branch.FullName) + { + _selectedRemote = repo.Remotes.Find(x => x.Name == branch.Remote); + break; + } + } + } + + // Set default remote to the first if it has not been set. + if (_selectedRemote == null) + { + var remote = null as Models.Remote; + if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + remote = repo.Remotes.Find(x => x.Name == _repo.Settings.DefaultRemote); + + _selectedRemote = remote ?? repo.Remotes[0]; + } + + // Auto select preferred remote branch. + AutoSelectBranchByRemote(); + } + + public override bool CanStartDirectly() + { + return !string.IsNullOrEmpty(_selectedRemoteBranch?.Head); + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + + var remoteBranchName = _selectedRemoteBranch.Name; + ProgressDescription = $"Push {_selectedLocalBranch.Name} -> {_selectedRemote.Name}/{remoteBranchName} ..."; + + var log = _repo.CreateLog("Push"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Push( + _repo.FullPath, + _selectedLocalBranch.Name, + _selectedRemote.Name, + remoteBranchName, + PushAllTags, + _repo.Submodules.Count > 0 && CheckSubmodules, + _isSetTrackOptionVisible && Tracking, + ForcePush).Use(log).Exec(); + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private void AutoSelectBranchByRemote() + { + // Gather branches. + var branches = new List(); + foreach (var branch in _repo.Branches) + { + if (branch.Remote == _selectedRemote.Name) + branches.Add(branch); + } + + // If selected local branch has upstream. Try to find it in current remote branches. + if (!string.IsNullOrEmpty(_selectedLocalBranch.Upstream)) + { + foreach (var branch in branches) + { + if (_selectedLocalBranch.Upstream == branch.FullName) + { + RemoteBranches = branches; + SelectedRemoteBranch = branch; + return; + } + } + } + + // Try to find a remote branch with the same name of selected local branch. + foreach (var branch in branches) + { + if (_selectedLocalBranch.Name == branch.Name) + { + RemoteBranches = branches; + SelectedRemoteBranch = branch; + return; + } + } + + // Add a fake new branch. + var fake = new Models.Branch() + { + Name = _selectedLocalBranch.Name, + Remote = _selectedRemote.Name, + }; + branches.Add(fake); + RemoteBranches = branches; + SelectedRemoteBranch = fake; + } + + private readonly Repository _repo = null; + private Models.Branch _selectedLocalBranch = null; + private Models.Remote _selectedRemote = null; + private List _remoteBranches = []; + private Models.Branch _selectedRemoteBranch = null; + private bool _isSetTrackOptionVisible = false; + } +} diff --git a/src/ViewModels/PushTag.cs b/src/ViewModels/PushTag.cs new file mode 100644 index 00000000..8cdf9767 --- /dev/null +++ b/src/ViewModels/PushTag.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class PushTag : Popup + { + public Models.Tag Target + { + get; + } + + public List Remotes + { + get => _repo.Remotes; + } + + public Models.Remote SelectedRemote + { + get; + set; + } + + public bool PushAllRemotes + { + get => _pushAllRemotes; + set => SetProperty(ref _pushAllRemotes, value); + } + + public PushTag(Repository repo, Models.Tag target) + { + _repo = repo; + Target = target; + SelectedRemote = _repo.Remotes[0]; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Pushing tag ..."; + + var log = _repo.CreateLog("Push Tag"); + Use(log); + + return Task.Run(() => + { + var succ = true; + var tag = $"refs/tags/{Target.Name}"; + if (_pushAllRemotes) + { + foreach (var remote in _repo.Remotes) + { + succ = new Commands.Push(_repo.FullPath, remote.Name, tag, false).Use(log).Exec(); + if (!succ) + break; + } + } + else + { + succ = new Commands.Push(_repo.FullPath, SelectedRemote.Name, tag, false).Use(log).Exec(); + } + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + private bool _pushAllRemotes = false; + } +} diff --git a/src/ViewModels/Rebase.cs b/src/ViewModels/Rebase.cs new file mode 100644 index 00000000..0fc8ec89 --- /dev/null +++ b/src/ViewModels/Rebase.cs @@ -0,0 +1,64 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Rebase : Popup + { + public Models.Branch Current + { + get; + private set; + } + + public object On + { + get; + private set; + } + + public bool AutoStash + { + get; + set; + } + + public Rebase(Repository repo, Models.Branch current, Models.Branch on) + { + _repo = repo; + _revision = on.Head; + Current = current; + On = on; + AutoStash = true; + } + + public Rebase(Repository repo, Models.Branch current, Models.Commit on) + { + _repo = repo; + _revision = on.SHA; + Current = current; + On = on; + AutoStash = true; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + _repo.ClearCommitMessage(); + ProgressDescription = "Rebasing ..."; + + var log = _repo.CreateLog("Rebase"); + Use(log); + + return Task.Run(() => + { + new Commands.Rebase(_repo.FullPath, _revision, AutoStash).Use(log).Exec(); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo; + private readonly string _revision; + } +} diff --git a/src/ViewModels/RemoveWorktree.cs b/src/ViewModels/RemoveWorktree.cs new file mode 100644 index 00000000..d5de8533 --- /dev/null +++ b/src/ViewModels/RemoveWorktree.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class RemoveWorktree : Popup + { + public Models.Worktree Target + { + get; + } + + public bool Force + { + get; + set; + } = false; + + public RemoveWorktree(Repository repo, Models.Worktree target) + { + _repo = repo; + Target = target; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Remove worktree ..."; + + var log = _repo.CreateLog("Remove worktree"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Worktree(_repo.FullPath).Use(log).Remove(Target.FullPath, Force); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/RenameBranch.cs b/src/ViewModels/RenameBranch.cs new file mode 100644 index 00000000..22cd2688 --- /dev/null +++ b/src/ViewModels/RenameBranch.cs @@ -0,0 +1,106 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class RenameBranch : Popup + { + public Models.Branch Target + { + get; + } + + [Required(ErrorMessage = "Branch name is required!!!")] + [RegularExpression(@"^[\w \-/\.#\+]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(RenameBranch), nameof(ValidateBranchName))] + public string Name + { + get => _name; + set => SetProperty(ref _name, value, true); + } + + public RenameBranch(Repository repo, Models.Branch target) + { + _repo = repo; + _name = target.Name; + Target = target; + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is RenameBranch rename) + { + var fixedName = rename.FixName(name); + foreach (var b in rename._repo.Branches) + { + if (b.IsLocal && b != rename.Target && b.Name == fixedName) + { + return new ValidationResult("A branch with same name already exists!!!"); + } + } + } + + return ValidationResult.Success; + } + + public override Task Sure() + { + var fixedName = FixName(_name); + if (fixedName == Target.Name) + return null; + + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Rename '{Target.Name}'"; + + var log = _repo.CreateLog($"Rename Branch '{Target.Name}'"); + Use(log); + + return Task.Run(() => + { + var isCurrent = Target.IsCurrent; + var oldName = Target.FullName; + var succ = Commands.Branch.Rename(_repo.FullPath, Target.Name, fixedName, log); + log.Complete(); + + CallUIThread(() => + { + ProgressDescription = "Waiting for branch updated..."; + + if (succ) + { + foreach (var filter in _repo.Settings.HistoriesFilters) + { + if (filter.Type == Models.FilterType.LocalBranch && + filter.Pattern.Equals(oldName, StringComparison.Ordinal)) + { + filter.Pattern = $"refs/heads/{fixedName}"; + break; + } + } + } + + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + + if (isCurrent) + Task.Delay(400).Wait(); + + return succ; + }); + } + + private string FixName(string name) + { + if (!name.Contains(' ')) + return name; + + var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return string.Join("-", parts); + } + + private readonly Repository _repo; + private string _name; + } +} diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs new file mode 100644 index 00000000..742d3634 --- /dev/null +++ b/src/ViewModels/Repository.cs @@ -0,0 +1,2966 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Repository : ObservableObject, Models.IRepository + { + public bool IsBare + { + get; + } + + public string FullPath + { + get => _fullpath; + set + { + if (value != null) + { + var normalized = value.Replace('\\', '/').TrimEnd('/'); + SetProperty(ref _fullpath, normalized); + } + else + { + SetProperty(ref _fullpath, null); + } + } + } + + public string GitDir + { + get => _gitDir; + set => SetProperty(ref _gitDir, value); + } + + public Models.RepositorySettings Settings + { + get => _settings; + } + + public Models.GitFlow GitFlow + { + get; + set; + } = new Models.GitFlow(); + + public Models.FilterMode HistoriesFilterMode + { + get => _historiesFilterMode; + private set => SetProperty(ref _historiesFilterMode, value); + } + + public bool HasAllowedSignersFile + { + get => _hasAllowedSignersFile; + } + + public int SelectedViewIndex + { + get => _selectedViewIndex; + set + { + if (SetProperty(ref _selectedViewIndex, value)) + { + switch (value) + { + case 1: + SelectedView = _workingCopy; + break; + case 2: + SelectedView = _stashesPage; + break; + default: + SelectedView = _histories; + break; + } + } + } + } + + public object SelectedView + { + get => _selectedView; + set => SetProperty(ref _selectedView, value); + } + + public bool EnableReflog + { + get => _settings.EnableReflog; + set + { + if (value != _settings.EnableReflog) + { + _settings.EnableReflog = value; + OnPropertyChanged(); + Task.Run(RefreshCommits); + } + } + } + + public bool EnableFirstParentInHistories + { + get => _settings.EnableFirstParentInHistories; + set + { + if (value != _settings.EnableFirstParentInHistories) + { + _settings.EnableFirstParentInHistories = value; + OnPropertyChanged(); + Task.Run(RefreshCommits); + } + } + } + + public bool OnlyHighlightCurrentBranchInHistories + { + get => _settings.OnlyHighlightCurrentBranchInHistories; + set + { + if (value != _settings.OnlyHighlightCurrentBranchInHistories) + { + _settings.OnlyHighlightCurrentBranchInHistories = value; + OnPropertyChanged(); + } + } + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + { + var builder = BuildBranchTree(_branches, _remotes); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + VisibleTags = BuildVisibleTags(); + VisibleSubmodules = BuildVisibleSubmodules(); + } + } + } + + public List Remotes + { + get => _remotes; + private set => SetProperty(ref _remotes, value); + } + + public List Branches + { + get => _branches; + private set => SetProperty(ref _branches, value); + } + + public Models.Branch CurrentBranch + { + get => _currentBranch; + private set + { + var oldHead = _currentBranch?.Head; + if (SetProperty(ref _currentBranch, value)) + { + if (oldHead != _currentBranch.Head && _workingCopy is { UseAmend: true }) + _workingCopy.UseAmend = false; + } + } + } + + public List LocalBranchTrees + { + get => _localBranchTrees; + private set => SetProperty(ref _localBranchTrees, value); + } + + public List RemoteBranchTrees + { + get => _remoteBranchTrees; + private set => SetProperty(ref _remoteBranchTrees, value); + } + + public List Worktrees + { + get => _worktrees; + private set => SetProperty(ref _worktrees, value); + } + + public List Tags + { + get => _tags; + private set => SetProperty(ref _tags, value); + } + + public bool ShowTagsAsTree + { + get => Preferences.Instance.ShowTagsAsTree; + set + { + if (value != Preferences.Instance.ShowTagsAsTree) + { + Preferences.Instance.ShowTagsAsTree = value; + VisibleTags = BuildVisibleTags(); + OnPropertyChanged(); + } + } + } + + public object VisibleTags + { + get => _visibleTags; + private set => SetProperty(ref _visibleTags, value); + } + + public List Submodules + { + get => _submodules; + private set => SetProperty(ref _submodules, value); + } + + public bool ShowSubmodulesAsTree + { + get => Preferences.Instance.ShowSubmodulesAsTree; + set + { + if (value != Preferences.Instance.ShowSubmodulesAsTree) + { + Preferences.Instance.ShowSubmodulesAsTree = value; + VisibleSubmodules = BuildVisibleSubmodules(); + OnPropertyChanged(); + } + } + } + + public object VisibleSubmodules + { + get => _visibleSubmodules; + private set => SetProperty(ref _visibleSubmodules, value); + } + + public int LocalChangesCount + { + get => _localChangesCount; + private set => SetProperty(ref _localChangesCount, value); + } + + public int StashesCount + { + get => _stashesCount; + private set => SetProperty(ref _stashesCount, value); + } + + public int LocalBranchesCount + { + get => _localBranchesCount; + private set => SetProperty(ref _localBranchesCount, value); + } + + public bool IncludeUntracked + { + get => _settings.IncludeUntrackedInLocalChanges; + set + { + if (value != _settings.IncludeUntrackedInLocalChanges) + { + _settings.IncludeUntrackedInLocalChanges = value; + OnPropertyChanged(); + Task.Run(RefreshWorkingCopyChanges); + } + } + } + + public bool IsSearching + { + get => _isSearching; + set + { + if (SetProperty(ref _isSearching, value)) + { + if (value) + { + SelectedViewIndex = 0; + CalcWorktreeFilesForSearching(); + } + else + { + SearchedCommits = new List(); + SelectedSearchedCommit = null; + SearchCommitFilter = string.Empty; + MatchedFilesForSearching = null; + _requestingWorktreeFiles = false; + _worktreeFiles = null; + } + } + } + } + + public bool IsSearchLoadingVisible + { + get => _isSearchLoadingVisible; + private set => SetProperty(ref _isSearchLoadingVisible, value); + } + + public bool OnlySearchCommitsInCurrentBranch + { + get => _onlySearchCommitsInCurrentBranch; + set + { + if (SetProperty(ref _onlySearchCommitsInCurrentBranch, value) && !string.IsNullOrEmpty(_searchCommitFilter)) + StartSearchCommits(); + } + } + + public int SearchCommitFilterType + { + get => _searchCommitFilterType; + set + { + if (SetProperty(ref _searchCommitFilterType, value)) + { + CalcWorktreeFilesForSearching(); + if (!string.IsNullOrEmpty(_searchCommitFilter)) + StartSearchCommits(); + } + } + } + + public string SearchCommitFilter + { + get => _searchCommitFilter; + set + { + if (SetProperty(ref _searchCommitFilter, value) && IsSearchingCommitsByFilePath()) + CalcMatchedFilesForSearching(); + } + } + + public List MatchedFilesForSearching + { + get => _matchedFilesForSearching; + private set => SetProperty(ref _matchedFilesForSearching, value); + } + + public List SearchedCommits + { + get => _searchedCommits; + set => SetProperty(ref _searchedCommits, value); + } + + public Models.Commit SelectedSearchedCommit + { + get => _selectedSearchedCommit; + set + { + if (SetProperty(ref _selectedSearchedCommit, value) && value != null) + NavigateToCommit(value.SHA); + } + } + + public bool IsLocalBranchGroupExpanded + { + get => _settings.IsLocalBranchesExpandedInSideBar; + set + { + if (value != _settings.IsLocalBranchesExpandedInSideBar) + { + _settings.IsLocalBranchesExpandedInSideBar = value; + OnPropertyChanged(); + } + } + } + + public bool IsRemoteGroupExpanded + { + get => _settings.IsRemotesExpandedInSideBar; + set + { + if (value != _settings.IsRemotesExpandedInSideBar) + { + _settings.IsRemotesExpandedInSideBar = value; + OnPropertyChanged(); + } + } + } + + public bool IsTagGroupExpanded + { + get => _settings.IsTagsExpandedInSideBar; + set + { + if (value != _settings.IsTagsExpandedInSideBar) + { + _settings.IsTagsExpandedInSideBar = value; + OnPropertyChanged(); + } + } + } + + public bool IsSubmoduleGroupExpanded + { + get => _settings.IsSubmodulesExpandedInSideBar; + set + { + if (value != _settings.IsSubmodulesExpandedInSideBar) + { + _settings.IsSubmodulesExpandedInSideBar = value; + OnPropertyChanged(); + } + } + } + + public bool IsWorktreeGroupExpanded + { + get => _settings.IsWorktreeExpandedInSideBar; + set + { + if (value != _settings.IsWorktreeExpandedInSideBar) + { + _settings.IsWorktreeExpandedInSideBar = value; + OnPropertyChanged(); + } + } + } + + public bool IsSortingLocalBranchByName + { + get => _settings.LocalBranchSortMode == Models.BranchSortMode.Name; + } + + public bool IsSortingRemoteBranchByName + { + get => _settings.RemoteBranchSortMode == Models.BranchSortMode.Name; + } + + public bool IsSortingTagsByName + { + get => _settings.TagSortMode == Models.TagSortMode.Name; + } + + public InProgressContext InProgressContext + { + get => _workingCopy?.InProgressContext; + } + + public Models.BisectState BisectState + { + get => _bisectState; + private set => SetProperty(ref _bisectState, value); + } + + public bool IsBisectCommandRunning + { + get => _isBisectCommandRunning; + private set => SetProperty(ref _isBisectCommandRunning, value); + } + + public bool IsAutoFetching + { + get => _isAutoFetching; + private set => SetProperty(ref _isAutoFetching, value); + } + + public int CommitDetailActivePageIndex + { + get; + set; + } = 0; + + public AvaloniaList Logs + { + get; + private set; + } = new AvaloniaList(); + + public Repository(bool isBare, string path, string gitDir) + { + IsBare = isBare; + FullPath = path; + GitDir = gitDir; + } + + public void Open() + { + var settingsFile = Path.Combine(_gitDir, "sourcegit.settings"); + if (File.Exists(settingsFile)) + { + try + { + _settings = JsonSerializer.Deserialize(File.ReadAllText(settingsFile), JsonCodeGen.Default.RepositorySettings); + } + catch + { + _settings = new Models.RepositorySettings(); + } + } + else + { + _settings = new Models.RepositorySettings(); + } + + try + { + // For worktrees, we need to watch the $GIT_COMMON_DIR instead of the $GIT_DIR. + var gitDirForWatcher = _gitDir; + if (_gitDir.Replace('\\', '/').IndexOf("/worktrees/", StringComparison.Ordinal) > 0) + { + var commonDir = new Commands.QueryGitCommonDir(_fullpath).Result(); + if (!string.IsNullOrEmpty(commonDir)) + gitDirForWatcher = commonDir; + } + + _watcher = new Models.Watcher(this, _fullpath, gitDirForWatcher); + } + catch (Exception ex) + { + App.RaiseException(string.Empty, $"Failed to start watcher for repository: '{_fullpath}'. You may need to press 'F5' to refresh repository manually!\n\nReason: {ex.Message}"); + } + + if (_settings.HistoriesFilters.Count > 0) + _historiesFilterMode = _settings.HistoriesFilters[0].Mode; + else + _historiesFilterMode = Models.FilterMode.None; + + _histories = new Histories(this); + _workingCopy = new WorkingCopy(this); + _stashesPage = new StashesPage(this); + _selectedView = _histories; + _selectedViewIndex = 0; + + _workingCopy.CommitMessage = _settings.LastCommitMessage; + _autoFetchTimer = new Timer(AutoFetchImpl, null, 5000, 5000); + RefreshAll(); + } + + public void Close() + { + SelectedView = null; // Do NOT modify. Used to remove exists widgets for GC.Collect + Logs.Clear(); + + _settings.LastCommitMessage = _workingCopy.CommitMessage; + + var settingsSerialized = JsonSerializer.Serialize(_settings, JsonCodeGen.Default.RepositorySettings); + try + { + File.WriteAllText(Path.Combine(_gitDir, "sourcegit.settings"), settingsSerialized); + } + catch + { + // Ignore + } + _autoFetchTimer.Dispose(); + _autoFetchTimer = null; + + _settings = null; + _historiesFilterMode = Models.FilterMode.None; + + _watcher?.Dispose(); + _histories.Dispose(); + _workingCopy.Dispose(); + _stashesPage.Dispose(); + + _watcher = null; + _histories = null; + _workingCopy = null; + _stashesPage = null; + + _localChangesCount = 0; + _stashesCount = 0; + + _remotes.Clear(); + _branches.Clear(); + _localBranchTrees.Clear(); + _remoteBranchTrees.Clear(); + _tags.Clear(); + _visibleTags = null; + _submodules.Clear(); + _visibleSubmodules = null; + _searchedCommits.Clear(); + _selectedSearchedCommit = null; + + _requestingWorktreeFiles = false; + _worktreeFiles = null; + _matchedFilesForSearching = null; + } + + public bool CanCreatePopup() + { + var page = GetOwnerPage(); + if (page == null) + return false; + + return !_isAutoFetching && page.CanCreatePopup(); + } + + public void ShowPopup(Popup popup) + { + var page = GetOwnerPage(); + if (page != null) + page.Popup = popup; + } + + public void ShowAndStartPopup(Popup popup) + { + GetOwnerPage()?.StartPopup(popup); + } + + public bool IsGitFlowEnabled() + { + return GitFlow is { IsValid: true } && + _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.Master, StringComparison.Ordinal)) != null && + _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.Develop, StringComparison.Ordinal)) != null; + } + + public Models.GitFlowBranchType GetGitFlowType(Models.Branch b) + { + if (!IsGitFlowEnabled()) + return Models.GitFlowBranchType.None; + + var name = b.Name; + if (name.StartsWith(GitFlow.FeaturePrefix, StringComparison.Ordinal)) + return Models.GitFlowBranchType.Feature; + if (name.StartsWith(GitFlow.ReleasePrefix, StringComparison.Ordinal)) + return Models.GitFlowBranchType.Release; + if (name.StartsWith(GitFlow.HotfixPrefix, StringComparison.Ordinal)) + return Models.GitFlowBranchType.Hotfix; + return Models.GitFlowBranchType.None; + } + + public CommandLog CreateLog(string name) + { + var log = new CommandLog(name); + Logs.Insert(0, log); + return log; + } + + public void RefreshAll() + { + Task.Run(RefreshCommits); + Task.Run(RefreshBranches); + Task.Run(RefreshTags); + Task.Run(RefreshSubmodules); + Task.Run(RefreshWorktrees); + Task.Run(RefreshWorkingCopyChanges); + Task.Run(RefreshStashes); + + Task.Run(() => + { + var config = new Commands.Config(_fullpath).ListAll(); + _hasAllowedSignersFile = config.TryGetValue("gpg.ssh.allowedSignersFile", out var allowedSignersFile) && !string.IsNullOrEmpty(allowedSignersFile); + + if (config.TryGetValue("gitflow.branch.master", out var masterName)) + GitFlow.Master = masterName; + if (config.TryGetValue("gitflow.branch.develop", out var developName)) + GitFlow.Develop = developName; + if (config.TryGetValue("gitflow.prefix.feature", out var featurePrefix)) + GitFlow.FeaturePrefix = featurePrefix; + if (config.TryGetValue("gitflow.prefix.release", out var releasePrefix)) + GitFlow.ReleasePrefix = releasePrefix; + if (config.TryGetValue("gitflow.prefix.hotfix", out var hotfixPrefix)) + GitFlow.HotfixPrefix = hotfixPrefix; + }); + } + + public ContextMenu CreateContextMenuForExternalTools() + { + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + + RenderOptions.SetBitmapInterpolationMode(menu, BitmapInterpolationMode.HighQuality); + RenderOptions.SetEdgeMode(menu, EdgeMode.Antialias); + RenderOptions.SetTextRenderingMode(menu, TextRenderingMode.Antialias); + + var explore = new MenuItem(); + explore.Header = App.Text("Repository.Explore"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.Click += (_, e) => + { + Native.OS.OpenInFileManager(_fullpath); + e.Handled = true; + }; + + var terminal = new MenuItem(); + terminal.Header = App.Text("Repository.Terminal"); + terminal.Icon = App.CreateMenuIcon("Icons.Terminal"); + terminal.Click += (_, e) => + { + Native.OS.OpenTerminal(_fullpath); + e.Handled = true; + }; + + menu.Items.Add(explore); + menu.Items.Add(terminal); + + var tools = Native.OS.ExternalTools; + if (tools.Count > 0) + { + menu.Items.Add(new MenuItem() { Header = "-" }); + + foreach (var tool in Native.OS.ExternalTools) + { + var dupTool = tool; + + var item = new MenuItem(); + item.Header = App.Text("Repository.OpenIn", dupTool.Name); + item.Icon = new Image { Width = 16, Height = 16, Source = dupTool.IconImage }; + item.Click += (_, e) => + { + dupTool.Open(_fullpath); + e.Handled = true; + }; + + menu.Items.Add(item); + } + } + + var urls = new Dictionary(); + foreach (var r in _remotes) + { + if (r.TryGetVisitURL(out var visit)) + urls.Add(r.Name, visit); + } + + if (urls.Count > 0) + { + menu.Items.Add(new MenuItem() { Header = "-" }); + + foreach (var (name, addr) in urls) + { + var item = new MenuItem(); + item.Header = App.Text("Repository.Visit", name); + item.Icon = App.CreateMenuIcon("Icons.Remotes"); + item.Click += (_, e) => + { + Native.OS.OpenBrowser(addr); + e.Handled = true; + }; + + menu.Items.Add(item); + } + } + + return menu; + } + + public void Fetch(bool autoStart) + { + if (!CanCreatePopup()) + return; + + if (_remotes.Count == 0) + { + App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + return; + } + + if (autoStart) + ShowAndStartPopup(new Fetch(this)); + else + ShowPopup(new Fetch(this)); + } + + public void Pull(bool autoStart) + { + if (!CanCreatePopup()) + return; + + if (_remotes.Count == 0) + { + App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + return; + } + + if (_currentBranch == null) + { + App.RaiseException(_fullpath, "Can NOT find current branch!!!"); + return; + } + + var pull = new Pull(this, null); + if (autoStart && pull.SelectedBranch != null) + ShowAndStartPopup(pull); + else + ShowPopup(pull); + } + + public void Push(bool autoStart) + { + if (!CanCreatePopup()) + return; + + if (_remotes.Count == 0) + { + App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + return; + } + + if (_currentBranch == null) + { + App.RaiseException(_fullpath, "Can NOT find current branch!!!"); + return; + } + + if (autoStart) + ShowAndStartPopup(new Push(this, null)); + else + ShowPopup(new Push(this, null)); + } + + public void ApplyPatch() + { + if (!CanCreatePopup()) + return; + ShowPopup(new Apply(this)); + } + + public void Cleanup() + { + if (!CanCreatePopup()) + return; + ShowAndStartPopup(new Cleanup(this)); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public void ClearSearchCommitFilter() + { + SearchCommitFilter = string.Empty; + } + + public void ClearMatchedFilesForSearching() + { + MatchedFilesForSearching = null; + } + + public void StartSearchCommits() + { + if (_histories == null) + return; + + IsSearchLoadingVisible = true; + SelectedSearchedCommit = null; + MatchedFilesForSearching = null; + + Task.Run(() => + { + var visible = null as List; + var method = (Models.CommitSearchMethod)_searchCommitFilterType; + + if (method == Models.CommitSearchMethod.BySHA) + { + var commit = new Commands.QuerySingleCommit(_fullpath, _searchCommitFilter).Result(); + visible = commit == null ? [] : [commit]; + } + else + { + visible = new Commands.QueryCommits(_fullpath, _searchCommitFilter, method, _onlySearchCommitsInCurrentBranch).Result(); + } + + Dispatcher.UIThread.Invoke(() => + { + SearchedCommits = visible; + IsSearchLoadingVisible = false; + }); + }); + } + + public void SetWatcherEnabled(bool enabled) + { + _watcher?.SetEnabled(enabled); + } + + public void MarkBranchesDirtyManually() + { + if (_watcher == null) + { + Task.Run(RefreshBranches); + Task.Run(RefreshCommits); + Task.Run(RefreshWorkingCopyChanges); + Task.Run(RefreshWorktrees); + } + else + { + _watcher.MarkBranchDirtyManually(); + } + } + + public void MarkTagsDirtyManually() + { + if (_watcher == null) + { + Task.Run(RefreshTags); + Task.Run(RefreshCommits); + } + else + { + _watcher.MarkTagDirtyManually(); + } + } + + public void MarkWorkingCopyDirtyManually() + { + if (_watcher == null) + Task.Run(RefreshWorkingCopyChanges); + else + _watcher.MarkWorkingCopyDirtyManually(); + } + + public void MarkFetched() + { + _lastFetchTime = DateTime.Now; + } + + public void NavigateToCommit(string sha, bool isDelayMode = false) + { + if (isDelayMode) + { + _navigateToCommitDelayed = sha; + } + else if (_histories != null) + { + SelectedViewIndex = 0; + _histories.NavigateTo(sha); + } + } + + public void ClearCommitMessage() + { + if (_workingCopy is not null) + _workingCopy.CommitMessage = string.Empty; + } + + public void ClearHistoriesFilter() + { + _settings.HistoriesFilters.Clear(); + HistoriesFilterMode = Models.FilterMode.None; + + ResetBranchTreeFilterMode(LocalBranchTrees); + ResetBranchTreeFilterMode(RemoteBranchTrees); + ResetTagFilterMode(); + Task.Run(RefreshCommits); + } + + public void RemoveHistoriesFilter(Models.Filter filter) + { + if (_settings.HistoriesFilters.Remove(filter)) + { + HistoriesFilterMode = _settings.HistoriesFilters.Count > 0 ? _settings.HistoriesFilters[0].Mode : Models.FilterMode.None; + RefreshHistoriesFilters(true); + } + } + + public void UpdateBranchNodeIsExpanded(BranchTreeNode node) + { + if (_settings == null || !string.IsNullOrWhiteSpace(_filter)) + return; + + if (node.IsExpanded) + { + if (!_settings.ExpandedBranchNodesInSideBar.Contains(node.Path)) + _settings.ExpandedBranchNodesInSideBar.Add(node.Path); + } + else + { + _settings.ExpandedBranchNodesInSideBar.Remove(node.Path); + } + } + + public void SetTagFilterMode(Models.Tag tag, Models.FilterMode mode) + { + var changed = _settings.UpdateHistoriesFilter(tag.Name, Models.FilterType.Tag, mode); + if (changed) + RefreshHistoriesFilters(true); + } + + public void SetBranchFilterMode(Models.Branch branch, Models.FilterMode mode, bool clearExists, bool refresh) + { + var node = FindBranchNode(branch.IsLocal ? _localBranchTrees : _remoteBranchTrees, branch.FullName); + if (node != null) + SetBranchFilterMode(node, mode, clearExists, refresh); + } + + public void SetBranchFilterMode(BranchTreeNode node, Models.FilterMode mode, bool clearExists, bool refresh) + { + var isLocal = node.Path.StartsWith("refs/heads/", StringComparison.Ordinal); + var tree = isLocal ? _localBranchTrees : _remoteBranchTrees; + + if (clearExists) + { + _settings.HistoriesFilters.Clear(); + HistoriesFilterMode = Models.FilterMode.None; + } + + if (node.Backend is Models.Branch branch) + { + var type = isLocal ? Models.FilterType.LocalBranch : Models.FilterType.RemoteBranch; + var changed = _settings.UpdateHistoriesFilter(node.Path, type, mode); + if (!changed) + return; + + if (isLocal && !string.IsNullOrEmpty(branch.Upstream) && !branch.IsUpstreamGone) + _settings.UpdateHistoriesFilter(branch.Upstream, Models.FilterType.RemoteBranch, mode); + } + else + { + var type = isLocal ? Models.FilterType.LocalBranchFolder : Models.FilterType.RemoteBranchFolder; + var changed = _settings.UpdateHistoriesFilter(node.Path, type, mode); + if (!changed) + return; + + _settings.RemoveChildrenBranchFilters(node.Path); + } + + var parentType = isLocal ? Models.FilterType.LocalBranchFolder : Models.FilterType.RemoteBranchFolder; + var cur = node; + do + { + var lastSepIdx = cur.Path.LastIndexOf('/'); + if (lastSepIdx <= 0) + break; + + var parentPath = cur.Path.Substring(0, lastSepIdx); + var parent = FindBranchNode(tree, parentPath); + if (parent == null) + break; + + _settings.UpdateHistoriesFilter(parent.Path, parentType, Models.FilterMode.None); + cur = parent; + } while (true); + + RefreshHistoriesFilters(refresh); + } + + public void StashAll(bool autoStart) + { + _workingCopy?.StashAll(autoStart); + } + + public void SkipMerge() + { + _workingCopy?.SkipMerge(); + } + + public void AbortMerge() + { + _workingCopy?.AbortMerge(); + } + + public List GetCustomActions(Models.CustomActionScope scope) + { + var actions = new List(); + + foreach (var act in Preferences.Instance.CustomActions) + { + if (act.Scope == scope) + actions.Add(act); + } + + foreach (var act in _settings.CustomActions) + { + if (act.Scope == scope) + actions.Add(act); + } + + return actions; + } + + public void Bisect(string subcmd) + { + IsBisectCommandRunning = true; + SetWatcherEnabled(false); + + var log = CreateLog($"Bisect({subcmd})"); + Task.Run(() => + { + var succ = new Commands.Bisect(_fullpath, subcmd).Use(log).Exec(); + log.Complete(); + + Dispatcher.UIThread.Invoke(() => + { + if (!succ) + App.RaiseException(_fullpath, log.Content.Substring(log.Content.IndexOf('\n')).Trim()); + else if (log.Content.Contains("is the first bad commit")) + App.SendNotification(_fullpath, log.Content.Substring(log.Content.IndexOf('\n')).Trim()); + + MarkBranchesDirtyManually(); + SetWatcherEnabled(true); + IsBisectCommandRunning = false; + }); + }); + } + + public bool MayHaveSubmodules() + { + var modulesFile = Path.Combine(_fullpath, ".gitmodules"); + var info = new FileInfo(modulesFile); + return info.Exists && info.Length > 20; + } + + public void RefreshBranches() + { + var branches = new Commands.QueryBranches(_fullpath).Result(out var localBranchesCount); + var remotes = new Commands.QueryRemotes(_fullpath).Result(); + var builder = BuildBranchTree(branches, remotes); + + Dispatcher.UIThread.Invoke(() => + { + lock (_lockRemotes) + Remotes = remotes; + + Branches = branches; + CurrentBranch = branches.Find(x => x.IsCurrent); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + LocalBranchesCount = localBranchesCount; + + if (_workingCopy != null) + _workingCopy.HasRemotes = remotes.Count > 0; + + var hasPendingPullOrPush = CurrentBranch?.TrackStatus.IsVisible ?? false; + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasPendingPullOrPush, !hasPendingPullOrPush); + }); + } + + public void RefreshWorktrees() + { + var worktrees = new Commands.Worktree(_fullpath).List(); + var cleaned = new List(); + + foreach (var worktree in worktrees) + { + if (worktree.IsBare || worktree.FullPath.Equals(_fullpath)) + continue; + + cleaned.Add(worktree); + } + + Dispatcher.UIThread.Invoke(() => + { + Worktrees = cleaned; + }); + } + + public void RefreshTags() + { + var tags = new Commands.QueryTags(_fullpath).Result(); + Dispatcher.UIThread.Invoke(() => + { + Tags = tags; + VisibleTags = BuildVisibleTags(); + }); + } + + public void RefreshCommits() + { + Dispatcher.UIThread.Invoke(() => _histories.IsLoading = true); + + var builder = new StringBuilder(); + builder.Append($"-{Preferences.Instance.MaxHistoryCommits} "); + + if (_settings.EnableTopoOrderInHistories) + builder.Append("--topo-order "); + else + builder.Append("--date-order "); + + if (_settings.EnableReflog) + builder.Append("--reflog "); + if (_settings.EnableFirstParentInHistories) + builder.Append("--first-parent "); + + var filters = _settings.BuildHistoriesFilter(); + if (string.IsNullOrEmpty(filters)) + builder.Append("--branches --remotes --tags HEAD"); + else + builder.Append(filters); + + var commits = new Commands.QueryCommits(_fullpath, builder.ToString()).Result(); + var graph = Models.CommitGraph.Parse(commits, _settings.EnableFirstParentInHistories); + + Dispatcher.UIThread.Invoke(() => + { + if (_histories != null) + { + _histories.IsLoading = false; + _histories.Commits = commits; + _histories.Graph = graph; + + BisectState = _histories.UpdateBisectInfo(); + + if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) + NavigateToCommit(_navigateToCommitDelayed); + } + + _navigateToCommitDelayed = string.Empty; + }); + } + + public void RefreshSubmodules() + { + if (!MayHaveSubmodules()) + { + if (_submodules.Count > 0) + { + Dispatcher.UIThread.Invoke(() => + { + Submodules = []; + VisibleSubmodules = BuildVisibleSubmodules(); + }); + } + + return; + } + + var submodules = new Commands.QuerySubmodules(_fullpath).Result(); + _watcher?.SetSubmodules(submodules); + + Dispatcher.UIThread.Invoke(() => + { + bool hasChanged = _submodules.Count != submodules.Count; + if (!hasChanged) + { + var old = new Dictionary(); + foreach (var module in _submodules) + old.Add(module.Path, module); + + foreach (var module in submodules) + { + if (!old.TryGetValue(module.Path, out var exist)) + { + hasChanged = true; + break; + } + + hasChanged = !exist.SHA.Equals(module.SHA, StringComparison.Ordinal) || + !exist.URL.Equals(module.URL, StringComparison.Ordinal) || + exist.Status != module.Status; + + if (hasChanged) + break; + } + } + + if (hasChanged) + { + Submodules = submodules; + VisibleSubmodules = BuildVisibleSubmodules(); + } + }); + } + + public void RefreshWorkingCopyChanges() + { + if (IsBare) + return; + + var changes = new Commands.QueryLocalChanges(_fullpath, _settings.IncludeUntrackedInLocalChanges).Result(); + if (_workingCopy == null) + return; + + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + _workingCopy.SetData(changes); + + Dispatcher.UIThread.Invoke(() => + { + LocalChangesCount = changes.Count; + OnPropertyChanged(nameof(InProgressContext)); + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasLocalChanges, changes.Count == 0); + }); + } + + public void RefreshStashes() + { + if (IsBare) + return; + + var stashes = new Commands.QueryStashes(_fullpath).Result(); + Dispatcher.UIThread.Invoke(() => + { + if (_stashesPage != null) + _stashesPage.Stashes = stashes; + + StashesCount = stashes.Count; + }); + } + + public void CreateNewBranch() + { + if (_currentBranch == null) + { + App.RaiseException(_fullpath, "Git cannot create a branch before your first commit."); + return; + } + + if (CanCreatePopup()) + ShowPopup(new CreateBranch(this, _currentBranch)); + } + + public void CheckoutBranch(Models.Branch branch) + { + if (branch.IsLocal) + { + var worktree = _worktrees.Find(x => x.Branch.Equals(branch.FullName, StringComparison.Ordinal)); + if (worktree != null) + { + OpenWorktree(worktree); + return; + } + } + + if (IsBare) + return; + + if (!CanCreatePopup()) + return; + + if (branch.IsLocal) + { + if (_localChangesCount > 0 || _submodules.Count > 0) + ShowPopup(new Checkout(this, branch.Name)); + else + ShowAndStartPopup(new Checkout(this, branch.Name)); + } + else + { + foreach (var b in _branches) + { + if (b.IsLocal && + b.Upstream.Equals(branch.FullName, StringComparison.Ordinal) && + b.TrackStatus.Ahead.Count == 0) + { + if (b.TrackStatus.Behind.Count > 0) + ShowPopup(new CheckoutAndFastForward(this, b, branch)); + else if (!b.IsCurrent) + CheckoutBranch(b); + + return; + } + } + + ShowPopup(new CreateBranch(this, branch)); + } + } + + public void DeleteBranch(Models.Branch branch) + { + if (CanCreatePopup()) + ShowPopup(new DeleteBranch(this, branch)); + } + + public void DeleteMultipleBranches(List branches, bool isLocal) + { + if (CanCreatePopup()) + ShowPopup(new DeleteMultipleBranches(this, branches, isLocal)); + } + + public void MergeMultipleBranches(List branches) + { + if (CanCreatePopup()) + ShowPopup(new MergeMultiple(this, branches)); + } + + public void CreateNewTag() + { + if (_currentBranch == null) + { + App.RaiseException(_fullpath, "Git cannot create a branch before your first commit."); + return; + } + + if (CanCreatePopup()) + ShowPopup(new CreateTag(this, _currentBranch)); + } + + public void DeleteTag(Models.Tag tag) + { + if (CanCreatePopup()) + ShowPopup(new DeleteTag(this, tag)); + } + + public void AddRemote() + { + if (CanCreatePopup()) + ShowPopup(new AddRemote(this)); + } + + public void DeleteRemote(Models.Remote remote) + { + if (CanCreatePopup()) + ShowPopup(new DeleteRemote(this, remote)); + } + + public void AddSubmodule() + { + if (CanCreatePopup()) + ShowPopup(new AddSubmodule(this)); + } + + public void UpdateSubmodules() + { + if (CanCreatePopup()) + ShowPopup(new UpdateSubmodules(this)); + } + + public void OpenSubmodule(string submodule) + { + var selfPage = GetOwnerPage(); + if (selfPage == null) + return; + + var root = Path.GetFullPath(Path.Combine(_fullpath, submodule)); + var normalizedPath = root.Replace('\\', '/').TrimEnd('/'); + + var node = Preferences.Instance.FindNode(normalizedPath); + if (node == null) + { + node = new RepositoryNode() + { + Id = normalizedPath, + Name = Path.GetFileName(normalizedPath), + Bookmark = selfPage.Node.Bookmark, + IsRepository = true, + }; + } + + App.GetLauncher().OpenRepositoryInTab(node, null); + } + + public void AddWorktree() + { + if (CanCreatePopup()) + ShowPopup(new AddWorktree(this)); + } + + public void PruneWorktrees() + { + if (CanCreatePopup()) + ShowAndStartPopup(new PruneWorktrees(this)); + } + + public void OpenWorktree(Models.Worktree worktree) + { + var node = Preferences.Instance.FindNode(worktree.FullPath); + if (node == null) + { + node = new RepositoryNode() + { + Id = worktree.FullPath, + Name = Path.GetFileName(worktree.FullPath), + Bookmark = 0, + IsRepository = true, + }; + } + + App.GetLauncher()?.OpenRepositoryInTab(node, null); + } + + public List GetPreferredOpenAIServices() + { + var services = Preferences.Instance.OpenAIServices; + if (services == null || services.Count == 0) + return []; + + if (services.Count == 1) + return [services[0]]; + + var preferred = _settings.PreferredOpenAIService; + var all = new List(); + foreach (var service in services) + { + if (service.Name.Equals(preferred, StringComparison.Ordinal)) + return [service]; + + all.Add(service); + } + + return all; + } + + public ContextMenu CreateContextMenuForGitFlow() + { + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + + if (IsGitFlowEnabled()) + { + var startFeature = new MenuItem(); + startFeature.Header = App.Text("GitFlow.StartFeature"); + startFeature.Icon = App.CreateMenuIcon("Icons.GitFlow.Feature"); + startFeature.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Feature)); + e.Handled = true; + }; + + var startRelease = new MenuItem(); + startRelease.Header = App.Text("GitFlow.StartRelease"); + startRelease.Icon = App.CreateMenuIcon("Icons.GitFlow.Release"); + startRelease.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Release)); + e.Handled = true; + }; + + var startHotfix = new MenuItem(); + startHotfix.Header = App.Text("GitFlow.StartHotfix"); + startHotfix.Icon = App.CreateMenuIcon("Icons.GitFlow.Hotfix"); + startHotfix.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Hotfix)); + e.Handled = true; + }; + + menu.Items.Add(startFeature); + menu.Items.Add(startRelease); + menu.Items.Add(startHotfix); + } + else + { + var init = new MenuItem(); + init.Header = App.Text("GitFlow.Init"); + init.Icon = App.CreateMenuIcon("Icons.Init"); + init.Click += (_, e) => + { + if (_currentBranch == null) + { + App.RaiseException(_fullpath, "Git flow init failed: No branch found!!!"); + } + else if (CanCreatePopup()) + { + ShowPopup(new InitGitFlow(this)); + } + + e.Handled = true; + }; + menu.Items.Add(init); + } + return menu; + } + + public ContextMenu CreateContextMenuForGitLFS() + { + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + + var lfs = new Commands.LFS(_fullpath); + if (lfs.IsEnabled()) + { + var addPattern = new MenuItem(); + addPattern.Header = App.Text("GitLFS.AddTrackPattern"); + addPattern.Icon = App.CreateMenuIcon("Icons.File.Add"); + addPattern.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new LFSTrackCustomPattern(this)); + + e.Handled = true; + }; + menu.Items.Add(addPattern); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var fetch = new MenuItem(); + fetch.Header = App.Text("GitLFS.Fetch"); + fetch.Icon = App.CreateMenuIcon("Icons.Fetch"); + fetch.IsEnabled = _remotes.Count > 0; + fetch.Click += (_, e) => + { + if (CanCreatePopup()) + { + if (_remotes.Count == 1) + ShowAndStartPopup(new LFSFetch(this)); + else + ShowPopup(new LFSFetch(this)); + } + + e.Handled = true; + }; + menu.Items.Add(fetch); + + var pull = new MenuItem(); + pull.Header = App.Text("GitLFS.Pull"); + pull.Icon = App.CreateMenuIcon("Icons.Pull"); + pull.IsEnabled = _remotes.Count > 0; + pull.Click += (_, e) => + { + if (CanCreatePopup()) + { + if (_remotes.Count == 1) + ShowAndStartPopup(new LFSPull(this)); + else + ShowPopup(new LFSPull(this)); + } + + e.Handled = true; + }; + menu.Items.Add(pull); + + var push = new MenuItem(); + push.Header = App.Text("GitLFS.Push"); + push.Icon = App.CreateMenuIcon("Icons.Push"); + push.IsEnabled = _remotes.Count > 0; + push.Click += (_, e) => + { + if (CanCreatePopup()) + { + if (_remotes.Count == 1) + ShowAndStartPopup(new LFSPush(this)); + else + ShowPopup(new LFSPush(this)); + } + + e.Handled = true; + }; + menu.Items.Add(push); + + var prune = new MenuItem(); + prune.Header = App.Text("GitLFS.Prune"); + prune.Icon = App.CreateMenuIcon("Icons.Clean"); + prune.Click += (_, e) => + { + if (CanCreatePopup()) + ShowAndStartPopup(new LFSPrune(this)); + + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(prune); + + var locks = new MenuItem(); + locks.Header = App.Text("GitLFS.Locks"); + locks.Icon = App.CreateMenuIcon("Icons.Lock"); + locks.IsEnabled = _remotes.Count > 0; + if (_remotes.Count == 1) + { + locks.Click += (_, e) => + { + App.ShowWindow(new LFSLocks(this, _remotes[0].Name), true); + e.Handled = true; + }; + } + else + { + foreach (var remote in _remotes) + { + var remoteName = remote.Name; + var lockRemote = new MenuItem(); + lockRemote.Header = remoteName; + lockRemote.Click += (_, e) => + { + App.ShowWindow(new LFSLocks(this, remoteName), true); + e.Handled = true; + }; + locks.Items.Add(lockRemote); + } + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(locks); + } + else + { + var install = new MenuItem(); + install.Header = App.Text("GitLFS.Install"); + install.Icon = App.CreateMenuIcon("Icons.Init"); + install.Click += (_, e) => + { + var log = CreateLog("Install LFS"); + var succ = new Commands.LFS(_fullpath).Install(log); + if (succ) + App.SendNotification(_fullpath, $"LFS enabled successfully!"); + + log.Complete(); + e.Handled = true; + }; + menu.Items.Add(install); + } + + return menu; + } + + public ContextMenu CreateContextMenuForCustomAction() + { + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + + var actions = GetCustomActions(Models.CustomActionScope.Repository); + if (actions.Count > 0) + { + foreach (var action in actions) + { + var dup = action; + var item = new MenuItem(); + item.Icon = App.CreateMenuIcon("Icons.Action"); + item.Header = dup.Name; + item.Click += (_, e) => + { + if (CanCreatePopup()) + ShowAndStartPopup(new ExecuteCustomAction(this, dup)); + + e.Handled = true; + }; + + menu.Items.Add(item); + } + } + else + { + menu.Items.Add(new MenuItem() { Header = App.Text("Repository.CustomActions.Empty") }); + } + + return menu; + } + + public ContextMenu CreateContextMenuForHistoriesPage() + { + var layout = new MenuItem(); + layout.Header = App.Text("Repository.HistoriesLayout"); + layout.IsEnabled = false; + + var isHorizontal = Preferences.Instance.UseTwoColumnsLayoutInHistories; + var horizontal = new MenuItem(); + horizontal.Header = App.Text("Repository.HistoriesLayout.Horizontal"); + if (isHorizontal) + horizontal.Icon = App.CreateMenuIcon("Icons.Check"); + horizontal.Click += (_, ev) => + { + Preferences.Instance.UseTwoColumnsLayoutInHistories = true; + ev.Handled = true; + }; + + var vertical = new MenuItem(); + vertical.Header = App.Text("Repository.HistoriesLayout.Vertical"); + if (!isHorizontal) + vertical.Icon = App.CreateMenuIcon("Icons.Check"); + vertical.Click += (_, ev) => + { + Preferences.Instance.UseTwoColumnsLayoutInHistories = false; + ev.Handled = true; + }; + + var order = new MenuItem(); + order.Header = App.Text("Repository.HistoriesOrder"); + order.IsEnabled = false; + + var dateOrder = new MenuItem(); + dateOrder.Header = App.Text("Repository.HistoriesOrder.ByDate"); + dateOrder.SetValue(Views.MenuItemExtension.CommandProperty, "--date-order"); + if (!_settings.EnableTopoOrderInHistories) + dateOrder.Icon = App.CreateMenuIcon("Icons.Check"); + dateOrder.Click += (_, ev) => + { + if (_settings.EnableTopoOrderInHistories) + { + _settings.EnableTopoOrderInHistories = false; + Task.Run(RefreshCommits); + } + + ev.Handled = true; + }; + + var topoOrder = new MenuItem(); + topoOrder.Header = App.Text("Repository.HistoriesOrder.Topo"); + topoOrder.SetValue(Views.MenuItemExtension.CommandProperty, "--top-order"); + if (_settings.EnableTopoOrderInHistories) + topoOrder.Icon = App.CreateMenuIcon("Icons.Check"); + topoOrder.Click += (_, ev) => + { + if (!_settings.EnableTopoOrderInHistories) + { + _settings.EnableTopoOrderInHistories = true; + Task.Run(RefreshCommits); + } + + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(layout); + menu.Items.Add(horizontal); + menu.Items.Add(vertical); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(order); + menu.Items.Add(dateOrder); + menu.Items.Add(topoOrder); + return menu; + } + + public void DiscardAllChanges() + { + if (CanCreatePopup()) + ShowPopup(new Discard(this)); + } + + public void ClearStashes() + { + if (CanCreatePopup()) + ShowPopup(new ClearStashes(this)); + } + + public ContextMenu CreateContextMenuForLocalBranch(Models.Branch branch) + { + var menu = new ContextMenu(); + + var push = new MenuItem(); + push.Header = App.Text("BranchCM.Push", branch.Name); + push.Icon = App.CreateMenuIcon("Icons.Push"); + push.IsEnabled = _remotes.Count > 0; + push.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Push(this, branch)); + e.Handled = true; + }; + + if (branch.IsCurrent) + { + if (!IsBare) + { + if (!string.IsNullOrEmpty(branch.Upstream)) + { + var upstream = branch.Upstream.Substring(13); + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", upstream); + fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = branch.TrackStatus.Ahead.Count == 0; + fastForward.Click += (_, e) => + { + var b = _branches.Find(x => x.FriendlyName == upstream); + if (b == null) + return; + + if (CanCreatePopup()) + ShowAndStartPopup(new Merge(this, b, branch.Name, true)); + + e.Handled = true; + }; + + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.Pull", upstream); + pull.Icon = App.CreateMenuIcon("Icons.Pull"); + pull.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Pull(this, null)); + e.Handled = true; + }; + + menu.Items.Add(fastForward); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(pull); + } + } + + menu.Items.Add(push); + } + else + { + if (!IsBare) + { + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", branch.Name); + checkout.Icon = App.CreateMenuIcon("Icons.Check"); + checkout.Click += (_, e) => + { + CheckoutBranch(branch); + e.Handled = true; + }; + menu.Items.Add(checkout); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var worktree = _worktrees.Find(x => x.Branch == branch.FullName); + var upstream = _branches.Find(x => x.FullName == branch.Upstream); + if (upstream != null && worktree == null) + { + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", upstream.FriendlyName); + fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = branch.TrackStatus.Ahead.Count == 0; + fastForward.Click += (_, e) => + { + if (CanCreatePopup()) + ShowAndStartPopup(new ResetWithoutCheckout(this, branch, upstream)); + e.Handled = true; + }; + menu.Items.Add(fastForward); + + var fetchInto = new MenuItem(); + fetchInto.Header = App.Text("BranchCM.FetchInto", upstream.FriendlyName, branch.Name); + fetchInto.Icon = App.CreateMenuIcon("Icons.Fetch"); + fetchInto.IsEnabled = branch.TrackStatus.Ahead.Count == 0; + fetchInto.Click += (_, e) => + { + if (CanCreatePopup()) + ShowAndStartPopup(new FetchInto(this, branch, upstream)); + e.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(fetchInto); + } + + menu.Items.Add(push); + + if (!IsBare) + { + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", branch.Name, _currentBranch.Name); + merge.Icon = App.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Merge(this, branch, _currentBranch.Name, false)); + e.Handled = true; + }; + + var rebase = new MenuItem(); + rebase.Header = App.Text("BranchCM.Rebase", _currentBranch.Name, branch.Name); + rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); + rebase.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Rebase(this, _currentBranch, branch)); + e.Handled = true; + }; + + menu.Items.Add(merge); + menu.Items.Add(rebase); + } + + if (worktree == null) + { + var selectedCommit = (_histories?.DetailContext as CommitDetail)?.Commit; + if (selectedCommit != null && !selectedCommit.SHA.Equals(branch.Head, StringComparison.Ordinal)) + { + var move = new MenuItem(); + move.Header = App.Text("BranchCM.ResetToSelectedCommit", branch.Name, selectedCommit.SHA.Substring(0, 10)); + move.Icon = App.CreateMenuIcon("Icons.Reset"); + move.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new ResetWithoutCheckout(this, branch, selectedCommit)); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(move); + } + } + + var compareWithCurrent = new MenuItem(); + compareWithCurrent.Header = App.Text("BranchCM.CompareWithCurrent", _currentBranch.Name); + compareWithCurrent.Icon = App.CreateMenuIcon("Icons.Compare"); + compareWithCurrent.Click += (_, _) => + { + App.ShowWindow(new BranchCompare(_fullpath, branch, _currentBranch), false); + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(compareWithCurrent); + + if (_localChangesCount > 0) + { + var compareWithWorktree = new MenuItem(); + compareWithWorktree.Header = App.Text("BranchCM.CompareWithWorktree"); + compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); + compareWithWorktree.Click += (_, _) => + { + SelectedSearchedCommit = null; + + if (_histories != null) + { + var target = new Commands.QuerySingleCommit(_fullpath, branch.Head).Result(); + _histories.AutoSelectedCommit = null; + _histories.DetailContext = new RevisionCompare(_fullpath, target, null); + } + }; + menu.Items.Add(compareWithWorktree); + } + } + + if (!IsBare) + { + var type = GetGitFlowType(branch); + if (type != Models.GitFlowBranchType.None) + { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", branch.Name); + finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); + finish.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new GitFlowFinish(this, branch, type)); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(finish); + } + } + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", branch.Name); + rename.Icon = App.CreateMenuIcon("Icons.Rename"); + rename.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new RenameBranch(this, branch)); + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", branch.Name); + delete.Icon = App.CreateMenuIcon("Icons.Clear"); + delete.IsEnabled = !branch.IsCurrent; + delete.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new DeleteBranch(this, branch)); + e.Handled = true; + }; + + var createBranch = new MenuItem(); + createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new CreateBranch(this, branch)); + e.Handled = true; + }; + + var createTag = new MenuItem(); + createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new CreateTag(this, branch)); + e.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(rename); + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(createBranch); + menu.Items.Add(createTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + TryToAddCustomActionsToBranchContextMenu(menu, branch); + + if (!IsBare) + { + var remoteBranches = new List(); + foreach (var b in _branches) + { + if (!b.IsLocal) + remoteBranches.Add(b); + } + + if (remoteBranches.Count > 0) + { + var tracking = new MenuItem(); + tracking.Header = App.Text("BranchCM.Tracking"); + tracking.Icon = App.CreateMenuIcon("Icons.Track"); + tracking.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new SetUpstream(this, branch, remoteBranches)); + e.Handled = true; + }; + menu.Items.Add(tracking); + } + } + + var archive = new MenuItem(); + archive.Icon = App.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Archive(this, branch)); + e.Handled = true; + }; + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(branch.Name); + e.Handled = true; + }; + menu.Items.Add(copy); + + return menu; + } + + public ContextMenu CreateContextMenuForRemote(Models.Remote remote) + { + var menu = new ContextMenu(); + + if (remote.TryGetVisitURL(out string visitURL)) + { + var visit = new MenuItem(); + visit.Header = App.Text("RemoteCM.OpenInBrowser"); + visit.Icon = App.CreateMenuIcon("Icons.OpenWith"); + visit.Click += (_, e) => + { + Native.OS.OpenBrowser(visitURL); + e.Handled = true; + }; + + menu.Items.Add(visit); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var fetch = new MenuItem(); + fetch.Header = App.Text("RemoteCM.Fetch"); + fetch.Icon = App.CreateMenuIcon("Icons.Fetch"); + fetch.Click += (_, e) => + { + if (CanCreatePopup()) + ShowAndStartPopup(new Fetch(this, remote)); + e.Handled = true; + }; + + var prune = new MenuItem(); + prune.Header = App.Text("RemoteCM.Prune"); + prune.Icon = App.CreateMenuIcon("Icons.Clean"); + prune.Click += (_, e) => + { + if (CanCreatePopup()) + ShowAndStartPopup(new PruneRemote(this, remote)); + e.Handled = true; + }; + + var edit = new MenuItem(); + edit.Header = App.Text("RemoteCM.Edit"); + edit.Icon = App.CreateMenuIcon("Icons.Edit"); + edit.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new EditRemote(this, remote)); + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("RemoteCM.Delete"); + delete.Icon = App.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new DeleteRemote(this, remote)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("RemoteCM.CopyURL"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(remote.URL); + e.Handled = true; + }; + + menu.Items.Add(fetch); + menu.Items.Add(prune); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(edit); + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + return menu; + } + + public ContextMenu CreateContextMenuForRemoteBranch(Models.Branch branch) + { + var menu = new ContextMenu(); + var name = branch.FriendlyName; + + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", name); + checkout.Icon = App.CreateMenuIcon("Icons.Check"); + checkout.Click += (_, e) => + { + CheckoutBranch(branch); + e.Handled = true; + }; + menu.Items.Add(checkout); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (_currentBranch != null) + { + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.PullInto", name, _currentBranch.Name); + pull.Icon = App.CreateMenuIcon("Icons.Pull"); + pull.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Pull(this, branch)); + e.Handled = true; + }; + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", name, _currentBranch.Name); + merge.Icon = App.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Merge(this, branch, _currentBranch.Name, false)); + e.Handled = true; + }; + + var rebase = new MenuItem(); + rebase.Header = App.Text("BranchCM.Rebase", _currentBranch.Name, name); + rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); + rebase.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Rebase(this, _currentBranch, branch)); + e.Handled = true; + }; + + menu.Items.Add(pull); + menu.Items.Add(merge); + menu.Items.Add(rebase); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var compareWithHead = new MenuItem(); + compareWithHead.Header = App.Text("BranchCM.CompareWithHead"); + compareWithHead.Icon = App.CreateMenuIcon("Icons.Compare"); + compareWithHead.Click += (_, _) => + { + App.ShowWindow(new BranchCompare(_fullpath, branch, _currentBranch), false); + }; + menu.Items.Add(compareWithHead); + + if (_localChangesCount > 0) + { + var compareWithWorktree = new MenuItem(); + compareWithWorktree.Header = App.Text("BranchCM.CompareWithWorktree"); + compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); + compareWithWorktree.Click += (_, _) => + { + SelectedSearchedCommit = null; + + if (_histories != null) + { + var target = new Commands.QuerySingleCommit(_fullpath, branch.Head).Result(); + _histories.AutoSelectedCommit = null; + _histories.DetailContext = new RevisionCompare(_fullpath, target, null); + } + }; + menu.Items.Add(compareWithWorktree); + } + menu.Items.Add(new MenuItem() { Header = "-" }); + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", name); + delete.Icon = App.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new DeleteBranch(this, branch)); + e.Handled = true; + }; + + var createBranch = new MenuItem(); + createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new CreateBranch(this, branch)); + e.Handled = true; + }; + + var createTag = new MenuItem(); + createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new CreateTag(this, branch)); + e.Handled = true; + }; + + var archive = new MenuItem(); + archive.Icon = App.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, e) => + { + if (CanCreatePopup()) + ShowPopup(new Archive(this, branch)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(name); + e.Handled = true; + }; + + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(createBranch); + menu.Items.Add(createTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + TryToAddCustomActionsToBranchContextMenu(menu, branch); + menu.Items.Add(copy); + + return menu; + } + + public ContextMenu CreateContextMenuForTag(Models.Tag tag) + { + var createBranch = new MenuItem(); + createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (_, ev) => + { + if (CanCreatePopup()) + ShowPopup(new CreateBranch(this, tag)); + ev.Handled = true; + }; + + var pushTag = new MenuItem(); + pushTag.Header = App.Text("TagCM.Push", tag.Name); + pushTag.Icon = App.CreateMenuIcon("Icons.Push"); + pushTag.IsEnabled = _remotes.Count > 0; + pushTag.Click += (_, ev) => + { + if (CanCreatePopup()) + ShowPopup(new PushTag(this, tag)); + ev.Handled = true; + }; + + var deleteTag = new MenuItem(); + deleteTag.Header = App.Text("TagCM.Delete", tag.Name); + deleteTag.Icon = App.CreateMenuIcon("Icons.Clear"); + deleteTag.Click += (_, ev) => + { + if (CanCreatePopup()) + ShowPopup(new DeleteTag(this, tag)); + ev.Handled = true; + }; + + var archive = new MenuItem(); + archive.Icon = App.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, ev) => + { + if (CanCreatePopup()) + ShowPopup(new Archive(this, tag)); + ev.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("TagCM.Copy"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, ev) => + { + App.CopyText(tag.Name); + ev.Handled = true; + }; + + var copyMessage = new MenuItem(); + copyMessage.Header = App.Text("TagCM.CopyMessage"); + copyMessage.Icon = App.CreateMenuIcon("Icons.Copy"); + copyMessage.IsEnabled = !string.IsNullOrEmpty(tag.Message); + copyMessage.Click += (_, ev) => + { + App.CopyText(tag.Message); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(createBranch); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(pushTag); + menu.Items.Add(deleteTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + menu.Items.Add(copyMessage); + return menu; + } + + public ContextMenu CreateContextMenuForBranchSortMode(bool local) + { + var mode = local ? _settings.LocalBranchSortMode : _settings.RemoteBranchSortMode; + var changeMode = new Action(m => + { + if (local) + { + _settings.LocalBranchSortMode = m; + OnPropertyChanged(nameof(IsSortingLocalBranchByName)); + } + else + { + _settings.RemoteBranchSortMode = m; + OnPropertyChanged(nameof(IsSortingRemoteBranchByName)); + } + + var builder = BuildBranchTree(_branches, _remotes); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + }); + + var byNameAsc = new MenuItem(); + byNameAsc.Header = App.Text("Repository.BranchSort.ByName"); + if (mode == Models.BranchSortMode.Name) + byNameAsc.Icon = App.CreateMenuIcon("Icons.Check"); + byNameAsc.Click += (_, ev) => + { + if (mode != Models.BranchSortMode.Name) + changeMode(Models.BranchSortMode.Name); + + ev.Handled = true; + }; + + var byCommitterDate = new MenuItem(); + byCommitterDate.Header = App.Text("Repository.BranchSort.ByCommitterDate"); + if (mode == Models.BranchSortMode.CommitterDate) + byCommitterDate.Icon = App.CreateMenuIcon("Icons.Check"); + byCommitterDate.Click += (_, ev) => + { + if (mode != Models.BranchSortMode.CommitterDate) + changeMode(Models.BranchSortMode.CommitterDate); + + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + menu.Items.Add(byNameAsc); + menu.Items.Add(byCommitterDate); + return menu; + } + + public ContextMenu CreateContextMenuForTagSortMode() + { + var mode = _settings.TagSortMode; + var changeMode = new Action(m => + { + if (_settings.TagSortMode != m) + { + _settings.TagSortMode = m; + OnPropertyChanged(nameof(IsSortingTagsByName)); + VisibleTags = BuildVisibleTags(); + } + }); + + var byCreatorDate = new MenuItem(); + byCreatorDate.Header = App.Text("Repository.Tags.OrderByCreatorDate"); + if (mode == Models.TagSortMode.CreatorDate) + byCreatorDate.Icon = App.CreateMenuIcon("Icons.Check"); + byCreatorDate.Click += (_, ev) => + { + changeMode(Models.TagSortMode.CreatorDate); + ev.Handled = true; + }; + + var byName = new MenuItem(); + byName.Header = App.Text("Repository.Tags.OrderByName"); + if (mode == Models.TagSortMode.Name) + byName.Icon = App.CreateMenuIcon("Icons.Check"); + byName.Click += (_, ev) => + { + changeMode(Models.TagSortMode.Name); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + menu.Items.Add(byCreatorDate); + menu.Items.Add(byName); + return menu; + } + + public ContextMenu CreateContextMenuForSubmodule(Models.Submodule submodule) + { + var open = new MenuItem(); + open.Header = App.Text("Submodule.Open"); + open.Icon = App.CreateMenuIcon("Icons.Folder.Open"); + open.IsEnabled = submodule.Status != Models.SubmoduleStatus.NotInited; + open.Click += (_, ev) => + { + OpenSubmodule(submodule.Path); + ev.Handled = true; + }; + + var deinit = new MenuItem(); + deinit.Header = App.Text("Submodule.Deinit"); + deinit.Icon = App.CreateMenuIcon("Icons.Undo"); + deinit.IsEnabled = submodule.Status != Models.SubmoduleStatus.NotInited; + deinit.Click += (_, ev) => + { + if (CanCreatePopup()) + ShowPopup(new DeinitSubmodule(this, submodule.Path)); + ev.Handled = true; + }; + + var rm = new MenuItem(); + rm.Header = App.Text("Submodule.Remove"); + rm.Icon = App.CreateMenuIcon("Icons.Clear"); + rm.Click += (_, ev) => + { + if (CanCreatePopup()) + ShowPopup(new DeleteSubmodule(this, submodule.Path)); + ev.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("Submodule.CopyPath"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, ev) => + { + App.CopyText(submodule.Path); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(open); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(deinit); + menu.Items.Add(rm); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + return menu; + } + + public ContextMenu CreateContextMenuForWorktree(Models.Worktree worktree) + { + var menu = new ContextMenu(); + + if (worktree.IsLocked) + { + var unlock = new MenuItem(); + unlock.Header = App.Text("Worktree.Unlock"); + unlock.Icon = App.CreateMenuIcon("Icons.Unlock"); + unlock.Click += (_, ev) => + { + SetWatcherEnabled(false); + var log = CreateLog("Unlock Worktree"); + var succ = new Commands.Worktree(_fullpath).Use(log).Unlock(worktree.FullPath); + if (succ) + worktree.IsLocked = false; + log.Complete(); + SetWatcherEnabled(true); + ev.Handled = true; + }; + menu.Items.Add(unlock); + } + else + { + var loc = new MenuItem(); + loc.Header = App.Text("Worktree.Lock"); + loc.Icon = App.CreateMenuIcon("Icons.Lock"); + loc.Click += (_, ev) => + { + SetWatcherEnabled(false); + var log = CreateLog("Lock Worktree"); + var succ = new Commands.Worktree(_fullpath).Use(log).Lock(worktree.FullPath); + if (succ) + worktree.IsLocked = true; + log.Complete(); + SetWatcherEnabled(true); + ev.Handled = true; + }; + menu.Items.Add(loc); + } + + var remove = new MenuItem(); + remove.Header = App.Text("Worktree.Remove"); + remove.Icon = App.CreateMenuIcon("Icons.Clear"); + remove.Click += (_, ev) => + { + if (CanCreatePopup()) + ShowPopup(new RemoveWorktree(this, worktree)); + ev.Handled = true; + }; + menu.Items.Add(remove); + + var copy = new MenuItem(); + copy.Header = App.Text("Worktree.CopyPath"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(worktree.FullPath); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copy); + + return menu; + } + + private LauncherPage GetOwnerPage() + { + var launcher = App.GetLauncher(); + if (launcher == null) + return null; + + foreach (var page in launcher.Pages) + { + if (page.Node.Id.Equals(_fullpath)) + return page; + } + + return null; + } + + private BranchTreeNode.Builder BuildBranchTree(List branches, List remotes) + { + var builder = new BranchTreeNode.Builder(_settings.LocalBranchSortMode, _settings.RemoteBranchSortMode); + if (string.IsNullOrEmpty(_filter)) + { + builder.SetExpandedNodes(_settings.ExpandedBranchNodesInSideBar); + builder.Run(branches, remotes, false); + + foreach (var invalid in builder.InvalidExpandedNodes) + _settings.ExpandedBranchNodesInSideBar.Remove(invalid); + } + else + { + var visibles = new List(); + foreach (var b in branches) + { + if (b.FullName.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visibles.Add(b); + } + + builder.Run(visibles, remotes, true); + } + + var historiesFilters = _settings.CollectHistoriesFilters(); + UpdateBranchTreeFilterMode(builder.Locals, historiesFilters); + UpdateBranchTreeFilterMode(builder.Remotes, historiesFilters); + return builder; + } + + private object BuildVisibleTags() + { + switch (_settings.TagSortMode) + { + case Models.TagSortMode.CreatorDate: + _tags.Sort((l, r) => r.CreatorDate.CompareTo(l.CreatorDate)); + break; + default: + _tags.Sort((l, r) => Models.NumericSort.Compare(l.Name, r.Name)); + break; + } + + var visible = new List(); + if (string.IsNullOrEmpty(_filter)) + { + visible.AddRange(_tags); + } + else + { + foreach (var t in _tags) + { + if (t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(t); + } + } + + var historiesFilters = _settings.CollectHistoriesFilters(); + UpdateTagFilterMode(historiesFilters); + + if (Preferences.Instance.ShowTagsAsTree) + return TagCollectionAsTree.Build(visible, _visibleTags as TagCollectionAsTree); + else + return new TagCollectionAsList() { Tags = visible }; + } + + private object BuildVisibleSubmodules() + { + var visible = new List(); + if (string.IsNullOrEmpty(_filter)) + { + visible.AddRange(_submodules); + } + else + { + foreach (var s in _submodules) + { + if (s.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(s); + } + } + + if (Preferences.Instance.ShowSubmodulesAsTree) + return SubmoduleCollectionAsTree.Build(visible, _visibleSubmodules as SubmoduleCollectionAsTree); + else + return new SubmoduleCollectionAsList() { Submodules = visible }; + } + + private void RefreshHistoriesFilters(bool refresh) + { + if (_settings.HistoriesFilters.Count > 0) + HistoriesFilterMode = _settings.HistoriesFilters[0].Mode; + else + HistoriesFilterMode = Models.FilterMode.None; + + if (!refresh) + return; + + var filters = _settings.CollectHistoriesFilters(); + UpdateBranchTreeFilterMode(LocalBranchTrees, filters); + UpdateBranchTreeFilterMode(RemoteBranchTrees, filters); + UpdateTagFilterMode(filters); + + Task.Run(RefreshCommits); + } + + private void UpdateBranchTreeFilterMode(List nodes, Dictionary filters) + { + foreach (var node in nodes) + { + node.FilterMode = filters.GetValueOrDefault(node.Path, Models.FilterMode.None); + + if (!node.IsBranch) + UpdateBranchTreeFilterMode(node.Children, filters); + } + } + + private void UpdateTagFilterMode(Dictionary filters) + { + foreach (var tag in _tags) + { + tag.FilterMode = filters.GetValueOrDefault(tag.Name, Models.FilterMode.None); + } + } + + private void ResetBranchTreeFilterMode(List nodes) + { + foreach (var node in nodes) + { + node.FilterMode = Models.FilterMode.None; + if (!node.IsBranch) + ResetBranchTreeFilterMode(node.Children); + } + } + + private void ResetTagFilterMode() + { + foreach (var tag in _tags) + tag.FilterMode = Models.FilterMode.None; + } + + private BranchTreeNode FindBranchNode(List nodes, string path) + { + foreach (var node in nodes) + { + if (node.Path.Equals(path, StringComparison.Ordinal)) + return node; + + if (path.StartsWith(node.Path, StringComparison.Ordinal)) + { + var founded = FindBranchNode(node.Children, path); + if (founded != null) + return founded; + } + } + + return null; + } + + private void TryToAddCustomActionsToBranchContextMenu(ContextMenu menu, Models.Branch branch) + { + var actions = GetCustomActions(Models.CustomActionScope.Branch); + if (actions.Count == 0) + return; + + var custom = new MenuItem(); + custom.Header = App.Text("BranchCM.CustomAction"); + custom.Icon = App.CreateMenuIcon("Icons.Action"); + + foreach (var action in actions) + { + var dup = action; + var item = new MenuItem(); + item.Icon = App.CreateMenuIcon("Icons.Action"); + item.Header = dup.Name; + item.Click += (_, e) => + { + if (CanCreatePopup()) + ShowAndStartPopup(new ExecuteCustomAction(this, dup, branch)); + + e.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + private bool IsSearchingCommitsByFilePath() + { + return _isSearching && _searchCommitFilterType == (int)Models.CommitSearchMethod.ByFile; + } + + private void CalcWorktreeFilesForSearching() + { + if (!IsSearchingCommitsByFilePath()) + { + _requestingWorktreeFiles = false; + _worktreeFiles = null; + MatchedFilesForSearching = null; + GC.Collect(); + return; + } + + if (_requestingWorktreeFiles) + return; + + _requestingWorktreeFiles = true; + + Task.Run(() => + { + _worktreeFiles = new Commands.QueryRevisionFileNames(_fullpath, "HEAD").Result(); + Dispatcher.UIThread.Invoke(() => + { + if (IsSearchingCommitsByFilePath() && _requestingWorktreeFiles) + CalcMatchedFilesForSearching(); + + _requestingWorktreeFiles = false; + }); + }); + } + + private void CalcMatchedFilesForSearching() + { + if (_worktreeFiles == null || _worktreeFiles.Count == 0 || _searchCommitFilter.Length < 3) + { + MatchedFilesForSearching = null; + return; + } + + var matched = new List(); + foreach (var file in _worktreeFiles) + { + if (file.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) && file.Length != _searchCommitFilter.Length) + { + matched.Add(file); + if (matched.Count > 100) + break; + } + } + + MatchedFilesForSearching = matched; + } + + private void AutoFetchImpl(object sender) + { + try + { + if (!_settings.EnableAutoFetch || _isAutoFetching) + return; + + var lockFile = Path.Combine(_gitDir, "index.lock"); + if (File.Exists(lockFile)) + return; + + var now = DateTime.Now; + var desire = _lastFetchTime.AddMinutes(_settings.AutoFetchInterval); + if (desire > now) + return; + + var remotes = new List(); + lock (_lockRemotes) + { + foreach (var remote in _remotes) + remotes.Add(remote.Name); + } + + Dispatcher.UIThread.Invoke(() => IsAutoFetching = true); + foreach (var remote in remotes) + new Commands.Fetch(_fullpath, remote, false, false) { RaiseError = false }.Exec(); + _lastFetchTime = DateTime.Now; + Dispatcher.UIThread.Invoke(() => IsAutoFetching = false); + } + catch + { + // DO nothing, but prevent `System.AggregateException` + } + } + + private string _fullpath = string.Empty; + private string _gitDir = string.Empty; + private Models.RepositorySettings _settings = null; + private Models.FilterMode _historiesFilterMode = Models.FilterMode.None; + private bool _hasAllowedSignersFile = false; + + private Models.Watcher _watcher = null; + private Histories _histories = null; + private WorkingCopy _workingCopy = null; + private StashesPage _stashesPage = null; + private int _selectedViewIndex = 0; + private object _selectedView = null; + + private int _localBranchesCount = 0; + private int _localChangesCount = 0; + private int _stashesCount = 0; + + private bool _isSearching = false; + private bool _isSearchLoadingVisible = false; + private int _searchCommitFilterType = (int)Models.CommitSearchMethod.ByMessage; + private bool _onlySearchCommitsInCurrentBranch = false; + private string _searchCommitFilter = string.Empty; + private List _searchedCommits = new List(); + private Models.Commit _selectedSearchedCommit = null; + private bool _requestingWorktreeFiles = false; + private List _worktreeFiles = null; + private List _matchedFilesForSearching = null; + + private string _filter = string.Empty; + private readonly Lock _lockRemotes = new(); + private List _remotes = new List(); + private List _branches = new List(); + private Models.Branch _currentBranch = null; + private List _localBranchTrees = new List(); + private List _remoteBranchTrees = new List(); + private List _worktrees = new List(); + private List _tags = new List(); + private object _visibleTags = null; + private List _submodules = new List(); + private object _visibleSubmodules = null; + + private bool _isAutoFetching = false; + private Timer _autoFetchTimer = null; + private DateTime _lastFetchTime = DateTime.MinValue; + + private Models.BisectState _bisectState = Models.BisectState.None; + private bool _isBisectCommandRunning = false; + + private string _navigateToCommitDelayed = string.Empty; + } +} diff --git a/src/ViewModels/RepositoryConfigure.cs b/src/ViewModels/RepositoryConfigure.cs new file mode 100644 index 00000000..d69ff711 --- /dev/null +++ b/src/ViewModels/RepositoryConfigure.cs @@ -0,0 +1,355 @@ +using System.Collections.Generic; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RepositoryConfigure : ObservableObject + { + public string UserName + { + get; + set; + } + + public string UserEmail + { + get; + set; + } + + public List Remotes + { + get; + } + + public string DefaultRemote + { + get => _repo.Settings.DefaultRemote; + set + { + if (_repo.Settings.DefaultRemote != value) + { + _repo.Settings.DefaultRemote = value; + OnPropertyChanged(); + } + } + } + + public int PreferredMergeMode + { + get => _repo.Settings.PreferredMergeMode; + set + { + if (_repo.Settings.PreferredMergeMode != value) + { + _repo.Settings.PreferredMergeMode = value; + OnPropertyChanged(); + } + } + } + + public bool GPGCommitSigningEnabled + { + get; + set; + } + + public bool GPGTagSigningEnabled + { + get; + set; + } + + public string GPGUserSigningKey + { + get; + set; + } + + public string HttpProxy + { + get => _httpProxy; + set => SetProperty(ref _httpProxy, value); + } + + public bool EnablePruneOnFetch + { + get; + set; + } + + public bool EnableAutoFetch + { + get => _repo.Settings.EnableAutoFetch; + set => _repo.Settings.EnableAutoFetch = value; + } + + public int? AutoFetchInterval + { + get => _repo.Settings.AutoFetchInterval; + set + { + if (value is null || value < 1) + return; + + var interval = (int)value; + if (_repo.Settings.AutoFetchInterval != interval) + _repo.Settings.AutoFetchInterval = interval; + } + } + + public AvaloniaList CommitTemplates + { + get => _repo.Settings.CommitTemplates; + } + + public Models.CommitTemplate SelectedCommitTemplate + { + get => _selectedCommitTemplate; + set => SetProperty(ref _selectedCommitTemplate, value); + } + + public AvaloniaList IssueTrackerRules + { + get => _repo.Settings.IssueTrackerRules; + } + + public Models.IssueTrackerRule SelectedIssueTrackerRule + { + get => _selectedIssueTrackerRule; + set => SetProperty(ref _selectedIssueTrackerRule, value); + } + + public List AvailableOpenAIServices + { + get; + private set; + } + + public string PreferredOpenAIService + { + get => _repo.Settings.PreferredOpenAIService; + set => _repo.Settings.PreferredOpenAIService = value; + } + + public AvaloniaList CustomActions + { + get => _repo.Settings.CustomActions; + } + + public Models.CustomAction SelectedCustomAction + { + get => _selectedCustomAction; + set => SetProperty(ref _selectedCustomAction, value); + } + + public RepositoryConfigure(Repository repo) + { + _repo = repo; + + Remotes = new List(); + foreach (var remote in _repo.Remotes) + Remotes.Add(remote.Name); + + AvailableOpenAIServices = new List() { "---" }; + foreach (var service in Preferences.Instance.OpenAIServices) + AvailableOpenAIServices.Add(service.Name); + + if (!AvailableOpenAIServices.Contains(PreferredOpenAIService)) + PreferredOpenAIService = "---"; + + _cached = new Commands.Config(repo.FullPath).ListAll(); + if (_cached.TryGetValue("user.name", out var name)) + UserName = name; + if (_cached.TryGetValue("user.email", out var email)) + UserEmail = email; + if (_cached.TryGetValue("commit.gpgsign", out var gpgCommitSign)) + GPGCommitSigningEnabled = gpgCommitSign == "true"; + if (_cached.TryGetValue("tag.gpgsign", out var gpgTagSign)) + GPGTagSigningEnabled = gpgTagSign == "true"; + if (_cached.TryGetValue("user.signingkey", out var signingKey)) + GPGUserSigningKey = signingKey; + if (_cached.TryGetValue("http.proxy", out var proxy)) + HttpProxy = proxy; + if (_cached.TryGetValue("fetch.prune", out var prune)) + EnablePruneOnFetch = (prune == "true"); + } + + public void ClearHttpProxy() + { + HttpProxy = string.Empty; + } + + public void AddCommitTemplate() + { + var template = new Models.CommitTemplate() { Name = "New Template" }; + _repo.Settings.CommitTemplates.Add(template); + SelectedCommitTemplate = template; + } + + public void RemoveSelectedCommitTemplate() + { + if (_selectedCommitTemplate != null) + _repo.Settings.CommitTemplates.Remove(_selectedCommitTemplate); + SelectedCommitTemplate = null; + } + + public void AddSampleGithubIssueTracker() + { + var link = "https://github.com/username/repository/issues/$1"; + foreach (var remote in _repo.Remotes) + { + if (remote.URL.Contains("github.com", System.StringComparison.Ordinal) && + remote.TryGetVisitURL(out string url)) + { + link = $"{url}/issues/$1"; + break; + } + } + + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Github ISSUE", "#(\\d+)", link); + } + + public void AddSampleJiraIssueTracker() + { + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Jira Tracker", "PROJ-(\\d+)", "https://jira.yourcompany.com/browse/PROJ-$1"); + } + + public void AddSampleAzureWorkItemTracker() + { + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Azure DevOps Tracker", "#(\\d+)", "https://dev.azure.com/yourcompany/workspace/_workitems/edit/$1"); + } + + public void AddSampleGitLabIssueTracker() + { + var link = "https://gitlab.com/username/repository/-/issues/$1"; + foreach (var remote in _repo.Remotes) + { + if (remote.TryGetVisitURL(out string url)) + { + link = $"{url}/-/issues/$1"; + break; + } + } + + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("GitLab ISSUE", "#(\\d+)", link); + } + + public void AddSampleGitLabMergeRequestTracker() + { + var link = "https://gitlab.com/username/repository/-/merge_requests/$1"; + foreach (var remote in _repo.Remotes) + { + if (remote.TryGetVisitURL(out string url)) + { + link = $"{url}/-/merge_requests/$1"; + break; + } + } + + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("GitLab MR", "!(\\d+)", link); + } + + public void AddSampleGiteeIssueTracker() + { + var link = "https://gitee.com/username/repository/issues/$1"; + foreach (var remote in _repo.Remotes) + { + if (remote.URL.Contains("gitee.com", System.StringComparison.Ordinal) && + remote.TryGetVisitURL(out string url)) + { + link = $"{url}/issues/$1"; + break; + } + } + + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Gitee ISSUE", "#([0-9A-Z]{6,10})", link); + } + + public void AddSampleGiteePullRequestTracker() + { + var link = "https://gitee.com/username/repository/pulls/$1"; + foreach (var remote in _repo.Remotes) + { + if (remote.URL.Contains("gitee.com", System.StringComparison.Ordinal) && + remote.TryGetVisitURL(out string url)) + { + link = $"{url}/pulls/$1"; + } + } + + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("Gitee Pull Request", "!(\\d+)", link); + } + + public void NewIssueTracker() + { + SelectedIssueTrackerRule = _repo.Settings.AddIssueTracker("New Issue Tracker", "#(\\d+)", "https://xxx/$1"); + } + + public void RemoveSelectedIssueTracker() + { + _repo.Settings.RemoveIssueTracker(_selectedIssueTrackerRule); + SelectedIssueTrackerRule = null; + } + + public void AddNewCustomAction() + { + SelectedCustomAction = _repo.Settings.AddNewCustomAction(); + } + + public void RemoveSelectedCustomAction() + { + _repo.Settings.RemoveCustomAction(_selectedCustomAction); + SelectedCustomAction = null; + } + + public void MoveSelectedCustomActionUp() + { + if (_selectedCustomAction != null) + _repo.Settings.MoveCustomActionUp(_selectedCustomAction); + } + + public void MoveSelectedCustomActionDown() + { + if (_selectedCustomAction != null) + _repo.Settings.MoveCustomActionDown(_selectedCustomAction); + } + + public void Save() + { + SetIfChanged("user.name", UserName, ""); + SetIfChanged("user.email", UserEmail, ""); + SetIfChanged("commit.gpgsign", GPGCommitSigningEnabled ? "true" : "false", "false"); + SetIfChanged("tag.gpgsign", GPGTagSigningEnabled ? "true" : "false", "false"); + SetIfChanged("user.signingkey", GPGUserSigningKey, ""); + SetIfChanged("http.proxy", HttpProxy, ""); + SetIfChanged("fetch.prune", EnablePruneOnFetch ? "true" : "false", "false"); + } + + private void SetIfChanged(string key, string value, string defValue) + { + bool changed = false; + if (_cached.TryGetValue(key, out var old)) + { + changed = old != value; + } + else if (!string.IsNullOrEmpty(value) && value != defValue) + { + changed = true; + } + + if (changed) + { + new Commands.Config(_repo.FullPath).Set(key, value); + } + } + + private readonly Repository _repo = null; + private readonly Dictionary _cached = null; + private string _httpProxy; + private Models.CommitTemplate _selectedCommitTemplate = null; + private Models.IssueTrackerRule _selectedIssueTrackerRule = null; + private Models.CustomAction _selectedCustomAction = null; + } +} diff --git a/src/ViewModels/RepositoryNode.cs b/src/ViewModels/RepositoryNode.cs new file mode 100644 index 00000000..c65d1dbd --- /dev/null +++ b/src/ViewModels/RepositoryNode.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Serialization; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RepositoryNode : ObservableObject + { + public string Id + { + get => _id; + set + { + var normalized = value.Replace('\\', '/').TrimEnd('/'); + SetProperty(ref _id, normalized); + } + } + + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public int Bookmark + { + get => _bookmark; + set => SetProperty(ref _bookmark, value); + } + + public bool IsRepository + { + get => _isRepository; + set => SetProperty(ref _isRepository, value); + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + [JsonIgnore] + public bool IsVisible + { + get => _isVisible; + set => SetProperty(ref _isVisible, value); + } + + [JsonIgnore] + public bool IsInvalid + { + get => _isRepository && !Directory.Exists(_id); + } + + [JsonIgnore] + public int Depth + { + get; + set; + } = 0; + + public List SubNodes + { + get; + set; + } = []; + + public void Edit() + { + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new EditRepositoryNode(this); + } + + public void AddSubFolder() + { + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new CreateGroup(this); + } + + public void OpenInFileManager() + { + if (!IsRepository) + return; + Native.OS.OpenInFileManager(_id); + } + + public void OpenTerminal() + { + if (!IsRepository) + return; + Native.OS.OpenTerminal(_id); + } + + public void Delete() + { + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new DeleteRepositoryNode(this); + } + + private string _id = string.Empty; + private string _name = string.Empty; + private bool _isRepository = false; + private int _bookmark = 0; + private bool _isExpanded = false; + private bool _isVisible = true; + } +} diff --git a/src/ViewModels/Reset.cs b/src/ViewModels/Reset.cs new file mode 100644 index 00000000..d3377a99 --- /dev/null +++ b/src/ViewModels/Reset.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Reset : Popup + { + public Models.Branch Current + { + get; + } + + public Models.Commit To + { + get; + } + + public Models.ResetMode SelectedMode + { + get; + set; + } + + public Reset(Repository repo, Models.Branch current, Models.Commit to) + { + _repo = repo; + Current = current; + To = to; + SelectedMode = Models.ResetMode.Supported[1]; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Reset current branch to {To.SHA} ..."; + + var log = _repo.CreateLog($"Reset HEAD to '{To.SHA}'"); + Use(log); + + return Task.Run(() => + { + var succ = new Commands.Reset(_repo.FullPath, To.SHA, SelectedMode.Arg).Use(log).Exec(); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/ResetWithoutCheckout.cs b/src/ViewModels/ResetWithoutCheckout.cs new file mode 100644 index 00000000..3a9582ef --- /dev/null +++ b/src/ViewModels/ResetWithoutCheckout.cs @@ -0,0 +1,53 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ResetWithoutCheckout : Popup + { + public Models.Branch Target + { + get; + } + + public object To + { + get; + } + + public ResetWithoutCheckout(Repository repo, Models.Branch target, Models.Branch to) + { + _repo = repo; + _revision = to.Head; + Target = target; + To = to; + } + + public ResetWithoutCheckout(Repository repo, Models.Branch target, Models.Commit to) + { + _repo = repo; + _revision = to.SHA; + Target = target; + To = to; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Reset {Target.Name} to {_revision} ..."; + + var log = _repo.CreateLog($"Reset '{Target.Name}' to '{_revision}'"); + Use(log); + + return Task.Run(() => + { + var succ = Commands.Branch.Create(_repo.FullPath, Target.Name, _revision, true, log); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo = null; + private readonly string _revision = string.Empty; + } +} diff --git a/src/ViewModels/Revert.cs b/src/ViewModels/Revert.cs new file mode 100644 index 00000000..bb07e2ff --- /dev/null +++ b/src/ViewModels/Revert.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Revert : Popup + { + public Models.Commit Target + { + get; + } + + public bool AutoCommit + { + get; + set; + } + + public Revert(Repository repo, Models.Commit target) + { + _repo = repo; + Target = target; + AutoCommit = true; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + _repo.ClearCommitMessage(); + ProgressDescription = $"Revert commit '{Target.SHA}' ..."; + + var log = _repo.CreateLog($"Revert '{Target.SHA}'"); + Use(log); + + return Task.Run(() => + { + new Commands.Revert(_repo.FullPath, Target.SHA, AutoCommit).Use(log).Exec(); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs new file mode 100644 index 00000000..39400aa3 --- /dev/null +++ b/src/ViewModels/RevisionCompare.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RevisionCompare : ObservableObject, IDisposable + { + public object StartPoint + { + get => _startPoint; + private set => SetProperty(ref _startPoint, value); + } + + public object EndPoint + { + get => _endPoint; + private set => SetProperty(ref _endPoint, value); + } + + public bool CanSaveAsPatch + { + get => _canSaveAsPatch; + } + + public List VisibleChanges + { + get => _visibleChanges; + private set => SetProperty(ref _visibleChanges, value); + } + + public List SelectedChanges + { + get => _selectedChanges; + set + { + if (SetProperty(ref _selectedChanges, value)) + { + if (value != null && value.Count == 1) + { + var option = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), value[0]); + DiffContext = new DiffContext(_repo, option, _diffContext); + } + else + { + DiffContext = null; + } + } + } + } + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + { + RefreshVisible(); + } + } + } + + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public RevisionCompare(string repo, Models.Commit startPoint, Models.Commit endPoint) + { + _repo = repo; + _startPoint = (object)startPoint ?? new Models.Null(); + _endPoint = (object)endPoint ?? new Models.Null(); + _canSaveAsPatch = startPoint != null && endPoint != null; + + Task.Run(Refresh); + } + + public void Dispose() + { + _repo = null; + _startPoint = null; + _endPoint = null; + if (_changes != null) + _changes.Clear(); + if (_visibleChanges != null) + _visibleChanges.Clear(); + if (_selectedChanges != null) + _selectedChanges.Clear(); + _searchFilter = null; + _diffContext = null; + } + + public void NavigateTo(string commitSHA) + { + var launcher = App.GetLauncher(); + if (launcher == null) + return; + + foreach (var page in launcher.Pages) + { + if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) + { + repo.NavigateToCommit(commitSHA); + break; + } + } + } + + public void Swap() + { + (StartPoint, EndPoint) = (_endPoint, _startPoint); + SelectedChanges = []; + Task.Run(Refresh); + } + + public void SaveAsPatch(string saveTo) + { + Task.Run(() => + { + var succ = Commands.SaveChangesAsPatch.ProcessRevisionCompareChanges(_repo, _changes, GetSHA(_startPoint), GetSHA(_endPoint), saveTo); + if (succ) + Dispatcher.UIThread.Invoke(() => App.SendNotification(_repo, App.Text("SaveAsPatchSuccess"))); + }); + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; + } + + public ContextMenu CreateChangeContextMenu() + { + if (_selectedChanges == null || _selectedChanges.Count != 1) + return null; + + var change = _selectedChanges[0]; + var menu = new ContextMenu(); + + var diffWithMerger = new MenuItem(); + diffWithMerger.Header = App.Text("DiffWithMerger"); + diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); + diffWithMerger.Click += (_, ev) => + { + var opt = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), change); + var toolType = Preferences.Instance.ExternalMergeToolType; + var toolPath = Preferences.Instance.ExternalMergeToolPath; + + Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, toolType, toolPath, opt)); + ev.Handled = true; + }; + menu.Items.Add(diffWithMerger); + + if (change.Index != Models.ChangeState.Deleted) + { + var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(full); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(full, true); + ev.Handled = true; + }; + menu.Items.Add(explore); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, ev) => + { + App.CopyText(change.Path); + ev.Handled = true; + }; + menu.Items.Add(copyPath); + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyFullPath.Click += (_, e) => + { + App.CopyText(Native.OS.GetAbsPath(_repo, change.Path)); + e.Handled = true; + }; + menu.Items.Add(copyFullPath); + + return menu; + } + + private void RefreshVisible() + { + if (_changes == null) + return; + + if (string.IsNullOrEmpty(_searchFilter)) + { + VisibleChanges = _changes; + } + else + { + var visible = new List(); + foreach (var c in _changes) + { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + + VisibleChanges = visible; + } + } + + private void Refresh() + { + _changes = new Commands.CompareRevisions(_repo, GetSHA(_startPoint), GetSHA(_endPoint)).Result(); + + var visible = _changes; + if (!string.IsNullOrWhiteSpace(_searchFilter)) + { + visible = []; + foreach (var c in _changes) + { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + } + + Dispatcher.UIThread.Invoke(() => VisibleChanges = visible); + } + + private string GetSHA(object obj) + { + return obj is Models.Commit commit ? commit.SHA : string.Empty; + } + + private string _repo; + private object _startPoint = null; + private object _endPoint = null; + private bool _canSaveAsPatch = false; + private List _changes = null; + private List _visibleChanges = null; + private List _selectedChanges = null; + private string _searchFilter = string.Empty; + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/RevisionFileTreeNode.cs b/src/ViewModels/RevisionFileTreeNode.cs new file mode 100644 index 00000000..083e9d33 --- /dev/null +++ b/src/ViewModels/RevisionFileTreeNode.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.IO; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RevisionFileTreeNode : ObservableObject + { + public Models.Object Backend { get; set; } = null; + public int Depth { get; set; } = 0; + public List Children { get; set; } = new List(); + + public string Name + { + get => Backend == null ? string.Empty : Path.GetFileName(Backend.Path); + } + + public bool IsFolder + { + get => Backend != null && Backend.Type == Models.ObjectType.Tree; + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + private bool _isExpanded = false; + } +} diff --git a/src/ViewModels/RevisionLFSImage.cs b/src/ViewModels/RevisionLFSImage.cs new file mode 100644 index 00000000..e0b9a348 --- /dev/null +++ b/src/ViewModels/RevisionLFSImage.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RevisionLFSImage : ObservableObject + { + public Models.RevisionLFSObject LFS + { + get; + } + + public Models.RevisionImageFile Image + { + get => _image; + private set => SetProperty(ref _image, value); + } + + public RevisionLFSImage(string repo, string file, Models.LFSObject lfs, Models.ImageDecoder decoder) + { + LFS = new Models.RevisionLFSObject() { Object = lfs }; + + Task.Run(() => + { + var source = ImageSource.FromLFSObject(repo, lfs, decoder); + var img = new Models.RevisionImageFile(file, source.Bitmap, source.Size); + Dispatcher.UIThread.Invoke(() => Image = img); + }); + } + + private Models.RevisionImageFile _image = null; + } +} diff --git a/src/ViewModels/Reword.cs b/src/ViewModels/Reword.cs new file mode 100644 index 00000000..72dd9e58 --- /dev/null +++ b/src/ViewModels/Reword.cs @@ -0,0 +1,55 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Reword : Popup + { + public Models.Commit Head + { + get; + } + + [Required(ErrorMessage = "Commit message is required!!!")] + public string Message + { + get => _message; + set => SetProperty(ref _message, value, true); + } + + public Reword(Repository repo, Models.Commit head) + { + _repo = repo; + _oldMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, head.SHA).Result(); + _message = _oldMessage; + Head = head; + } + + public override Task Sure() + { + if (string.Compare(_message, _oldMessage, StringComparison.Ordinal) == 0) + return null; + + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Editing head commit message ..."; + + var log = _repo.CreateLog("Reword HEAD"); + Use(log); + + var signOff = _repo.Settings.EnableSignOffForCommit; + return Task.Run(() => + { + // For reword (only changes the commit message), disable `--reset-author` + var succ = new Commands.Commit(_repo.FullPath, _message, signOff, true, false).Use(log).Run(); + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo; + private readonly string _oldMessage; + private string _message; + } +} diff --git a/src/ViewModels/ScanRepositories.cs b/src/ViewModels/ScanRepositories.cs new file mode 100644 index 00000000..4d05a996 --- /dev/null +++ b/src/ViewModels/ScanRepositories.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class ScanRepositories : Popup + { + public string RootDir + { + get; + } + + public ScanRepositories(string rootDir) + { + GetManagedRepositories(Preferences.Instance.RepositoryNodes, _managed); + RootDir = rootDir; + } + + public override Task Sure() + { + ProgressDescription = $"Scan repositories under '{RootDir}' ..."; + + return Task.Run(() => + { + var watch = new Stopwatch(); + watch.Start(); + + var rootDir = new DirectoryInfo(RootDir); + var found = new List(); + GetUnmanagedRepositories(rootDir, found, new EnumerationOptions() + { + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + IgnoreInaccessible = true, + }); + + // Make sure this task takes at least 0.5s to avoid that the popup panel do not disappear very quickly. + var remain = 500 - (int)watch.Elapsed.TotalMilliseconds; + watch.Stop(); + if (remain > 0) + Task.Delay(remain).Wait(); + + Dispatcher.UIThread.Invoke(() => + { + var normalizedRoot = rootDir.FullName.Replace('\\', '/').TrimEnd('/'); + + foreach (var f in found) + { + var parent = new DirectoryInfo(f).Parent!.FullName.Replace('\\', '/').TrimEnd('/'); + if (parent.Equals(normalizedRoot, StringComparison.Ordinal)) + { + Preferences.Instance.FindOrAddNodeByRepositoryPath(f, null, false, false); + } + else if (parent.StartsWith(normalizedRoot, StringComparison.Ordinal)) + { + var relative = parent.Substring(normalizedRoot.Length).TrimStart('/'); + var group = FindOrCreateGroupRecursive(Preferences.Instance.RepositoryNodes, relative); + Preferences.Instance.FindOrAddNodeByRepositoryPath(f, group, false, false); + } + } + + Preferences.Instance.AutoRemoveInvalidNode(); + Preferences.Instance.Save(); + + Welcome.Instance.Refresh(); + }); + + return true; + }); + } + + private void GetManagedRepositories(List group, HashSet repos) + { + foreach (var node in group) + { + if (node.IsRepository) + repos.Add(node.Id); + else + GetManagedRepositories(node.SubNodes, repos); + } + } + + private void GetUnmanagedRepositories(DirectoryInfo dir, List outs, EnumerationOptions opts, int depth = 0) + { + var subdirs = dir.GetDirectories("*", opts); + foreach (var subdir in subdirs) + { + if (subdir.Name.StartsWith(".", StringComparison.Ordinal) || + subdir.Name.Equals("node_modules", StringComparison.Ordinal)) + continue; + + CallUIThread(() => ProgressDescription = $"Scanning {subdir.FullName}..."); + + var normalizedSelf = subdir.FullName.Replace('\\', '/').TrimEnd('/'); + if (_managed.Contains(normalizedSelf)) + continue; + + var gitDir = Path.Combine(subdir.FullName, ".git"); + if (Directory.Exists(gitDir) || File.Exists(gitDir)) + { + var test = new Commands.QueryRepositoryRootPath(subdir.FullName).ReadToEnd(); + if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) + { + var normalized = test.StdOut.Trim().Replace('\\', '/').TrimEnd('/'); + if (!_managed.Contains(normalized)) + outs.Add(normalized); + } + + continue; + } + + var isBare = new Commands.IsBareRepository(subdir.FullName).Result(); + if (isBare) + { + outs.Add(normalizedSelf); + continue; + } + + if (depth < 5) + GetUnmanagedRepositories(subdir, outs, opts, depth + 1); + } + } + + private RepositoryNode FindOrCreateGroupRecursive(List collection, string path) + { + RepositoryNode node = null; + foreach (var name in path.Split('/')) + { + node = FindOrCreateGroup(collection, name); + collection = node.SubNodes; + } + + return node; + } + + private RepositoryNode FindOrCreateGroup(List collection, string name) + { + foreach (var node in collection) + { + if (node.Name.Equals(name, StringComparison.Ordinal)) + return node; + } + + var added = new RepositoryNode() + { + Id = Guid.NewGuid().ToString(), + Name = name, + IsRepository = false, + IsExpanded = true, + }; + collection.Add(added); + + Preferences.Instance.SortNodes(collection); + return added; + } + + private HashSet _managed = new(); + } +} diff --git a/src/ViewModels/SelfUpdate.cs b/src/ViewModels/SelfUpdate.cs new file mode 100644 index 00000000..3b471576 --- /dev/null +++ b/src/ViewModels/SelfUpdate.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class SelfUpdate : ObservableObject + { + public object Data + { + get => _data; + set => SetProperty(ref _data, value); + } + + private object _data = null; + } +} diff --git a/src/ViewModels/SetUpstream.cs b/src/ViewModels/SetUpstream.cs new file mode 100644 index 00000000..a51586e6 --- /dev/null +++ b/src/ViewModels/SetUpstream.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class SetUpstream : Popup + { + public Models.Branch Local + { + get; + } + + public List RemoteBranches + { + get; + private set; + } + + public Models.Branch SelectedRemoteBranch + { + get; + set; + } + + public bool Unset + { + get => _unset; + set => SetProperty(ref _unset, value); + } + + public SetUpstream(Repository repo, Models.Branch local, List remoteBranches) + { + _repo = repo; + Local = local; + RemoteBranches = remoteBranches; + Unset = false; + + if (!string.IsNullOrEmpty(local.Upstream)) + { + var upstream = remoteBranches.Find(x => x.FullName == local.Upstream); + if (upstream != null) + SelectedRemoteBranch = upstream; + } + + if (SelectedRemoteBranch == null) + { + var upstream = remoteBranches.Find(x => x.Name == local.Name); + if (upstream != null) + SelectedRemoteBranch = upstream; + } + } + + public override Task Sure() + { + ProgressDescription = "Setting upstream..."; + + var upstream = (_unset || SelectedRemoteBranch == null) ? string.Empty : SelectedRemoteBranch.FullName; + if (upstream == Local.Upstream) + return null; + + var log = _repo.CreateLog("Set Upstream"); + Use(log); + + return Task.Run(() => + { + var succ = Commands.Branch.SetUpstream(_repo.FullPath, Local.Name, upstream.Replace("refs/remotes/", ""), log); + if (succ) + _repo.RefreshBranches(); + + log.Complete(); + return true; + }); + } + + private readonly Repository _repo; + private bool _unset = false; + } +} diff --git a/src/ViewModels/Squash.cs b/src/ViewModels/Squash.cs new file mode 100644 index 00000000..8e9bae8b --- /dev/null +++ b/src/ViewModels/Squash.cs @@ -0,0 +1,70 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Squash : Popup + { + public Models.Commit Target + { + get; + } + + [Required(ErrorMessage = "Commit message is required!!!")] + public string Message + { + get => _message; + set => SetProperty(ref _message, value, true); + } + + public Squash(Repository repo, Models.Commit target, string shaToGetPreferMessage) + { + _repo = repo; + _message = new Commands.QueryCommitFullMessage(_repo.FullPath, shaToGetPreferMessage).Result(); + Target = target; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = "Squashing ..."; + + var log = _repo.CreateLog("Squash"); + Use(log); + + return Task.Run(() => + { + var signOff = _repo.Settings.EnableSignOffForCommit; + var autoStashed = false; + var succ = false; + + if (_repo.LocalChangesCount > 0) + { + succ = new Commands.Stash(_repo.FullPath).Use(log).Push("SQUASH_AUTO_STASH"); + if (!succ) + { + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return false; + } + + autoStashed = true; + } + + succ = new Commands.Reset(_repo.FullPath, Target.SHA, "--soft").Use(log).Exec(); + if (succ) + succ = new Commands.Commit(_repo.FullPath, _message, signOff, true, false).Use(log).Run(); + + if (succ && autoStashed) + new Commands.Stash(_repo.FullPath).Use(log).Pop("stash@{0}"); + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return succ; + }); + } + + private readonly Repository _repo; + private string _message; + } +} diff --git a/src/ViewModels/StashChanges.cs b/src/ViewModels/StashChanges.cs new file mode 100644 index 00000000..11e449fb --- /dev/null +++ b/src/ViewModels/StashChanges.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class StashChanges : Popup + { + public string Message + { + get; + set; + } + + public bool HasSelectedFiles + { + get; + } + + public bool IncludeUntracked + { + get => _repo.Settings.IncludeUntrackedWhenStash; + set => _repo.Settings.IncludeUntrackedWhenStash = value; + } + + public bool OnlyStaged + { + get => _repo.Settings.OnlyStagedWhenStash; + set => _repo.Settings.OnlyStagedWhenStash = value; + } + + public bool KeepIndex + { + get => _repo.Settings.KeepIndexWhenStash; + set => _repo.Settings.KeepIndexWhenStash = value; + } + + public bool AutoRestore + { + get => _repo.Settings.AutoRestoreAfterStash; + set => _repo.Settings.AutoRestoreAfterStash = value; + } + + public StashChanges(Repository repo, List changes, bool hasSelectedFiles) + { + _repo = repo; + _changes = changes; + HasSelectedFiles = hasSelectedFiles; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + ProgressDescription = $"Stash changes ..."; + + var log = _repo.CreateLog("Stash Local Changes"); + Use(log); + + return Task.Run(() => + { + var succ = false; + + if (!HasSelectedFiles) + { + if (OnlyStaged) + { + if (Native.OS.GitVersion >= Models.GitVersions.STASH_PUSH_ONLY_STAGED) + { + succ = new Commands.Stash(_repo.FullPath).Use(log).PushOnlyStaged(Message, KeepIndex); + } + else + { + var staged = new List(); + foreach (var c in _changes) + { + if (c.Index != Models.ChangeState.None && c.Index != Models.ChangeState.Untracked) + staged.Add(c); + } + + succ = StashWithChanges(staged, log); + } + } + else + { + succ = new Commands.Stash(_repo.FullPath).Use(log).Push(Message, IncludeUntracked, KeepIndex); + } + } + else + { + succ = StashWithChanges(_changes, log); + } + + if (AutoRestore && succ) + succ = new Commands.Stash(_repo.FullPath).Use(log).Apply("stash@{0}", true); + + log.Complete(); + CallUIThread(() => + { + _repo.MarkWorkingCopyDirtyManually(); + _repo.SetWatcherEnabled(true); + }); + + return succ; + }); + } + + private bool StashWithChanges(List changes, CommandLog log) + { + if (changes.Count == 0) + return true; + + var succ = false; + if (Native.OS.GitVersion >= Models.GitVersions.STASH_PUSH_WITH_PATHSPECFILE) + { + var paths = new List(); + foreach (var c in changes) + paths.Add(c.Path); + + var pathSpecFile = Path.GetTempFileName(); + File.WriteAllLines(pathSpecFile, paths); + succ = new Commands.Stash(_repo.FullPath).Use(log).Push(Message, pathSpecFile, KeepIndex); + File.Delete(pathSpecFile); + } + else + { + for (int i = 0; i < changes.Count; i += 32) + { + var count = Math.Min(32, changes.Count - i); + var step = changes.GetRange(i, count); + succ = new Commands.Stash(_repo.FullPath).Use(log).Push(Message, step, KeepIndex); + if (!succ) + break; + } + } + + return succ; + } + + private readonly Repository _repo = null; + private readonly List _changes = null; + } +} diff --git a/src/ViewModels/StashesPage.cs b/src/ViewModels/StashesPage.cs new file mode 100644 index 00000000..f039d54e --- /dev/null +++ b/src/ViewModels/StashesPage.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class StashesPage : ObservableObject, IDisposable + { + public List Stashes + { + get => _stashes; + set + { + if (SetProperty(ref _stashes, value)) + RefreshVisible(); + } + } + + public List VisibleStashes + { + get => _visibleStashes; + private set + { + if (SetProperty(ref _visibleStashes, value)) + SelectedStash = null; + } + } + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + RefreshVisible(); + } + } + + public Models.Stash SelectedStash + { + get => _selectedStash; + set + { + if (SetProperty(ref _selectedStash, value)) + { + if (value == null) + { + Changes = null; + _untracked.Clear(); + } + else + { + Task.Run(() => + { + var changes = new Commands.CompareRevisions(_repo.FullPath, $"{value.SHA}^", value.SHA).Result(); + var untracked = new List(); + + if (value.Parents.Count == 3) + { + untracked = new Commands.CompareRevisions(_repo.FullPath, Models.Commit.EmptyTreeSHA1, value.Parents[2]).Result(); + var needSort = changes.Count > 0 && untracked.Count > 0; + + foreach (var c in untracked) + changes.Add(c); + + if (needSort) + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + } + + Dispatcher.UIThread.Invoke(() => + { + _untracked = untracked; + Changes = changes; + }); + }); + } + } + } + } + + public List Changes + { + get => _changes; + private set + { + if (SetProperty(ref _changes, value)) + SelectedChange = value is { Count: > 0 } ? value[0] : null; + } + } + + public Models.Change SelectedChange + { + get => _selectedChange; + set + { + if (SetProperty(ref _selectedChange, value)) + { + if (value == null) + DiffContext = null; + else if (_untracked.Contains(value)) + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(Models.Commit.EmptyTreeSHA1, _selectedStash.Parents[2], value), _diffContext); + else + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, value), _diffContext); + } + } + } + + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public StashesPage(Repository repo) + { + _repo = repo; + } + + public void Dispose() + { + _stashes?.Clear(); + _changes?.Clear(); + _untracked.Clear(); + + _repo = null; + _selectedStash = null; + _selectedChange = null; + _diffContext = null; + } + + public ContextMenu MakeContextMenu(Models.Stash stash) + { + if (stash == null) + return null; + + var apply = new MenuItem(); + apply.Header = App.Text("StashCM.Apply"); + apply.Click += (_, ev) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new ApplyStash(_repo, stash)); + + ev.Handled = true; + }; + + var drop = new MenuItem(); + drop.Header = App.Text("StashCM.Drop"); + drop.Click += (_, ev) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new DropStash(_repo, stash)); + + ev.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("StashCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("StashCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var opts = new List(); + foreach (var c in _changes) + { + if (_untracked.Contains(c)) + opts.Add(new Models.DiffOption(Models.Commit.EmptyTreeSHA1, _selectedStash.Parents[2], c)); + else + opts.Add(new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, c)); + } + + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessStashChanges(_repo.FullPath, opts, storageFile.Path.LocalPath)); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(apply); + menu.Items.Add(drop); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(patch); + return menu; + } + + public ContextMenu MakeContextMenuForChange(Models.Change change) + { + if (change == null) + return null; + + var diffWithMerger = new MenuItem(); + diffWithMerger.Header = App.Text("DiffWithMerger"); + diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); + diffWithMerger.Click += (_, ev) => + { + var toolType = Preferences.Instance.ExternalMergeToolType; + var toolPath = Preferences.Instance.ExternalMergeToolPath; + var opt = new Models.DiffOption($"{_selectedStash.SHA}^", _selectedStash.SHA, change); + + Task.Run(() => Commands.MergeTool.OpenForDiff(_repo.FullPath, toolType, toolPath, opt)); + ev.Handled = true; + }; + + var fullPath = Path.Combine(_repo.FullPath, change.Path); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(fullPath); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(fullPath, true); + ev.Handled = true; + }; + + var resetToThisRevision = new MenuItem(); + resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); + resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); + resetToThisRevision.Click += async (_, ev) => + { + var log = _repo.CreateLog($"Reset File to '{_selectedStash.SHA}'"); + + await Task.Run(() => + { + if (_untracked.Contains(change)) + { + Commands.SaveRevisionFile.Run(_repo.FullPath, _selectedStash.Parents[2], change.Path, fullPath); + } + else if (change.Index == Models.ChangeState.Added) + { + Commands.SaveRevisionFile.Run(_repo.FullPath, _selectedStash.SHA, change.Path, fullPath); + } + else + { + new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevision(change.Path, $"{_selectedStash.SHA}"); + } + }); + + log.Complete(); + ev.Handled = true; + }; + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, ev) => + { + App.CopyText(change.Path); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyFullPath.Click += (_, e) => + { + App.CopyText(Native.OS.GetAbsPath(_repo.FullPath, change.Path)); + e.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(diffWithMerger); + menu.Items.Add(explore); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(resetToThisRevision); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + + return menu; + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; + } + + public void Drop(Models.Stash stash) + { + if (stash != null && _repo.CanCreatePopup()) + _repo.ShowPopup(new DropStash(_repo, stash)); + } + + private void RefreshVisible() + { + if (string.IsNullOrEmpty(_searchFilter)) + { + VisibleStashes = _stashes; + } + else + { + var visible = new List(); + foreach (var s in _stashes) + { + if (s.Message.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(s); + } + + VisibleStashes = visible; + } + } + + private Repository _repo = null; + private List _stashes = []; + private List _visibleStashes = []; + private string _searchFilter = string.Empty; + private Models.Stash _selectedStash = null; + private List _changes = null; + private List _untracked = []; + private Models.Change _selectedChange = null; + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/Statistics.cs b/src/ViewModels/Statistics.cs new file mode 100644 index 00000000..3a87607e --- /dev/null +++ b/src/ViewModels/Statistics.cs @@ -0,0 +1,92 @@ +using System.Threading.Tasks; + +using Avalonia.Media; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Statistics : ObservableObject + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public int SelectedIndex + { + get => _selectedIndex; + set + { + if (SetProperty(ref _selectedIndex, value)) + RefreshReport(); + } + } + + public Models.StatisticsReport SelectedReport + { + get => _selectedReport; + private set + { + value?.ChangeAuthor(null); + SetProperty(ref _selectedReport, value); + } + } + + public uint SampleColor + { + get => Preferences.Instance.StatisticsSampleColor; + set + { + if (value != Preferences.Instance.StatisticsSampleColor) + { + Preferences.Instance.StatisticsSampleColor = value; + OnPropertyChanged(nameof(SampleBrush)); + _selectedReport?.ChangeColor(value); + } + } + } + + public IBrush SampleBrush + { + get => new SolidColorBrush(SampleColor); + } + + public Statistics(string repo) + { + Task.Run(() => + { + var result = new Commands.Statistics(repo, Preferences.Instance.MaxHistoryCommits).Result(); + Dispatcher.UIThread.Invoke(() => + { + _data = result; + RefreshReport(); + IsLoading = false; + }); + }); + } + + private void RefreshReport() + { + if (_data == null) + return; + + var report = _selectedIndex switch + { + 0 => _data.All, + 1 => _data.Month, + _ => _data.Week, + }; + + report.ChangeColor(SampleColor); + SelectedReport = report; + } + + private bool _isLoading = true; + private Models.Statistics _data = null; + private Models.StatisticsReport _selectedReport = null; + private int _selectedIndex = 0; + } +} diff --git a/src/ViewModels/SubmoduleCollection.cs b/src/ViewModels/SubmoduleCollection.cs new file mode 100644 index 00000000..b3688366 --- /dev/null +++ b/src/ViewModels/SubmoduleCollection.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; + +using Avalonia.Collections; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class SubmoduleTreeNode : ObservableObject + { + public string FullPath { get; private set; } = string.Empty; + public int Depth { get; private set; } = 0; + public Models.Submodule Module { get; private set; } = null; + public List Children { get; private set; } = []; + public int Counter = 0; + + public bool IsFolder + { + get => Module == null; + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public string ChildCounter + { + get => Counter > 0 ? $"({Counter})" : string.Empty; + } + + public bool IsDirty + { + get => Module?.IsDirty ?? false; + } + + public SubmoduleTreeNode(Models.Submodule module, int depth) + { + FullPath = module.Path; + Depth = depth; + Module = module; + IsExpanded = false; + } + + public SubmoduleTreeNode(string path, int depth, bool isExpanded) + { + FullPath = path; + Depth = depth; + IsExpanded = isExpanded; + Counter = 1; + } + + public static List Build(IList submodules, HashSet expanded) + { + var nodes = new List(); + var folders = new Dictionary(); + + foreach (var module in submodules) + { + var sepIdx = module.Path.IndexOf('/', StringComparison.Ordinal); + if (sepIdx == -1) + { + nodes.Add(new SubmoduleTreeNode(module, 0)); + } + else + { + SubmoduleTreeNode lastFolder = null; + int depth = 0; + + while (sepIdx != -1) + { + var folder = module.Path.Substring(0, sepIdx); + if (folders.TryGetValue(folder, out var value)) + { + lastFolder = value; + lastFolder.Counter++; + } + else if (lastFolder == null) + { + lastFolder = new SubmoduleTreeNode(folder, depth, expanded.Contains(folder)); + folders.Add(folder, lastFolder); + InsertFolder(nodes, lastFolder); + } + else + { + var cur = new SubmoduleTreeNode(folder, depth, expanded.Contains(folder)); + folders.Add(folder, cur); + InsertFolder(lastFolder.Children, cur); + lastFolder = cur; + } + + depth++; + sepIdx = module.Path.IndexOf('/', sepIdx + 1); + } + + lastFolder?.Children.Add(new SubmoduleTreeNode(module, depth)); + } + } + + folders.Clear(); + return nodes; + } + + private static void InsertFolder(List collection, SubmoduleTreeNode subFolder) + { + for (int i = 0; i < collection.Count; i++) + { + if (!collection[i].IsFolder) + { + collection.Insert(i, subFolder); + return; + } + } + + collection.Add(subFolder); + } + + private bool _isExpanded = false; + } + + public class SubmoduleCollectionAsTree + { + public List Tree + { + get; + set; + } = []; + + public AvaloniaList Rows + { + get; + set; + } = []; + + public static SubmoduleCollectionAsTree Build(List submodules, SubmoduleCollectionAsTree old) + { + var oldExpanded = new HashSet(); + if (old != null) + { + foreach (var row in old.Rows) + { + if (row.IsFolder && row.IsExpanded) + oldExpanded.Add(row.FullPath); + } + } + + var collection = new SubmoduleCollectionAsTree(); + collection.Tree = SubmoduleTreeNode.Build(submodules, oldExpanded); + + var rows = new List(); + MakeTreeRows(rows, collection.Tree); + collection.Rows.AddRange(rows); + + return collection; + } + + public void ToggleExpand(SubmoduleTreeNode node) + { + node.IsExpanded = !node.IsExpanded; + + var rows = Rows; + var depth = node.Depth; + var idx = rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subrows = new List(); + MakeTreeRows(subrows, node.Children); + rows.InsertRange(idx + 1, subrows); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < rows.Count; i++) + { + var row = rows[i]; + if (row.Depth <= depth) + break; + + removeCount++; + } + rows.RemoveRange(idx + 1, removeCount); + } + } + + private static void MakeTreeRows(List rows, List nodes) + { + foreach (var node in nodes) + { + rows.Add(node); + + if (!node.IsExpanded || !node.IsFolder) + continue; + + MakeTreeRows(rows, node.Children); + } + } + } + + public class SubmoduleCollectionAsList + { + public List Submodules + { + get; + set; + } = []; + } +} diff --git a/src/ViewModels/TagCollection.cs b/src/ViewModels/TagCollection.cs new file mode 100644 index 00000000..0f2cc5af --- /dev/null +++ b/src/ViewModels/TagCollection.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; + +using Avalonia.Collections; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class TagTreeNodeToolTip + { + public string Name { get; private set; } + public bool IsAnnotated { get; private set; } + public string Message { get; private set; } + + public TagTreeNodeToolTip(Models.Tag t) + { + Name = t.Name; + IsAnnotated = t.IsAnnotated; + Message = t.Message; + } + } + + public class TagTreeNode : ObservableObject + { + public string FullPath { get; private set; } + public int Depth { get; private set; } = 0; + public Models.Tag Tag { get; private set; } = null; + public TagTreeNodeToolTip ToolTip { get; private set; } = null; + public List Children { get; private set; } = []; + public int Counter { get; set; } = 0; + + public bool IsFolder + { + get => Tag == null; + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public string TagsCount + { + get => Counter > 0 ? $"({Counter})" : string.Empty; + } + + public TagTreeNode(Models.Tag t, int depth) + { + FullPath = t.Name; + Depth = depth; + Tag = t; + ToolTip = new TagTreeNodeToolTip(t); + IsExpanded = false; + } + + public TagTreeNode(string path, bool isExpanded, int depth) + { + FullPath = path; + Depth = depth; + IsExpanded = isExpanded; + Counter = 1; + } + + public static List Build(List tags, HashSet expanded) + { + var nodes = new List(); + var folders = new Dictionary(); + + foreach (var tag in tags) + { + var sepIdx = tag.Name.IndexOf('/', StringComparison.Ordinal); + if (sepIdx == -1) + { + nodes.Add(new TagTreeNode(tag, 0)); + } + else + { + TagTreeNode lastFolder = null; + int depth = 0; + + while (sepIdx != -1) + { + var folder = tag.Name.Substring(0, sepIdx); + if (folders.TryGetValue(folder, out var value)) + { + lastFolder = value; + lastFolder.Counter++; + } + else if (lastFolder == null) + { + lastFolder = new TagTreeNode(folder, expanded.Contains(folder), depth); + folders.Add(folder, lastFolder); + InsertFolder(nodes, lastFolder); + } + else + { + var cur = new TagTreeNode(folder, expanded.Contains(folder), depth); + folders.Add(folder, cur); + InsertFolder(lastFolder.Children, cur); + lastFolder = cur; + } + + depth++; + sepIdx = tag.Name.IndexOf('/', sepIdx + 1); + } + + lastFolder?.Children.Add(new TagTreeNode(tag, depth)); + } + } + + folders.Clear(); + return nodes; + } + + private static void InsertFolder(List collection, TagTreeNode subFolder) + { + for (int i = 0; i < collection.Count; i++) + { + if (!collection[i].IsFolder) + { + collection.Insert(i, subFolder); + return; + } + } + + collection.Add(subFolder); + } + + private bool _isExpanded = true; + } + + public class TagCollectionAsList + { + public List Tags + { + get; + set; + } = []; + } + + public class TagCollectionAsTree + { + public List Tree + { + get; + set; + } = []; + + public AvaloniaList Rows + { + get; + set; + } = []; + + public static TagCollectionAsTree Build(List tags, TagCollectionAsTree old) + { + var oldExpanded = new HashSet(); + if (old != null) + { + foreach (var row in old.Rows) + { + if (row.IsFolder && row.IsExpanded) + oldExpanded.Add(row.FullPath); + } + } + + var collection = new TagCollectionAsTree(); + collection.Tree = TagTreeNode.Build(tags, oldExpanded); + + var rows = new List(); + MakeTreeRows(rows, collection.Tree); + collection.Rows.AddRange(rows); + + return collection; + } + + public void ToggleExpand(TagTreeNode node) + { + node.IsExpanded = !node.IsExpanded; + + var rows = Rows; + var depth = node.Depth; + var idx = rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subrows = new List(); + MakeTreeRows(subrows, node.Children); + rows.InsertRange(idx + 1, subrows); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < rows.Count; i++) + { + var row = rows[i]; + if (row.Depth <= depth) + break; + + removeCount++; + } + rows.RemoveRange(idx + 1, removeCount); + } + } + + private static void MakeTreeRows(List rows, List nodes) + { + foreach (var node in nodes) + { + rows.Add(node); + + if (!node.IsExpanded || !node.IsFolder) + continue; + + MakeTreeRows(rows, node.Children); + } + } + } +} diff --git a/src/ViewModels/TwoSideTextDiff.cs b/src/ViewModels/TwoSideTextDiff.cs new file mode 100644 index 00000000..3fb1e63b --- /dev/null +++ b/src/ViewModels/TwoSideTextDiff.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; + +using Avalonia; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class TwoSideTextDiff : ObservableObject + { + public string File { get; set; } + public List Old { get; set; } = new List(); + public List New { get; set; } = new List(); + public int MaxLineNumber = 0; + + public Vector SyncScrollOffset + { + get => _syncScrollOffset; + set => SetProperty(ref _syncScrollOffset, value); + } + + public TwoSideTextDiff(Models.TextDiff diff, TwoSideTextDiff previous = null) + { + File = diff.File; + MaxLineNumber = diff.MaxLineNumber; + + foreach (var line in diff.Lines) + { + switch (line.Type) + { + case Models.TextDiffLineType.Added: + New.Add(line); + break; + case Models.TextDiffLineType.Deleted: + Old.Add(line); + break; + default: + FillEmptyLines(); + Old.Add(line); + New.Add(line); + break; + } + } + + FillEmptyLines(); + + if (previous != null && previous.File == File) + _syncScrollOffset = previous._syncScrollOffset; + } + + public void ConvertsToCombinedRange(Models.TextDiff combined, ref int startLine, ref int endLine, bool isOldSide) + { + endLine = Math.Min(endLine, combined.Lines.Count - 1); + + var oneSide = isOldSide ? Old : New; + var firstContentLine = -1; + for (int i = startLine; i <= endLine; i++) + { + var line = oneSide[i]; + if (line.Type != Models.TextDiffLineType.None) + { + firstContentLine = i; + break; + } + } + + if (firstContentLine < 0) + return; + + var endContentLine = -1; + for (int i = Math.Min(endLine, oneSide.Count - 1); i >= startLine; i--) + { + var line = oneSide[i]; + if (line.Type != Models.TextDiffLineType.None) + { + endContentLine = i; + break; + } + } + + if (endContentLine < 0) + return; + + var firstContent = oneSide[firstContentLine]; + var endContent = oneSide[endContentLine]; + startLine = combined.Lines.IndexOf(firstContent); + endLine = combined.Lines.IndexOf(endContent); + } + + private void FillEmptyLines() + { + if (Old.Count < New.Count) + { + int diff = New.Count - Old.Count; + for (int i = 0; i < diff; i++) + Old.Add(new Models.TextDiffLine()); + } + else if (Old.Count > New.Count) + { + int diff = Old.Count - New.Count; + for (int i = 0; i < diff; i++) + New.Add(new Models.TextDiffLine()); + } + } + + private Vector _syncScrollOffset = Vector.Zero; + } +} diff --git a/src/ViewModels/UpdateSubmodules.cs b/src/ViewModels/UpdateSubmodules.cs new file mode 100644 index 00000000..df2d5565 --- /dev/null +++ b/src/ViewModels/UpdateSubmodules.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class UpdateSubmodules : Popup + { + public List Submodules + { + get; + } = []; + + public string SelectedSubmodule + { + get; + set; + } + + public bool UpdateAll + { + get => _updateAll; + set => SetProperty(ref _updateAll, value); + } + + public bool EnableInit + { + get; + set; + } = true; + + public bool EnableRecursive + { + get; + set; + } = true; + + public bool EnableRemote + { + get; + set; + } = false; + + public UpdateSubmodules(Repository repo) + { + _repo = repo; + + foreach (var submodule in _repo.Submodules) + Submodules.Add(submodule.Path); + + SelectedSubmodule = Submodules.Count > 0 ? Submodules[0] : string.Empty; + } + + public override Task Sure() + { + _repo.SetWatcherEnabled(false); + + List targets; + if (_updateAll) + targets = Submodules; + else + targets = [SelectedSubmodule]; + + var log = _repo.CreateLog("Update Submodule"); + Use(log); + + return Task.Run(() => + { + new Commands.Submodule(_repo.FullPath) + .Use(log) + .Update(targets, EnableInit, EnableRecursive, EnableRemote); + + log.Complete(); + CallUIThread(() => _repo.SetWatcherEnabled(true)); + return true; + }); + } + + private readonly Repository _repo = null; + private bool _updateAll = true; + } +} diff --git a/src/ViewModels/ViewLogs.cs b/src/ViewModels/ViewLogs.cs new file mode 100644 index 00000000..21ab81ab --- /dev/null +++ b/src/ViewModels/ViewLogs.cs @@ -0,0 +1,33 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ViewLogs : ObservableObject + { + public AvaloniaList Logs + { + get => _repo.Logs; + } + + public CommandLog SelectedLog + { + get => _selectedLog; + set => SetProperty(ref _selectedLog, value); + } + + public ViewLogs(Repository repo) + { + _repo = repo; + } + + public void ClearAll() + { + SelectedLog = null; + Logs.Clear(); + } + + private Repository _repo = null; + private CommandLog _selectedLog = null; + } +} diff --git a/src/ViewModels/Welcome.cs b/src/ViewModels/Welcome.cs new file mode 100644 index 00000000..069dcf38 --- /dev/null +++ b/src/ViewModels/Welcome.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Collections; +using Avalonia.Controls; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Welcome : ObservableObject + { + public static Welcome Instance => _instance; + + public AvaloniaList Rows + { + get; + private set; + } = []; + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + Refresh(); + } + } + + public Welcome() + { + Refresh(); + } + + public void Refresh() + { + if (string.IsNullOrWhiteSpace(_searchFilter)) + { + foreach (var node in Preferences.Instance.RepositoryNodes) + ResetVisibility(node); + } + else + { + foreach (var node in Preferences.Instance.RepositoryNodes) + SetVisibilityBySearch(node); + } + + var rows = new List(); + MakeTreeRows(rows, Preferences.Instance.RepositoryNodes); + Rows.Clear(); + Rows.AddRange(rows); + } + + public void ToggleNodeIsExpanded(RepositoryNode node) + { + node.IsExpanded = !node.IsExpanded; + + var depth = node.Depth; + var idx = Rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subrows = new List(); + MakeTreeRows(subrows, node.SubNodes, depth + 1); + Rows.InsertRange(idx + 1, subrows); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < Rows.Count; i++) + { + var row = Rows[i]; + if (row.Depth <= depth) + break; + + removeCount++; + } + Rows.RemoveRange(idx + 1, removeCount); + } + } + + public void OpenOrInitRepository(string path, RepositoryNode parent, bool bMoveExistedNode) + { + if (!Directory.Exists(path)) + { + if (File.Exists(path)) + path = Path.GetDirectoryName(path); + else + return; + } + + var isBare = new Commands.IsBareRepository(path).Result(); + var repoRoot = path; + if (!isBare) + { + var test = new Commands.QueryRepositoryRootPath(path).ReadToEnd(); + if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + { + InitRepository(path, parent, test.StdErr); + return; + } + + repoRoot = test.StdOut.Trim(); + } + + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(repoRoot, parent, bMoveExistedNode); + Refresh(); + + var launcher = App.GetLauncher(); + launcher?.OpenRepositoryInTab(node, launcher.ActivePage); + } + + public void InitRepository(string path, RepositoryNode parent, string reason) + { + if (!Preferences.Instance.IsGitConfigured()) + { + App.RaiseException(string.Empty, App.Text("NotConfigured")); + return; + } + + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new Init(activePage.Node.Id, path, parent, reason); + } + + public void Clone() + { + if (!Preferences.Instance.IsGitConfigured()) + { + App.RaiseException(string.Empty, App.Text("NotConfigured")); + return; + } + + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new Clone(activePage.Node.Id); + } + + public void OpenTerminal() + { + if (!Preferences.Instance.IsGitConfigured()) + App.RaiseException(string.Empty, App.Text("NotConfigured")); + else + Native.OS.OpenTerminal(null); + } + + public void ScanDefaultCloneDir() + { + var defaultCloneDir = Preferences.Instance.GitDefaultCloneDir; + if (string.IsNullOrEmpty(defaultCloneDir)) + { + App.RaiseException(string.Empty, "The default clone directory hasn't been configured!"); + return; + } + + if (!Directory.Exists(defaultCloneDir)) + { + App.RaiseException(string.Empty, $"The default clone directory '{defaultCloneDir}' does not exist!"); + return; + } + + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.StartPopup(new ScanRepositories(defaultCloneDir)); + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; + } + + public void AddRootNode() + { + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new CreateGroup(null); + } + + public void MoveNode(RepositoryNode from, RepositoryNode to) + { + Preferences.Instance.MoveNode(from, to, true); + Refresh(); + } + + public ContextMenu CreateContextMenu(RepositoryNode node) + { + var menu = new ContextMenu(); + + if (!node.IsRepository && node.SubNodes.Count > 0) + { + var openAll = new MenuItem(); + openAll.Header = App.Text("Welcome.OpenAllInNode"); + openAll.Icon = App.CreateMenuIcon("Icons.Folder.Open"); + openAll.Click += (_, e) => + { + OpenAllInNode(App.GetLauncher(), node); + e.Handled = true; + }; + + menu.Items.Add(openAll); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (node.IsRepository) + { + var open = new MenuItem(); + open.Header = App.Text("Welcome.OpenOrInit"); + open.Icon = App.CreateMenuIcon("Icons.Folder.Open"); + open.Click += (_, e) => + { + App.GetLauncher()?.OpenRepositoryInTab(node, null); + e.Handled = true; + }; + + var explore = new MenuItem(); + explore.Header = App.Text("Repository.Explore"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.Click += (_, e) => + { + node.OpenInFileManager(); + e.Handled = true; + }; + + var terminal = new MenuItem(); + terminal.Header = App.Text("Repository.Terminal"); + terminal.Icon = App.CreateMenuIcon("Icons.Terminal"); + terminal.Click += (_, e) => + { + node.OpenTerminal(); + e.Handled = true; + }; + + menu.Items.Add(open); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(explore); + menu.Items.Add(terminal); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + else + { + var addSubFolder = new MenuItem(); + addSubFolder.Header = App.Text("Welcome.AddSubFolder"); + addSubFolder.Icon = App.CreateMenuIcon("Icons.Folder.Add"); + addSubFolder.Click += (_, e) => + { + node.AddSubFolder(); + e.Handled = true; + }; + menu.Items.Add(addSubFolder); + } + + var edit = new MenuItem(); + edit.Header = App.Text("Welcome.Edit"); + edit.Icon = App.CreateMenuIcon("Icons.Edit"); + edit.Click += (_, e) => + { + node.Edit(); + e.Handled = true; + }; + + var move = new MenuItem(); + move.Header = App.Text("Welcome.Move"); + move.Icon = App.CreateMenuIcon("Icons.MoveToAnotherGroup"); + move.Click += (_, e) => + { + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new MoveRepositoryNode(node); + + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("Welcome.Delete"); + delete.Icon = App.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + node.Delete(); + e.Handled = true; + }; + + menu.Items.Add(edit); + menu.Items.Add(move); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(delete); + + return menu; + } + + private void ResetVisibility(RepositoryNode node) + { + node.IsVisible = true; + foreach (var subNode in node.SubNodes) + ResetVisibility(subNode); + } + + private void SetVisibilityBySearch(RepositoryNode node) + { + if (!node.IsRepository) + { + if (node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + { + node.IsVisible = true; + foreach (var subNode in node.SubNodes) + ResetVisibility(subNode); + } + else + { + bool hasVisibleSubNode = false; + foreach (var subNode in node.SubNodes) + { + SetVisibilityBySearch(subNode); + hasVisibleSubNode |= subNode.IsVisible; + } + node.IsVisible = hasVisibleSubNode; + } + } + else + { + node.IsVisible = node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase) || + node.Id.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase); + } + } + + private void MakeTreeRows(List rows, List nodes, int depth = 0) + { + foreach (var node in nodes) + { + if (!node.IsVisible) + continue; + + node.Depth = depth; + rows.Add(node); + + if (node.IsRepository || !node.IsExpanded) + continue; + + MakeTreeRows(rows, node.SubNodes, depth + 1); + } + } + + private void OpenAllInNode(Launcher launcher, RepositoryNode node) + { + foreach (var subNode in node.SubNodes) + { + if (subNode.IsRepository) + launcher.OpenRepositoryInTab(subNode, null); + else if (subNode.SubNodes.Count > 0) + OpenAllInNode(launcher, subNode); + } + } + + private static Welcome _instance = new Welcome(); + private string _searchFilter = string.Empty; + } +} diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs new file mode 100644 index 00000000..09ebc6f6 --- /dev/null +++ b/src/ViewModels/WorkingCopy.cs @@ -0,0 +1,1815 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class WorkingCopy : ObservableObject, IDisposable + { + public bool IncludeUntracked + { + get => _repo.IncludeUntracked; + set + { + if (_repo.IncludeUntracked != value) + { + _repo.IncludeUntracked = value; + OnPropertyChanged(); + } + } + } + + public bool HasRemotes + { + get => _hasRemotes; + set => SetProperty(ref _hasRemotes, value); + } + + public bool HasUnsolvedConflicts + { + get => _hasUnsolvedConflicts; + set => SetProperty(ref _hasUnsolvedConflicts, value); + } + + public InProgressContext InProgressContext + { + get => _inProgressContext; + private set => SetProperty(ref _inProgressContext, value); + } + + public bool IsStaging + { + get => _isStaging; + private set => SetProperty(ref _isStaging, value); + } + + public bool IsUnstaging + { + get => _isUnstaging; + private set => SetProperty(ref _isUnstaging, value); + } + + public bool IsCommitting + { + get => _isCommitting; + private set => SetProperty(ref _isCommitting, value); + } + + public bool EnableSignOff + { + get => _repo.Settings.EnableSignOffForCommit; + set => _repo.Settings.EnableSignOffForCommit = value; + } + + public bool UseAmend + { + get => _useAmend; + set + { + if (SetProperty(ref _useAmend, value)) + { + if (value) + { + var currentBranch = _repo.CurrentBranch; + if (currentBranch == null) + { + App.RaiseException(_repo.FullPath, "No commits to amend!!!"); + _useAmend = false; + OnPropertyChanged(); + return; + } + + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, currentBranch.Head).Result(); + } + else + { + CommitMessage = string.Empty; + ResetAuthor = false; + } + + Staged = GetStagedChanges(); + VisibleStaged = GetVisibleChanges(_staged); + SelectedStaged = []; + } + } + } + + public bool ResetAuthor + { + get => _resetAuthor; + set => SetProperty(ref _resetAuthor, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + { + if (_isLoadingData) + return; + + VisibleUnstaged = GetVisibleChanges(_unstaged); + VisibleStaged = GetVisibleChanges(_staged); + SelectedUnstaged = []; + } + } + } + + public List Unstaged + { + get => _unstaged; + private set => SetProperty(ref _unstaged, value); + } + + public List VisibleUnstaged + { + get => _visibleUnstaged; + private set => SetProperty(ref _visibleUnstaged, value); + } + + public List Staged + { + get => _staged; + private set => SetProperty(ref _staged, value); + } + + public List VisibleStaged + { + get => _visibleStaged; + private set => SetProperty(ref _visibleStaged, value); + } + + public List SelectedUnstaged + { + get => _selectedUnstaged; + set + { + if (SetProperty(ref _selectedUnstaged, value)) + { + if (value == null || value.Count == 0) + { + if (_selectedStaged == null || _selectedStaged.Count == 0) + SetDetail(null, true); + } + else + { + if (_selectedStaged != null && _selectedStaged.Count > 0) + SelectedStaged = []; + + if (value.Count == 1) + SetDetail(value[0], true); + else + SetDetail(null, true); + } + } + } + } + + public List SelectedStaged + { + get => _selectedStaged; + set + { + if (SetProperty(ref _selectedStaged, value)) + { + if (value == null || value.Count == 0) + { + if (_selectedUnstaged == null || _selectedUnstaged.Count == 0) + SetDetail(null, false); + } + else + { + if (_selectedUnstaged != null && _selectedUnstaged.Count > 0) + SelectedUnstaged = []; + + if (value.Count == 1) + SetDetail(value[0], false); + else + SetDetail(null, false); + } + } + } + } + + public object DetailContext + { + get => _detailContext; + private set => SetProperty(ref _detailContext, value); + } + + public string CommitMessage + { + get => _commitMessage; + set => SetProperty(ref _commitMessage, value); + } + + public WorkingCopy(Repository repo) + { + _repo = repo; + } + + public void Dispose() + { + _repo = null; + _inProgressContext = null; + + _selectedUnstaged.Clear(); + OnPropertyChanged(nameof(SelectedUnstaged)); + + _selectedStaged.Clear(); + OnPropertyChanged(nameof(SelectedStaged)); + + _visibleUnstaged.Clear(); + OnPropertyChanged(nameof(VisibleUnstaged)); + + _visibleStaged.Clear(); + OnPropertyChanged(nameof(VisibleStaged)); + + _unstaged.Clear(); + OnPropertyChanged(nameof(Unstaged)); + + _staged.Clear(); + OnPropertyChanged(nameof(Staged)); + + _detailContext = null; + _commitMessage = string.Empty; + } + + public void SetData(List changes) + { + if (!IsChanged(_cached, changes)) + { + // Just force refresh selected changes. + Dispatcher.UIThread.Invoke(() => + { + HasUnsolvedConflicts = _cached.Find(x => x.IsConflicted) != null; + + UpdateDetail(); + UpdateInProgressState(); + }); + + return; + } + + _cached = changes; + _count = _cached.Count; + + var lastSelectedUnstaged = new HashSet(); + var lastSelectedStaged = new HashSet(); + if (_selectedUnstaged != null && _selectedUnstaged.Count > 0) + { + foreach (var c in _selectedUnstaged) + lastSelectedUnstaged.Add(c.Path); + } + else if (_selectedStaged != null && _selectedStaged.Count > 0) + { + foreach (var c in _selectedStaged) + lastSelectedStaged.Add(c.Path); + } + + var unstaged = new List(); + var hasConflict = false; + foreach (var c in changes) + { + if (c.WorkTree != Models.ChangeState.None) + { + unstaged.Add(c); + hasConflict |= c.IsConflicted; + } + } + + var visibleUnstaged = GetVisibleChanges(unstaged); + var selectedUnstaged = new List(); + foreach (var c in visibleUnstaged) + { + if (lastSelectedUnstaged.Contains(c.Path)) + selectedUnstaged.Add(c); + } + + var staged = GetStagedChanges(); + + var visibleStaged = GetVisibleChanges(staged); + var selectedStaged = new List(); + foreach (var c in visibleStaged) + { + if (lastSelectedStaged.Contains(c.Path)) + selectedStaged.Add(c); + } + + Dispatcher.UIThread.Invoke(() => + { + _isLoadingData = true; + HasUnsolvedConflicts = hasConflict; + VisibleUnstaged = visibleUnstaged; + VisibleStaged = visibleStaged; + Unstaged = unstaged; + Staged = staged; + SelectedUnstaged = selectedUnstaged; + SelectedStaged = selectedStaged; + _isLoadingData = false; + + UpdateDetail(); + UpdateInProgressState(); + }); + } + + public void OpenExternalMergeToolAllConflicts() + { + // No arg, mergetool runs on all files with merge conflicts! + UseExternalMergeTool(null); + } + + public void OpenAssumeUnchanged() + { + App.ShowWindow(new AssumeUnchangedManager(_repo), true); + } + + public void StashAll(bool autoStart) + { + if (!_repo.CanCreatePopup()) + return; + + if (autoStart) + _repo.ShowAndStartPopup(new StashChanges(_repo, _cached, false)); + else + _repo.ShowPopup(new StashChanges(_repo, _cached, false)); + } + + public void StageSelected(Models.Change next) + { + StageChanges(_selectedUnstaged, next); + } + + public void StageAll() + { + StageChanges(_visibleUnstaged, null); + } + + public void UnstageSelected(Models.Change next) + { + UnstageChanges(_selectedStaged, next); + } + + public void UnstageAll() + { + UnstageChanges(_visibleStaged, null); + } + + public void Discard(List changes) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Discard(_repo, changes)); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public async void UseTheirs(List changes) + { + _repo.SetWatcherEnabled(false); + + var files = new List(); + var needStage = new List(); + var log = _repo.CreateLog("Use Theirs"); + + foreach (var change in changes) + { + if (!change.IsConflicted) + continue; + + if (change.ConflictReason == Models.ConflictReason.BothDeleted || + change.ConflictReason == Models.ConflictReason.DeletedByThem || + change.ConflictReason == Models.ConflictReason.AddedByUs) + { + var fullpath = Path.Combine(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + File.Delete(fullpath); + + needStage.Add(change.Path); + } + else + { + files.Add(change.Path); + } + } + + if (files.Count > 0) + { + var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).Use(log).UseTheirs(files)); + if (succ) + needStage.AddRange(files); + } + + if (needStage.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync(pathSpecFile, needStage); + await Task.Run(() => new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).Exec()); + File.Delete(pathSpecFile); + } + + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + _repo.SetWatcherEnabled(true); + } + + public async void UseMine(List changes) + { + _repo.SetWatcherEnabled(false); + + var files = new List(); + var needStage = new List(); + var log = _repo.CreateLog("Use Mine"); + + foreach (var change in changes) + { + if (!change.IsConflicted) + continue; + + if (change.ConflictReason == Models.ConflictReason.BothDeleted || + change.ConflictReason == Models.ConflictReason.DeletedByUs || + change.ConflictReason == Models.ConflictReason.AddedByThem) + { + var fullpath = Path.Combine(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + File.Delete(fullpath); + + needStage.Add(change.Path); + } + else + { + files.Add(change.Path); + } + } + + if (files.Count > 0) + { + var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).Use(log).UseMine(files)); + if (succ) + needStage.AddRange(files); + } + + if (needStage.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync(pathSpecFile, needStage); + await Task.Run(() => new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).Exec()); + File.Delete(pathSpecFile); + } + + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + _repo.SetWatcherEnabled(true); + } + + public async void UseExternalMergeTool(Models.Change change) + { + var toolType = Preferences.Instance.ExternalMergeToolType; + var toolPath = Preferences.Instance.ExternalMergeToolPath; + var file = change?.Path; // NOTE: With no arg, mergetool runs on every file with merge conflicts! + await Task.Run(() => Commands.MergeTool.OpenForMerge(_repo.FullPath, toolType, toolPath, file)); + } + + public void ContinueMerge() + { + IsCommitting = true; + + if (_inProgressContext != null) + { + _repo.SetWatcherEnabled(false); + Task.Run(() => + { + var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); + if (File.Exists(mergeMsgFile) && !string.IsNullOrWhiteSpace(_commitMessage)) + File.WriteAllText(mergeMsgFile, _commitMessage); + + var succ = _inProgressContext.Continue(); + Dispatcher.UIThread.Invoke(() => + { + if (succ) + CommitMessage = string.Empty; + + _repo.SetWatcherEnabled(true); + IsCommitting = false; + }); + }); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + IsCommitting = false; + } + } + + public void SkipMerge() + { + IsCommitting = true; + + if (_inProgressContext != null) + { + _repo.SetWatcherEnabled(false); + Task.Run(() => + { + var succ = _inProgressContext.Skip(); + Dispatcher.UIThread.Invoke(() => + { + if (succ) + CommitMessage = string.Empty; + + _repo.SetWatcherEnabled(true); + IsCommitting = false; + }); + }); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + IsCommitting = false; + } + } + + public void AbortMerge() + { + IsCommitting = true; + + if (_inProgressContext != null) + { + _repo.SetWatcherEnabled(false); + Task.Run(() => + { + var succ = _inProgressContext.Abort(); + Dispatcher.UIThread.Invoke(() => + { + if (succ) + CommitMessage = string.Empty; + + _repo.SetWatcherEnabled(true); + IsCommitting = false; + }); + }); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + IsCommitting = false; + } + } + + public void Commit() + { + DoCommit(false, false); + } + + public void CommitWithAutoStage() + { + DoCommit(true, false); + } + + public void CommitWithPush() + { + DoCommit(false, true); + } + + public ContextMenu CreateContextMenuForUnstagedChanges() + { + if (_selectedUnstaged == null || _selectedUnstaged.Count == 0) + return null; + + var menu = new ContextMenu(); + if (_selectedUnstaged.Count == 1) + { + var change = _selectedUnstaged[0]; + var path = Path.GetFullPath(Path.Combine(_repo.FullPath, change.Path)); + + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(path) || Directory.Exists(path); + explore.Click += (_, e) => + { + Native.OS.OpenInFileManager(path, true); + e.Handled = true; + }; + menu.Items.Add(explore); + + var openWith = new MenuItem(); + openWith.Header = App.Text("OpenWith"); + openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); + openWith.IsEnabled = File.Exists(path); + openWith.Click += (_, e) => + { + Native.OS.OpenWithDefaultEditor(path); + e.Handled = true; + }; + menu.Items.Add(openWith); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (change.IsConflicted) + { + var useTheirs = new MenuItem(); + useTheirs.Icon = App.CreateMenuIcon("Icons.Incoming"); + useTheirs.Header = App.Text("FileCM.UseTheirs"); + useTheirs.Click += (_, e) => + { + UseTheirs(_selectedUnstaged); + e.Handled = true; + }; + + var useMine = new MenuItem(); + useMine.Icon = App.CreateMenuIcon("Icons.Local"); + useMine.Header = App.Text("FileCM.UseMine"); + useMine.Click += (_, e) => + { + UseMine(_selectedUnstaged); + e.Handled = true; + }; + + var openMerger = new MenuItem(); + openMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); + openMerger.Header = App.Text("FileCM.OpenWithExternalMerger"); + openMerger.Click += (_, e) => + { + UseExternalMergeTool(change); + e.Handled = true; + }; + + if (_inProgressContext is CherryPickInProgress cherryPick) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", cherryPick.HeadName); + useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); + } + else if (_inProgressContext is RebaseInProgress rebase) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", rebase.HeadName); + useMine.Header = App.Text("FileCM.ResolveUsing", rebase.BaseName); + } + else if (_inProgressContext is RevertInProgress revert) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", $"{revert.Head.SHA.AsSpan(0, 10)} (revert)"); + useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); + } + else if (_inProgressContext is MergeInProgress merge) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", merge.SourceName); + useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); + } + + menu.Items.Add(useTheirs); + menu.Items.Add(useMine); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(openMerger); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + else + { + var stage = new MenuItem(); + stage.Header = App.Text("FileCM.Stage"); + stage.Icon = App.CreateMenuIcon("Icons.File.Add"); + stage.Click += (_, e) => + { + StageChanges(_selectedUnstaged, null); + e.Handled = true; + }; + + var discard = new MenuItem(); + discard.Header = App.Text("FileCM.Discard"); + discard.Icon = App.CreateMenuIcon("Icons.Undo"); + discard.Click += (_, e) => + { + Discard(_selectedUnstaged); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.Stash"); + stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); + stash.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new StashChanges(_repo, _selectedUnstaged, true)); + + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedUnstaged, true, storageFile.Path.LocalPath)); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + var assumeUnchanged = new MenuItem(); + assumeUnchanged.Header = App.Text("FileCM.AssumeUnchanged"); + assumeUnchanged.Icon = App.CreateMenuIcon("Icons.File.Ignore"); + assumeUnchanged.IsVisible = change.WorkTree != Models.ChangeState.Untracked; + assumeUnchanged.Click += (_, e) => + { + var log = _repo.CreateLog("Assume File Unchanged"); + new Commands.AssumeUnchanged(_repo.FullPath, change.Path, true).Use(log).Exec(); + log.Complete(); + e.Handled = true; + }; + + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = App.CreateMenuIcon("Icons.Histories"); + history.Click += (_, e) => + { + App.ShowWindow(new FileHistories(_repo, change.Path), false); + e.Handled = true; + }; + + menu.Items.Add(stage); + menu.Items.Add(discard); + menu.Items.Add(stash); + menu.Items.Add(patch); + menu.Items.Add(assumeUnchanged); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(history); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var extension = Path.GetExtension(change.Path); + var hasExtra = false; + if (change.WorkTree == Models.ChangeState.Untracked) + { + var isRooted = change.Path.IndexOf('/', StringComparison.Ordinal) <= 0; + var addToIgnore = new MenuItem(); + addToIgnore.Header = App.Text("WorkingCopy.AddToGitIgnore"); + addToIgnore.Icon = App.CreateMenuIcon("Icons.GitIgnore"); + + var singleFile = new MenuItem(); + singleFile.Header = App.Text("WorkingCopy.AddToGitIgnore.SingleFile"); + singleFile.Click += (_, e) => + { + Commands.GitIgnore.Add(_repo.FullPath, change.Path); + e.Handled = true; + }; + addToIgnore.Items.Add(singleFile); + + var byParentFolder = new MenuItem(); + byParentFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.InSameFolder"); + byParentFolder.IsVisible = !isRooted; + byParentFolder.Click += (_, e) => + { + var dir = Path.GetDirectoryName(change.Path)!.Replace('\\', '/').TrimEnd('/'); + Commands.GitIgnore.Add(_repo.FullPath, dir + "/"); + e.Handled = true; + }; + addToIgnore.Items.Add(byParentFolder); + + if (!string.IsNullOrEmpty(extension)) + { + var byExtension = new MenuItem(); + byExtension.Header = App.Text("WorkingCopy.AddToGitIgnore.Extension", extension); + byExtension.Click += (_, e) => + { + Commands.GitIgnore.Add(_repo.FullPath, $"*{extension}"); + e.Handled = true; + }; + addToIgnore.Items.Add(byExtension); + + var byExtensionInSameFolder = new MenuItem(); + byExtensionInSameFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.ExtensionInSameFolder", extension); + byExtensionInSameFolder.IsVisible = !isRooted; + byExtensionInSameFolder.Click += (_, e) => + { + var dir = Path.GetDirectoryName(change.Path)!.Replace('\\', '/').TrimEnd('/'); + Commands.GitIgnore.Add(_repo.FullPath, $"{dir}/*{extension}"); + e.Handled = true; + }; + addToIgnore.Items.Add(byExtensionInSameFolder); + } + + menu.Items.Add(addToIgnore); + hasExtra = true; + } + + var lfsEnabled = new Commands.LFS(_repo.FullPath).IsEnabled(); + if (lfsEnabled) + { + var lfs = new MenuItem(); + lfs.Header = App.Text("GitLFS"); + lfs.Icon = App.CreateMenuIcon("Icons.LFS"); + + var isLFSFiltered = new Commands.IsLFSFiltered(_repo.FullPath, change.Path).Result(); + if (!isLFSFiltered) + { + var filename = Path.GetFileName(change.Path); + var lfsTrackThisFile = new MenuItem(); + lfsTrackThisFile.Header = App.Text("GitLFS.Track", filename); + lfsTrackThisFile.Click += async (_, e) => + { + var log = _repo.CreateLog("Track LFS"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Track(filename, true, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Tracking file named {filename} successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfs.Items.Add(lfsTrackThisFile); + + if (!string.IsNullOrEmpty(extension)) + { + var lfsTrackByExtension = new MenuItem(); + lfsTrackByExtension.Header = App.Text("GitLFS.TrackByExtension", extension); + lfsTrackByExtension.Click += async (_, e) => + { + var log = _repo.CreateLog("Track LFS"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Track($"*{extension}", false, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Tracking all *{extension} files successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfs.Items.Add(lfsTrackByExtension); + } + + lfs.Items.Add(new MenuItem() { Header = "-" }); + } + + var lfsLock = new MenuItem(); + lfsLock.Header = App.Text("GitLFS.Locks.Lock"); + lfsLock.Icon = App.CreateMenuIcon("Icons.Lock"); + lfsLock.IsEnabled = _repo.Remotes.Count > 0; + if (_repo.Remotes.Count == 1) + { + lfsLock.Click += async (_, e) => + { + var log = _repo.CreateLog("Lock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(_repo.Remotes[0].Name, change.Path, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + } + else + { + foreach (var remote in _repo.Remotes) + { + var remoteName = remote.Name; + var lockRemote = new MenuItem(); + lockRemote.Header = remoteName; + lockRemote.Click += async (_, e) => + { + var log = _repo.CreateLog("Lock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(remoteName, change.Path, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfsLock.Items.Add(lockRemote); + } + } + lfs.Items.Add(lfsLock); + + var lfsUnlock = new MenuItem(); + lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); + lfsUnlock.Icon = App.CreateMenuIcon("Icons.Unlock"); + lfsUnlock.IsEnabled = _repo.Remotes.Count > 0; + if (_repo.Remotes.Count == 1) + { + lfsUnlock.Click += async (_, e) => + { + var log = _repo.CreateLog("Unlock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(_repo.Remotes[0].Name, change.Path, false, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + } + else + { + foreach (var remote in _repo.Remotes) + { + var remoteName = remote.Name; + var unlockRemote = new MenuItem(); + unlockRemote.Header = remoteName; + unlockRemote.Click += async (_, e) => + { + var log = _repo.CreateLog("Unlock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(remoteName, change.Path, false, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfsUnlock.Items.Add(unlockRemote); + } + } + lfs.Items.Add(lfsUnlock); + + menu.Items.Add(lfs); + hasExtra = true; + } + + if (hasExtra) + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var copy = new MenuItem(); + copy.Header = App.Text("CopyPath"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, e) => + { + App.CopyText(change.Path); + e.Handled = true; + }; + menu.Items.Add(copy); + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyFullPath.Click += (_, e) => + { + App.CopyText(Native.OS.GetAbsPath(_repo.FullPath, change.Path)); + e.Handled = true; + }; + menu.Items.Add(copyFullPath); + } + else + { + var hasConflicts = false; + var hasNonConflicts = false; + foreach (var change in _selectedUnstaged) + { + if (change.IsConflicted) + hasConflicts = true; + else + hasNonConflicts = true; + } + + if (hasConflicts) + { + if (hasNonConflicts) + { + App.RaiseException(_repo.FullPath, "Selection contains both conflict and non-conflict changes!"); + return null; + } + + var useTheirs = new MenuItem(); + useTheirs.Icon = App.CreateMenuIcon("Icons.Incoming"); + useTheirs.Header = App.Text("FileCM.UseTheirs"); + useTheirs.Click += (_, e) => + { + UseTheirs(_selectedUnstaged); + e.Handled = true; + }; + + var useMine = new MenuItem(); + useMine.Icon = App.CreateMenuIcon("Icons.Local"); + useMine.Header = App.Text("FileCM.UseMine"); + useMine.Click += (_, e) => + { + UseMine(_selectedUnstaged); + e.Handled = true; + }; + + if (_inProgressContext is CherryPickInProgress cherryPick) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", cherryPick.HeadName); + useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); + } + else if (_inProgressContext is RebaseInProgress rebase) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", rebase.HeadName); + useMine.Header = App.Text("FileCM.ResolveUsing", rebase.BaseName); + } + else if (_inProgressContext is RevertInProgress revert) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", $"{revert.Head.SHA.AsSpan(0, 10)} (revert)"); + useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); + } + else if (_inProgressContext is MergeInProgress merge) + { + useTheirs.Header = App.Text("FileCM.ResolveUsing", merge.SourceName); + useMine.Header = App.Text("FileCM.ResolveUsing", _repo.CurrentBranch.Name); + } + + menu.Items.Add(useTheirs); + menu.Items.Add(useMine); + return menu; + } + + var stage = new MenuItem(); + stage.Header = App.Text("FileCM.StageMulti", _selectedUnstaged.Count); + stage.Icon = App.CreateMenuIcon("Icons.File.Add"); + stage.Click += (_, e) => + { + StageChanges(_selectedUnstaged, null); + e.Handled = true; + }; + + var discard = new MenuItem(); + discard.Header = App.Text("FileCM.DiscardMulti", _selectedUnstaged.Count); + discard.Icon = App.CreateMenuIcon("Icons.Undo"); + discard.Click += (_, e) => + { + Discard(_selectedUnstaged); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.StashMulti", _selectedUnstaged.Count); + stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); + stash.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new StashChanges(_repo, _selectedUnstaged, true)); + + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedUnstaged, true, storageFile.Path.LocalPath)); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + menu.Items.Add(stage); + menu.Items.Add(discard); + menu.Items.Add(stash); + menu.Items.Add(patch); + } + + return menu; + } + + public ContextMenu CreateContextMenuForStagedChanges() + { + if (_selectedStaged == null || _selectedStaged.Count == 0) + return null; + + var menu = new ContextMenu(); + + var ai = null as MenuItem; + var services = _repo.GetPreferredOpenAIServices(); + if (services.Count > 0) + { + ai = new MenuItem(); + ai.Icon = App.CreateMenuIcon("Icons.AIAssist"); + ai.Header = App.Text("ChangeCM.GenerateCommitMessage"); + + if (services.Count == 1) + { + ai.Click += (_, e) => + { + App.ShowWindow(new AIAssistant(_repo, services[0], _selectedStaged, t => CommitMessage = t), true); + e.Handled = true; + }; + } + else + { + foreach (var service in services) + { + var dup = service; + + var item = new MenuItem(); + item.Header = service.Name; + item.Click += (_, e) => + { + App.ShowWindow(new AIAssistant(_repo, dup, _selectedStaged, t => CommitMessage = t), true); + e.Handled = true; + }; + + ai.Items.Add(item); + } + } + } + + if (_selectedStaged.Count == 1) + { + var change = _selectedStaged[0]; + var path = Path.GetFullPath(Path.Combine(_repo.FullPath, change.Path)); + + var explore = new MenuItem(); + explore.IsEnabled = File.Exists(path) || Directory.Exists(path); + explore.Header = App.Text("RevealFile"); + explore.Icon = App.CreateMenuIcon("Icons.Explore"); + explore.Click += (_, e) => + { + Native.OS.OpenInFileManager(path, true); + e.Handled = true; + }; + + var openWith = new MenuItem(); + openWith.Header = App.Text("OpenWith"); + openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); + openWith.IsEnabled = File.Exists(path); + openWith.Click += (_, e) => + { + Native.OS.OpenWithDefaultEditor(path); + e.Handled = true; + }; + + var unstage = new MenuItem(); + unstage.Header = App.Text("FileCM.Unstage"); + unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); + unstage.Click += (_, e) => + { + UnstageChanges(_selectedStaged, null); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.Stash"); + stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); + stash.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new StashChanges(_repo, _selectedStaged, true)); + + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedStaged, false, storageFile.Path.LocalPath)); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = App.CreateMenuIcon("Icons.Histories"); + history.Click += (_, e) => + { + App.ShowWindow(new FileHistories(_repo, change.Path), false); + e.Handled = true; + }; + + menu.Items.Add(explore); + menu.Items.Add(openWith); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(unstage); + menu.Items.Add(stash); + menu.Items.Add(patch); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(history); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var lfsEnabled = new Commands.LFS(_repo.FullPath).IsEnabled(); + if (lfsEnabled) + { + var lfs = new MenuItem(); + lfs.Header = App.Text("GitLFS"); + lfs.Icon = App.CreateMenuIcon("Icons.LFS"); + + var lfsLock = new MenuItem(); + lfsLock.Header = App.Text("GitLFS.Locks.Lock"); + lfsLock.Icon = App.CreateMenuIcon("Icons.Lock"); + lfsLock.IsEnabled = _repo.Remotes.Count > 0; + if (_repo.Remotes.Count == 1) + { + lfsLock.Click += async (_, e) => + { + var log = _repo.CreateLog("Lock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(_repo.Remotes[0].Name, change.Path, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + } + else + { + foreach (var remote in _repo.Remotes) + { + var remoteName = remote.Name; + var lockRemote = new MenuItem(); + lockRemote.Header = remoteName; + lockRemote.Click += async (_, e) => + { + var log = _repo.CreateLog("Lock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(remoteName, change.Path, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfsLock.Items.Add(lockRemote); + } + } + lfs.Items.Add(lfsLock); + + var lfsUnlock = new MenuItem(); + lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); + lfsUnlock.Icon = App.CreateMenuIcon("Icons.Unlock"); + lfsUnlock.IsEnabled = _repo.Remotes.Count > 0; + if (_repo.Remotes.Count == 1) + { + lfsUnlock.Click += async (_, e) => + { + var log = _repo.CreateLog("Unlock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(_repo.Remotes[0].Name, change.Path, false, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + } + else + { + foreach (var remote in _repo.Remotes) + { + var remoteName = remote.Name; + var unlockRemote = new MenuItem(); + unlockRemote.Header = remoteName; + unlockRemote.Click += async (_, e) => + { + var log = _repo.CreateLog("Unlock LFS File"); + var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(remoteName, change.Path, false, log)); + if (succ) + App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); + + log.Complete(); + e.Handled = true; + }; + lfsUnlock.Items.Add(unlockRemote); + } + } + lfs.Items.Add(lfsUnlock); + + menu.Items.Add(lfs); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (ai != null) + { + menu.Items.Add(ai); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyPath.Click += (_, e) => + { + App.CopyText(change.Path); + e.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy"); + copyFullPath.Click += (_, e) => + { + App.CopyText(Native.OS.GetAbsPath(_repo.FullPath, change.Path)); + e.Handled = true; + }; + + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + } + else + { + var unstage = new MenuItem(); + unstage.Header = App.Text("FileCM.UnstageMulti", _selectedStaged.Count); + unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); + unstage.Click += (_, e) => + { + UnstageChanges(_selectedStaged, null); + e.Handled = true; + }; + + var stash = new MenuItem(); + stash.Header = App.Text("FileCM.StashMulti", _selectedStaged.Count); + stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); + stash.Click += (_, e) => + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new StashChanges(_repo, _selectedStaged, true)); + + e.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = App.GetStorageProvider(); + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedStaged, false, storageFile.Path.LocalPath)); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + e.Handled = true; + }; + + menu.Items.Add(unstage); + menu.Items.Add(stash); + menu.Items.Add(patch); + + if (ai != null) + { + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(ai); + } + } + + return menu; + } + + public ContextMenu CreateContextMenuForCommitMessages() + { + var menu = new ContextMenu(); + + var gitTemplate = new Commands.Config(_repo.FullPath).Get("commit.template"); + var templateCount = _repo.Settings.CommitTemplates.Count; + if (templateCount == 0 && string.IsNullOrEmpty(gitTemplate)) + { + menu.Items.Add(new MenuItem() + { + Header = App.Text("WorkingCopy.NoCommitTemplates"), + Icon = App.CreateMenuIcon("Icons.Code"), + IsEnabled = false + }); + } + else + { + for (int i = 0; i < templateCount; i++) + { + var template = _repo.Settings.CommitTemplates[i]; + var item = new MenuItem(); + item.Header = App.Text("WorkingCopy.UseCommitTemplate", template.Name); + item.Icon = App.CreateMenuIcon("Icons.Code"); + item.Click += (_, e) => + { + CommitMessage = template.Apply(_repo.CurrentBranch, _staged); + e.Handled = true; + }; + menu.Items.Add(item); + } + + if (!string.IsNullOrEmpty(gitTemplate)) + { + var friendlyName = gitTemplate; + if (!OperatingSystem.IsWindows()) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; + if (gitTemplate.StartsWith(home, StringComparison.Ordinal)) + friendlyName = $"~{gitTemplate.AsSpan(prefixLen)}"; + } + + var gitTemplateItem = new MenuItem(); + gitTemplateItem.Header = App.Text("WorkingCopy.UseCommitTemplate", friendlyName); + gitTemplateItem.Icon = App.CreateMenuIcon("Icons.Code"); + gitTemplateItem.Click += (_, e) => + { + if (File.Exists(gitTemplate)) + CommitMessage = File.ReadAllText(gitTemplate); + e.Handled = true; + }; + menu.Items.Add(gitTemplateItem); + } + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var historiesCount = _repo.Settings.CommitMessages.Count; + if (historiesCount == 0) + { + menu.Items.Add(new MenuItem() + { + Header = App.Text("WorkingCopy.NoCommitHistories"), + Icon = App.CreateMenuIcon("Icons.Histories"), + IsEnabled = false + }); + } + else + { + for (int i = 0; i < historiesCount; i++) + { + var message = _repo.Settings.CommitMessages[i].Trim().ReplaceLineEndings("\n"); + var subjectEndIdx = message.IndexOf('\n'); + var subject = subjectEndIdx > 0 ? message.Substring(0, subjectEndIdx) : message; + var item = new MenuItem(); + item.Header = subject; + item.Icon = App.CreateMenuIcon("Icons.Histories"); + item.Click += (_, e) => + { + CommitMessage = message; + e.Handled = true; + }; + + menu.Items.Add(item); + } + } + + return menu; + } + + public ContextMenu CreateContextForOpenAI() + { + if (_staged == null || _staged.Count == 0) + { + App.RaiseException(_repo.FullPath, "No files added to commit!"); + return null; + } + + var services = _repo.GetPreferredOpenAIServices(); + if (services.Count == 0) + { + App.RaiseException(_repo.FullPath, "Bad configuration for OpenAI"); + return null; + } + + if (services.Count == 1) + { + App.ShowWindow(new AIAssistant(_repo, services[0], _staged, t => CommitMessage = t), true); + return null; + } + + var menu = new ContextMenu() { Placement = PlacementMode.TopEdgeAlignedLeft }; + foreach (var service in services) + { + var dup = service; + var item = new MenuItem(); + item.Header = service.Name; + item.Click += (_, e) => + { + App.ShowWindow(new AIAssistant(_repo, dup, _staged, t => CommitMessage = t), true); + e.Handled = true; + }; + + menu.Items.Add(item); + } + + return menu; + } + + private List GetVisibleChanges(List changes) + { + if (string.IsNullOrEmpty(_filter)) + return changes; + + var visible = new List(); + + foreach (var c in changes) + { + if (c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } + + return visible; + } + + private List GetStagedChanges() + { + if (_useAmend) + { + var head = new Commands.QuerySingleCommit(_repo.FullPath, "HEAD").Result(); + return new Commands.QueryStagedChangesWithAmend(_repo.FullPath, head.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : $"{head.SHA}^").Result(); + } + + var rs = new List(); + foreach (var c in _cached) + { + if (c.Index != Models.ChangeState.None) + rs.Add(c); + } + return rs; + } + + private void UpdateDetail() + { + if (_selectedUnstaged.Count == 1) + SetDetail(_selectedUnstaged[0], true); + else if (_selectedStaged.Count == 1) + SetDetail(_selectedStaged[0], false); + else + SetDetail(null, false); + } + + private void UpdateInProgressState() + { + if (string.IsNullOrEmpty(_commitMessage)) + { + var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); + if (File.Exists(mergeMsgFile)) + CommitMessage = File.ReadAllText(mergeMsgFile); + } + + if (File.Exists(Path.Combine(_repo.GitDir, "CHERRY_PICK_HEAD"))) + { + InProgressContext = new CherryPickInProgress(_repo); + } + else if (Directory.Exists(Path.Combine(_repo.GitDir, "rebase-merge")) || Directory.Exists(Path.Combine(_repo.GitDir, "rebase-apply"))) + { + var rebasing = new RebaseInProgress(_repo); + InProgressContext = rebasing; + + if (string.IsNullOrEmpty(_commitMessage)) + { + var rebaseMsgFile = Path.Combine(_repo.GitDir, "rebase-merge", "message"); + if (File.Exists(rebaseMsgFile)) + CommitMessage = File.ReadAllText(rebaseMsgFile); + else if (rebasing.StoppedAt != null) + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, rebasing.StoppedAt.SHA).Result(); + } + } + else if (File.Exists(Path.Combine(_repo.GitDir, "REVERT_HEAD"))) + { + InProgressContext = new RevertInProgress(_repo); + } + else if (File.Exists(Path.Combine(_repo.GitDir, "MERGE_HEAD"))) + { + InProgressContext = new MergeInProgress(_repo); + } + else + { + InProgressContext = null; + } + } + + private async void StageChanges(List changes, Models.Change next) + { + var count = changes.Count; + if (count == 0) + return; + + // Use `_selectedUnstaged` instead of `SelectedUnstaged` to avoid UI refresh. + _selectedUnstaged = next != null ? [next] : []; + + IsStaging = true; + _repo.SetWatcherEnabled(false); + + var log = _repo.CreateLog("Stage"); + if (count == _unstaged.Count) + { + await Task.Run(() => new Commands.Add(_repo.FullPath, _repo.IncludeUntracked).Use(log).Exec()); + } + else + { + var paths = new List(); + foreach (var c in changes) + paths.Add(c.Path); + + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync(pathSpecFile, paths); + await Task.Run(() => new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).Exec()); + File.Delete(pathSpecFile); + } + log.Complete(); + + _repo.MarkWorkingCopyDirtyManually(); + _repo.SetWatcherEnabled(true); + IsStaging = false; + } + + private async void UnstageChanges(List changes, Models.Change next) + { + var count = changes.Count; + if (count == 0) + return; + + // Use `_selectedStaged` instead of `SelectedStaged` to avoid UI refresh. + _selectedStaged = next != null ? [next] : []; + + IsUnstaging = true; + _repo.SetWatcherEnabled(false); + + var log = _repo.CreateLog("Unstage"); + if (_useAmend) + { + log.AppendLine("$ git update-index --index-info "); + await Task.Run(() => new Commands.UnstageChangesForAmend(_repo.FullPath, changes).Exec()); + } + else + { + var paths = new List(); + foreach (var c in changes) + { + paths.Add(c.Path); + if (c.Index == Models.ChangeState.Renamed) + paths.Add(c.OriginalPath); + } + + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync(pathSpecFile, paths); + await Task.Run(() => new Commands.Restore(_repo.FullPath, pathSpecFile, true).Use(log).Exec()); + File.Delete(pathSpecFile); + } + log.Complete(); + + _repo.MarkWorkingCopyDirtyManually(); + _repo.SetWatcherEnabled(true); + IsUnstaging = false; + } + + private void SetDetail(Models.Change change, bool isUnstaged) + { + if (_isLoadingData) + return; + + if (change == null) + DetailContext = null; + else if (change.IsConflicted) + DetailContext = new Conflict(_repo, this, change); + else + DetailContext = new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged), _detailContext as DiffContext); + } + + private void DoCommit(bool autoStage, bool autoPush, bool allowEmpty = false, bool confirmWithFilter = false) + { + if (string.IsNullOrWhiteSpace(_commitMessage)) + return; + + if (!_repo.CanCreatePopup()) + { + App.RaiseException(_repo.FullPath, "Repository has an unfinished job! Please wait!"); + return; + } + + if (!string.IsNullOrEmpty(_filter) && _staged.Count > _visibleStaged.Count && !confirmWithFilter) + { + var confirmMessage = App.Text("WorkingCopy.ConfirmCommitWithFilter", _staged.Count, _visibleStaged.Count, _staged.Count - _visibleStaged.Count); + App.ShowWindow(new ConfirmCommit(confirmMessage, () => DoCommit(autoStage, autoPush, allowEmpty, true)), true); + return; + } + + if (!_useAmend && !allowEmpty) + { + if ((autoStage && _count == 0) || (!autoStage && _staged.Count == 0)) + { + App.ShowWindow(new ConfirmEmptyCommit(_count > 0, stageAll => DoCommit(stageAll, autoPush, true, confirmWithFilter)), true); + return; + } + } + + IsCommitting = true; + _repo.Settings.PushCommitMessage(_commitMessage); + _repo.SetWatcherEnabled(false); + + var signOff = _repo.Settings.EnableSignOffForCommit; + var log = _repo.CreateLog("Commit"); + Task.Run(() => + { + var succ = true; + if (autoStage && _unstaged.Count > 0) + succ = new Commands.Add(_repo.FullPath, _repo.IncludeUntracked).Use(log).Exec(); + + if (succ) + succ = new Commands.Commit(_repo.FullPath, _commitMessage, signOff, _useAmend, _resetAuthor).Use(log).Run(); + + log.Complete(); + + Dispatcher.UIThread.Post(() => + { + if (succ) + { + CommitMessage = string.Empty; + UseAmend = false; + + if (autoPush && _repo.Remotes.Count > 0) + { + if (_repo.CurrentBranch == null) + { + var currentBranchName = Commands.Branch.ShowCurrent(_repo.FullPath); + var tmp = new Models.Branch() { Name = currentBranchName }; + _repo.ShowAndStartPopup(new Push(_repo, tmp)); + } + else + { + _repo.ShowAndStartPopup(new Push(_repo, null)); + } + } + } + + _repo.MarkBranchesDirtyManually(); + _repo.SetWatcherEnabled(true); + IsCommitting = false; + }); + }); + } + + private bool IsChanged(List old, List cur) + { + if (old.Count != cur.Count) + return true; + + for (int idx = 0; idx < old.Count; idx++) + { + var o = old[idx]; + var c = cur[idx]; + if (o.Path != c.Path || o.Index != c.Index || o.WorkTree != c.WorkTree) + return true; + } + + return false; + } + + private Repository _repo = null; + private bool _isLoadingData = false; + private bool _isStaging = false; + private bool _isUnstaging = false; + private bool _isCommitting = false; + private bool _useAmend = false; + private bool _resetAuthor = false; + private bool _hasRemotes = false; + private List _cached = []; + private List _unstaged = []; + private List _visibleUnstaged = []; + private List _staged = []; + private List _visibleStaged = []; + private List _selectedUnstaged = []; + private List _selectedStaged = []; + private int _count = 0; + private object _detailContext = null; + private string _filter = string.Empty; + private string _commitMessage = string.Empty; + + private bool _hasUnsolvedConflicts = false; + private InProgressContext _inProgressContext = null; + } +} diff --git a/src/ViewModels/Workspace.cs b/src/ViewModels/Workspace.cs new file mode 100644 index 00000000..3ae935c7 --- /dev/null +++ b/src/ViewModels/Workspace.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Workspace : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public uint Color + { + get => _color; + set + { + if (SetProperty(ref _color, value)) + OnPropertyChanged(nameof(Brush)); + } + } + + public List Repositories + { + get; + set; + } = new List(); + + public int ActiveIdx + { + get; + set; + } = 0; + + public bool IsActive + { + get => _isActive; + set => SetProperty(ref _isActive, value); + } + + public bool RestoreOnStartup + { + get => _restoreOnStartup; + set => SetProperty(ref _restoreOnStartup, value); + } + + public string DefaultCloneDir + { + get => _defaultCloneDir; + set => SetProperty(ref _defaultCloneDir, value); + } + + public IBrush Brush + { + get => new SolidColorBrush(_color); + } + + private string _name = string.Empty; + private uint _color = 4278221015; + private bool _isActive = false; + private bool _restoreOnStartup = true; + private string _defaultCloneDir = string.Empty; + } +} diff --git a/src/ViewModels/WorkspaceSwitcher.cs b/src/ViewModels/WorkspaceSwitcher.cs new file mode 100644 index 00000000..7a2da9be --- /dev/null +++ b/src/ViewModels/WorkspaceSwitcher.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class WorkspaceSwitcher : ObservableObject, IDisposable + { + public List VisibleWorkspaces + { + get => _visibleWorkspaces; + private set => SetProperty(ref _visibleWorkspaces, value); + } + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + UpdateVisibleWorkspaces(); + } + } + + public Workspace SelectedWorkspace + { + get => _selectedWorkspace; + set => SetProperty(ref _selectedWorkspace, value); + } + + public WorkspaceSwitcher(Launcher launcher) + { + _launcher = launcher; + UpdateVisibleWorkspaces(); + } + + public void ClearFilter() + { + SearchFilter = string.Empty; + } + + public void Switch() + { + _launcher.SwitchWorkspace(_selectedWorkspace); + _launcher.CancelSwitcher(); + } + + public void Dispose() + { + _visibleWorkspaces.Clear(); + _selectedWorkspace = null; + _searchFilter = string.Empty; + } + + private void UpdateVisibleWorkspaces() + { + var visible = new List(); + if (string.IsNullOrEmpty(_searchFilter)) + { + visible.AddRange(Preferences.Instance.Workspaces); + } + else + { + foreach (var workspace in Preferences.Instance.Workspaces) + { + if (workspace.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(workspace); + } + } + + VisibleWorkspaces = visible; + SelectedWorkspace = visible.Count == 0 ? null : visible[0]; + } + + private Launcher _launcher = null; + private List _visibleWorkspaces = null; + private string _searchFilter = string.Empty; + private Workspace _selectedWorkspace = null; + } +} diff --git a/src/Views/AIAssistant.axaml b/src/Views/AIAssistant.axaml new file mode 100644 index 00000000..96ce56ec --- /dev/null +++ b/src/Views/AIAssistant.axaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/About.axaml.cs b/src/Views/About.axaml.cs new file mode 100644 index 00000000..d393f94c --- /dev/null +++ b/src/Views/About.axaml.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class About : ChromelessWindow + { + public About() + { + InitializeComponent(); + + var assembly = Assembly.GetExecutingAssembly(); + var ver = assembly.GetName().Version; + if (ver != null) + TxtVersion.Text = $"{ver.Major}.{ver.Minor:D2}"; + + var copyright = assembly.GetCustomAttribute(); + if (copyright != null) + TxtCopyright.Text = copyright.Copyright; + } + + private void OnVisitWebsite(object _, RoutedEventArgs e) + { + Native.OS.OpenBrowser("https://sourcegit-scm.github.io/"); + e.Handled = true; + } + + private void OnVisitSourceCode(object _, RoutedEventArgs e) + { + Native.OS.OpenBrowser("https://github.com/sourcegit-scm/sourcegit"); + e.Handled = true; + } + } +} diff --git a/src/Views/About.xaml b/src/Views/About.xaml deleted file mode 100644 index 9c4e14af..00000000 --- a/src/Views/About.xaml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/About.xaml.cs b/src/Views/About.xaml.cs deleted file mode 100644 index cce5e618..00000000 --- a/src/Views/About.xaml.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reflection; -using System.Windows; - -namespace SourceGit.Views { - - /// - /// 关于对话框 - /// - public partial class About : Controls.Window { - - public class Keymap { - public string Key { get; set; } - public string Desc { get; set; } - public Keymap(string k, string d) { Key = k; Desc = App.Text($"Hotkeys.{d}"); } - } - - public About() { - InitializeComponent(); - - var asm = Assembly.GetExecutingAssembly().GetName(); - version.Text = $"VERSION : v{asm.Version.Major}.{asm.Version.Minor}"; - - hotkeys.ItemsSource = new List() { - new Keymap("CTRL + T", "NewTab"), - new Keymap("CTRL + W", "CloseTab"), - new Keymap("CTRL + TAB", "NextTab"), - new Keymap("CTRL + [1-9]", "SwitchTo"), - new Keymap("CTRL + F", "Search"), - new Keymap("F5", "Refresh"), - new Keymap("SPACE", "ToggleStage"), - new Keymap("ESC", "CancelPopup"), - }; - } - - private void OnRequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) { - var info = new ProcessStartInfo("cmd", $"/c start {e.Uri.AbsoluteUri}"); - info.CreateNoWindow = true; - Process.Start(info); - } - - private void Quit(object sender, RoutedEventArgs e) { - Close(); - } - } -} diff --git a/src/Views/AddRemote.axaml b/src/Views/AddRemote.axaml new file mode 100644 index 00000000..f42fbf1f --- /dev/null +++ b/src/Views/AddRemote.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AddRemote.axaml.cs b/src/Views/AddRemote.axaml.cs new file mode 100644 index 00000000..4c3914f2 --- /dev/null +++ b/src/Views/AddRemote.axaml.cs @@ -0,0 +1,33 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class AddRemote : UserControl + { + public AddRemote() + { + InitializeComponent(); + } + + private async void SelectSSHKey(object _, RoutedEventArgs e) + { + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] + }; + + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + TxtSshKey.Text = selected[0].Path.LocalPath; + + e.Handled = true; + } + } +} diff --git a/src/Views/AddSubmodule.axaml b/src/Views/AddSubmodule.axaml new file mode 100644 index 00000000..2b5061ff --- /dev/null +++ b/src/Views/AddSubmodule.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/src/Views/AddSubmodule.axaml.cs b/src/Views/AddSubmodule.axaml.cs new file mode 100644 index 00000000..53e53a8f --- /dev/null +++ b/src/Views/AddSubmodule.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class AddSubmodule : UserControl + { + public AddSubmodule() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/AddWorktree.axaml b/src/Views/AddWorktree.axaml new file mode 100644 index 00000000..1811008c --- /dev/null +++ b/src/Views/AddWorktree.axaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AddWorktree.axaml.cs b/src/Views/AddWorktree.axaml.cs new file mode 100644 index 00000000..dad947de --- /dev/null +++ b/src/Views/AddWorktree.axaml.cs @@ -0,0 +1,40 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class AddWorktree : UserControl + { + public AddWorktree() + { + InitializeComponent(); + } + + private async void SelectLocation(object _, RoutedEventArgs e) + { + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + try + { + var selected = await toplevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder?.Path.ToString(); + TxtLocation.Text = folderPath; + } + } + catch (Exception exception) + { + App.RaiseException(string.Empty, $"Failed to select location: {exception.Message}"); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/Apply.axaml b/src/Views/Apply.axaml new file mode 100644 index 00000000..6c2478bb --- /dev/null +++ b/src/Views/Apply.axaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Apply.axaml.cs b/src/Views/Apply.axaml.cs new file mode 100644 index 00000000..bf8e4da4 --- /dev/null +++ b/src/Views/Apply.axaml.cs @@ -0,0 +1,33 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class Apply : UserControl + { + public Apply() + { + InitializeComponent(); + } + + private async void SelectPatchFile(object _, RoutedEventArgs e) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + return; + + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }] + }; + + var selected = await topLevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + TxtPatchFile.Text = selected[0].Path.LocalPath; + + e.Handled = true; + } + } +} diff --git a/src/Views/ApplyStash.axaml b/src/Views/ApplyStash.axaml new file mode 100644 index 00000000..44a97f42 --- /dev/null +++ b/src/Views/ApplyStash.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ApplyStash.axaml.cs b/src/Views/ApplyStash.axaml.cs new file mode 100644 index 00000000..7c8ba227 --- /dev/null +++ b/src/Views/ApplyStash.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ApplyStash : UserControl + { + public ApplyStash() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Archive.axaml b/src/Views/Archive.axaml new file mode 100644 index 00000000..e9d0ad02 --- /dev/null +++ b/src/Views/Archive.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Archive.axaml.cs b/src/Views/Archive.axaml.cs new file mode 100644 index 00000000..c0046969 --- /dev/null +++ b/src/Views/Archive.axaml.cs @@ -0,0 +1,33 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class Archive : UserControl + { + public Archive() + { + InitializeComponent(); + } + + private async void SelectOutputFile(object _, RoutedEventArgs e) + { + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FilePickerSaveOptions() + { + DefaultExtension = ".zip", + FileTypeChoices = [new FilePickerFileType("ZIP") { Patterns = ["*.zip"] }] + }; + + var selected = await toplevel.StorageProvider.SaveFilePickerAsync(options); + if (selected != null) + TxtSaveFile.Text = selected.Path.LocalPath; + + e.Handled = true; + } + } +} diff --git a/src/Views/Askpass.axaml b/src/Views/Askpass.axaml new file mode 100644 index 00000000..f4113b5e --- /dev/null +++ b/src/Views/Askpass.axaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AssumeUnchangedManager.axaml.cs b/src/Views/AssumeUnchangedManager.axaml.cs new file mode 100644 index 00000000..a0a5a352 --- /dev/null +++ b/src/Views/AssumeUnchangedManager.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class AssumeUnchangedManager : ChromelessWindow + { + public AssumeUnchangedManager() + { + InitializeComponent(); + } + + private void OnRemoveButtonClicked(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.AssumeUnchangedManager vm && sender is Button button) + vm.Remove(button.DataContext as string); + + e.Handled = true; + } + } +} diff --git a/src/Views/AutoFocusBehaviour.cs b/src/Views/AutoFocusBehaviour.cs new file mode 100644 index 00000000..57f07c88 --- /dev/null +++ b/src/Views/AutoFocusBehaviour.cs @@ -0,0 +1,42 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public class AutoFocusBehaviour : AvaloniaObject + { + public static readonly AttachedProperty IsEnabledProperty = + AvaloniaProperty.RegisterAttached("IsEnabled"); + + static AutoFocusBehaviour() + { + IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged); + } + + public static bool GetIsEnabled(AvaloniaObject elem) + { + return elem.GetValue(IsEnabledProperty); + } + + public static void SetIsEnabled(AvaloniaObject elem, bool value) + { + elem.SetValue(IsEnabledProperty, value); + } + + private static void OnIsEnabledChanged(TextBox elem, AvaloniaPropertyChangedEventArgs e) + { + if (GetIsEnabled(elem)) + { + elem.AttachedToVisualTree += (o, _) => + { + if (o is TextBox box) + { + box.Focus(NavigationMethod.Directional); + box.CaretIndex = box.Text?.Length ?? 0; + } + }; + } + } + } +} diff --git a/src/Views/Avatar.cs b/src/Views/Avatar.cs new file mode 100644 index 00000000..dcb3591d --- /dev/null +++ b/src/Views/Avatar.cs @@ -0,0 +1,225 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public class Avatar : Control, Models.IAvatarHost + { + public static readonly StyledProperty UserProperty = + AvaloniaProperty.Register(nameof(User)); + + public Models.User User + { + get => GetValue(UserProperty); + set => SetValue(UserProperty, value); + } + + public Avatar() + { + RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.HighQuality); + } + + public override void Render(DrawingContext context) + { + if (User == null) + return; + + var corner = (float)Math.Max(2, Bounds.Width / 16); + var rect = new Rect(0, 0, Bounds.Width, Bounds.Height); + var clip = context.PushClip(new RoundedRect(rect, corner)); + + if (_img != null) + { + context.DrawImage(_img, rect); + } + else + { + context.DrawRectangle(Brushes.White, new Pen(new SolidColorBrush(Colors.Black, 0.3f), 0.65f), rect, corner, corner); + + var offsetX = Bounds.Width / 10.0; + var offsetY = Bounds.Height / 10.0; + + var stepX = (Bounds.Width - offsetX * 2) / 5.0; + var stepY = (Bounds.Height - offsetY * 2) / 5.0; + + var user = User; + var lowered = user.Email.ToLower(CultureInfo.CurrentCulture).Trim(); + var hash = MD5.HashData(Encoding.Default.GetBytes(lowered)); + + var brush = new SolidColorBrush(new Color(255, hash[0], hash[1], hash[2])); + var switches = new bool[15]; + for (int i = 0; i < switches.Length; i++) + switches[i] = hash[i + 1] % 2 == 1; + + for (int row = 0; row < 5; row++) + { + var x = offsetX + stepX * 2; + var y = offsetY + stepY * row; + var idx = row * 3; + + if (switches[idx]) + context.FillRectangle(brush, new Rect(x, y, stepX, stepY)); + + if (switches[idx + 1]) + context.FillRectangle(brush, new Rect(x + stepX, y, stepX, stepY)); + + if (switches[idx + 2]) + context.FillRectangle(brush, new Rect(x + stepX * 2, y, stepX, stepY)); + } + + for (int row = 0; row < 5; row++) + { + var x = offsetX; + var y = offsetY + stepY * row; + var idx = row * 3 + 2; + + if (switches[idx]) + context.FillRectangle(brush, new Rect(x, y, stepX, stepY)); + + if (switches[idx - 1]) + context.FillRectangle(brush, new Rect(x + stepX, y, stepX, stepY)); + } + } + + clip.Dispose(); + } + + public void OnAvatarResourceChanged(string email, Bitmap image) + { + if (User.Email.Equals(email, StringComparison.Ordinal)) + { + _img = image; + InvalidateVisual(); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + Models.AvatarManager.Instance.Subscribe(this); + ContextRequested += OnContextRequested; + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + ContextRequested -= OnContextRequested; + Models.AvatarManager.Instance.Unsubscribe(this); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UserProperty) + { + var user = User; + if (user == null) + return; + + _img = Models.AvatarManager.Instance.Request(User.Email, false); + InvalidateVisual(); + } + } + + private void OnContextRequested(object sender, ContextRequestedEventArgs e) + { + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + { + e.Handled = true; + return; + } + + var refetch = new MenuItem(); + refetch.Icon = App.CreateMenuIcon("Icons.Loading"); + refetch.Header = App.Text("Avatar.Refetch"); + refetch.Click += (_, ev) => + { + if (User != null) + Models.AvatarManager.Instance.Request(User.Email, true); + + ev.Handled = true; + }; + + var load = new MenuItem(); + load.Icon = App.CreateMenuIcon("Icons.Folder.Open"); + load.Header = App.Text("Avatar.Load"); + load.Click += async (_, ev) => + { + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType("PNG") { Patterns = ["*.png"] }], + AllowMultiple = false, + }; + + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + { + var localFile = selected[0].Path.LocalPath; + Models.AvatarManager.Instance.SetFromLocal(User.Email, localFile); + } + + ev.Handled = true; + }; + + var saveAs = new MenuItem(); + saveAs.Icon = App.CreateMenuIcon("Icons.Save"); + saveAs.Header = App.Text("SaveAs"); + saveAs.Click += async (_, ev) => + { + var options = new FilePickerSaveOptions(); + options.Title = App.Text("SaveAs"); + options.DefaultExtension = ".png"; + options.FileTypeChoices = [new FilePickerFileType("PNG") { Patterns = ["*.png"] }]; + + var storageFile = await toplevel.StorageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + await using (var writer = File.OpenWrite(saveTo)) + { + if (_img != null) + { + _img.Save(writer); + } + else + { + var pixelSize = new PixelSize((int)Bounds.Width, (int)Bounds.Height); + var dpi = new Vector(96, 96); + + using (var rt = new RenderTargetBitmap(pixelSize, dpi)) + using (var ctx = rt.CreateDrawingContext()) + { + Render(ctx); + rt.Save(writer); + } + } + } + } + + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(refetch); + menu.Items.Add(load); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(saveAs); + + menu.Open(this); + } + + private Bitmap _img = null; + } +} diff --git a/src/Views/BisectStateIndicator.cs b/src/Views/BisectStateIndicator.cs new file mode 100644 index 00000000..0a581f53 --- /dev/null +++ b/src/Views/BisectStateIndicator.cs @@ -0,0 +1,140 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class BisectStateIndicator : Control + { + public static readonly StyledProperty BackgroundProperty = + AvaloniaProperty.Register(nameof(Background), Brushes.Transparent); + + public IBrush Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.White); + + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + public static readonly StyledProperty BisectProperty = + AvaloniaProperty.Register(nameof(Bisect)); + + public Models.Bisect Bisect + { + get => GetValue(BisectProperty); + set => SetValue(BisectProperty, value); + } + + static BisectStateIndicator() + { + AffectsMeasure(BisectProperty); + AffectsRender(BackgroundProperty, ForegroundProperty); + } + + public override void Render(DrawingContext context) + { + if (_flags == Models.BisectCommitFlag.None) + return; + + if (_prefix == null) + { + _prefix = LoadIcon("Icons.Bisect"); + _good = LoadIcon("Icons.Check"); + _bad = LoadIcon("Icons.Bad"); + } + + var x = 0.0; + + if (_flags.HasFlag(Models.BisectCommitFlag.Good)) + { + RenderImpl(context, Brushes.Green, _good, x); + x += 36; + } + + if (_flags.HasFlag(Models.BisectCommitFlag.Bad)) + RenderImpl(context, Brushes.Red, _bad, x); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var desiredFlags = Models.BisectCommitFlag.None; + var desiredWidth = 0.0; + if (Bisect is { } bisect && DataContext is Models.Commit commit) + { + var sha = commit.SHA; + if (bisect.Goods.Contains(sha)) + { + desiredFlags |= Models.BisectCommitFlag.Good; + desiredWidth = 36; + } + + if (bisect.Bads.Contains(sha)) + { + desiredFlags |= Models.BisectCommitFlag.Bad; + desiredWidth += 36; + } + } + + if (desiredFlags != _flags) + { + _flags = desiredFlags; + InvalidateVisual(); + } + + return new Size(desiredWidth, desiredWidth > 0 ? 16 : 0); + } + + private Geometry LoadIcon(string key) + { + var geo = this.FindResource(key) as StreamGeometry; + var drawGeo = geo!.Clone(); + var iconBounds = drawGeo.Bounds; + var translation = Matrix.CreateTranslation(-(Vector)iconBounds.Position); + var scale = Math.Min(10.0 / iconBounds.Width, 10.0 / iconBounds.Height); + var transform = translation * Matrix.CreateScale(scale, scale); + if (drawGeo.Transform == null || drawGeo.Transform.Value == Matrix.Identity) + drawGeo.Transform = new MatrixTransform(transform); + else + drawGeo.Transform = new MatrixTransform(drawGeo.Transform.Value * transform); + + return drawGeo; + } + + private void RenderImpl(DrawingContext context, IBrush brush, Geometry icon, double x) + { + var entireRect = new RoundedRect(new Rect(x, 0, 32, 16), new CornerRadius(2)); + var stateRect = new RoundedRect(new Rect(x + 16, 0, 16, 16), new CornerRadius(0, 2, 2, 0)); + context.DrawRectangle(Background, new Pen(brush), entireRect); + using (context.PushOpacity(.2)) + context.DrawRectangle(brush, null, stateRect); + context.DrawLine(new Pen(brush), new Point(x + 16, 0), new Point(x + 16, 16)); + + using (context.PushTransform(Matrix.CreateTranslation(x + 3, 3))) + context.DrawGeometry(Foreground, null, _prefix); + + using (context.PushTransform(Matrix.CreateTranslation(x + 19, 3))) + context.DrawGeometry(Foreground, null, icon); + } + + private Geometry _prefix = null; + private Geometry _good = null; + private Geometry _bad = null; + private Models.BisectCommitFlag _flags = Models.BisectCommitFlag.None; + } +} diff --git a/src/Views/Blame.axaml b/src/Views/Blame.axaml new file mode 100644 index 00000000..2f6f75c2 --- /dev/null +++ b/src/Views/Blame.axaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Blame.axaml.cs b/src/Views/Blame.axaml.cs new file mode 100644 index 00000000..171d51d9 --- /dev/null +++ b/src/Views/Blame.axaml.cs @@ -0,0 +1,460 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; + +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using AvaloniaEdit.TextMate; +using AvaloniaEdit.Utils; + +namespace SourceGit.Views +{ + public class BlameTextEditor : TextEditor + { + public class CommitInfoMargin : AbstractMargin + { + public CommitInfoMargin(BlameTextEditor editor) + { + _editor = editor; + ClipToBounds = true; + } + + public override void Render(DrawingContext context) + { + if (_editor.BlameData == null) + return; + + var view = TextView; + if (view is { VisualLinesValid: true }) + { + var typeface = view.CreateTypeface(); + var underlinePen = new Pen(Brushes.DarkOrange); + var width = Bounds.Width; + + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > _editor.BlameData.LineInfos.Count) + break; + + var info = _editor.BlameData.LineInfos[lineNumber - 1]; + var x = 0.0; + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset; + if (!info.IsFirstInGroup && y > view.DefaultLineHeight * 0.6) + continue; + + var shaLink = new FormattedText( + info.CommitSHA, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + Brushes.DarkOrange); + context.DrawText(shaLink, new Point(x, y)); + context.DrawLine(underlinePen, new Point(x, y + shaLink.Baseline + 2), new Point(x + shaLink.Width, y + shaLink.Baseline + 2)); + x += shaLink.Width + 8; + + var author = new FormattedText( + info.Author, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + context.DrawText(author, new Point(x, y)); + + var time = new FormattedText( + info.Time, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + context.DrawText(time, new Point(width - time.Width, y)); + } + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var view = TextView; + var maxWidth = 0.0; + if (view != null && view.VisualLinesValid && _editor.BlameData != null) + { + var typeface = view.CreateTypeface(); + var calculated = new HashSet(); + foreach (var line in view.VisualLines) + { + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > _editor.BlameData.LineInfos.Count) + break; + + var info = _editor.BlameData.LineInfos[lineNumber - 1]; + + if (!calculated.Add(info.CommitSHA)) + continue; + + var x = 0.0; + var shaLink = new FormattedText( + info.CommitSHA, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + Brushes.DarkOrange); + x += shaLink.Width + 8; + + var author = new FormattedText( + info.Author, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + x += author.Width + 8; + + var time = new FormattedText( + info.Time, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + x += time.Width; + + if (maxWidth < x) + maxWidth = x; + } + } + + return new Size(maxWidth, 0); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + var view = TextView; + if (!e.Handled && view is { VisualLinesValid: true }) + { + var pos = e.GetPosition(this); + var typeface = view.CreateTypeface(); + + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > _editor.BlameData.LineInfos.Count) + break; + + var info = _editor.BlameData.LineInfos[lineNumber - 1]; + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset; + var shaLink = new FormattedText( + info.CommitSHA, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + Brushes.DarkOrange); + + var rect = new Rect(0, y, shaLink.Width, shaLink.Height); + if (rect.Contains(pos)) + { + Cursor = Cursor.Parse("Hand"); + + if (DataContext is ViewModels.Blame blame) + { + var msg = blame.GetCommitMessage(info.CommitSHA); + ToolTip.SetTip(this, msg); + } + + return; + } + } + + Cursor = Cursor.Default; + ToolTip.SetTip(this, null); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + var view = TextView; + if (!e.Handled && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && view is { VisualLinesValid: true }) + { + var pos = e.GetPosition(this); + var typeface = view.CreateTypeface(); + + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > _editor.BlameData.LineInfos.Count) + break; + + var info = _editor.BlameData.LineInfos[lineNumber - 1]; + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset; + var shaLink = new FormattedText( + info.CommitSHA, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + Brushes.DarkOrange); + + var rect = new Rect(0, y, shaLink.Width, shaLink.Height); + if (rect.Contains(pos)) + { + if (DataContext is ViewModels.Blame blame) + blame.NavigateToCommit(info.CommitSHA); + + e.Handled = true; + break; + } + } + } + } + + private readonly BlameTextEditor _editor = null; + } + + public class VerticalSeparatorMargin : AbstractMargin + { + public VerticalSeparatorMargin(BlameTextEditor editor) + { + _editor = editor; + } + + public override void Render(DrawingContext context) + { + var pen = new Pen(_editor.BorderBrush); + context.DrawLine(pen, new Point(0, 0), new Point(0, Bounds.Height)); + } + + protected override Size MeasureOverride(Size availableSize) + { + return new Size(1, 0); + } + + private readonly BlameTextEditor _editor = null; + } + + public static readonly StyledProperty BlameDataProperty = + AvaloniaProperty.Register(nameof(BlameData)); + + public Models.BlameData BlameData + { + get => GetValue(BlameDataProperty); + set => SetValue(BlameDataProperty, value); + } + + public static readonly StyledProperty TabWidthProperty = + AvaloniaProperty.Register(nameof(TabWidth), 4); + + public int TabWidth + { + get => GetValue(TabWidthProperty); + set => SetValue(TabWidthProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextEditor); + + public BlameTextEditor() : base(new TextArea(), new TextDocument()) + { + IsReadOnly = true; + ShowLineNumbers = false; + WordWrap = false; + + Options.IndentationSize = TabWidth; + Options.EnableHyperlinks = false; + Options.EnableEmailHyperlinks = false; + + _textMate = Models.TextMateHelper.CreateForEditor(this); + + TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); + TextArea.LeftMargins.Add(new CommitInfoMargin(this) { Margin = new Thickness(8, 0) }); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); + TextArea.Caret.PositionChanged += OnTextAreaCaretPositionChanged; + TextArea.TextView.ContextRequested += OnTextViewContextRequested; + TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; + TextArea.TextView.Margin = new Thickness(4, 0); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (string.IsNullOrEmpty(_highlight)) + return; + + var view = TextArea.TextView; + if (view == null || !view.VisualLinesValid) + return; + + var color = (Color)this.FindResource("SystemAccentColor")!; + var brush = new SolidColorBrush(color, 0.4); + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > BlameData.LineInfos.Count) + break; + + var info = BlameData.LineInfos[lineNumber - 1]; + if (info.CommitSHA != _highlight) + continue; + + var startY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; + var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - view.VerticalOffset; + context.FillRectangle(brush, new Rect(0, startY, Bounds.Width, endY - startY)); + } + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + + TextArea.LeftMargins.Clear(); + TextArea.Caret.PositionChanged -= OnTextAreaCaretPositionChanged; + TextArea.TextView.ContextRequested -= OnTextViewContextRequested; + TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged; + + if (_textMate != null) + { + _textMate.Dispose(); + _textMate = null; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == BlameDataProperty) + { + if (BlameData != null) + { + Models.TextMateHelper.SetGrammarByFileName(_textMate, BlameData.File); + Text = BlameData.Content; + } + else + { + Text = string.Empty; + } + } + else if (change.Property == TabWidthProperty) + { + Options.IndentationSize = TabWidth; + } + else if (change.Property.Name == "ActualThemeVariant" && change.NewValue != null) + { + Models.TextMateHelper.SetThemeByApp(_textMate); + } + } + + private void OnTextAreaCaretPositionChanged(object sender, EventArgs e) + { + if (!TextArea.IsFocused) + return; + + var caret = TextArea.Caret; + if (caret == null || caret.Line > BlameData.LineInfos.Count) + return; + + _highlight = BlameData.LineInfos[caret.Line - 1].CommitSHA; + InvalidateVisual(); + } + + private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) + { + var selected = SelectedText; + if (string.IsNullOrEmpty(selected)) + return; + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, ev) => + { + App.CopyText(selected); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copy); + menu.Open(TextArea.TextView); + + e.Handled = true; + } + + private void OnTextViewVisualLinesChanged(object sender, EventArgs e) + { + foreach (var margin in TextArea.LeftMargins) + { + if (margin is CommitInfoMargin commitInfo) + { + commitInfo.InvalidateMeasure(); + break; + } + } + + InvalidateVisual(); + } + + private TextMate.Installation _textMate = null; + private string _highlight = string.Empty; + } + + public partial class Blame : ChromelessWindow + { + public Blame() + { + InitializeComponent(); + } + + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + GC.Collect(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (!e.Handled && DataContext is ViewModels.Blame blame) + { + if (e.InitialPressMouseButton == MouseButton.XButton1) + { + blame.Back(); + e.Handled = true; + } + else if (e.InitialPressMouseButton == MouseButton.XButton2) + { + blame.Forward(); + e.Handled = true; + } + } + } + } +} diff --git a/src/Views/Blame.xaml b/src/Views/Blame.xaml deleted file mode 100644 index f2c46655..00000000 --- a/src/Views/Blame.xaml +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Blame.xaml.cs b/src/Views/Blame.xaml.cs deleted file mode 100644 index 2a8c1598..00000000 --- a/src/Views/Blame.xaml.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Navigation; - -namespace SourceGit.Views { - /// - /// 逐行追溯 - /// - public partial class Blame : Controls.Window { - /// - /// DataGrid数据源结构 - /// - public class Record : INotifyPropertyChanged { - public event PropertyChangedEventHandler PropertyChanged; - - /// - /// 原始Blame行数据 - /// - public Models.BlameLine Line { get; set; } - - /// - /// 是否是第一行 - /// - public bool IsFirstLine { get; set; } = false; - - /// - /// 前一行与本行的提交不同 - /// - public bool IsFirstLineInGroup { get; set; } = false; - - /// - /// 是否当前选中,会影响背景色 - /// - private bool isSelected = false; - public bool IsSelected { - get { return isSelected; } - set { - if (isSelected != value) { - isSelected = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("IsSelected")); - } - } - } - } - - /// - /// Blame数据 - /// - public ObservableCollection Records { get; set; } - - public Blame(string repo, string file, string revision) { - InitializeComponent(); - - this.repo = repo; - Records = new ObservableCollection(); - txtFile.Text = $"{file}@{revision.Substring(0, 8)}"; - - Task.Run(() => { - var lfs = new Commands.LFS(repo).IsFiltered(file); - if (lfs) { - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - notSupport.Visibility = Visibility.Visible; - }); - return; - } - - var rs = new Commands.Blame(repo, file, revision).Result(); - if (rs.IsBinary) { - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - notSupport.Visibility = Visibility.Visible; - }); - } else { - string lastSHA = null; - foreach (var line in rs.Lines) { - var r = new Record(); - r.Line = line; - r.IsSelected = false; - - if (line.CommitSHA != lastSHA) { - lastSHA = line.CommitSHA; - r.IsFirstLineInGroup = true; - } else { - r.IsFirstLineInGroup = false; - } - - Records.Add(r); - } - - if (Records.Count > 0) Records[0].IsFirstLine = true; - - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - blame.ItemsSource = Records; - }); - } - }); - } - - #region WINDOW_COMMANDS - private void Minimize(object sender, RoutedEventArgs e) { - SystemCommands.MinimizeWindow(this); - } - - private void Quit(object sender, RoutedEventArgs e) { - Close(); - } - #endregion - - #region EVENTS - private T GetVisualChild(DependencyObject parent) where T : Visual { - T child = null; - - int count = VisualTreeHelper.GetChildrenCount(parent); - for (int i = 0; i < count; i++) { - Visual v = (Visual)VisualTreeHelper.GetChild(parent, i); - child = v as T; - - if (child == null) { - child = GetVisualChild(v); - } - - if (child != null) { - break; - } - } - - return child; - } - - private void OnViewerSizeChanged(object sender, SizeChangedEventArgs e) { - var total = blame.ActualWidth; - var offset = blame.NonFrozenColumnsViewportHorizontalOffset; - var minWidth = total - offset; - - var scroller = GetVisualChild(blame); - if (scroller != null && scroller.ComputedVerticalScrollBarVisibility == Visibility.Visible) minWidth -= 8; - - blame.Columns[2].MinWidth = minWidth; - blame.Columns[2].Width = DataGridLength.SizeToCells; - blame.UpdateLayout(); - } - - private void OnViewerRequestBringIntoView(object sender, RequestBringIntoViewEventArgs e) { - e.Handled = true; - } - - private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) { - var r = blame.SelectedItem as Record; - if (r == null) return; - - foreach (var one in Records) { - one.IsSelected = one.Line.CommitSHA == r.Line.CommitSHA; - } - } - - private void GotoCommit(object sender, RequestNavigateEventArgs e) { - Models.Watcher.Get(repo).NavigateTo(e.Uri.OriginalString); - e.Handled = true; - } - #endregion - - private string repo = null; - } -} diff --git a/src/Views/BranchCompare.axaml b/src/Views/BranchCompare.axaml new file mode 100644 index 00000000..1fe66e9b --- /dev/null +++ b/src/Views/BranchCompare.axaml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/BranchCompare.axaml.cs b/src/Views/BranchCompare.axaml.cs new file mode 100644 index 00000000..ca90a180 --- /dev/null +++ b/src/Views/BranchCompare.axaml.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class BranchCompare : ChromelessWindow + { + public BranchCompare() + { + InitializeComponent(); + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.BranchCompare vm && sender is ChangeCollectionView view) + { + var menu = vm.CreateChangeContextMenu(); + menu?.Open(view); + } + + e.Handled = true; + } + + private void OnPressedSHA(object sender, PointerPressedEventArgs e) + { + if (DataContext is ViewModels.BranchCompare vm && sender is TextBlock block) + vm.NavigateTo(block.Text); + + e.Handled = true; + } + } +} diff --git a/src/Views/BranchTree.axaml b/src/Views/BranchTree.axaml new file mode 100644 index 00000000..11434b8e --- /dev/null +++ b/src/Views/BranchTree.axaml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs new file mode 100644 index 00000000..045e9d1b --- /dev/null +++ b/src/Views/BranchTree.axaml.cs @@ -0,0 +1,539 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class BranchTreeNodeIcon : UserControl + { + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + UpdateContent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsExpandedProperty) + UpdateContent(); + } + + private void UpdateContent() + { + if (DataContext is not ViewModels.BranchTreeNode node) + { + Content = null; + return; + } + + if (node.Backend is Models.Remote) + { + CreateContent(new Thickness(0, 0, 0, 0), "Icons.Remote", false); + } + else if (node.Backend is Models.Branch branch) + { + if (branch.IsCurrent) + CreateContent(new Thickness(0, 0, 0, 0), "Icons.CheckCircled", true); + else + CreateContent(new Thickness(2, 0, 0, 0), "Icons.Branch", false); + } + else + { + if (node.IsExpanded) + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open", false); + else + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder", false); + } + } + + private void CreateContent(Thickness margin, string iconKey, bool highlight) + { + var geo = this.FindResource(iconKey) as StreamGeometry; + if (geo == null) + return; + + var path = new Path() + { + Width = 12, + Height = 12, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = margin, + Data = geo, + }; + + if (highlight) + path.Fill = Brushes.Green; + + Content = path; + } + } + + public class BranchTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.BranchTreeNode { IsBranch: false } node) + { + var tree = this.FindAncestorOfType(); + tree?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class BranchTreeNodeTrackStatusPresenter : Control + { + public static readonly StyledProperty FontFamilyProperty = + TextBlock.FontFamilyProperty.AddOwner(); + + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + public static readonly StyledProperty FontSizeProperty = + TextBlock.FontSizeProperty.AddOwner(); + + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.White); + + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + public static readonly StyledProperty BackgroundProperty = + AvaloniaProperty.Register(nameof(Background), Brushes.White); + + public IBrush Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + static BranchTreeNodeTrackStatusPresenter() + { + AffectsMeasure( + FontSizeProperty, + FontFamilyProperty, + ForegroundProperty); + + AffectsRender( + ForegroundProperty, + BackgroundProperty); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (_label != null) + { + context.DrawRectangle(Background, null, new RoundedRect(new Rect(8, 0, _label.Width + 18, 18), new CornerRadius(9))); + context.DrawText(_label, new Point(17, 9 - _label.Height * 0.5)); + } + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + _label = null; + + if (DataContext is ViewModels.BranchTreeNode { Backend: Models.Branch branch }) + { + var status = branch.TrackStatus.ToString(); + if (!string.IsNullOrEmpty(status)) + { + _label = new FormattedText( + status, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(FontFamily), + FontSize, + Foreground); + } + } + + return _label != null ? new Size(_label.Width + 18 /* Padding */ + 16 /* Margin */, 18) : new Size(0, 0); + } + + private FormattedText _label = null; + } + + public partial class BranchTree : UserControl + { + public static readonly StyledProperty> NodesProperty = + AvaloniaProperty.Register>(nameof(Nodes)); + + public List Nodes + { + get => GetValue(NodesProperty); + set => SetValue(NodesProperty, value); + } + + public AvaloniaList Rows + { + get; + private set; + } = new AvaloniaList(); + + public static readonly RoutedEvent SelectionChangedEvent = + RoutedEvent.Register(nameof(SelectionChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler SelectionChanged + { + add { AddHandler(SelectionChangedEvent, value); } + remove { RemoveHandler(SelectionChangedEvent, value); } + } + + public static readonly RoutedEvent RowsChangedEvent = + RoutedEvent.Register(nameof(RowsChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler RowsChanged + { + add { AddHandler(RowsChangedEvent, value); } + remove { RemoveHandler(RowsChangedEvent, value); } + } + + public BranchTree() + { + InitializeComponent(); + } + + public void UnselectAll() + { + BranchesPresenter.SelectedItem = null; + } + + public void ToggleNodeIsExpanded(ViewModels.BranchTreeNode node) + { + _disableSelectionChangingEvent = true; + node.IsExpanded = !node.IsExpanded; + + var rows = Rows; + var depth = node.Depth; + var idx = rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subtree = new List(); + MakeRows(subtree, node.Children, depth + 1); + rows.InsertRange(idx + 1, subtree); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < rows.Count; i++) + { + var row = rows[i]; + if (row.Depth <= depth) + break; + + row.IsSelected = false; + removeCount++; + } + rows.RemoveRange(idx + 1, removeCount); + } + + var repo = DataContext as ViewModels.Repository; + repo?.UpdateBranchNodeIsExpanded(node); + + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + _disableSelectionChangingEvent = false; + } + + protected override void OnSizeChanged(SizeChangedEventArgs e) + { + base.OnSizeChanged(e); + + if (Bounds.Height >= 23.0) + BranchesPresenter.Height = Bounds.Height; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == NodesProperty) + { + Rows.Clear(); + + if (Nodes is { Count: > 0 }) + { + var rows = new List(); + MakeRows(rows, Nodes, 0); + Rows.AddRange(rows); + } + + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + else if (change.Property == IsVisibleProperty) + { + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + private void OnNodePointerPressed(object sender, PointerPressedEventArgs e) + { + var p = e.GetCurrentPoint(this); + if (!p.Properties.IsLeftButtonPressed) + return; + + if (DataContext is not ViewModels.Repository repo) + return; + + if (sender is not Border { DataContext: ViewModels.BranchTreeNode node }) + return; + + if (node.Backend is not Models.Branch branch) + return; + + if (BranchesPresenter.SelectedItems is { Count: > 0 }) + { + var ctrl = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.KeyModifiers.HasFlag(ctrl) || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + return; + } + + repo.NavigateToCommit(branch.Head); + } + + private void OnNodesSelectionChanged(object _, SelectionChangedEventArgs e) + { + if (_disableSelectionChangingEvent) + return; + + var repo = DataContext as ViewModels.Repository; + if (repo?.Settings == null) + return; + + foreach (var item in e.AddedItems) + { + if (item is ViewModels.BranchTreeNode node) + node.IsSelected = true; + } + + foreach (var item in e.RemovedItems) + { + if (item is ViewModels.BranchTreeNode node) + node.IsSelected = false; + } + + var selected = BranchesPresenter.SelectedItems; + if (selected == null || selected.Count == 0) + return; + + var prev = null as ViewModels.BranchTreeNode; + foreach (var row in Rows) + { + if (row.IsSelected) + { + if (prev is { IsSelected: true }) + { + var prevTop = prev.CornerRadius.TopLeft; + prev.CornerRadius = new CornerRadius(prevTop, 0); + row.CornerRadius = new CornerRadius(0, 4); + } + else + { + row.CornerRadius = new CornerRadius(4); + } + } + + prev = row; + } + + RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); + } + + private void OnTreeContextRequested(object _1, ContextRequestedEventArgs _2) + { + var repo = DataContext as ViewModels.Repository; + if (repo?.Settings == null) + return; + + var selected = BranchesPresenter.SelectedItems; + if (selected == null || selected.Count == 0) + return; + + if (selected.Count == 1 && selected[0] is ViewModels.BranchTreeNode { Backend: Models.Remote remote }) + { + var menu = repo.CreateContextMenuForRemote(remote); + menu?.Open(this); + return; + } + + var branches = new List(); + foreach (var item in selected) + { + if (item is ViewModels.BranchTreeNode node) + CollectBranchesInNode(branches, node); + } + + if (branches.Count == 1) + { + var branch = branches[0]; + var menu = branch.IsLocal ? + repo.CreateContextMenuForLocalBranch(branch) : + repo.CreateContextMenuForRemoteBranch(branch); + menu?.Open(this); + } + else if (branches.Find(x => x.IsCurrent) == null) + { + var menu = new ContextMenu(); + + var mergeMulti = new MenuItem(); + mergeMulti.Header = App.Text("BranchCM.MergeMultiBranches", branches.Count); + mergeMulti.Icon = App.CreateMenuIcon("Icons.Merge"); + mergeMulti.Click += (_, ev) => + { + repo.MergeMultipleBranches(branches); + ev.Handled = true; + }; + menu.Items.Add(mergeMulti); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var deleteMulti = new MenuItem(); + deleteMulti.Header = App.Text("BranchCM.DeleteMultiBranches", branches.Count); + deleteMulti.Icon = App.CreateMenuIcon("Icons.Clear"); + deleteMulti.Click += (_, ev) => + { + repo.DeleteMultipleBranches(branches, branches[0].IsLocal); + ev.Handled = true; + }; + menu.Items.Add(deleteMulti); + + menu?.Open(this); + } + } + + private void OnTreeKeyDown(object _, KeyEventArgs e) + { + if (e.Key is not (Key.Delete or Key.Back)) + return; + + var repo = DataContext as ViewModels.Repository; + if (repo?.Settings == null) + return; + + var selected = BranchesPresenter.SelectedItems; + if (selected == null || selected.Count == 0) + return; + + if (selected.Count == 1 && selected[0] is ViewModels.BranchTreeNode { Backend: Models.Remote remote }) + { + repo.DeleteRemote(remote); + e.Handled = true; + return; + } + + var branches = new List(); + foreach (var item in selected) + { + if (item is ViewModels.BranchTreeNode node) + CollectBranchesInNode(branches, node); + } + + if (branches.Find(x => x.IsCurrent) != null) + return; + + if (branches.Count == 1) + repo.DeleteBranch(branches[0]); + else + repo.DeleteMultipleBranches(branches, branches[0].IsLocal); + + e.Handled = true; + } + + private void OnDoubleTappedBranchNode(object sender, TappedEventArgs _) + { + if (sender is Grid { DataContext: ViewModels.BranchTreeNode node }) + { + if (node.Backend is Models.Branch branch) + { + if (branch.IsCurrent) + return; + + if (DataContext is ViewModels.Repository { Settings: not null } repo) + repo.CheckoutBranch(branch); + } + else + { + ToggleNodeIsExpanded(node); + } + } + } + + private void MakeRows(List rows, List nodes, int depth) + { + foreach (var node in nodes) + { + node.Depth = depth; + node.IsSelected = false; + rows.Add(node); + + if (!node.IsExpanded || node.Backend is Models.Branch) + continue; + + MakeRows(rows, node.Children, depth + 1); + } + } + + private void CollectBranchesInNode(List outs, ViewModels.BranchTreeNode node) + { + if (node.Backend is Models.Branch branch && !outs.Contains(branch)) + { + outs.Add(branch); + return; + } + + foreach (var sub in node.Children) + CollectBranchesInNode(outs, sub); + } + + private bool _disableSelectionChangingEvent = false; + } +} diff --git a/src/Views/CaptionButtons.axaml b/src/Views/CaptionButtons.axaml new file mode 100644 index 00000000..4ac07880 --- /dev/null +++ b/src/Views/CaptionButtons.axaml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/src/Views/CaptionButtons.axaml.cs b/src/Views/CaptionButtons.axaml.cs new file mode 100644 index 00000000..650ccef0 --- /dev/null +++ b/src/Views/CaptionButtons.axaml.cs @@ -0,0 +1,51 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class CaptionButtons : UserControl + { + public static readonly StyledProperty IsCloseButtonOnlyProperty = + AvaloniaProperty.Register(nameof(IsCloseButtonOnly)); + + public bool IsCloseButtonOnly + { + get => GetValue(IsCloseButtonOnlyProperty); + set => SetValue(IsCloseButtonOnlyProperty, value); + } + + public CaptionButtons() + { + InitializeComponent(); + } + + private void MinimizeWindow(object _, RoutedEventArgs e) + { + var window = this.FindAncestorOfType(); + if (window != null) + window.WindowState = WindowState.Minimized; + + e.Handled = true; + } + + private void MaximizeOrRestoreWindow(object _, RoutedEventArgs e) + { + var window = this.FindAncestorOfType(); + if (window != null) + window.WindowState = window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + + e.Handled = true; + } + + private void CloseWindow(object _, RoutedEventArgs e) + { + var window = this.FindAncestorOfType(); + if (window != null) + window.Close(); + + e.Handled = true; + } + } +} diff --git a/src/Views/ChangeCollectionView.axaml b/src/Views/ChangeCollectionView.axaml new file mode 100644 index 00000000..43af3a9a --- /dev/null +++ b/src/Views/ChangeCollectionView.axaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ChangeCollectionView.axaml.cs b/src/Views/ChangeCollectionView.axaml.cs new file mode 100644 index 00000000..6623a60b --- /dev/null +++ b/src/Views/ChangeCollectionView.axaml.cs @@ -0,0 +1,510 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class ChangeTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.ChangeTreeNode { IsFolder: true } node) + { + var tree = this.FindAncestorOfType(); + tree?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class ChangeCollectionContainer : ListBox + { + protected override Type StyleKeyOverride => typeof(ListBox); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (SelectedItems is [ViewModels.ChangeTreeNode { IsFolder: true } node] && e.KeyModifiers == KeyModifiers.None) + { + if ((node.IsExpanded && e.Key == Key.Left) || (!node.IsExpanded && e.Key == Key.Right)) + { + this.FindAncestorOfType()?.ToggleNodeIsExpanded(node); + e.Handled = true; + } + } + + if (!e.Handled && e.Key != Key.Space && e.Key != Key.Enter) + base.OnKeyDown(e); + } + } + + public partial class ChangeCollectionView : UserControl + { + public static readonly StyledProperty IsUnstagedChangeProperty = + AvaloniaProperty.Register(nameof(IsUnstagedChange)); + + public bool IsUnstagedChange + { + get => GetValue(IsUnstagedChangeProperty); + set => SetValue(IsUnstagedChangeProperty, value); + } + + public static readonly StyledProperty SelectionModeProperty = + AvaloniaProperty.Register(nameof(SelectionMode)); + + public SelectionMode SelectionMode + { + get => GetValue(SelectionModeProperty); + set => SetValue(SelectionModeProperty, value); + } + + public static readonly StyledProperty ViewModeProperty = + AvaloniaProperty.Register(nameof(ViewMode), Models.ChangeViewMode.Tree); + + public Models.ChangeViewMode ViewMode + { + get => GetValue(ViewModeProperty); + set => SetValue(ViewModeProperty, value); + } + + public static readonly StyledProperty> ChangesProperty = + AvaloniaProperty.Register>(nameof(Changes)); + + public List Changes + { + get => GetValue(ChangesProperty); + set => SetValue(ChangesProperty, value); + } + + public static readonly StyledProperty AutoSelectFirstChangeProperty = + AvaloniaProperty.Register(nameof(AutoSelectFirstChange)); + + public bool AutoSelectFirstChange + { + get => GetValue(AutoSelectFirstChangeProperty); + set => SetValue(AutoSelectFirstChangeProperty, value); + } + + public static readonly StyledProperty> SelectedChangesProperty = + AvaloniaProperty.Register>(nameof(SelectedChanges)); + + public List SelectedChanges + { + get => GetValue(SelectedChangesProperty); + set => SetValue(SelectedChangesProperty, value); + } + + public static readonly RoutedEvent ChangeDoubleTappedEvent = + RoutedEvent.Register(nameof(ChangeDoubleTapped), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler ChangeDoubleTapped + { + add { AddHandler(ChangeDoubleTappedEvent, value); } + remove { RemoveHandler(ChangeDoubleTappedEvent, value); } + } + + public ChangeCollectionView() + { + InitializeComponent(); + } + + public void ToggleNodeIsExpanded(ViewModels.ChangeTreeNode node) + { + if (Content is ViewModels.ChangeCollectionAsTree tree) + { + node.IsExpanded = !node.IsExpanded; + + var depth = node.Depth; + var idx = tree.Rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subrows = new List(); + MakeTreeRows(subrows, node.Children); + tree.Rows.InsertRange(idx + 1, subrows); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < tree.Rows.Count; i++) + { + var row = tree.Rows[i]; + if (row.Depth <= depth) + break; + + removeCount++; + } + + tree.Rows.RemoveRange(idx + 1, removeCount); + } + } + } + + public Models.Change GetNextChangeWithoutSelection() + { + var selected = SelectedChanges; + var changes = Changes; + if (selected == null || selected.Count == 0) + return changes.Count > 0 ? changes[0] : null; + if (selected.Count == changes.Count) + return null; + + var set = new HashSet(); + foreach (var c in selected) + set.Add(c.Path); + + if (Content is ViewModels.ChangeCollectionAsTree tree) + { + var lastUnselected = -1; + for (int i = tree.Rows.Count - 1; i >= 0; i--) + { + var row = tree.Rows[i]; + if (!row.IsFolder) + { + if (set.Contains(row.FullPath)) + { + if (lastUnselected == -1) + continue; + + break; + } + + lastUnselected = i; + } + } + + if (lastUnselected != -1) + return tree.Rows[lastUnselected].Change; + } + else + { + var lastUnselected = -1; + for (int i = changes.Count - 1; i >= 0; i--) + { + if (set.Contains(changes[i].Path)) + { + if (lastUnselected == -1) + continue; + + break; + } + + lastUnselected = i; + } + + if (lastUnselected != -1) + return changes[lastUnselected]; + } + + return null; + } + + public void TakeFocus() + { + var container = this.FindDescendantOfType(); + if (container is { IsFocused: false }) + container.Focus(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ViewModeProperty) + UpdateDataSource(true); + else if (change.Property == ChangesProperty) + UpdateDataSource(false); + else if (change.Property == SelectedChangesProperty) + UpdateSelection(); + } + + private void OnRowDataContextChanged(object sender, EventArgs e) + { + if (sender is not Control control) + return; + + if (control.DataContext is ViewModels.ChangeTreeNode node) + { + if (node.Change is { } c) + UpdateRowTips(control, c); + else + ToolTip.SetTip(control, node.FullPath); + } + else if (control.DataContext is Models.Change change) + { + UpdateRowTips(control, change); + } + else + { + ToolTip.SetTip(control, null); + } + } + + private void OnRowDoubleTapped(object sender, TappedEventArgs e) + { + var grid = sender as Grid; + if (grid?.DataContext is ViewModels.ChangeTreeNode node) + { + if (node.IsFolder) + { + var posX = e.GetPosition(this).X; + if (posX < node.Depth * 16 + 16) + return; + + ToggleNodeIsExpanded(node); + } + else + { + RaiseEvent(new RoutedEventArgs(ChangeDoubleTappedEvent)); + } + } + else if (grid?.DataContext is Models.Change) + { + RaiseEvent(new RoutedEventArgs(ChangeDoubleTappedEvent)); + } + } + + private void OnRowSelectionChanged(object sender, SelectionChangedEventArgs _) + { + if (_disableSelectionChangingEvent) + return; + + _disableSelectionChangingEvent = true; + + var selected = new List(); + if (sender is ListBox { SelectedItems: { } selectedItems }) + { + foreach (var item in selectedItems) + { + if (item is Models.Change c) + selected.Add(c); + else if (item is ViewModels.ChangeTreeNode node) + CollectChangesInNode(selected, node); + } + } + + var old = SelectedChanges ?? []; + if (old.Count != selected.Count) + { + SetCurrentValue(SelectedChangesProperty, selected); + } + else + { + bool allEquals = true; + foreach (var c in old) + { + if (!selected.Contains(c)) + { + allEquals = false; + break; + } + } + + if (!allEquals) + SetCurrentValue(SelectedChangesProperty, selected); + } + + _disableSelectionChangingEvent = false; + } + + private void MakeTreeRows(List rows, List nodes) + { + foreach (var node in nodes) + { + rows.Add(node); + + if (!node.IsExpanded || !node.IsFolder) + continue; + + MakeTreeRows(rows, node.Children); + } + } + + private void UpdateDataSource(bool onlyViewModeChange) + { + _disableSelectionChangingEvent = !onlyViewModeChange; + + var changes = Changes; + if (changes == null || changes.Count == 0) + { + Content = null; + _disableSelectionChangingEvent = false; + return; + } + + var selected = SelectedChanges ?? []; + if (ViewMode == Models.ChangeViewMode.Tree) + { + HashSet oldFolded = new HashSet(); + if (Content is ViewModels.ChangeCollectionAsTree oldTree) + { + foreach (var row in oldTree.Rows) + { + if (row.IsFolder && !row.IsExpanded) + oldFolded.Add(row.FullPath); + } + } + + var tree = new ViewModels.ChangeCollectionAsTree(); + tree.Tree = ViewModels.ChangeTreeNode.Build(changes, oldFolded); + + var rows = new List(); + MakeTreeRows(rows, tree.Tree); + tree.Rows.AddRange(rows); + + if (!onlyViewModeChange && AutoSelectFirstChange) + { + foreach (var row in tree.Rows) + { + if (row.Change != null) + { + tree.SelectedRows.Add(row); + SetCurrentValue(SelectedChangesProperty, [row.Change]); + break; + } + } + } + else if (selected.Count > 0) + { + var sets = new HashSet(); + foreach (var c in selected) + sets.Add(c); + + var nodes = new List(); + foreach (var row in tree.Rows) + { + if (row.Change != null && sets.Contains(row.Change)) + nodes.Add(row); + } + + tree.SelectedRows.AddRange(nodes); + } + + Content = tree; + } + else if (ViewMode == Models.ChangeViewMode.Grid) + { + var grid = new ViewModels.ChangeCollectionAsGrid(); + grid.Changes.AddRange(changes); + + if (!onlyViewModeChange && AutoSelectFirstChange) + { + grid.SelectedChanges.Add(changes[0]); + SetCurrentValue(SelectedChangesProperty, [changes[0]]); + } + else if (selected.Count > 0) + { + grid.SelectedChanges.AddRange(selected); + } + + Content = grid; + } + else + { + var list = new ViewModels.ChangeCollectionAsList(); + list.Changes.AddRange(changes); + + if (!onlyViewModeChange && AutoSelectFirstChange) + { + list.SelectedChanges.Add(changes[0]); + SetCurrentValue(SelectedChangesProperty, [changes[0]]); + } + else if (selected.Count > 0) + { + list.SelectedChanges.AddRange(selected); + } + + Content = list; + } + + _disableSelectionChangingEvent = false; + } + + private void UpdateSelection() + { + if (_disableSelectionChangingEvent) + return; + + _disableSelectionChangingEvent = true; + + var selected = SelectedChanges ?? []; + if (Content is ViewModels.ChangeCollectionAsTree tree) + { + tree.SelectedRows.Clear(); + + if (selected.Count > 0) + { + var sets = new HashSet(); + foreach (var c in selected) + sets.Add(c); + + var nodes = new List(); + foreach (var row in tree.Rows) + { + if (row.Change != null && sets.Contains(row.Change)) + nodes.Add(row); + } + + tree.SelectedRows.AddRange(nodes); + } + } + else if (Content is ViewModels.ChangeCollectionAsGrid grid) + { + grid.SelectedChanges.Clear(); + if (selected.Count > 0) + grid.SelectedChanges.AddRange(selected); + } + else if (Content is ViewModels.ChangeCollectionAsList list) + { + list.SelectedChanges.Clear(); + if (selected.Count > 0) + list.SelectedChanges.AddRange(selected); + } + + _disableSelectionChangingEvent = false; + } + + private void CollectChangesInNode(List outs, ViewModels.ChangeTreeNode node) + { + if (node.IsFolder) + { + foreach (var child in node.Children) + CollectChangesInNode(outs, child); + } + else if (!outs.Contains(node.Change)) + { + outs.Add(node.Change); + } + } + + private void UpdateRowTips(Control control, Models.Change change) + { + var tip = new TextBlock() { TextWrapping = TextWrapping.Wrap }; + tip.Inlines!.Add(new Run(change.Path)); + tip.Inlines!.Add(new Run(" • ") { Foreground = Brushes.Gray }); + tip.Inlines!.Add(new Run(IsUnstagedChange ? change.WorkTreeDesc : change.IndexDesc) { Foreground = Brushes.Gray }); + if (change.IsConflicted) + { + tip.Inlines!.Add(new Run(" • ") { Foreground = Brushes.Gray }); + tip.Inlines!.Add(new Run(change.ConflictDesc) { Foreground = Brushes.Gray }); + } + + ToolTip.SetTip(control, tip); + } + + private bool _disableSelectionChangingEvent = false; + } +} diff --git a/src/Views/ChangeStatusIcon.cs b/src/Views/ChangeStatusIcon.cs new file mode 100644 index 00000000..d66ac11d --- /dev/null +++ b/src/Views/ChangeStatusIcon.cs @@ -0,0 +1,120 @@ +using System; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class ChangeStatusIcon : Control + { + private static readonly string[] INDICATOR = ["?", "±", "T", "+", "−", "➜", "❏", "★", "!"]; + private static readonly IBrush[] BACKGROUNDS = [ + Brushes.Transparent, + new LinearGradientBrush + { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(238, 160, 14), 0), new GradientStop(Color.FromRgb(228, 172, 67), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush + { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(238, 160, 14), 0), new GradientStop(Color.FromRgb(228, 172, 67), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush + { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(47, 185, 47), 0), new GradientStop(Color.FromRgb(75, 189, 75), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush + { + GradientStops = new GradientStops() { new GradientStop(Colors.Tomato, 0), new GradientStop(Color.FromRgb(252, 165, 150), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush + { + GradientStops = new GradientStops() { new GradientStop(Colors.Orchid, 0), new GradientStop(Color.FromRgb(248, 161, 245), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush + { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(238, 160, 14), 0), new GradientStop(Color.FromRgb(228, 172, 67), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + new LinearGradientBrush + { + GradientStops = new GradientStops() { new GradientStop(Color.FromRgb(47, 185, 47), 0), new GradientStop(Color.FromRgb(75, 189, 75), 1) }, + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }, + Brushes.OrangeRed, + ]; + + public static readonly StyledProperty IsUnstagedChangeProperty = + AvaloniaProperty.Register(nameof(IsUnstagedChange)); + + public bool IsUnstagedChange + { + get => GetValue(IsUnstagedChangeProperty); + set => SetValue(IsUnstagedChangeProperty, value); + } + + public static readonly StyledProperty ChangeProperty = + AvaloniaProperty.Register(nameof(Change)); + + public Models.Change Change + { + get => GetValue(ChangeProperty); + set => SetValue(ChangeProperty, value); + } + + public override void Render(DrawingContext context) + { + if (Change == null || Bounds.Width <= 0) + return; + + var typeface = new Typeface("fonts:SourceGit#JetBrains Mono"); + + IBrush background; + string indicator; + if (IsUnstagedChange) + { + background = BACKGROUNDS[(int)Change.WorkTree]; + indicator = INDICATOR[(int)Change.WorkTree]; + } + else + { + background = BACKGROUNDS[(int)Change.Index]; + indicator = INDICATOR[(int)Change.Index]; + } + + var txt = new FormattedText( + indicator, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + Bounds.Width * 0.8, + Brushes.White); + + var corner = (float)Math.Max(2, Bounds.Width / 16); + var textOrigin = new Point((Bounds.Width - txt.Width) * 0.5, (Bounds.Height - txt.Height) * 0.5); + context.DrawRectangle(background, null, new Rect(0, 0, Bounds.Width, Bounds.Height), corner, corner); + context.DrawText(txt, textOrigin); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsUnstagedChangeProperty || change.Property == ChangeProperty) + InvalidateVisual(); + } + } +} diff --git a/src/Views/ChangeViewModeSwitcher.axaml b/src/Views/ChangeViewModeSwitcher.axaml new file mode 100644 index 00000000..911fb41d --- /dev/null +++ b/src/Views/ChangeViewModeSwitcher.axaml @@ -0,0 +1,43 @@ + + + diff --git a/src/Views/ChangeViewModeSwitcher.axaml.cs b/src/Views/ChangeViewModeSwitcher.axaml.cs new file mode 100644 index 00000000..ed306619 --- /dev/null +++ b/src/Views/ChangeViewModeSwitcher.axaml.cs @@ -0,0 +1,41 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class ChangeViewModeSwitcher : UserControl + { + public static readonly StyledProperty ViewModeProperty = + AvaloniaProperty.Register(nameof(ViewMode)); + + public Models.ChangeViewMode ViewMode + { + get => GetValue(ViewModeProperty); + set => SetValue(ViewModeProperty, value); + } + + public ChangeViewModeSwitcher() + { + InitializeComponent(); + } + + private void SwitchToList(object sender, RoutedEventArgs e) + { + ViewMode = Models.ChangeViewMode.List; + e.Handled = true; + } + + private void SwitchToGrid(object sender, RoutedEventArgs e) + { + ViewMode = Models.ChangeViewMode.Grid; + e.Handled = true; + } + + private void SwitchToTree(object sender, RoutedEventArgs e) + { + ViewMode = Models.ChangeViewMode.Tree; + e.Handled = true; + } + } +} diff --git a/src/Views/Checkout.axaml b/src/Views/Checkout.axaml new file mode 100644 index 00000000..42b9cec5 --- /dev/null +++ b/src/Views/Checkout.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Checkout.axaml.cs b/src/Views/Checkout.axaml.cs new file mode 100644 index 00000000..f8398a1d --- /dev/null +++ b/src/Views/Checkout.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Checkout : UserControl + { + public Checkout() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/CheckoutAndFastForward.axaml b/src/Views/CheckoutAndFastForward.axaml new file mode 100644 index 00000000..40ca3e14 --- /dev/null +++ b/src/Views/CheckoutAndFastForward.axaml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CheckoutAndFastForward.axaml.cs b/src/Views/CheckoutAndFastForward.axaml.cs new file mode 100644 index 00000000..c54f5a1f --- /dev/null +++ b/src/Views/CheckoutAndFastForward.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CheckoutAndFastForward : UserControl + { + public CheckoutAndFastForward() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/CheckoutCommit.axaml b/src/Views/CheckoutCommit.axaml new file mode 100644 index 00000000..11b4b5d0 --- /dev/null +++ b/src/Views/CheckoutCommit.axaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CheckoutCommit.axaml.cs b/src/Views/CheckoutCommit.axaml.cs new file mode 100644 index 00000000..375816c9 --- /dev/null +++ b/src/Views/CheckoutCommit.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CheckoutCommit : UserControl + { + public CheckoutCommit() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/CherryPick.axaml b/src/Views/CherryPick.axaml new file mode 100644 index 00000000..bf66864c --- /dev/null +++ b/src/Views/CherryPick.axaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CherryPick.axaml.cs b/src/Views/CherryPick.axaml.cs new file mode 100644 index 00000000..e4a37e20 --- /dev/null +++ b/src/Views/CherryPick.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CherryPick : UserControl + { + public CherryPick() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/ChromelessWindow.cs b/src/Views/ChromelessWindow.cs new file mode 100644 index 00000000..1662bcd7 --- /dev/null +++ b/src/Views/ChromelessWindow.cs @@ -0,0 +1,77 @@ +using System; + +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public class ChromelessWindow : Window + { + public bool UseSystemWindowFrame + { + get => Native.OS.UseSystemWindowFrame; + } + + protected override Type StyleKeyOverride => typeof(Window); + + public ChromelessWindow() + { + Focusable = true; + Native.OS.SetupForWindow(this); + } + + public void BeginMoveWindow(object _, PointerPressedEventArgs e) + { + if (e.ClickCount == 1) + BeginMoveDrag(e); + + e.Handled = true; + } + + public void MaximizeOrRestoreWindow(object _, TappedEventArgs e) + { + if (WindowState == WindowState.Maximized) + WindowState = WindowState.Normal; + else + WindowState = WindowState.Maximized; + + e.Handled = true; + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (Classes.Contains("custom_window_frame") && CanResize) + { + string[] borderNames = [ + "PART_BorderTopLeft", + "PART_BorderTop", + "PART_BorderTopRight", + "PART_BorderLeft", + "PART_BorderRight", + "PART_BorderBottomLeft", + "PART_BorderBottom", + "PART_BorderBottomRight", + ]; + + foreach (var name in borderNames) + { + var border = e.NameScope.Find(name); + if (border != null) + { + border.PointerPressed -= OnWindowBorderPointerPressed; + border.PointerPressed += OnWindowBorderPointerPressed; + } + } + } + } + + private void OnWindowBorderPointerPressed(object sender, PointerPressedEventArgs e) + { + if (sender is Border border && border.Tag is WindowEdge edge && CanResize) + BeginResizeDrag(edge, e); + } + } +} diff --git a/src/Views/Cleanup.axaml b/src/Views/Cleanup.axaml new file mode 100644 index 00000000..d385712d --- /dev/null +++ b/src/Views/Cleanup.axaml @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/Views/Cleanup.axaml.cs b/src/Views/Cleanup.axaml.cs new file mode 100644 index 00000000..bf4e7267 --- /dev/null +++ b/src/Views/Cleanup.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Cleanup : UserControl + { + public Cleanup() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/ClearStashes.axaml b/src/Views/ClearStashes.axaml new file mode 100644 index 00000000..b986211b --- /dev/null +++ b/src/Views/ClearStashes.axaml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/src/Views/ClearStashes.axaml.cs b/src/Views/ClearStashes.axaml.cs new file mode 100644 index 00000000..99535829 --- /dev/null +++ b/src/Views/ClearStashes.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ClearStashes : UserControl + { + public ClearStashes() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Clone.axaml b/src/Views/Clone.axaml new file mode 100644 index 00000000..8c7c9faf --- /dev/null +++ b/src/Views/Clone.axaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Clone.axaml.cs b/src/Views/Clone.axaml.cs new file mode 100644 index 00000000..9316721a --- /dev/null +++ b/src/Views/Clone.axaml.cs @@ -0,0 +1,59 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class Clone : UserControl + { + public Clone() + { + InitializeComponent(); + } + + private async void SelectParentFolder(object _, RoutedEventArgs e) + { + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + try + { + var selected = await toplevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder?.Path.ToString(); + TxtParentFolder.Text = folderPath; + } + } + catch (Exception exception) + { + App.RaiseException(string.Empty, $"Failed to select parent folder: {exception.Message}"); + } + + e.Handled = true; + } + + private async void SelectSSHKey(object _, RoutedEventArgs e) + { + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] + }; + + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + TxtSshKey.Text = selected[0].Path.LocalPath; + + e.Handled = true; + } + } +} diff --git a/src/Views/Clone.xaml b/src/Views/Clone.xaml deleted file mode 100644 index 07536e82..00000000 --- a/src/Views/Clone.xaml +++ /dev/null @@ -1,261 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitBaseInfo.axaml.cs b/src/Views/CommitBaseInfo.axaml.cs new file mode 100644 index 00000000..ac9b53cc --- /dev/null +++ b/src/Views/CommitBaseInfo.axaml.cs @@ -0,0 +1,156 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace SourceGit.Views +{ + public partial class CommitBaseInfo : UserControl + { + public static readonly StyledProperty FullMessageProperty = + AvaloniaProperty.Register(nameof(FullMessage)); + + public Models.CommitFullMessage FullMessage + { + get => GetValue(FullMessageProperty); + set => SetValue(FullMessageProperty, value); + } + + public static readonly StyledProperty SignInfoProperty = + AvaloniaProperty.Register(nameof(SignInfo)); + + public Models.CommitSignInfo SignInfo + { + get => GetValue(SignInfoProperty); + set => SetValue(SignInfoProperty, value); + } + + public static readonly StyledProperty SupportsContainsInProperty = + AvaloniaProperty.Register(nameof(SupportsContainsIn)); + + public bool SupportsContainsIn + { + get => GetValue(SupportsContainsInProperty); + set => SetValue(SupportsContainsInProperty, value); + } + + public static readonly StyledProperty> WebLinksProperty = + AvaloniaProperty.Register>(nameof(WebLinks)); + + public List WebLinks + { + get => GetValue(WebLinksProperty); + set => SetValue(WebLinksProperty, value); + } + + public static readonly StyledProperty> ChildrenProperty = + AvaloniaProperty.Register>(nameof(Children)); + + public List Children + { + get => GetValue(ChildrenProperty); + set => SetValue(ChildrenProperty, value); + } + + public CommitBaseInfo() + { + InitializeComponent(); + } + + private void OnCopyCommitSHA(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: Models.Commit commit }) + App.CopyText(commit.SHA); + + e.Handled = true; + } + + private void OnOpenWebLink(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Control control) + { + var links = WebLinks; + if (links.Count > 1) + { + var menu = new ContextMenu(); + + foreach (var link in links) + { + var url = $"{link.URLPrefix}{detail.Commit.SHA}"; + var item = new MenuItem() { Header = link.Name }; + item.Click += (_, ev) => + { + Native.OS.OpenBrowser(url); + ev.Handled = true; + }; + + menu.Items.Add(item); + } + + menu.Open(control); + } + else if (links.Count == 1) + { + var url = $"{links[0].URLPrefix}{detail.Commit.SHA}"; + Native.OS.OpenBrowser(url); + } + } + + e.Handled = true; + } + + private void OnOpenContainsIn(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Button button) + { + var tracking = new CommitRelationTracking(detail); + var flyout = new Flyout(); + flyout.Content = tracking; + flyout.ShowAt(button); + } + + e.Handled = true; + } + + private void OnSHAPointerEntered(object sender, PointerEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Control { DataContext: string sha } ctl) + { + var tooltip = ToolTip.GetTip(ctl); + if (tooltip is Models.Commit commit && commit.SHA == sha) + return; + + Task.Run(() => + { + var c = detail.GetParent(sha); + if (c == null) + return; + + Dispatcher.UIThread.Invoke(() => + { + if (ctl.IsEffectivelyVisible && ctl.DataContext is string newSHA && newSHA == sha) + ToolTip.SetTip(ctl, c); + }); + }); + } + + e.Handled = true; + } + + private void OnSHAPressed(object sender, PointerPressedEventArgs e) + { + var point = e.GetCurrentPoint(this); + + if (point.Properties.IsLeftButtonPressed && DataContext is ViewModels.CommitDetail detail && sender is Control { DataContext: string sha }) + { + detail.NavigateTo(sha); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/CommitChanges.axaml b/src/Views/CommitChanges.axaml new file mode 100644 index 00000000..4dafee37 --- /dev/null +++ b/src/Views/CommitChanges.axaml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitChanges.axaml.cs b/src/Views/CommitChanges.axaml.cs new file mode 100644 index 00000000..c3d30018 --- /dev/null +++ b/src/Views/CommitChanges.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CommitChanges : UserControl + { + public CommitChanges() + { + InitializeComponent(); + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is ChangeCollectionView { SelectedChanges: { } selected } view && + selected.Count == 1 && + DataContext is ViewModels.CommitDetail vm) + { + var menu = vm.CreateChangeContextMenu(selected[0]); + menu?.Open(view); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/CommitDetail.axaml b/src/Views/CommitDetail.axaml new file mode 100644 index 00000000..d6ce74a1 --- /dev/null +++ b/src/Views/CommitDetail.axaml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitDetail.axaml.cs b/src/Views/CommitDetail.axaml.cs new file mode 100644 index 00000000..f0599c66 --- /dev/null +++ b/src/Views/CommitDetail.axaml.cs @@ -0,0 +1,35 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class CommitDetail : UserControl + { + public CommitDetail() + { + InitializeComponent(); + } + + private void OnChangeDoubleTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Grid grid && grid.DataContext is Models.Change change) + { + detail.ActivePageIndex = 1; + detail.SelectedChanges = new() { change }; + } + + e.Handled = true; + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Grid grid && grid.DataContext is Models.Change change) + { + var menu = detail.CreateChangeContextMenu(change); + menu?.Open(grid); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs new file mode 100644 index 00000000..5db39300 --- /dev/null +++ b/src/Views/CommitGraph.cs @@ -0,0 +1,245 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class CommitGraph : Control + { + public static readonly StyledProperty GraphProperty = + AvaloniaProperty.Register(nameof(Graph)); + + public Models.CommitGraph Graph + { + get => GetValue(GraphProperty); + set => SetValue(GraphProperty, value); + } + + public static readonly StyledProperty DotBrushProperty = + AvaloniaProperty.Register(nameof(DotBrush), Brushes.Transparent); + + public IBrush DotBrush + { + get => GetValue(DotBrushProperty); + set => SetValue(DotBrushProperty, value); + } + + public static readonly StyledProperty OnlyHighlightCurrentBranchProperty = + AvaloniaProperty.Register(nameof(OnlyHighlightCurrentBranch), true); + + public bool OnlyHighlightCurrentBranch + { + get => GetValue(OnlyHighlightCurrentBranchProperty); + set => SetValue(OnlyHighlightCurrentBranchProperty, value); + } + + static CommitGraph() + { + AffectsRender(GraphProperty, DotBrushProperty, OnlyHighlightCurrentBranchProperty); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var graph = Graph; + if (graph == null) + return; + + var histories = this.FindAncestorOfType(); + if (histories == null) + return; + + var list = histories.CommitListContainer; + if (list == null) + return; + + var container = list.ItemsPanelRoot as VirtualizingStackPanel; + if (container == null) + return; + + var item = list.ContainerFromIndex(container.FirstRealizedIndex); + if (item == null) + return; + + var width = histories.CommitListHeader.ColumnDefinitions[0].ActualWidth; + var height = Bounds.Height; + var rowHeight = item.Bounds.Height; + var startY = container.FirstRealizedIndex * rowHeight - item.TranslatePoint(new Point(0, 0), list).Value!.Y; + var endY = startY + height + 28; + + using (context.PushClip(new Rect(0, 0, width, height))) + using (context.PushTransform(Matrix.CreateTranslation(0, -startY))) + { + DrawCurves(context, graph, startY, endY, rowHeight); + DrawAnchors(context, graph, startY, endY, rowHeight); + } + } + + private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) + { + var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); + var onlyHighlightCurrentBranch = OnlyHighlightCurrentBranch; + + if (onlyHighlightCurrentBranch) + { + foreach (var link in graph.Links) + { + if (link.IsMerged) + continue; + + var startY = link.Start.Y * rowHeight; + var endY = link.End.Y * rowHeight; + + if (endY < top) + continue; + if (startY > bottom) + break; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(new Point(link.Start.X, startY), false); + ctx.QuadraticBezierTo(new Point(link.Control.X, link.Control.Y * rowHeight), new Point(link.End.X, endY)); + } + + context.DrawGeometry(null, grayedPen, geo); + } + } + + foreach (var line in graph.Paths) + { + var last = new Point(line.Points[0].X, line.Points[0].Y * rowHeight); + var size = line.Points.Count; + var endY = line.Points[size - 1].Y * rowHeight; + + if (endY < top) + continue; + if (last.Y > bottom) + break; + + var geo = new StreamGeometry(); + var pen = Models.CommitGraph.Pens[line.Color]; + + using (var ctx = geo.Open()) + { + var started = false; + var ended = false; + for (int i = 1; i < size; i++) + { + var cur = new Point(line.Points[i].X, line.Points[i].Y * rowHeight); + if (cur.Y < top) + { + last = cur; + continue; + } + + if (!started) + { + ctx.BeginFigure(last, false); + started = true; + } + + if (cur.Y > bottom) + { + cur = new Point(cur.X, bottom); + ended = true; + } + + if (cur.X > last.X) + { + ctx.QuadraticBezierTo(new Point(cur.X, last.Y), cur); + } + else if (cur.X < last.X) + { + if (i < size - 1) + { + var midY = (last.Y + cur.Y) / 2; + ctx.CubicBezierTo(new Point(last.X, midY + 4), new Point(cur.X, midY - 4), cur); + } + else + { + ctx.QuadraticBezierTo(new Point(last.X, cur.Y), cur); + } + } + else + { + ctx.LineTo(cur); + } + + if (ended) + break; + last = cur; + } + } + + if (!line.IsMerged && onlyHighlightCurrentBranch) + context.DrawGeometry(null, grayedPen, geo); + else + context.DrawGeometry(null, pen, geo); + } + + foreach (var link in graph.Links) + { + if (onlyHighlightCurrentBranch && !link.IsMerged) + continue; + + var startY = link.Start.Y * rowHeight; + var endY = link.End.Y * rowHeight; + + if (endY < top) + continue; + if (startY > bottom) + break; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(new Point(link.Start.X, startY), false); + ctx.QuadraticBezierTo(new Point(link.Control.X, link.Control.Y * rowHeight), new Point(link.End.X, endY)); + } + + context.DrawGeometry(null, Models.CommitGraph.Pens[link.Color], geo); + } + } + + private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom, double rowHeight) + { + var dotFill = DotBrush; + var dotFillPen = new Pen(dotFill, 2); + var grayedPen = new Pen(Brushes.Gray, Models.CommitGraph.Pens[0].Thickness); + var onlyHighlightCurrentBranch = OnlyHighlightCurrentBranch; + + foreach (var dot in graph.Dots) + { + var center = new Point(dot.Center.X, dot.Center.Y * rowHeight); + + if (center.Y < top) + continue; + if (center.Y > bottom) + break; + + var pen = Models.CommitGraph.Pens[dot.Color]; + if (!dot.IsMerged && onlyHighlightCurrentBranch) + pen = grayedPen; + + switch (dot.Type) + { + case Models.CommitGraph.DotType.Head: + context.DrawEllipse(dotFill, pen, center, 6, 6); + context.DrawEllipse(pen.Brush, null, center, 3, 3); + break; + case Models.CommitGraph.DotType.Merge: + context.DrawEllipse(pen.Brush, null, center, 6, 6); + context.DrawLine(dotFillPen, new Point(center.X, center.Y - 3), new Point(center.X, center.Y + 3)); + context.DrawLine(dotFillPen, new Point(center.X - 3, center.Y), new Point(center.X + 3, center.Y)); + break; + default: + context.DrawEllipse(dotFill, pen, center, 3, 3); + break; + } + } + } + } +} diff --git a/src/Views/CommitMessagePresenter.cs b/src/Views/CommitMessagePresenter.cs new file mode 100644 index 00000000..61119991 --- /dev/null +++ b/src/Views/CommitMessagePresenter.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class CommitMessagePresenter : SelectableTextBlock + { + public static readonly StyledProperty FullMessageProperty = + AvaloniaProperty.Register(nameof(FullMessage)); + + public Models.CommitFullMessage FullMessage + { + get => GetValue(FullMessageProperty); + set => SetValue(FullMessageProperty, value); + } + + protected override Type StyleKeyOverride => typeof(SelectableTextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == FullMessageProperty) + { + Inlines!.Clear(); + _inlineCommits.Clear(); + _lastHover = null; + ClearHoveredIssueLink(); + + var message = FullMessage?.Message; + if (string.IsNullOrEmpty(message)) + return; + + var links = FullMessage?.Inlines; + if (links == null || links.Count == 0) + { + Inlines.Add(new Run(message)); + return; + } + + var inlines = new List(); + var pos = 0; + for (var i = 0; i < links.Count; i++) + { + var link = links[i]; + if (link.Start > pos) + inlines.Add(new Run(message.Substring(pos, link.Start - pos))); + + var run = new Run(message.Substring(link.Start, link.Length)); + run.Classes.Add(link.Type == Models.InlineElementType.CommitSHA ? "commit_link" : "issue_link"); + inlines.Add(run); + + pos = link.Start + link.Length; + } + + if (pos < message.Length) + inlines.Add(new Run(message.Substring(pos))); + + Inlines.AddRange(inlines); + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (Equals(e.Pointer.Captured, this)) + { + var relativeSelfY = e.GetPosition(this).Y; + if (relativeSelfY <= 0 || relativeSelfY > Bounds.Height) + return; + + var scrollViewer = this.FindAncestorOfType(); + if (scrollViewer != null) + { + var relativeY = e.GetPosition(scrollViewer).Y; + if (relativeY <= 8) + scrollViewer.LineUp(); + else if (relativeY >= scrollViewer.Bounds.Height - 8) + scrollViewer.LineDown(); + } + } + else if (FullMessage is { Inlines: { Count: > 0 } links }) + { + var point = e.GetPosition(this) - new Point(Padding.Left, Padding.Top); + var x = Math.Min(Math.Max(point.X, 0), Math.Max(TextLayout.WidthIncludingTrailingWhitespace, 0)); + var y = Math.Min(Math.Max(point.Y, 0), Math.Max(TextLayout.Height, 0)); + point = new Point(x, y); + + var pos = TextLayout.HitTestPoint(point).TextPosition; + if (links.Intersect(pos, 1) is { } link) + SetHoveredIssueLink(link); + else + ClearHoveredIssueLink(); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + var point = e.GetCurrentPoint(this); + + if (_lastHover != null) + { + var link = _lastHover.Link; + e.Pointer.Capture(null); + + if (_lastHover.Type == Models.InlineElementType.CommitSHA) + { + var parentView = this.FindAncestorOfType(); + if (parentView is { DataContext: ViewModels.CommitDetail detail }) + { + if (point.Properties.IsLeftButtonPressed) + { + detail.NavigateTo(_lastHover.Link); + } + else if (point.Properties.IsRightButtonPressed) + { + var open = new MenuItem(); + open.Header = App.Text("SHALinkCM.NavigateTo"); + open.Icon = App.CreateMenuIcon("Icons.Commit"); + open.Click += (_, ev) => + { + detail.NavigateTo(link); + ev.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("SHALinkCM.CopySHA"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, ev) => + { + App.CopyText(link); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(open); + menu.Items.Add(copy); + menu.Open(this); + } + } + } + else + { + if (point.Properties.IsLeftButtonPressed) + { + Native.OS.OpenBrowser(link); + } + else if (point.Properties.IsRightButtonPressed) + { + var open = new MenuItem(); + open.Header = App.Text("IssueLinkCM.OpenInBrowser"); + open.Icon = App.CreateMenuIcon("Icons.OpenWith"); + open.Click += (_, ev) => + { + Native.OS.OpenBrowser(link); + ev.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("IssueLinkCM.CopyLink"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, ev) => + { + App.CopyText(link); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(open); + menu.Items.Add(copy); + menu.Open(this); + } + } + + e.Handled = true; + return; + } + + if (point.Properties.IsLeftButtonPressed && e.ClickCount == 3) + { + var text = Inlines?.Text; + if (string.IsNullOrEmpty(text)) + { + e.Handled = true; + return; + } + + var position = e.GetPosition(this) - new Point(Padding.Left, Padding.Top); + var x = Math.Min(Math.Max(position.X, 0), Math.Max(TextLayout.WidthIncludingTrailingWhitespace, 0)); + var y = Math.Min(Math.Max(position.Y, 0), Math.Max(TextLayout.Height, 0)); + position = new Point(x, y); + + var textPos = TextLayout.HitTestPoint(position).TextPosition; + var lineStart = 0; + var lineEnd = text.IndexOf('\n', lineStart); + if (lineEnd <= 0) + { + lineEnd = text.Length; + } + else + { + while (lineEnd < textPos) + { + lineStart = lineEnd + 1; + lineEnd = text.IndexOf('\n', lineStart); + if (lineEnd == -1) + { + lineEnd = text.Length; + break; + } + } + } + + SetCurrentValue(SelectionStartProperty, lineStart); + SetCurrentValue(SelectionEndProperty, lineEnd); + + e.Pointer.Capture(this); + e.Handled = true; + return; + } + + base.OnPointerPressed(e); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + ClearHoveredIssueLink(); + } + + private void ProcessHoverCommitLink(Models.InlineElement link) + { + var sha = link.Link; + + // If we have already queried this SHA, just use it. + if (_inlineCommits.TryGetValue(sha, out var exist)) + { + ToolTip.SetTip(this, exist); + return; + } + + var parentView = this.FindAncestorOfType(); + if (parentView is { DataContext: ViewModels.CommitDetail detail }) + { + // Record the SHA of current viewing commit in the CommitDetail panel to determine if it is changed after + // asynchronous queries. + var lastDetailCommit = detail.Commit.SHA; + Task.Run(() => + { + var c = detail.GetParent(sha); + Dispatcher.UIThread.Invoke(() => + { + // Make sure the DataContext of CommitBaseInfo is not changed. + var currentParent = this.FindAncestorOfType(); + if (currentParent is { DataContext: ViewModels.CommitDetail currentDetail } && + currentDetail.Commit.SHA == lastDetailCommit) + { + _inlineCommits.TryAdd(sha, c); + + // Make sure user still hovers the target SHA. + if (_lastHover == link && c != null) + ToolTip.SetTip(this, c); + } + }); + }); + } + } + + private void SetHoveredIssueLink(Models.InlineElement link) + { + if (link == _lastHover) + return; + + SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); + + _lastHover = link; + if (link.Type == Models.InlineElementType.Link) + ToolTip.SetTip(this, link.Link); + else + ProcessHoverCommitLink(link); + } + + private void ClearHoveredIssueLink() + { + if (_lastHover != null) + { + ToolTip.SetTip(this, null); + SetCurrentValue(CursorProperty, Cursor.Parse("IBeam")); + _lastHover = null; + } + } + + private Models.InlineElement _lastHover = null; + private Dictionary _inlineCommits = new(); + } +} diff --git a/src/Views/CommitMessageTextBox.axaml b/src/Views/CommitMessageTextBox.axaml new file mode 100644 index 00000000..73c3a193 --- /dev/null +++ b/src/Views/CommitMessageTextBox.axaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitMessageTextBox.axaml.cs b/src/Views/CommitMessageTextBox.axaml.cs new file mode 100644 index 00000000..83d6f900 --- /dev/null +++ b/src/Views/CommitMessageTextBox.axaml.cs @@ -0,0 +1,216 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public class EnhancedTextBox : TextBox + { + public static readonly RoutedEvent PreviewKeyDownEvent = + RoutedEvent.Register(nameof(KeyEventArgs), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler PreviewKeyDown + { + add { AddHandler(PreviewKeyDownEvent, value); } + remove { RemoveHandler(PreviewKeyDownEvent, value); } + } + + protected override Type StyleKeyOverride => typeof(TextBox); + + public void Paste(string text) + { + OnTextInput(new TextInputEventArgs() { Text = text }); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + var dump = new KeyEventArgs() + { + RoutedEvent = PreviewKeyDownEvent, + Route = RoutingStrategies.Direct, + Source = e.Source, + Key = e.Key, + KeyModifiers = e.KeyModifiers, + PhysicalKey = e.PhysicalKey, + KeySymbol = e.KeySymbol, + }; + + RaiseEvent(dump); + + if (dump.Handled) + e.Handled = true; + else + base.OnKeyDown(e); + } + } + + public partial class CommitMessageTextBox : UserControl + { + public enum TextChangeWay + { + None, + FromSource, + FromEditor, + } + + public static readonly StyledProperty ShowAdvancedOptionsProperty = + AvaloniaProperty.Register(nameof(ShowAdvancedOptions), false); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), string.Empty); + + public static readonly StyledProperty SubjectProperty = + AvaloniaProperty.Register(nameof(Subject), string.Empty); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description), string.Empty); + + public bool ShowAdvancedOptions + { + get => GetValue(ShowAdvancedOptionsProperty); + set => SetValue(ShowAdvancedOptionsProperty, value); + } + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string Subject + { + get => GetValue(SubjectProperty); + set => SetValue(SubjectProperty, value); + } + + public string Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public CommitMessageTextBox() + { + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == TextProperty && _changingWay == TextChangeWay.None) + { + _changingWay = TextChangeWay.FromSource; + var normalized = Text.ReplaceLineEndings("\n"); + var subjectEnd = normalized.IndexOf("\n\n", StringComparison.Ordinal); + if (subjectEnd == -1) + { + SetCurrentValue(SubjectProperty, normalized.ReplaceLineEndings(" ")); + SetCurrentValue(DescriptionProperty, string.Empty); + } + else + { + SetCurrentValue(SubjectProperty, normalized.Substring(0, subjectEnd).ReplaceLineEndings(" ")); + SetCurrentValue(DescriptionProperty, normalized.Substring(subjectEnd + 2)); + } + _changingWay = TextChangeWay.None; + } + else if ((change.Property == SubjectProperty || change.Property == DescriptionProperty) && _changingWay == TextChangeWay.None) + { + _changingWay = TextChangeWay.FromEditor; + SetCurrentValue(TextProperty, $"{Subject}\n\n{Description}"); + _changingWay = TextChangeWay.None; + } + } + + private async void OnSubjectTextBoxPreviewKeyDown(object _, KeyEventArgs e) + { + if (e.Key == Key.Enter || (e.Key == Key.Right && SubjectEditor.CaretIndex == Subject.Length)) + { + DescriptionEditor.Focus(); + DescriptionEditor.CaretIndex = 0; + e.Handled = true; + } + else if (e.Key == Key.V && e.KeyModifiers == (OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + e.Handled = true; + + var text = await App.GetClipboardTextAsync(); + if (!string.IsNullOrWhiteSpace(text)) + { + text = text.Trim(); + + if (SubjectEditor.CaretIndex == Subject.Length) + { + var idx = text.IndexOf('\n'); + if (idx == -1) + { + SubjectEditor.Paste(text); + } + else + { + SubjectEditor.Paste(text.Substring(0, idx)); + DescriptionEditor.Focus(); + DescriptionEditor.CaretIndex = 0; + DescriptionEditor.Paste(text.Substring(idx + 1).Trim()); + } + } + else + { + SubjectEditor.Paste(text.ReplaceLineEndings(" ")); + } + } + } + } + + private void OnDescriptionTextBoxPreviewKeyDown(object _, KeyEventArgs e) + { + if ((e.Key == Key.Back || e.Key == Key.Left) && DescriptionEditor.CaretIndex == 0) + { + SubjectEditor.Focus(); + SubjectEditor.CaretIndex = Subject.Length; + e.Handled = true; + } + } + + private void OnOpenCommitMessagePicker(object sender, RoutedEventArgs e) + { + if (sender is Button button && DataContext is ViewModels.WorkingCopy vm) + { + var menu = vm.CreateContextMenuForCommitMessages(); + menu.Placement = PlacementMode.TopEdgeAlignedLeft; + menu?.Open(button); + } + + e.Handled = true; + } + + private void OnOpenOpenAIHelper(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm && sender is Control control) + { + var menu = vm.CreateContextForOpenAI(); + menu?.Open(control); + } + + e.Handled = true; + } + + private void OnOpenConventionalCommitHelper(object _, RoutedEventArgs e) + { + App.ShowWindow(new ViewModels.ConventionalCommitMessageBuilder(text => Text = text), true); + e.Handled = true; + } + + private void CopyAllText(object sender, RoutedEventArgs e) + { + App.CopyText(Text); + e.Handled = true; + } + + private TextChangeWay _changingWay = TextChangeWay.None; + } +} diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs new file mode 100644 index 00000000..507da1c2 --- /dev/null +++ b/src/Views/CommitRefsPresenter.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class CommitRefsPresenter : Control + { + public class RenderItem + { + public Geometry Icon { get; set; } = null; + public FormattedText Label { get; set; } = null; + public IBrush Brush { get; set; } = null; + public bool IsHead { get; set; } = false; + public double Width { get; set; } = 0.0; + } + + public static readonly StyledProperty FontFamilyProperty = + TextBlock.FontFamilyProperty.AddOwner(); + + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + public static readonly StyledProperty FontSizeProperty = + TextBlock.FontSizeProperty.AddOwner(); + + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + public static readonly StyledProperty BackgroundProperty = + AvaloniaProperty.Register(nameof(Background), Brushes.Transparent); + + public IBrush Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.White); + + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + public static readonly StyledProperty UseGraphColorProperty = + AvaloniaProperty.Register(nameof(UseGraphColor)); + + public bool UseGraphColor + { + get => GetValue(UseGraphColorProperty); + set => SetValue(UseGraphColorProperty, value); + } + + public static readonly StyledProperty AllowWrapProperty = + AvaloniaProperty.Register(nameof(AllowWrap)); + + public bool AllowWrap + { + get => GetValue(AllowWrapProperty); + set => SetValue(AllowWrapProperty, value); + } + + public static readonly StyledProperty ShowTagsProperty = + AvaloniaProperty.Register(nameof(ShowTags), true); + + public bool ShowTags + { + get => GetValue(ShowTagsProperty); + set => SetValue(ShowTagsProperty, value); + } + + static CommitRefsPresenter() + { + AffectsMeasure( + FontFamilyProperty, + FontSizeProperty, + ForegroundProperty, + UseGraphColorProperty, + BackgroundProperty, + ShowTagsProperty); + } + + public override void Render(DrawingContext context) + { + if (_items.Count == 0) + return; + + var useGraphColor = UseGraphColor; + var fg = Foreground; + var bg = Background; + var allowWrap = AllowWrap; + var x = 1.0; + var y = 0.0; + + foreach (var item in _items) + { + if (allowWrap && x > 1.0 && x + item.Width > Bounds.Width) + { + x = 1.0; + y += 20.0; + } + + var entireRect = new RoundedRect(new Rect(x, y, item.Width, 16), new CornerRadius(2)); + + if (item.IsHead) + { + if (useGraphColor) + { + if (bg != null) + context.DrawRectangle(bg, null, entireRect); + + using (context.PushOpacity(.6)) + context.DrawRectangle(item.Brush, null, entireRect); + } + + context.DrawText(item.Label, new Point(x + 16, y + 8.0 - item.Label.Height * 0.5)); + } + else + { + if (bg != null) + context.DrawRectangle(bg, null, entireRect); + + var labelRect = new RoundedRect(new Rect(x + 16, y, item.Label.Width + 8, 16), new CornerRadius(0, 2, 2, 0)); + using (context.PushOpacity(.2)) + context.DrawRectangle(item.Brush, null, labelRect); + + context.DrawLine(new Pen(item.Brush), new Point(x + 16, y), new Point(x + 16, y + 16)); + context.DrawText(item.Label, new Point(x + 20, y + 8.0 - item.Label.Height * 0.5)); + } + + context.DrawRectangle(null, new Pen(item.Brush), entireRect); + + using (context.PushTransform(Matrix.CreateTranslation(x + 3, y + 3))) + context.DrawGeometry(fg, null, item.Icon); + + x += item.Width + 4; + } + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + _items.Clear(); + + var commit = DataContext as Models.Commit; + if (commit == null) + return new Size(0, 0); + + var refs = commit.Decorators; + if (refs != null && refs.Count > 0) + { + var typeface = new Typeface(FontFamily); + var typefaceBold = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); + var fg = Foreground; + var normalBG = UseGraphColor ? commit.Brush : Brushes.Gray; + var labelSize = FontSize; + var requiredWidth = 0.0; + var requiredHeight = 16.0; + var x = 0.0; + var allowWrap = AllowWrap; + var showTags = ShowTags; + + foreach (var decorator in refs) + { + if (!showTags && decorator.Type == Models.DecoratorType.Tag) + continue; + + var isHead = decorator.Type == Models.DecoratorType.CurrentBranchHead || + decorator.Type == Models.DecoratorType.CurrentCommitHead; + + var label = new FormattedText( + decorator.Name, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + isHead ? typefaceBold : typeface, + isHead ? labelSize + 1 : labelSize, + fg); + + var item = new RenderItem() + { + Label = label, + Brush = normalBG, + IsHead = isHead + }; + + StreamGeometry geo; + switch (decorator.Type) + { + case Models.DecoratorType.CurrentBranchHead: + case Models.DecoratorType.CurrentCommitHead: + geo = this.FindResource("Icons.Head") as StreamGeometry; + break; + case Models.DecoratorType.RemoteBranchHead: + geo = this.FindResource("Icons.Remote") as StreamGeometry; + break; + case Models.DecoratorType.Tag: + item.Brush = Brushes.Gray; + geo = this.FindResource("Icons.Tag") as StreamGeometry; + break; + default: + geo = this.FindResource("Icons.Branch") as StreamGeometry; + break; + } + + var drawGeo = geo!.Clone(); + var iconBounds = drawGeo.Bounds; + var translation = Matrix.CreateTranslation(-(Vector)iconBounds.Position); + var scale = Math.Min(10.0 / iconBounds.Width, 10.0 / iconBounds.Height); + var transform = translation * Matrix.CreateScale(scale, scale); + if (drawGeo.Transform == null || drawGeo.Transform.Value == Matrix.Identity) + drawGeo.Transform = new MatrixTransform(transform); + else + drawGeo.Transform = new MatrixTransform(drawGeo.Transform.Value * transform); + + item.Icon = drawGeo; + item.Width = 16 + (isHead ? 0 : 4) + label.Width + 4; + _items.Add(item); + + x += item.Width + 4; + if (allowWrap) + { + if (x > availableSize.Width) + { + requiredHeight += 20.0; + x = item.Width; + } + } + } + + if (allowWrap && requiredHeight > 16.0) + requiredWidth = availableSize.Width; + else + requiredWidth = x + 2; + + InvalidateVisual(); + return new Size(requiredWidth, requiredHeight); + } + + InvalidateVisual(); + return new Size(0, 0); + } + + private List _items = new List(); + } +} diff --git a/src/Views/CommitRelationTracking.axaml b/src/Views/CommitRelationTracking.axaml new file mode 100644 index 00000000..9d036e10 --- /dev/null +++ b/src/Views/CommitRelationTracking.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitRelationTracking.axaml.cs b/src/Views/CommitRelationTracking.axaml.cs new file mode 100644 index 00000000..1e436552 --- /dev/null +++ b/src/Views/CommitRelationTracking.axaml.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Threading; + +namespace SourceGit.Views +{ + public partial class CommitRelationTracking : UserControl + { + public CommitRelationTracking() + { + InitializeComponent(); + } + + public CommitRelationTracking(ViewModels.CommitDetail detail) + { + InitializeComponent(); + + LoadingIcon.IsVisible = true; + + Task.Run(() => + { + var containsIn = detail.GetRefsContainsThisCommit(); + Dispatcher.UIThread.Invoke(() => + { + Container.ItemsSource = containsIn; + LoadingIcon.IsVisible = false; + }); + }); + } + } +} diff --git a/src/Views/CommitStatusIndicator.cs b/src/Views/CommitStatusIndicator.cs new file mode 100644 index 00000000..7073011a --- /dev/null +++ b/src/Views/CommitStatusIndicator.cs @@ -0,0 +1,90 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class CommitStatusIndicator : Control + { + public static readonly StyledProperty CurrentBranchProperty = + AvaloniaProperty.Register(nameof(CurrentBranch)); + + public Models.Branch CurrentBranch + { + get => GetValue(CurrentBranchProperty); + set => SetValue(CurrentBranchProperty, value); + } + + public static readonly StyledProperty AheadBrushProperty = + AvaloniaProperty.Register(nameof(AheadBrush)); + + public IBrush AheadBrush + { + get => GetValue(AheadBrushProperty); + set => SetValue(AheadBrushProperty, value); + } + + public static readonly StyledProperty BehindBrushProperty = + AvaloniaProperty.Register(nameof(BehindBrush)); + + public IBrush BehindBrush + { + get => GetValue(BehindBrushProperty); + set => SetValue(BehindBrushProperty, value); + } + + private enum Status + { + Normal, + Ahead, + Behind, + } + + public override void Render(DrawingContext context) + { + if (_status == Status.Normal) + return; + + context.DrawEllipse(_status == Status.Ahead ? AheadBrush : BehindBrush, null, new Rect(0, 0, 5, 5)); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (DataContext is Models.Commit commit && CurrentBranch is not null) + { + var sha = commit.SHA; + var track = CurrentBranch.TrackStatus; + + if (track.Ahead.Contains(sha)) + _status = Status.Ahead; + else if (track.Behind.Contains(sha)) + _status = Status.Behind; + else + _status = Status.Normal; + } + else + { + _status = Status.Normal; + } + + return _status == Status.Normal ? new Size(0, 0) : new Size(9, 5); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == CurrentBranchProperty) + InvalidateMeasure(); + } + + private Status _status = Status.Normal; + } +} diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs new file mode 100644 index 00000000..bfeab34f --- /dev/null +++ b/src/Views/CommitSubjectPresenter.cs @@ -0,0 +1,363 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public partial class CommitSubjectPresenter : Control + { + public static readonly StyledProperty FontFamilyProperty = + AvaloniaProperty.Register(nameof(FontFamily)); + + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + public static readonly StyledProperty CodeFontFamilyProperty = + AvaloniaProperty.Register(nameof(CodeFontFamily)); + + public FontFamily CodeFontFamily + { + get => GetValue(CodeFontFamilyProperty); + set => SetValue(CodeFontFamilyProperty, value); + } + + public static readonly StyledProperty FontSizeProperty = + TextBlock.FontSizeProperty.AddOwner(); + + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + public static readonly StyledProperty FontWeightProperty = + TextBlock.FontWeightProperty.AddOwner(); + + public FontWeight FontWeight + { + get => GetValue(FontWeightProperty); + set => SetValue(FontWeightProperty, value); + } + + public static readonly StyledProperty InlineCodeBackgroundProperty = + AvaloniaProperty.Register(nameof(InlineCodeBackground), Brushes.Transparent); + + public IBrush InlineCodeBackground + { + get => GetValue(InlineCodeBackgroundProperty); + set => SetValue(InlineCodeBackgroundProperty, value); + } + + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.White); + + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + public static readonly StyledProperty LinkForegroundProperty = + AvaloniaProperty.Register(nameof(LinkForeground), Brushes.White); + + public IBrush LinkForeground + { + get => GetValue(LinkForegroundProperty); + set => SetValue(LinkForegroundProperty, value); + } + + public static readonly StyledProperty SubjectProperty = + AvaloniaProperty.Register(nameof(Subject)); + + public string Subject + { + get => GetValue(SubjectProperty); + set => SetValue(SubjectProperty, value); + } + + public static readonly StyledProperty> IssueTrackerRulesProperty = + AvaloniaProperty.Register>(nameof(IssueTrackerRules)); + + public AvaloniaList IssueTrackerRules + { + get => GetValue(IssueTrackerRulesProperty); + set => SetValue(IssueTrackerRulesProperty, value); + } + + public override void Render(DrawingContext context) + { + if (_needRebuildInlines) + { + _needRebuildInlines = false; + GenerateFormattedTextElements(); + } + + if (_inlines.Count == 0) + return; + + var ro = new RenderOptions() + { + TextRenderingMode = TextRenderingMode.SubpixelAntialias, + EdgeMode = EdgeMode.Antialias + }; + + using (context.PushRenderOptions(ro)) + { + var height = Bounds.Height; + var width = Bounds.Width; + foreach (var inline in _inlines) + { + if (inline.X > width) + return; + + if (inline.Element is { Type: Models.InlineElementType.Code }) + { + var rect = new Rect(inline.X, (height - inline.Text.Height - 2) * 0.5, inline.Text.WidthIncludingTrailingWhitespace + 8, inline.Text.Height + 2); + var roundedRect = new RoundedRect(rect, new CornerRadius(4)); + context.DrawRectangle(InlineCodeBackground, null, roundedRect); + context.DrawText(inline.Text, new Point(inline.X + 4, (height - inline.Text.Height) * 0.5)); + } + else + { + context.DrawText(inline.Text, new Point(inline.X, (height - inline.Text.Height) * 0.5)); + } + } + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty) + { + _elements.Clear(); + ClearHoveredIssueLink(); + + var subject = Subject; + if (string.IsNullOrEmpty(subject)) + { + _needRebuildInlines = true; + InvalidateVisual(); + return; + } + + var rules = IssueTrackerRules ?? []; + foreach (var rule in rules) + rule.Matches(_elements, subject); + + var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject); + if (!keywordMatch.Success) + keywordMatch = REG_KEYWORD_FORMAT2().Match(subject); + + if (keywordMatch.Success && _elements.Intersect(0, keywordMatch.Length) == null) + _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, keywordMatch.Length, string.Empty)); + + var codeMatches = REG_INLINECODE_FORMAT().Matches(subject); + for (var i = 0; i < codeMatches.Count; i++) + { + var match = codeMatches[i]; + if (!match.Success) + continue; + + var start = match.Index; + var len = match.Length; + if (_elements.Intersect(start, len) != null) + continue; + + _elements.Add(new Models.InlineElement(Models.InlineElementType.Code, start, len, string.Empty)); + } + + _elements.Sort(); + _needRebuildInlines = true; + InvalidateVisual(); + } + else if (change.Property == FontFamilyProperty || + change.Property == CodeFontFamilyProperty || + change.Property == FontSizeProperty || + change.Property == FontWeightProperty || + change.Property == ForegroundProperty || + change.Property == LinkForegroundProperty) + { + _needRebuildInlines = true; + InvalidateVisual(); + } + else if (change.Property == InlineCodeBackgroundProperty) + { + InvalidateVisual(); + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + var point = e.GetPosition(this); + foreach (var inline in _inlines) + { + if (inline.Element is not { Type: Models.InlineElementType.Link } link) + continue; + + if (inline.X > point.X || inline.X + inline.Text.WidthIncludingTrailingWhitespace < point.X) + continue; + + _lastHover = link; + SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); + ToolTip.SetTip(this, link.Link); + e.Handled = true; + return; + } + + ClearHoveredIssueLink(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (_lastHover != null) + Native.OS.OpenBrowser(_lastHover.Link); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + ClearHoveredIssueLink(); + } + + private void GenerateFormattedTextElements() + { + _inlines.Clear(); + + var subject = Subject; + if (string.IsNullOrEmpty(subject)) + return; + + var fontFamily = FontFamily; + var codeFontFamily = CodeFontFamily; + var fontSize = FontSize; + var foreground = Foreground; + var linkForeground = LinkForeground; + var typeface = new Typeface(fontFamily, FontStyle.Normal, FontWeight); + var codeTypeface = new Typeface(codeFontFamily, FontStyle.Normal, FontWeight); + var pos = 0; + var x = 0.0; + for (var i = 0; i < _elements.Count; i++) + { + var elem = _elements[i]; + if (elem.Start > pos) + { + var normal = new FormattedText( + subject.Substring(pos, elem.Start - pos), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + fontSize, + foreground); + + _inlines.Add(new Inline(x, normal, null)); + x += normal.WidthIncludingTrailingWhitespace; + } + + if (elem.Type == Models.InlineElementType.Keyword) + { + var keyword = new FormattedText( + subject.Substring(elem.Start, elem.Length), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(fontFamily, FontStyle.Normal, FontWeight.Bold), + fontSize, + foreground); + _inlines.Add(new Inline(x, keyword, elem)); + x += keyword.WidthIncludingTrailingWhitespace; + } + else if (elem.Type == Models.InlineElementType.Link) + { + var link = new FormattedText( + subject.Substring(elem.Start, elem.Length), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + fontSize, + linkForeground); + _inlines.Add(new Inline(x, link, elem)); + x += link.WidthIncludingTrailingWhitespace; + } + else if (elem.Type == Models.InlineElementType.Code) + { + var link = new FormattedText( + subject.Substring(elem.Start + 1, elem.Length - 2), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + codeTypeface, + fontSize - 0.5, + foreground); + _inlines.Add(new Inline(x, link, elem)); + x += link.WidthIncludingTrailingWhitespace + 8; + } + + pos = elem.Start + elem.Length; + } + + if (pos < subject.Length) + { + var normal = new FormattedText( + subject.Substring(pos), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + fontSize, + foreground); + + _inlines.Add(new Inline(x, normal, null)); + } + } + + private void ClearHoveredIssueLink() + { + if (_lastHover != null) + { + ToolTip.SetTip(this, null); + SetCurrentValue(CursorProperty, Cursor.Parse("Arrow")); + _lastHover = null; + } + } + + [GeneratedRegex(@"`.*?`")] + private static partial Regex REG_INLINECODE_FORMAT(); + + [GeneratedRegex(@"^\[[\w\s]+\]")] + private static partial Regex REG_KEYWORD_FORMAT1(); + + [GeneratedRegex(@"^\S+([\<\(][\w\s_\-\*,]+[\>\)])?\!?\s?:\s")] + private static partial Regex REG_KEYWORD_FORMAT2(); + + private class Inline + { + public double X { get; set; } = 0; + public FormattedText Text { get; set; } = null; + public Models.InlineElement Element { get; set; } = null; + + public Inline(double x, FormattedText text, Models.InlineElement elem) + { + X = x; + Text = text; + Element = elem; + } + } + + private Models.InlineElementCollector _elements = new(); + private List _inlines = []; + private Models.InlineElement _lastHover = null; + private bool _needRebuildInlines = false; + } +} diff --git a/src/Views/CommitTimeTextBlock.cs b/src/Views/CommitTimeTextBlock.cs new file mode 100644 index 00000000..db63e8a6 --- /dev/null +++ b/src/Views/CommitTimeTextBlock.cs @@ -0,0 +1,166 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace SourceGit.Views +{ + public class CommitTimeTextBlock : TextBlock + { + public static readonly StyledProperty ShowAsDateTimeProperty = + AvaloniaProperty.Register(nameof(ShowAsDateTime), true); + + public bool ShowAsDateTime + { + get => GetValue(ShowAsDateTimeProperty); + set => SetValue(ShowAsDateTimeProperty, value); + } + + public static readonly StyledProperty DateTimeFormatProperty = + AvaloniaProperty.Register(nameof(DateTimeFormat), 0); + + public int DateTimeFormat + { + get => GetValue(DateTimeFormatProperty); + set => SetValue(DateTimeFormatProperty, value); + } + + public static readonly StyledProperty UseAuthorTimeProperty = + AvaloniaProperty.Register(nameof(UseAuthorTime), true); + + public bool UseAuthorTime + { + get => GetValue(UseAuthorTimeProperty); + set => SetValue(UseAuthorTimeProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UseAuthorTimeProperty) + { + SetCurrentValue(TextProperty, GetDisplayText()); + } + else if (change.Property == ShowAsDateTimeProperty) + { + SetCurrentValue(TextProperty, GetDisplayText()); + + if (ShowAsDateTime) + StopTimer(); + else + StartTimer(); + } + else if (change.Property == DateTimeFormatProperty) + { + if (ShowAsDateTime) + SetCurrentValue(TextProperty, GetDisplayText()); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (!ShowAsDateTime) + StartTimer(); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + StopTimer(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + SetCurrentValue(TextProperty, GetDisplayText()); + } + + private void StartTimer() + { + if (_refreshTimer != null) + return; + + _refreshTimer = DispatcherTimer.Run(() => + { + Dispatcher.UIThread.Invoke(() => + { + var text = GetDisplayText(); + if (!text.Equals(Text, StringComparison.Ordinal)) + Text = text; + }); + + return true; + }, TimeSpan.FromSeconds(10)); + } + + private void StopTimer() + { + if (_refreshTimer != null) + { + _refreshTimer.Dispose(); + _refreshTimer = null; + } + } + + private string GetDisplayText() + { + var commit = DataContext as Models.Commit; + if (commit == null) + return string.Empty; + + if (ShowAsDateTime) + return UseAuthorTime ? commit.AuthorTimeStr : commit.CommitterTimeStr; + + var timestamp = UseAuthorTime ? commit.AuthorTime : commit.CommitterTime; + var now = DateTime.Now; + var localTime = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + var span = now - localTime; + if (span.TotalMinutes < 1) + return App.Text("Period.JustNow"); + + if (span.TotalHours < 1) + return App.Text("Period.MinutesAgo", (int)span.TotalMinutes); + + if (span.TotalDays < 1) + { + var hours = (int)span.TotalHours; + return hours == 1 ? App.Text("Period.HourAgo") : App.Text("Period.HoursAgo", hours); + } + + var lastDay = now.AddDays(-1).Date; + if (localTime >= lastDay) + return App.Text("Period.Yesterday"); + + if ((localTime.Year == now.Year && localTime.Month == now.Month) || span.TotalDays < 28) + { + var diffDay = now.Date - localTime.Date; + return App.Text("Period.DaysAgo", (int)diffDay.TotalDays); + } + + var lastMonth = now.AddMonths(-1).Date; + if (localTime.Year == lastMonth.Year && localTime.Month == lastMonth.Month) + return App.Text("Period.LastMonth"); + + if (localTime.Year == now.Year || localTime > now.AddMonths(-11)) + { + var diffMonth = (12 + now.Month - localTime.Month) % 12; + return App.Text("Period.MonthsAgo", diffMonth); + } + + var diffYear = now.Year - localTime.Year; + if (diffYear == 1) + return App.Text("Period.LastYear"); + + return App.Text("Period.YearsAgo", diffYear); + } + + private IDisposable _refreshTimer = null; + } +} diff --git a/src/Views/ConfigureWorkspace.axaml b/src/Views/ConfigureWorkspace.axaml new file mode 100644 index 00000000..b239399a --- /dev/null +++ b/src/Views/ConfigureWorkspace.axaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ConfigureWorkspace.axaml.cs b/src/Views/ConfigureWorkspace.axaml.cs new file mode 100644 index 00000000..06294caf --- /dev/null +++ b/src/Views/ConfigureWorkspace.axaml.cs @@ -0,0 +1,48 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class ConfigureWorkspace : ChromelessWindow + { + public ConfigureWorkspace() + { + InitializeComponent(); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + base.OnClosing(e); + + if (!Design.IsDesignMode) + ViewModels.Preferences.Instance.Save(); + } + + private async void SelectDefaultCloneDir(object _, RoutedEventArgs e) + { + var workspace = DataContext as ViewModels.ConfigureWorkspace; + if (workspace?.Selected == null) + return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + try + { + var selected = await StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder?.Path.ToString(); + workspace.Selected.DefaultCloneDir = folderPath; + } + } + catch (Exception ex) + { + App.RaiseException(string.Empty, $"Failed to select default clone directory: {ex.Message}"); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/ConfirmCommit.axaml b/src/Views/ConfirmCommit.axaml new file mode 100644 index 00000000..a835f0b6 --- /dev/null +++ b/src/Views/ConfirmCommit.axaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Conflict.axaml.cs b/src/Views/Conflict.axaml.cs new file mode 100644 index 00000000..6121b5c8 --- /dev/null +++ b/src/Views/Conflict.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class Conflict : UserControl + { + public Conflict() + { + InitializeComponent(); + } + + private void OnPressedSHA(object sender, PointerPressedEventArgs e) + { + var repoView = this.FindAncestorOfType(); + if (repoView is { DataContext: ViewModels.Repository repo } && sender is TextBlock text) + repo.NavigateToCommit(text.Text); + + e.Handled = true; + } + } +} diff --git a/src/Views/Controls/Avatar.cs b/src/Views/Controls/Avatar.cs deleted file mode 100644 index 9b5cfe6b..00000000 --- a/src/Views/Controls/Avatar.cs +++ /dev/null @@ -1,233 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Net; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; - -namespace SourceGit.Views.Controls { - - /// - /// 头像控件 - /// - public class Avatar : Image { - - /// - /// 显示FallbackLabel时的背景色 - /// - private static readonly Brush[] BACKGROUND_BRUSHES = new Brush[] { - new LinearGradientBrush(Colors.Orange, Color.FromRgb(255, 213, 134), 90), - new LinearGradientBrush(Colors.DodgerBlue, Colors.LightSkyBlue, 90), - new LinearGradientBrush(Colors.LimeGreen, Color.FromRgb(124, 241, 124), 90), - new LinearGradientBrush(Colors.Orchid, Color.FromRgb(248, 161, 245), 90), - new LinearGradientBrush(Colors.Tomato, Color.FromRgb(252, 165, 150), 90), - }; - - /// - /// 头像资源本地缓存路径 - /// - public static readonly string CACHE_PATH = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SourceGit", - "avatars"); - - /// - /// 邮件属性定义 - /// - public static readonly DependencyProperty EmailProperty = DependencyProperty.Register( - "Email", - typeof(string), - typeof(Avatar), - new PropertyMetadata(null, OnEmailChanged)); - - /// - /// 邮件属性 - /// - public string Email { - get { return (string)GetValue(EmailProperty); } - set { SetValue(EmailProperty, value); } - } - - /// - /// 下载头像失败时显示的Label属性定义 - /// - public static readonly DependencyProperty FallbackLabelProperty = DependencyProperty.Register( - "FallbackLabel", - typeof(string), - typeof(Avatar), - new PropertyMetadata("?", OnFallbackLabelChanged)); - - /// - /// 下载头像失败时显示的Label属性 - /// - public string FallbackLabel { - get { return (string)GetValue(FallbackLabelProperty); } - set { SetValue(FallbackLabelProperty, value); } - } - - private static Dictionary> requesting = new Dictionary>(); - private static Dictionary loaded = new Dictionary(); - private static Task loader = null; - - private int colorIdx = 0; - private FormattedText label = null; - - public Avatar() { - SetValue(RenderOptions.BitmapScalingModeProperty, BitmapScalingMode.HighQuality); - SetValue(RenderOptions.ClearTypeHintProperty, ClearTypeHint.Auto); - Unloaded += (o, e) => Cancel(Email); - } - - /// - /// 取消一个下载任务 - /// - /// - private void Cancel(string email) { - if (!string.IsNullOrEmpty(email) && requesting.ContainsKey(email)) { - if (requesting[email].Count <= 1) { - requesting.Remove(email); - } else { - requesting[email].Remove(this); - } - } - } - - /// - /// 渲染实现 - /// - /// - protected override void OnRender(DrawingContext dc) { - var corner = Math.Max(2, Width / 16); - - if (Source == null && label != null) { - var offsetX = (double)0; - if (HorizontalAlignment == HorizontalAlignment.Right) { - offsetX = -Width * 0.5; - } else if (HorizontalAlignment == HorizontalAlignment.Left) { - offsetX = Width * 0.5; - } - - Brush brush = BACKGROUND_BRUSHES[colorIdx]; - dc.DrawRoundedRectangle(brush, null, new Rect(-Width * 0.5 + offsetX, -Height * 0.5, Width, Height), corner, corner); - dc.DrawText(label, new Point(label.Width * -0.5 + offsetX, label.Height * -0.5)); - } else { - dc.PushClip(new RectangleGeometry(new Rect(0, 0, Width, Height), corner, corner)); - base.OnRender(dc); - } - } - - /// - /// 显示文本变化时触发 - /// - /// - /// - private static void OnFallbackLabelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - Avatar a = d as Avatar; - if (a == null) return; - - var placeholder = a.FallbackLabel.Length > 0 ? a.FallbackLabel.Substring(0, 1) : "?"; - - a.colorIdx = 0; - a.label = new FormattedText( - placeholder, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - new Typeface(new FontFamily(Models.Preference.Instance.General.FontFamilyWindow), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), - a.Width * 0.65, - Brushes.White, - VisualTreeHelper.GetDpi(a).PixelsPerDip); - - var chars = placeholder.ToCharArray(); - foreach (var ch in chars) a.colorIdx += Math.Abs(ch); - a.colorIdx = a.colorIdx % BACKGROUND_BRUSHES.Length; - } - - /// - /// 邮件变化时触发 - /// - /// - /// - private static void OnEmailChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - Avatar a = d as Avatar; - if (a == null) return; - - a.Cancel(e.OldValue as string); - a.Source = null; - a.InvalidateVisual(); - - var email = e.NewValue as string; - if (string.IsNullOrEmpty(email)) return; - - if (loaded.ContainsKey(email)) { - a.Source = loaded[email]; - return; - } - - if (requesting.ContainsKey(email)) { - requesting[email].Add(a); - return; - } - - byte[] hash = MD5.Create().ComputeHash(Encoding.Default.GetBytes(email.ToLower().Trim())); - string md5 = ""; - for (int i = 0; i < hash.Length; i++) md5 += hash[i].ToString("x2"); - md5 = md5.ToLower(); - - string filePath = Path.Combine(CACHE_PATH, md5); - if (File.Exists(filePath)) { - var img = new BitmapImage(new Uri(filePath)); - loaded.Add(email, img); - a.Source = img; - return; - } - - requesting.Add(email, new List()); - requesting[email].Add(a); - - Action job = () => { - if (!requesting.ContainsKey(email)) return; - - try { - var req = WebRequest.CreateHttp($"https://cravatar.cn/avatar/{md5}?d=404"); - req.Timeout = 2000; - req.Method = "GET"; - - var rsp = req.GetResponse() as HttpWebResponse; - if (rsp != null && rsp.StatusCode == HttpStatusCode.OK) { - using (var reader = rsp.GetResponseStream()) - using (var writer = File.OpenWrite(filePath)) { - reader.CopyTo(writer); - } - - a.Dispatcher.Invoke(() => { - var img = new BitmapImage(new Uri(filePath)); - loaded.Add(email, img); - - if (requesting.ContainsKey(email)) { - foreach (var one in requesting[email]) one.Source = img; - } - }); - } else { - if (!loaded.ContainsKey(email)) loaded.Add(email, null); - } - } catch { - if (!loaded.ContainsKey(email)) loaded.Add(email, null); - } - - requesting.Remove(email); - }; - - if (loader != null && !loader.IsCompleted) { - loader = loader.ContinueWith(t => { job(); }); - } else { - loader = Task.Run(job); - } - } - } -} diff --git a/src/Views/Controls/Badge.cs b/src/Views/Controls/Badge.cs deleted file mode 100644 index 94e75188..00000000 --- a/src/Views/Controls/Badge.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Controls { - - /// - /// 徽章 - /// - public class Badge : Border { - private TextBlock label = null; - - public static readonly DependencyProperty LabelProperty = DependencyProperty.Register( - "Label", - typeof(string), - typeof(Border), - new PropertyMetadata("", OnLabelChanged)); - - public string Label { - get { return (string)GetValue(LabelProperty); } - set { SetValue(LabelProperty, value); } - } - - public Badge() { - Width = double.NaN; - Height = 18; - CornerRadius = new CornerRadius(9); - VerticalAlignment = VerticalAlignment.Center; - Visibility = Visibility.Collapsed; - - SetResourceReference(BackgroundProperty, "Brush.Badge"); - - label = new TextBlock(); - label.FontSize = 10; - label.HorizontalAlignment = HorizontalAlignment.Center; - label.Margin = new Thickness(9, 0, 9, 0); - Child = label; - } - - private static void OnLabelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - Badge badge = d as Badge; - if (badge != null) { - var text = e.NewValue as string; - if (string.IsNullOrEmpty(text) || text == "0") { - badge.Visibility = Visibility.Collapsed; - } else { - badge.label.Text = text; - badge.Visibility = Visibility.Visible; - } - } - } - } -} diff --git a/src/Views/Controls/ChangeDisplaySwitcher.cs b/src/Views/Controls/ChangeDisplaySwitcher.cs deleted file mode 100644 index 6dfcad0c..00000000 --- a/src/Views/Controls/ChangeDisplaySwitcher.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Media; -using System.Windows.Shapes; - -namespace SourceGit.Views.Controls { - /// - /// 用于切换变更显示模式的按钮 - /// - public class ChangeDisplaySwitcher : Button { - - public static readonly DependencyProperty ModeProperty = DependencyProperty.Register( - "Mode", - typeof(Models.Change.DisplayMode), - typeof(ChangeDisplaySwitcher), - new PropertyMetadata(Models.Change.DisplayMode.Tree, OnModeChanged)); - - public Models.Change.DisplayMode Mode { - get { return (Models.Change.DisplayMode)GetValue(ModeProperty); } - set { SetValue(ModeProperty, value); } - } - - public static readonly RoutedEvent ModeChangedEvent = EventManager.RegisterRoutedEvent( - "ModeChanged", - RoutingStrategy.Bubble, - typeof(RoutedEventHandler), - typeof(ChangeDisplaySwitcher)); - - public event RoutedEventHandler ModeChanged { - add { AddHandler(ModeChangedEvent, value); } - remove { RemoveHandler(ModeChangedEvent, value); } - } - - private Path icon = null; - - public ChangeDisplaySwitcher() { - icon = new Path(); - icon.Data = FindResource("Icon.Tree") as Geometry; - icon.SetResourceReference(Path.FillProperty, "Brush.FG2"); - - Content = icon; - Style = FindResource("Style.Button") as Style; - BorderThickness = new Thickness(0); - ToolTip = App.Text("ChangeDisplayMode"); - - Click += OnClicked; - } - - private void OnClicked(object sender, RoutedEventArgs e) { - if (ContextMenu != null) { - ContextMenu.IsOpen = true; - e.Handled = true; - return; - } - - var menu = new ContextMenu(); - menu.Placement = PlacementMode.Bottom; - menu.PlacementTarget = this; - menu.StaysOpen = false; - menu.Focusable = true; - - FillMenu(menu, "ChangeDisplayMode.Tree", "Icon.Tree", Models.Change.DisplayMode.Tree); - FillMenu(menu, "ChangeDisplayMode.List", "Icon.List", Models.Change.DisplayMode.List); - FillMenu(menu, "ChangeDisplayMode.Grid", "Icon.Grid", Models.Change.DisplayMode.Grid); - - ContextMenu = menu; - ContextMenu.IsOpen = true; - e.Handled = true; - } - - private void FillMenu(ContextMenu menu, string header, string icon, Models.Change.DisplayMode useMode) { - var iconMode = new Path(); - iconMode.Width = 12; - iconMode.Height = 12; - iconMode.Data = FindResource(icon) as Geometry; - iconMode.SetResourceReference(Path.FillProperty, "Brush.FG2"); - - var item = new MenuItem(); - item.Icon = iconMode; - item.Header = App.Text(header); - item.Click += (o, e) => Mode = useMode; - - menu.Items.Add(item); - } - - private static void OnModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var elem = d as ChangeDisplaySwitcher; - if (elem != null) { - switch (elem.Mode) { - case Models.Change.DisplayMode.Tree: - elem.icon.Data = elem.FindResource("Icon.Tree") as Geometry; - break; - case Models.Change.DisplayMode.List: - elem.icon.Data = elem.FindResource("Icon.List") as Geometry; - break; - case Models.Change.DisplayMode.Grid: - elem.icon.Data = elem.FindResource("Icon.Grid") as Geometry; - break; - } - elem.RaiseEvent(new RoutedEventArgs(ModeChangedEvent)); - } - } - } -} diff --git a/src/Views/Controls/ChangeStatusIcon.cs b/src/Views/Controls/ChangeStatusIcon.cs deleted file mode 100644 index 8b306098..00000000 --- a/src/Views/Controls/ChangeStatusIcon.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Media; - -namespace SourceGit.Views.Controls { - /// - /// 变更状态图标 - /// - class ChangeStatusIcon : FrameworkElement { - - public static readonly DependencyProperty ChangeProperty = DependencyProperty.Register( - "Change", - typeof(Models.Change), - typeof(ChangeStatusIcon), - new PropertyMetadata(null, ForceDirty)); - - public Models.Change Change { - get { return (Models.Change)GetValue(ChangeProperty); } - set { SetValue(ChangeProperty, value); } - } - - public static readonly DependencyProperty IsLocalChangeProperty = DependencyProperty.Register( - "IsLocalChange", - typeof(bool), - typeof(ChangeStatusIcon), - new PropertyMetadata(false, ForceDirty)); - - public bool IsLocalChange { - get { return (bool)GetValue(IsLocalChangeProperty); } - set { SetValue(IsLocalChangeProperty, value); } - } - - private Brush background; - private FormattedText label; - - public ChangeStatusIcon() { - HorizontalAlignment = HorizontalAlignment.Center; - VerticalAlignment = VerticalAlignment.Center; - } - - protected override void OnRender(DrawingContext dc) { - if (background == null || label == null) return; - var corner = Math.Max(2, Width / 16); - dc.DrawRoundedRectangle(background, null, new Rect(0, 0, Width, Height), corner, corner); - dc.DrawText(label, new Point((Width - label.Width) * 0.5, 0)); - } - - private static void ForceDirty(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var icon = d as ChangeStatusIcon; - if (icon == null) return; - - if (icon.Change == null) { - icon.background = null; - icon.label = null; - return; - } - - string txt; - if (icon.IsLocalChange) { - if (icon.Change.IsConflit) { - icon.background = Brushes.OrangeRed; - txt = "!"; - } else { - icon.background = GetBackground(icon.Change.WorkTree); - txt = GetLabel(icon.Change.WorkTree); - } - } else { - icon.background = GetBackground(icon.Change.Index); - txt = GetLabel(icon.Change.Index); - } - - icon.label = new FormattedText( - txt, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - new Typeface(new FontFamily(Models.Preference.Instance.General.FontFamilyWindow), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), - icon.Width * 0.8, - new SolidColorBrush(Color.FromRgb(241, 241, 241)), - VisualTreeHelper.GetDpi(icon).PixelsPerDip); - - icon.InvalidateVisual(); - } - - private static Brush GetBackground(Models.Change.Status status) { - switch (status) { - case Models.Change.Status.Modified: return new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90); - case Models.Change.Status.Added: return new LinearGradientBrush(Color.FromRgb(47, 185, 47), Color.FromRgb(75, 189, 75), 90); - case Models.Change.Status.Deleted: return new LinearGradientBrush(Colors.Tomato, Color.FromRgb(252, 165, 150), 90); - case Models.Change.Status.Renamed: return new LinearGradientBrush(Colors.Orchid, Color.FromRgb(248, 161, 245), 90); - case Models.Change.Status.Copied: return new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90); - case Models.Change.Status.Unmerged: return new LinearGradientBrush(Color.FromRgb(238, 160, 14), Color.FromRgb(228, 172, 67), 90); - case Models.Change.Status.Untracked: return new LinearGradientBrush(Color.FromRgb(47, 185, 47), Color.FromRgb(75, 189, 75), 90); - default: return Brushes.Transparent; - } - } - - private static string GetLabel(Models.Change.Status status) { - switch (status) { - case Models.Change.Status.Modified: return "±"; - case Models.Change.Status.Added: return "+"; - case Models.Change.Status.Deleted: return "−"; - case Models.Change.Status.Renamed: return "➜"; - case Models.Change.Status.Copied: return "❏"; - case Models.Change.Status.Unmerged: return "U"; - case Models.Change.Status.Untracked: return "★"; - default: return "?"; - } - } - } -} diff --git a/src/Views/Controls/Chart.cs b/src/Views/Controls/Chart.cs deleted file mode 100644 index aed1fecb..00000000 --- a/src/Views/Controls/Chart.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Windows; -using System.Windows.Input; -using System.Windows.Media; - -namespace SourceGit.Views.Controls { - /// - /// 绘制提交频率柱状图 - /// - public class Chart : FrameworkElement { - public static readonly int LABEL_UNIT = 32; - public static readonly double MAX_SHAPE_WIDTH = 24; - - public static readonly DependencyProperty LineBrushProperty = DependencyProperty.Register( - "LineBrush", - typeof(Brush), - typeof(Chart), - new PropertyMetadata(Brushes.White)); - - public Brush LineBrush { - get { return (Brush)GetValue(LineBrushProperty); } - set { SetValue(LineBrushProperty, value); } - } - - public static readonly DependencyProperty ChartBrushProperty = DependencyProperty.Register( - "ChartBrush", - typeof(Brush), - typeof(Chart), - new PropertyMetadata(Brushes.White)); - - public Brush ChartBrush { - get { return (Brush)GetValue(ChartBrushProperty); } - set { SetValue(ChartBrushProperty, value); } - } - - private List samples = new List(); - private List hitboxes = new List(); - private int maxV = 0; - - /// - /// 设置绘制数据 - /// - /// 数据源 - public void SetData(List samples) { - this.samples = samples; - this.hitboxes.Clear(); - - maxV = 0; - foreach (var s in samples) { - if (maxV < s.Count) maxV = s.Count; - } - maxV = (int)Math.Ceiling(maxV / 10.0) * 10; - - InvalidateVisual(); - } - - protected override void OnMouseMove(MouseEventArgs e) { - base.OnMouseMove(e); - InvalidateVisual(); - } - - protected override void OnRender(DrawingContext dc) { - base.OnRender(dc); - - var font = new FontFamily("Consolas"); - var pen = new Pen(LineBrush, 1); - dc.DrawLine(pen, new Point(LABEL_UNIT, 0), new Point(LABEL_UNIT, ActualHeight - LABEL_UNIT)); - dc.DrawLine(pen, new Point(LABEL_UNIT, ActualHeight - LABEL_UNIT), new Point(ActualWidth, ActualHeight - LABEL_UNIT)); - - if (samples.Count == 0) return; - - var stepV = (ActualHeight - LABEL_UNIT) / 5; - var labelStepV = maxV / 5; - var gridPen = new Pen(LineBrush, 1) { DashStyle = DashStyles.Dash }; - for (int i = 1; i < 5; i++) { - var vLabel = new FormattedText( - $"{maxV - i * labelStepV}", - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - new Typeface(font, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), - 12.0, - LineBrush, - VisualTreeHelper.GetDpi(this).PixelsPerDip); - - var dashHeight = i * stepV; - var vy = Math.Max(0, dashHeight - vLabel.Height * 0.5); - dc.PushOpacity(.1); - dc.DrawLine(gridPen, new Point(LABEL_UNIT + 1, dashHeight), new Point(ActualWidth, dashHeight)); - dc.Pop(); - dc.DrawText(vLabel, new Point(0, vy)); - } - - var stepX = (ActualWidth - LABEL_UNIT) / samples.Count; - if (hitboxes.Count == 0) { - var shapeWidth = Math.Min(LABEL_UNIT, stepX - 4); - for (int i = 0; i < samples.Count; i++) { - var h = samples[i].Count * (ActualHeight - LABEL_UNIT) / maxV; - var x = LABEL_UNIT + 1 + stepX * i + (stepX - shapeWidth) * 0.5; - var y = ActualHeight - LABEL_UNIT - h; - hitboxes.Add(new Rect(x, y, shapeWidth, h)); - } - } - - var mouse = Mouse.GetPosition(this); - for (int i = 0; i < samples.Count; i++) { - var hLabel = new FormattedText( - samples[i].Name, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - new Typeface(font, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), - 10.0, - LineBrush, - VisualTreeHelper.GetDpi(this).PixelsPerDip); - var rect = hitboxes[i]; - var xLabel = rect.X - (hLabel.Width - rect.Width) * 0.5; - var yLabel = ActualHeight - LABEL_UNIT + 4; - - dc.DrawRectangle(ChartBrush, null, rect); - - if (stepX < LABEL_UNIT) { - dc.PushTransform(new TranslateTransform(xLabel, yLabel)); - dc.PushTransform(new RotateTransform(45, hLabel.Width * 0.5, hLabel.Height * 0.5)); - dc.DrawText(hLabel, new Point(0, 0)); - dc.Pop(); - dc.Pop(); - } else { - dc.DrawText(hLabel, new Point(xLabel, yLabel)); - } - } - - for (int i = 0; i < samples.Count; i++) { - var rect = hitboxes[i]; - if (rect.Contains(mouse)) { - var tooltip = new FormattedText( - $"{samples[i].Count}", - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - new Typeface(font, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), - 12.0, - FindResource("Brush.FG1") as Brush, - VisualTreeHelper.GetDpi(this).PixelsPerDip); - - var tx = rect.X - (tooltip.Width - rect.Width) * 0.5; - var ty = rect.Y - tooltip.Height - 4; - dc.DrawText(tooltip, new Point(tx, ty)); - break; - } - } - } - } -} diff --git a/src/Views/Controls/CommitGraph.cs b/src/Views/Controls/CommitGraph.cs deleted file mode 100644 index 6ee730f4..00000000 --- a/src/Views/Controls/CommitGraph.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Windows; -using System.Windows.Media; - -namespace SourceGit.Views.Controls { - - /// - /// 提交线路图 - /// - public class CommitGraph : FrameworkElement { - public static readonly Pen[] PENS = new Pen[] { - new Pen(Brushes.Orange, 2), - new Pen(Brushes.ForestGreen, 2), - new Pen(Brushes.Gold, 2), - new Pen(Brushes.Magenta, 2), - new Pen(Brushes.Red, 2), - new Pen(Brushes.Gray, 2), - new Pen(Brushes.Turquoise, 2), - new Pen(Brushes.Olive, 2), - }; - - public static readonly double UNIT_WIDTH = 12; - public static readonly double HALF_WIDTH = 6; - public static readonly double UNIT_HEIGHT = 24; - public static readonly double HALF_HEIGHT = 12; - - public class Path { - public List Points = new List(); - public int Color = 0; - } - - public class PathHelper { - public string Next; - public bool IsMerged; - public double LastX; - public double LastY; - public double EndY; - public Path Path; - - public PathHelper(string next, bool isMerged, int color, Point start) { - Next = next; - IsMerged = isMerged; - LastX = start.X; - LastY = start.Y; - EndY = LastY; - - Path = new Path(); - Path.Color = color % PENS.Length; - Path.Points.Add(start); - } - - public PathHelper(string next, bool isMerged, int color, Point start, Point to) { - Next = next; - IsMerged = isMerged; - LastX = to.X; - LastY = to.Y; - EndY = LastY; - - Path = new Path(); - Path.Color = color % PENS.Length; - Path.Points.Add(start); - Path.Points.Add(to); - } - - public void Add(double x, double y, bool isEnd = false) { - if (x > LastX) { - Add(new Point(LastX, LastY)); - Add(new Point(x, y - HALF_HEIGHT)); - if (isEnd) Add(new Point(x, y)); - } else if (x < LastX) { - if (y > LastY + HALF_HEIGHT) Add(new Point(LastX, LastY + HALF_HEIGHT)); - Add(new Point(x, y)); - } else if (isEnd) { - Add(new Point(x, y)); - } - - LastX = x; - LastY = y; - } - - private void Add(Point p) { - if (EndY < p.Y) { - Path.Points.Add(p); - EndY = p.Y; - } - } - } - - public class Link { - public Point Start; - public Point Control; - public Point End; - public int Color; - } - - public class Dot { - public Point Center; - public int Color; - } - - public class Data { - public List Paths = new List(); - public List Links = new List(); - public List Dots = new List(); - } - - private Data data = null; - private double startY = 0; - - public CommitGraph() { - Models.Theme.AddListener(this, InvalidateVisual); - IsHitTestVisible = false; - ClipToBounds = true; - } - - public void SetOffset(double offset) { - startY = offset; - InvalidateVisual(); - } - - public void SetData(List commits, bool isSearchResult = false) { - if (isSearchResult) { - foreach (var c in commits) c.Margin = new Thickness(0); - data = null; - return; - } - - var temp = new Data(); - var unsolved = new List(); - var mapUnsolved = new Dictionary(); - var ended = new List(); - var offsetY = -HALF_HEIGHT; - var colorIdx = 0; - - foreach (var commit in commits) { - var major = null as PathHelper; - var isMerged = commit.IsMerged; - var oldCount = unsolved.Count; - - // 更新Y坐标 - offsetY += UNIT_HEIGHT; - - // 找到第一个依赖于本提交的树,将其他依赖于本提交的树标记为终止,并对已存在的线路调整(防止线重合) - double offsetX = -HALF_WIDTH; - foreach (var l in unsolved) { - if (l.Next == commit.SHA) { - if (major == null) { - offsetX += UNIT_WIDTH; - major = l; - - if (commit.Parents.Count > 0) { - major.Next = commit.Parents[0]; - if (!mapUnsolved.ContainsKey(major.Next)) mapUnsolved.Add(major.Next, major); - } else { - major.Next = "ENDED"; - ended.Add(l); - } - - major.Add(offsetX, offsetY); - } else { - ended.Add(l); - } - - isMerged = isMerged || l.IsMerged; - } else { - if (!mapUnsolved.ContainsKey(l.Next)) mapUnsolved.Add(l.Next, l); - offsetX += UNIT_WIDTH; - l.Add(offsetX, offsetY); - } - } - - // 处理本提交为非当前分支HEAD的情况(创建新依赖线路) - if (major == null && commit.Parents.Count > 0) { - offsetX += UNIT_WIDTH; - major = new PathHelper(commit.Parents[0], isMerged, colorIdx, new Point(offsetX, offsetY)); - unsolved.Add(major); - temp.Paths.Add(major.Path); - colorIdx++; - } - - // 确定本提交的点的位置 - Point position = new Point(offsetX, offsetY); - if (major != null) { - major.IsMerged = isMerged; - position.X = major.LastX; - position.Y = offsetY; - temp.Dots.Add(new Dot() { Center = position, Color = major.Path.Color }); - } else { - temp.Dots.Add(new Dot() { Center = position, Color = 0 }); - } - - // 处理本提交的其他依赖 - for (int j = 1; j < commit.Parents.Count; j++) { - var parent = commit.Parents[j]; - if (mapUnsolved.ContainsKey(parent)) { - var l = mapUnsolved[parent]; - var link = new Link(); - - link.Start = position; - link.End = new Point(l.LastX, offsetY + HALF_HEIGHT); - link.Control = new Point(link.End.X, link.Start.Y); - link.Color = l.Path.Color; - temp.Links.Add(link); - } else { - offsetX += UNIT_WIDTH; - - // 防止有下一个提交有ended线时,新的分支线与旧线重合 - var l = new PathHelper(commit.Parents[j], isMerged, colorIdx, position, new Point(offsetX, position.Y + HALF_HEIGHT)); - unsolved.Add(l); - temp.Paths.Add(l.Path); - colorIdx++; - } - } - - // 处理已终止的线 - foreach (var l in ended) { - l.Add(position.X, position.Y, true); - unsolved.Remove(l); - } - - // 加入本次提交 - commit.IsMerged = isMerged; - commit.Margin = new Thickness(Math.Max(offsetX + HALF_WIDTH, oldCount * UNIT_WIDTH), 0, 0, 0); - - // 清理 - ended.Clear(); - mapUnsolved.Clear(); - } - - // 处理尚未终结的线 - for (int i = 0; i < unsolved.Count; i++) { - var path = unsolved[i]; - var endY = (commits.Count - 0.5) * UNIT_HEIGHT; - - if (path.Path.Points.Count == 1 && path.Path.Points[0].Y == endY) continue; - path.Add((i + 0.5) * UNIT_WIDTH, endY, true); - } - unsolved.Clear(); - - Dispatcher.Invoke(() => { - data = temp; - InvalidateVisual(); - }); - } - - protected override void OnRender(DrawingContext dc) { - if (data == null) return; - - dc.PushTransform(new TranslateTransform(0, -startY)); - - // 计算边界 - var top = startY; - var bottom = startY + ActualHeight; - - // 绘制线 - DrawCurves(dc, top, bottom); - - // 绘制点 - var dotFill = FindResource("Brush.Contents") as Brush; - foreach (var dot in data.Dots) { - if (dot.Center.Y < top) continue; - if (dot.Center.Y > bottom) break; - - dc.DrawEllipse(dotFill, PENS[dot.Color], dot.Center, 3, 3); - } - } - - private void DrawCurves(DrawingContext dc, double top, double bottom) { - foreach (var line in data.Paths) { - var last = line.Points[0]; - var size = line.Points.Count; - - if (line.Points[size - 1].Y < top) continue; - if (last.Y > bottom) continue; - - var geo = new StreamGeometry(); - var pen = PENS[line.Color]; - using (var ctx = geo.Open()) { - var started = false; - var ended = false; - for (int i = 1; i < size; i++) { - var cur = line.Points[i]; - if (cur.Y < top) { - last = cur; - continue; - } - - if (!started) { - ctx.BeginFigure(last, false, false); - started = true; - } - - if (cur.Y > bottom) { - cur.Y = bottom; - ended = true; - } - - if (cur.X > last.X) { - ctx.QuadraticBezierTo(new Point(cur.X, last.Y), cur, true, false); - } else if (cur.X < last.X) { - if (i < size - 1) { - var midY = (last.Y + cur.Y) / 2; - var midX = (last.X + cur.X) / 2; - ctx.PolyQuadraticBezierTo(new Point[] { - new Point(last.X, midY), - new Point(midX, midY), - new Point(cur.X, midY), - cur}, true, false); - } else { - ctx.QuadraticBezierTo(new Point(last.X, cur.Y), cur, true, false); - } - } else { - ctx.LineTo(cur, true, false); - } - - if (ended) break; - last = cur; - } - } - - geo.Freeze(); - dc.DrawGeometry(null, pen, geo); - } - - foreach (var link in data.Links) { - if (link.End.Y < top) continue; - if (link.Start.Y > bottom) break; - - var geo = new StreamGeometry(); - using (var ctx = geo.Open()) { - ctx.BeginFigure(link.Start, false, false); - ctx.QuadraticBezierTo(link.Control, link.End, true, false); - } - - geo.Freeze(); - dc.DrawGeometry(null, PENS[link.Color], geo); - } - } - } -} diff --git a/src/Views/Controls/HighlightableTextBlock.cs b/src/Views/Controls/HighlightableTextBlock.cs deleted file mode 100644 index 849d3978..00000000 --- a/src/Views/Controls/HighlightableTextBlock.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Documents; -using System.Windows.Media; - -namespace SourceGit.Views.Controls { - /// - /// 支持部分高亮的文本组件 - /// - public class HighlightableTextBlock : TextBlock { - private static readonly Brush BG_EMPTY = new SolidColorBrush(Color.FromArgb(60, 0, 0, 0)); - private static readonly Brush BG_ADDED = new SolidColorBrush(Color.FromArgb(60, 0, 255, 0)); - private static readonly Brush BG_DELETED = new SolidColorBrush(Color.FromArgb(60, 255, 0, 0)); - private static readonly Brush HL_ADDED = new SolidColorBrush(Color.FromArgb(128, 0, 255, 0)); - private static readonly Brush HL_DELETED = new SolidColorBrush(Color.FromArgb(128, 255, 0, 0)); - - public static readonly DependencyProperty DataProperty = DependencyProperty.Register( - "Data", - typeof(Models.TextChanges.Line), - typeof(HighlightableTextBlock), - new PropertyMetadata(null, OnContentChanged)); - - public Models.TextChanges.Line Data { - get { return (Models.TextChanges.Line)GetValue(DataProperty); } - set { SetValue(DataProperty, value); } - } - - private static void OnContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var txt = d as HighlightableTextBlock; - if (txt == null) return; - - txt.Inlines.Clear(); - txt.Text = null; - txt.Background = Brushes.Transparent; - txt.FontStyle = FontStyles.Normal; - - if (txt.Data == null) return; - - Brush highlightBrush = Brushes.Transparent; - switch (txt.Data.Mode) { - case Models.TextChanges.LineMode.None: - txt.Background = BG_EMPTY; - break; - case Models.TextChanges.LineMode.Indicator: - txt.FontStyle = FontStyles.Italic; - break; - case Models.TextChanges.LineMode.Added: - txt.Background = BG_ADDED; - highlightBrush = HL_ADDED; - break; - case Models.TextChanges.LineMode.Deleted: - txt.Background = BG_DELETED; - highlightBrush = HL_DELETED; - break; - default: - break; - } - - txt.SetResourceReference(ForegroundProperty, txt.Data.Mode == Models.TextChanges.LineMode.Indicator ? "Brush.FG2" : "Brush.FG1"); - - if (txt.Data.Highlights == null || txt.Data.Highlights.Count == 0) { - txt.Text = txt.Data.Content; - return; - } - - var started = 0; - foreach (var highlight in txt.Data.Highlights) { - if (started < highlight.Start) { - txt.Inlines.Add(new Run(txt.Data.Content.Substring(started, highlight.Start - started))); - } - - txt.Inlines.Add(new Run() { - Background = highlightBrush, - Text = txt.Data.Content.Substring(highlight.Start, highlight.Count), - }); - - started = highlight.Start + highlight.Count; - } - - if (started < txt.Data.Content.Length) { - txt.Inlines.Add(new Run(txt.Data.Content.Substring(started))); - } - } - } -} diff --git a/src/Views/Controls/IconButton.cs b/src/Views/Controls/IconButton.cs deleted file mode 100644 index af9efc61..00000000 --- a/src/Views/Controls/IconButton.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; - -namespace SourceGit.Views.Controls { - - /// - /// 简化只有一个Icon的Button - /// - public class IconButton : Button { - - public static readonly DependencyProperty IconProperty = DependencyProperty.Register( - "Icon", - typeof(Geometry), - typeof(IconButton), - new PropertyMetadata(null)); - - public Geometry Icon { - get { return (Geometry)GetValue(IconProperty); } - set { SetValue(IconProperty, value); } - } - - public static readonly DependencyProperty IconSizeProperty = DependencyProperty.Register( - "IconSize", - typeof(double), - typeof(IconButton), - new PropertyMetadata(14.0)); - - public double IconSize { - get { return (double)GetValue(IconSizeProperty); } - set { SetValue(IconSizeProperty, value); } - } - - public static readonly DependencyProperty HoverBackgroundProperty = DependencyProperty.Register( - "HoverBackground", - typeof(Brush), - typeof(IconButton), - new PropertyMetadata(Brushes.Transparent)); - - public Brush HoverBackground { - get { return (Brush)GetValue(HoverBackgroundProperty); } - set { SetValue(HoverBackgroundProperty, value); } - } - } -} diff --git a/src/Views/Controls/Loading.cs b/src/Views/Controls/Loading.cs deleted file mode 100644 index 22c5d40b..00000000 --- a/src/Views/Controls/Loading.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Animation; -using System.Windows.Shapes; - -namespace SourceGit.Views.Controls { - - /// - /// 加载中图标 - /// - public class Loading : UserControl { - private Path icon = null; - - public static readonly DependencyProperty IsAnimatingProperty = DependencyProperty.Register( - "IsAnimating", - typeof(bool), - typeof(Loading), - new PropertyMetadata(false, OnIsAnimatingChanged)); - - public bool IsAnimating { - get { return (bool)GetValue(IsAnimatingProperty); } - set { SetValue(IsAnimatingProperty, value); } - } - - public Loading() { - icon = new Path(); - icon.Data = FindResource("Icon.Loading") as Geometry; - icon.RenderTransformOrigin = new Point(.5, .5); - icon.RenderTransform = new RotateTransform(0); - icon.Width = double.NaN; - icon.Height = double.NaN; - - AddChild(icon); - } - - private static void OnIsAnimatingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var loading = d as Loading; - if (loading == null) return; - - if (loading.IsAnimating) { - DoubleAnimation anim = new DoubleAnimation(0, 360, TimeSpan.FromSeconds(1)); - anim.RepeatBehavior = RepeatBehavior.Forever; - loading.icon.RenderTransform.BeginAnimation(RotateTransform.AngleProperty, anim); - } else { - loading.icon.RenderTransform.BeginAnimation(RotateTransform.AngleProperty, null); - } - } - } -} diff --git a/src/Views/Controls/PageContainer.cs b/src/Views/Controls/PageContainer.cs deleted file mode 100644 index 3ffdb0f7..00000000 --- a/src/Views/Controls/PageContainer.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Collections.Generic; -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Controls { - - /// - /// 用于方便切换子页面的组件 - /// - public class PageContainer : Grid { - private Dictionary pages; - private string front; - - public PageContainer() { - pages = new Dictionary(); - front = null; - - Loaded += OnLoaded; - } - - public void Add(string id, UIElement view) { - view.Visibility = Visibility.Collapsed; - pages.Add(id, view); - Children.Add(view); - } - - public UIElement Get(string id) { - if (pages.ContainsKey(id)) return pages[id]; - return null; - } - - public void Goto(string id) { - if (!pages.ContainsKey(id)) return; - - if (!string.IsNullOrEmpty(front)) { - if (front == id) return; - pages[front].Visibility = Visibility.Collapsed; - } - - front = id; - pages[front].Visibility = Visibility.Visible; - } - - public void Remove(string id) { - if (!pages.ContainsKey(id)) return; - if (front == id) front = null; - Children.Remove(pages[id]); - pages.Remove(id); - } - - private void OnLoaded(object sender, RoutedEventArgs e) { - foreach (var child in Children) { - var elem = child as UIElement; - var id = elem.Uid; - if (string.IsNullOrEmpty(id)) continue; - - pages.Add(id, elem); - front = id; - } - - if (!string.IsNullOrEmpty(front)) { - pages[front].Visibility = Visibility.Visible; - } - } - } -} diff --git a/src/Views/Controls/PopupWidget.cs b/src/Views/Controls/PopupWidget.cs deleted file mode 100644 index 9491c638..00000000 --- a/src/Views/Controls/PopupWidget.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Controls { - - /// - /// 可显示弹出面板的容器接口 - /// - public interface IPopupContainer { - void Show(PopupWidget widget); - void ShowAndStart(PopupWidget widget); - void UpdateProgress(string message); - void ClosePopups(bool unlock); - } - - /// - /// 可弹出面板 - /// - public class PopupWidget : UserControl { - private static Dictionary containers = new Dictionary(); - private static string currentContainer = null; - private IPopupContainer mine = null; - - /// - /// 注册一个弹出容器 - /// - /// 页面ID - /// 容器实例 - public static void RegisterContainer(string id, IPopupContainer container) { - if (containers.ContainsKey(id)) containers[id] = container; - else containers.Add(id, container); - } - - /// - /// 删除一个弹出容器 - /// - /// 容器ID - public static void UnregisterContainer(string id) { - if (containers.ContainsKey(id)) containers.Remove(id); - } - - /// - /// 设置当前的弹出容器 - /// - /// 容器ID - public static void SetCurrentContainer(string id) { - if (containers.ContainsKey(id)) currentContainer = id; - } - - /// - /// 构造函数 - /// - public PopupWidget() { - Height = double.NaN; - Padding = new Thickness(1); - } - - /// - /// 显示 - /// - public void Show() { - if (string.IsNullOrEmpty(currentContainer) || !containers.ContainsKey(currentContainer)) return; - mine = containers[currentContainer]; - mine.Show(this); - } - - /// - /// 显示并直接点击开始 - /// - public void ShowAndStart() { - if (string.IsNullOrEmpty(currentContainer) || !containers.ContainsKey(currentContainer)) return; - mine = containers[currentContainer]; - mine.ShowAndStart(this); - } - - /// - /// 窗体标题 - /// - /// 返回具体的标题 - public virtual string GetTitle() { - return "TITLE"; - } - - /// - /// 点击确定时的回调,由程序自己 - /// - /// 返回一个任务,任务预期返回类型为bool,表示是否关闭Popup - public virtual Task Start() { - return null; - } - - /// - /// 更新进度显示 - /// - /// - protected void UpdateProgress(string message) { - mine?.UpdateProgress(message); - } - } -} diff --git a/src/Views/Controls/TextEdit.cs b/src/Views/Controls/TextEdit.cs deleted file mode 100644 index 07266bf3..00000000 --- a/src/Views/Controls/TextEdit.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - -namespace SourceGit.Views.Controls { - - /// - /// 扩展默认TextBox - /// - public class TextEdit : TextBox { - public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.Register( - "Placeholder", - typeof(string), - typeof(TextEdit), - new PropertyMetadata("")); - - public string Placeholder { - get { return (string)GetValue(PlaceholderProperty); } - set { SetValue(PlaceholderProperty, value); } - } - - public static readonly DependencyProperty PlaceholderVisibilityProperty = DependencyProperty.Register( - "PlaceholderVisibility", - typeof(Visibility), - typeof(TextEdit), - new PropertyMetadata(Visibility.Visible)); - - public Visibility PlaceholderVisibility { - get { return (Visibility)GetValue(PlaceholderVisibilityProperty); } - set { SetValue(PlaceholderVisibilityProperty, value); } - } - - public TextEdit() { - TextChanged += OnTextChanged; - SelectionChanged += OnSelectionChanged; - } - - private void OnTextChanged(object sender, TextChangedEventArgs e) { - PlaceholderVisibility = string.IsNullOrEmpty(Text) ? Visibility.Visible : Visibility.Collapsed; - } - - private void OnSelectionChanged(object sender, RoutedEventArgs e) { - if (!IsFocused) return; - - if (Mouse.LeftButton == MouseButtonState.Pressed && SelectionLength > 0) { - var p = Mouse.GetPosition(this); - if (p.X <= 8) { - LineLeft(); - } else if (p.X >= ActualWidth - 8) { - LineRight(); - } - - if (p.Y <= 8) { - LineUp(); - } else if (p.Y >= ActualHeight - 8) { - LineDown(); - } - } else { - var rect = GetRectFromCharacterIndex(CaretIndex); - if (rect.Left <= 0) { - ScrollToHorizontalOffset(HorizontalOffset + rect.Left); - } else if (rect.Right >= ActualWidth) { - ScrollToHorizontalOffset(HorizontalOffset + rect.Right); - } - - if (rect.Top <= 0) { - ScrollToVerticalOffset(VerticalOffset + rect.Top); - } else if (rect.Bottom >= ActualHeight) { - ScrollToVerticalOffset(VerticalOffset + rect.Bottom); - } - } - } - } -} diff --git a/src/Views/Controls/Tree.cs b/src/Views/Controls/Tree.cs deleted file mode 100644 index 95e6ecc5..00000000 --- a/src/Views/Controls/Tree.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Media; - -namespace SourceGit.Views.Controls { - - /// - /// 树 - /// - public class Tree : TreeView { - public static readonly DependencyProperty MultiSelectionProperty = DependencyProperty.Register( - "MultiSelection", - typeof(bool), - typeof(Tree), - new PropertyMetadata(false)); - - public bool MultiSelection { - get { return (bool)GetValue(MultiSelectionProperty); } - set { SetValue(MultiSelectionProperty, value); } - } - - public static readonly DependencyProperty IndentProperty = DependencyProperty.Register( - "Indent", - typeof(double), - typeof(TreeItem), - new PropertyMetadata(16.0)); - - public double Indent { - get { return (double)GetValue(IndentProperty); } - set { SetValue(IndentProperty, value); } - } - - public static readonly RoutedEvent SelectionChangedEvent = EventManager.RegisterRoutedEvent( - "SelectionChanged", - RoutingStrategy.Bubble, - typeof(RoutedEventHandler), - typeof(Tree)); - - public event RoutedEventHandler SelectionChanged { - add { AddHandler(SelectionChangedEvent, value); } - remove { RemoveHandler(SelectionChangedEvent, value); } - } - - public List Selected { - get; - set; - } = new List(); - - public TreeItem FindItem(DependencyObject elem) { - if (elem == null) return null; - if (elem is TreeItem) return elem as TreeItem; - if (elem is Tree) return null; - return FindItem(VisualTreeHelper.GetParent(elem)); - } - - public void SelectAll() { - SelectAllChildren(this); - RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); - } - - public void UnselectAll() { - if (Selected.Count == 0) return; - - UnselectAllChildren(this); - Selected.Clear(); - RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); - } - - public void Select(object dataContext) { - if (Selected.Count == 1 && Selected[0] == dataContext) return; - - var item = FindItemByDataContext(this, dataContext); - if (item != null) { - AddSelected(item, true); - item.BringIntoView(); - } - } - - protected override DependencyObject GetContainerForItemOverride() { - return new TreeItem(0, Indent); - } - - protected override bool IsItemItsOwnContainerOverride(object item) { - return item is TreeItem; - } - - protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) { - base.OnItemsSourceChanged(oldValue, newValue); - - if (Selected.Count > 0) { - Selected.Clear(); - RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); - } - } - - protected override void OnPreviewKeyDown(KeyEventArgs e) { - base.OnPreviewKeyDown(e); - - if (MultiSelection && e.Key == Key.A && Keyboard.Modifiers == ModifierKeys.Control) { - SelectAll(); - e.Handled = true; - } - } - - protected override void OnPreviewMouseDown(MouseButtonEventArgs e) { - base.OnPreviewMouseDown(e); - - var hit = VisualTreeHelper.HitTest(this, e.GetPosition(this)); - if (hit == null || hit.VisualHit == null) return; - - var item = FindItem(hit.VisualHit); - if (item == null) return; - - if (!MultiSelection) { - if (item.IsChecked) return; - AddSelected(item, true); - return; - } - - if (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) { - if (item.IsChecked) { - RemoveSelected(item); - } else { - AddSelected(item, false); - } - } else if ((Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift)) && Selected.Count > 0) { - var last = FindItemByDataContext(this, Selected.Last()); - if (last == item) return; - - var lastPos = last.PointToScreen(new Point(0, 0)); - var curPos = item.PointToScreen(new Point(0, 0)); - if (lastPos.Y > curPos.Y) { - SelectRange(this, item, last); - } else { - SelectRange(this, last, item); - } - - AddSelected(item, false); - } else if (e.RightButton == MouseButtonState.Pressed) { - if (item.IsChecked) return; - AddSelected(item, true); - } else { - if (item.IsChecked && Selected.Count == 1) return; - AddSelected(item, true); - } - } - - private TreeItem FindItemByDataContext(ItemsControl control, object data) { - if (control == null) return null; - - for (int i = 0; i < control.Items.Count; i++) { - var child = control.ItemContainerGenerator.ContainerFromIndex(i) as TreeItem; - if (control.Items[i] == data) return child; - - var found = FindItemByDataContext(child, data); - if (found != null) return found; - } - - return null; - } - - private void AddSelected(TreeItem item, bool removeOthers) { - if (!item.IsVisible) return; - - if (removeOthers && Selected.Count > 0) { - UnselectAllChildren(this); - Selected.Clear(); - } - - item.IsChecked = true; - Selected.Add(item.DataContext); - RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); - } - - private void RemoveSelected(TreeItem item) { - item.IsChecked = false; - Selected.Remove(item.DataContext); - RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); - } - - private void SelectAllChildren(ItemsControl control) { - for (int i = 0; i < control.Items.Count; i++) { - var child = control.ItemContainerGenerator.ContainerFromIndex(i) as TreeItem; - if (child == null) continue; - - child.IsChecked = true; - Selected.Add(control.Items[i]); - SelectAllChildren(child); - } - } - - private void UnselectAllChildren(ItemsControl control) { - for (int i = 0; i < control.Items.Count; i++) { - var child = control.ItemContainerGenerator.ContainerFromIndex(i) as TreeItem; - if (child == null) continue; - if (child.IsChecked) child.IsChecked = false; - UnselectAllChildren(child); - } - } - - private int SelectRange(ItemsControl control, TreeItem from, TreeItem to, int matches = 0) { - for (int i = 0; i < control.Items.Count; i++) { - var child = control.ItemContainerGenerator.ContainerFromIndex(i) as TreeItem; - if (child == null) continue; - - if (matches == 1) { - if (child == to) return 2; - Selected.Add(control.Items[i]); - child.IsChecked = true; - if (TryEndRangeSelection(child, to)) return 2; - } else if (child == from) { - matches = 1; - if (TryEndRangeSelection(child, to)) return 2; - } else { - matches = SelectRange(child, from, to, matches); - if (matches == 2) return 2; - } - } - - return matches; - } - - private bool TryEndRangeSelection(ItemsControl control, TreeItem end) { - for (int i = 0; i < control.Items.Count; i++) { - var child = control.ItemContainerGenerator.ContainerFromIndex(i) as TreeItem; - if (child == null) continue; - - if (child == end) { - return true; - } else { - Selected.Add(control.Items[i]); - child.IsChecked = true; - - var ended = TryEndRangeSelection(child, end); - if (ended) return true; - } - } - - return false; - } - } -} diff --git a/src/Views/Controls/TreeItem.cs b/src/Views/Controls/TreeItem.cs deleted file mode 100644 index 53c6a54a..00000000 --- a/src/Views/Controls/TreeItem.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Controls { - - /// - /// 树节点 - /// - public class TreeItem : TreeViewItem { - - public static readonly DependencyProperty IsCheckedProperty = DependencyProperty.Register( - "IsChecked", - typeof(bool), - typeof(TreeItem), - new PropertyMetadata(false)); - - public bool IsChecked { - get { return (bool)GetValue(IsCheckedProperty); } - set { SetValue(IsCheckedProperty, value); } - } - - private int depth = 0; - private double indent = 16; - - public TreeItem(int depth, double indent) { - this.depth = depth; - this.indent = indent; - - Padding = new Thickness(indent * depth, 0, 0, 0); - } - - protected override DependencyObject GetContainerForItemOverride() { - return new TreeItem(depth + 1, indent); - } - - protected override bool IsItemItsOwnContainerOverride(object item) { - return item is TreeItem; - } - } -} diff --git a/src/Views/Controls/Window.cs b/src/Views/Controls/Window.cs deleted file mode 100644 index c379f13c..00000000 --- a/src/Views/Controls/Window.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Windows; -using System.Runtime.InteropServices; -using System.Windows.Interop; -using System.Windows.Media; - -namespace SourceGit.Views.Controls { - /// - /// 项目使用的窗体基类 - /// - public class Window : System.Windows.Window { - - [StructLayout(LayoutKind.Sequential)] - private struct OSVERSIONINFOEX { - public int Size; - public int Major; - public int Minor; - public int Build; - public int Platform; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] - public string CSDVersion; - public ushort ServicePackMajor; - public ushort ServicePackMinor; - public short SuiteMask; - public byte ProductType; - public byte Reserved; - } - - [DllImport("ntdll.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern int RtlGetVersion(ref OSVERSIONINFOEX version); - - [DllImport("dwmapi.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern long DwmSetWindowAttribute(IntPtr hwnd, - uint attribute, - ref uint pvAttribute, - uint cbAttribute); - - public static readonly DependencyProperty IsMaximizedProperty = DependencyProperty.Register( - "IsMaximized", - typeof(bool), - typeof(Window), - new PropertyMetadata(false, OnIsMaximizedChanged)); - - public bool IsMaximized { - get { return (bool)GetValue(IsMaximizedProperty); } - set { SetValue(IsMaximizedProperty, value); } - } - - public Window() { - Style = FindResource("Style.Window") as Style; - Loaded += OnWindowLoaded; - } - - private void OnWindowLoaded(object sender, RoutedEventArgs e) { - OnStateChanged(null); - - // Windows 11 需要特殊处理一下边框,使得其与Window 10下表现一致 - OSVERSIONINFOEX version = new OSVERSIONINFOEX() { Size = Marshal.SizeOf(typeof(OSVERSIONINFOEX)) }; - if (RtlGetVersion(ref version) == 0 && version.Major >= 10 && version.Build >= 22000) { - Models.Theme.Changed += UpdateBorderColor; - Unloaded += (_, __) => Models.Theme.Changed -= UpdateBorderColor; - - UpdateBorderColor(); - } - } - - private void UpdateBorderColor() { - IntPtr hWnd = new WindowInteropHelper(GetWindow(this)).EnsureHandle(); - Color color = (BorderBrush as SolidColorBrush).Color; - uint preference = ((uint)color.B << 16) | ((uint)color.G << 8) | (uint)color.R; - DwmSetWindowAttribute(hWnd, 34, ref preference, sizeof(uint)); - } - - protected override void OnStateChanged(EventArgs e) { - if (WindowState == WindowState.Maximized) { - if (!IsMaximized) IsMaximized = true; - BorderThickness = new Thickness(0); - Padding = new Thickness((SystemParameters.MaximizedPrimaryScreenWidth - SystemParameters.WorkArea.Width) / 2); - } else { - if (IsMaximized) IsMaximized = false; - BorderThickness = new Thickness(1); - Padding = new Thickness(0); - } - } - - private static void OnIsMaximizedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - Window w = d as Window; - if (w != null) { - if (w.IsMaximized) { - SystemCommands.MaximizeWindow(w); - } else if (w.WindowState != WindowState.Minimized) { - SystemCommands.RestoreWindow(w); - } - } - } - } -} diff --git a/src/Views/ConventionalCommitMessageBuilder.axaml b/src/Views/ConventionalCommitMessageBuilder.axaml new file mode 100644 index 00000000..a64037f8 --- /dev/null +++ b/src/Views/ConventionalCommitMessageBuilder.axaml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/DiffView.axaml.cs b/src/Views/DiffView.axaml.cs new file mode 100644 index 00000000..54f9617a --- /dev/null +++ b/src/Views/DiffView.axaml.cs @@ -0,0 +1,64 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class DiffView : UserControl + { + public DiffView() + { + InitializeComponent(); + } + + private void OnGotoFirstChange(object _, RoutedEventArgs e) + { + this.FindDescendantOfType()?.GotoFirstChange(); + e.Handled = true; + } + + private void OnGotoPrevChange(object _, RoutedEventArgs e) + { + this.FindDescendantOfType()?.GotoPrevChange(); + e.Handled = true; + } + + private void OnGotoNextChange(object _, RoutedEventArgs e) + { + this.FindDescendantOfType()?.GotoNextChange(); + e.Handled = true; + } + + private void OnGotoLastChange(object _, RoutedEventArgs e) + { + this.FindDescendantOfType()?.GotoLastChange(); + e.Handled = true; + } + + private void OnBlockNavigationChanged(object sender, RoutedEventArgs e) + { + if (sender is TextDiffView textDiff) + BlockNavigationIndicator.Text = textDiff.BlockNavigation?.Indicator ?? string.Empty; + } + + private void OnUseFullTextDiffClicked(object sender, RoutedEventArgs e) + { + var textDiffView = this.FindDescendantOfType(); + if (textDiffView == null) + return; + + var presenter = textDiffView.FindDescendantOfType(); + if (presenter == null) + return; + + if (presenter.DataContext is Models.TextDiff combined) + combined.ScrollOffset = Vector.Zero; + else if (presenter.DataContext is ViewModels.TwoSideTextDiff twoSides) + twoSides.File = string.Empty; // Just to reset `SyncScrollOffset` without affect UI refresh. + + (DataContext as ViewModels.DiffContext)?.ToggleFullTextDiff(); + e.Handled = true; + } + } +} diff --git a/src/Views/Discard.axaml b/src/Views/Discard.axaml new file mode 100644 index 00000000..1699b051 --- /dev/null +++ b/src/Views/Discard.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Discard.axaml.cs b/src/Views/Discard.axaml.cs new file mode 100644 index 00000000..84f1b141 --- /dev/null +++ b/src/Views/Discard.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Discard : UserControl + { + public Discard() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/DropStash.axaml b/src/Views/DropStash.axaml new file mode 100644 index 00000000..32685a2c --- /dev/null +++ b/src/Views/DropStash.axaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/src/Views/DropStash.axaml.cs b/src/Views/DropStash.axaml.cs new file mode 100644 index 00000000..36f532ba --- /dev/null +++ b/src/Views/DropStash.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class DropStash : UserControl + { + public DropStash() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/EditRemote.axaml b/src/Views/EditRemote.axaml new file mode 100644 index 00000000..7d64a53a --- /dev/null +++ b/src/Views/EditRemote.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/EditRemote.axaml.cs b/src/Views/EditRemote.axaml.cs new file mode 100644 index 00000000..7d88704e --- /dev/null +++ b/src/Views/EditRemote.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class EditRemote : UserControl + { + public EditRemote() + { + InitializeComponent(); + } + + private async void SelectSSHKey(object _, RoutedEventArgs e) + { + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] }; + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + TxtSshKey.Text = selected[0].Path.LocalPath; + + e.Handled = true; + } + } +} diff --git a/src/Views/EditRepositoryNode.axaml b/src/Views/EditRepositoryNode.axaml new file mode 100644 index 00000000..615e3f11 --- /dev/null +++ b/src/Views/EditRepositoryNode.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/EditRepositoryNode.axaml.cs b/src/Views/EditRepositoryNode.axaml.cs new file mode 100644 index 00000000..967eb0ae --- /dev/null +++ b/src/Views/EditRepositoryNode.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class EditRepositoryNode : UserControl + { + public EditRepositoryNode() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/EnhancedSelectableTextBlock.cs b/src/Views/EnhancedSelectableTextBlock.cs new file mode 100644 index 00000000..183b7021 --- /dev/null +++ b/src/Views/EnhancedSelectableTextBlock.cs @@ -0,0 +1,20 @@ +using System; + +using Avalonia; +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public class EnhancedSelectableTextBlock : SelectableTextBlock + { + protected override Type StyleKeyOverride => typeof(SelectableTextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == TextProperty) + UpdateLayout(); + } + } +} diff --git a/src/Views/ExecuteCustomAction.axaml b/src/Views/ExecuteCustomAction.axaml new file mode 100644 index 00000000..9ee2b55d --- /dev/null +++ b/src/Views/ExecuteCustomAction.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/src/Views/ExecuteCustomAction.axaml.cs b/src/Views/ExecuteCustomAction.axaml.cs new file mode 100644 index 00000000..e4f9cecf --- /dev/null +++ b/src/Views/ExecuteCustomAction.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ExecuteCustomAction : UserControl + { + public ExecuteCustomAction() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Fetch.axaml b/src/Views/Fetch.axaml new file mode 100644 index 00000000..67669380 --- /dev/null +++ b/src/Views/Fetch.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Fetch.axaml.cs b/src/Views/Fetch.axaml.cs new file mode 100644 index 00000000..1212ee3d --- /dev/null +++ b/src/Views/Fetch.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Fetch : UserControl + { + public Fetch() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/FetchInto.axaml b/src/Views/FetchInto.axaml new file mode 100644 index 00000000..4a0c0966 --- /dev/null +++ b/src/Views/FetchInto.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/src/Views/FetchInto.axaml.cs b/src/Views/FetchInto.axaml.cs new file mode 100644 index 00000000..c61c052e --- /dev/null +++ b/src/Views/FetchInto.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class FetchInto : UserControl + { + public FetchInto() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/FileHistories.axaml b/src/Views/FileHistories.axaml new file mode 100644 index 00000000..be0c91a0 --- /dev/null +++ b/src/Views/FileHistories.axaml @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/FileHistories.axaml.cs b/src/Views/FileHistories.axaml.cs new file mode 100644 index 00000000..3e7d5dc6 --- /dev/null +++ b/src/Views/FileHistories.axaml.cs @@ -0,0 +1,80 @@ +using System; + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class FileHistories : ChromelessWindow + { + public FileHistories() + { + InitializeComponent(); + } + + private void OnPressCommitSHA(object sender, PointerPressedEventArgs e) + { + if (sender is TextBlock { DataContext: Models.Commit commit } && + DataContext is ViewModels.FileHistories vm) + { + vm.NavigateToCommit(commit); + } + + e.Handled = true; + } + + private async void OnResetToSelectedRevision(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: ViewModels.FileHistoriesSingleRevision single }) + { + await single.ResetToSelectedRevision(); + NotifyDonePanel.IsVisible = true; + } + + e.Handled = true; + } + + private void OnCloseNotifyPanel(object _, PointerPressedEventArgs e) + { + NotifyDonePanel.IsVisible = false; + e.Handled = true; + } + + private async void OnSaveAsPatch(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: ViewModels.FileHistoriesCompareRevisions compare }) + { + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await StorageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + await compare.SaveAsPatch(storageFile.Path.LocalPath); + + NotifyDonePanel.IsVisible = true; + e.Handled = true; + } + } + + private void OnCommitSubjectDataContextChanged(object sender, EventArgs e) + { + if (sender is Border border) + ToolTip.SetTip(border, null); + } + + private void OnCommitSubjectPointerMoved(object sender, PointerEventArgs e) + { + if (sender is Border { DataContext: Models.Commit commit } border && + DataContext is ViewModels.FileHistories vm) + { + var tooltip = ToolTip.GetTip(border); + if (tooltip == null) + ToolTip.SetTip(border, vm.GetCommitFullMessage(commit)); + } + } + } +} diff --git a/src/Views/FileHistories.xaml b/src/Views/FileHistories.xaml deleted file mode 100644 index f205ee1e..00000000 --- a/src/Views/FileHistories.xaml +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/FileHistories.xaml.cs b/src/Views/FileHistories.xaml.cs deleted file mode 100644 index 7d627421..00000000 --- a/src/Views/FileHistories.xaml.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Navigation; - -namespace SourceGit.Views { - - /// - /// 文件历史 - /// - public partial class FileHistories : Controls.Window { - private string repo = null; - private string file = null; - private bool isLFSEnabled = false; - - public FileHistories(string repo, string file) { - this.repo = repo; - this.file = file; - this.isLFSEnabled = new Commands.LFS(repo).IsFiltered(file); - - InitializeComponent(); - - Task.Run(() => { - var commits = new Commands.Commits(repo, $"-n 10000 -- \"{file}\"").Result(); - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - commitList.ItemsSource = commits; - commitList.SelectedIndex = 0; - }); - }); - } - - #region WINDOW_COMMANDS - private void Minimize(object sender, RoutedEventArgs e) { - SystemCommands.MinimizeWindow(this); - } - - private void Quit(object sender, RoutedEventArgs e) { - Close(); - } - #endregion - - #region EVENTS - private void OnCommitSelectedChanged(object sender, SelectedCellsChangedEventArgs e) { - var commit = (sender as DataGrid).SelectedItem as Models.Commit; - if (commit == null) return; - - var start = $"{commit.SHA}^"; - if (commit.Parents.Count == 0) start = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - - diffViewer.Diff(repo, new Widgets.DiffViewer.Option() { - RevisionRange = new string[] { start, commit.SHA }, - Path = file, - UseLFS = isLFSEnabled, - }); - } - - private void GotoCommit(object sender, RequestNavigateEventArgs e) { - Models.Watcher.Get(repo).NavigateTo(e.Uri.OriginalString); - e.Handled = true; - } - #endregion - } -} diff --git a/src/Views/FilterModeInGraph.axaml b/src/Views/FilterModeInGraph.axaml new file mode 100644 index 00000000..520a3836 --- /dev/null +++ b/src/Views/FilterModeInGraph.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/src/Views/FilterModeInGraph.axaml.cs b/src/Views/FilterModeInGraph.axaml.cs new file mode 100644 index 00000000..c3987f91 --- /dev/null +++ b/src/Views/FilterModeInGraph.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class FilterModeInGraph : UserControl + { + public FilterModeInGraph() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/FilterModeSwitchButton.axaml b/src/Views/FilterModeSwitchButton.axaml new file mode 100644 index 00000000..b202c434 --- /dev/null +++ b/src/Views/FilterModeSwitchButton.axaml @@ -0,0 +1,32 @@ + + + diff --git a/src/Views/FilterModeSwitchButton.axaml.cs b/src/Views/FilterModeSwitchButton.axaml.cs new file mode 100644 index 00000000..b3b2c3da --- /dev/null +++ b/src/Views/FilterModeSwitchButton.axaml.cs @@ -0,0 +1,165 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class FilterModeSwitchButton : UserControl + { + public static readonly StyledProperty ModeProperty = + AvaloniaProperty.Register(nameof(Mode)); + + public Models.FilterMode Mode + { + get => GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public static readonly StyledProperty IsNoneVisibleProperty = + AvaloniaProperty.Register(nameof(IsNoneVisible)); + + public bool IsNoneVisible + { + get => GetValue(IsNoneVisibleProperty); + set => SetValue(IsNoneVisibleProperty, value); + } + + public static readonly StyledProperty IsContextMenuOpeningProperty = + AvaloniaProperty.Register(nameof(IsContextMenuOpening)); + + public bool IsContextMenuOpening + { + get => GetValue(IsContextMenuOpeningProperty); + set => SetValue(IsContextMenuOpeningProperty, value); + } + + public FilterModeSwitchButton() + { + IsVisible = false; + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ModeProperty || + change.Property == IsNoneVisibleProperty || + change.Property == IsContextMenuOpeningProperty) + { + var visible = (Mode != Models.FilterMode.None || IsNoneVisible || IsContextMenuOpening); + SetCurrentValue(IsVisibleProperty, visible); + } + } + + private void OnChangeFilterModeButtonClicked(object sender, RoutedEventArgs e) + { + var repoView = this.FindAncestorOfType(); + if (repoView == null) + return; + + var repo = repoView.DataContext as ViewModels.Repository; + if (repo == null) + return; + + var button = sender as Button; + if (button == null) + return; + + var menu = new ContextMenu(); + var mode = Models.FilterMode.None; + if (DataContext is Models.Tag tag) + { + mode = tag.FilterMode; + + if (mode != Models.FilterMode.None) + { + var unset = new MenuItem(); + unset.Header = App.Text("Repository.FilterCommits.Default"); + unset.Click += (_, ev) => + { + repo.SetTagFilterMode(tag, Models.FilterMode.None); + ev.Handled = true; + }; + + menu.Items.Add(unset); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var include = new MenuItem(); + include.Icon = App.CreateMenuIcon("Icons.Filter"); + include.Header = App.Text("Repository.FilterCommits.Include"); + include.IsEnabled = mode != Models.FilterMode.Included; + include.Click += (_, ev) => + { + repo.SetTagFilterMode(tag, Models.FilterMode.Included); + ev.Handled = true; + }; + + var exclude = new MenuItem(); + exclude.Icon = App.CreateMenuIcon("Icons.EyeClose"); + exclude.Header = App.Text("Repository.FilterCommits.Exclude"); + exclude.IsEnabled = mode != Models.FilterMode.Excluded; + exclude.Click += (_, ev) => + { + repo.SetTagFilterMode(tag, Models.FilterMode.Excluded); + ev.Handled = true; + }; + + menu.Items.Add(include); + menu.Items.Add(exclude); + } + else if (DataContext is ViewModels.BranchTreeNode node) + { + mode = node.FilterMode; + + if (mode != Models.FilterMode.None) + { + var unset = new MenuItem(); + unset.Header = App.Text("Repository.FilterCommits.Default"); + unset.Click += (_, ev) => + { + repo.SetBranchFilterMode(node, Models.FilterMode.None, false, true); + ev.Handled = true; + }; + + menu.Items.Add(unset); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var include = new MenuItem(); + include.Icon = App.CreateMenuIcon("Icons.Filter"); + include.Header = App.Text("Repository.FilterCommits.Include"); + include.IsEnabled = mode != Models.FilterMode.Included; + include.Click += (_, ev) => + { + repo.SetBranchFilterMode(node, Models.FilterMode.Included, false, true); + ev.Handled = true; + }; + + var exclude = new MenuItem(); + exclude.Icon = App.CreateMenuIcon("Icons.EyeClose"); + exclude.Header = App.Text("Repository.FilterCommits.Exclude"); + exclude.IsEnabled = mode != Models.FilterMode.Excluded; + exclude.Click += (_, ev) => + { + repo.SetBranchFilterMode(node, Models.FilterMode.Excluded, false, true); + ev.Handled = true; + }; + + menu.Items.Add(include); + menu.Items.Add(exclude); + } + + if (mode == Models.FilterMode.None) + { + IsContextMenuOpening = true; + menu.Closed += (_, _) => IsContextMenuOpening = false; + } + + menu.Open(button); + e.Handled = true; + } + } +} diff --git a/src/Views/GitFlowFinish.axaml b/src/Views/GitFlowFinish.axaml new file mode 100644 index 00000000..fa847bba --- /dev/null +++ b/src/Views/GitFlowFinish.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/GitFlowFinish.axaml.cs b/src/Views/GitFlowFinish.axaml.cs new file mode 100644 index 00000000..28564766 --- /dev/null +++ b/src/Views/GitFlowFinish.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class GitFlowFinish : UserControl + { + public GitFlowFinish() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/GitFlowStart.axaml b/src/Views/GitFlowStart.axaml new file mode 100644 index 00000000..aed970de --- /dev/null +++ b/src/Views/GitFlowStart.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/src/Views/GitFlowStart.axaml.cs b/src/Views/GitFlowStart.axaml.cs new file mode 100644 index 00000000..6498f39d --- /dev/null +++ b/src/Views/GitFlowStart.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class GitFlowStart : UserControl + { + public GitFlowStart() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml new file mode 100644 index 00000000..7afe12fa --- /dev/null +++ b/src/Views/Histories.axaml @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs new file mode 100644 index 00000000..18630e4c --- /dev/null +++ b/src/Views/Histories.axaml.cs @@ -0,0 +1,239 @@ +using System; +using System.Text; + +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class HistoriesLayout : Grid + { + public static readonly StyledProperty UseHorizontalProperty = + AvaloniaProperty.Register(nameof(UseHorizontal), false); + + public bool UseHorizontal + { + get => GetValue(UseHorizontalProperty); + set => SetValue(UseHorizontalProperty, value); + } + + protected override Type StyleKeyOverride => typeof(Grid); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UseHorizontalProperty && IsLoaded) + RefreshLayout(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + RefreshLayout(); + } + + private void RefreshLayout() + { + if (UseHorizontal) + { + var rowSpan = RowDefinitions.Count; + for (int i = 0; i < Children.Count; i++) + { + var child = Children[i]; + child.SetValue(RowProperty, 0); + child.SetValue(RowSpanProperty, rowSpan); + child.SetValue(ColumnProperty, i); + child.SetValue(ColumnSpanProperty, 1); + + if (child is GridSplitter splitter) + splitter.BorderThickness = new Thickness(1, 0, 0, 0); + } + } + else + { + var colSpan = ColumnDefinitions.Count; + for (int i = 0; i < Children.Count; i++) + { + var child = Children[i]; + child.SetValue(RowProperty, i); + child.SetValue(RowSpanProperty, 1); + child.SetValue(ColumnProperty, 0); + child.SetValue(ColumnSpanProperty, colSpan); + + if (child is GridSplitter splitter) + splitter.BorderThickness = new Thickness(0, 1, 0, 0); + } + } + } + } + + public partial class Histories : UserControl + { + public static readonly StyledProperty AuthorNameColumnWidthProperty = + AvaloniaProperty.Register(nameof(AuthorNameColumnWidth), new GridLength(120)); + + public GridLength AuthorNameColumnWidth + { + get => GetValue(AuthorNameColumnWidthProperty); + set => SetValue(AuthorNameColumnWidthProperty, value); + } + + public static readonly StyledProperty CurrentBranchProperty = + AvaloniaProperty.Register(nameof(CurrentBranch)); + + public Models.Branch CurrentBranch + { + get => GetValue(CurrentBranchProperty); + set => SetValue(CurrentBranchProperty, value); + } + + public static readonly StyledProperty BisectProperty = + AvaloniaProperty.Register(nameof(Bisect)); + + public Models.Bisect Bisect + { + get => GetValue(BisectProperty); + set => SetValue(BisectProperty, value); + } + + public static readonly StyledProperty> IssueTrackerRulesProperty = + AvaloniaProperty.Register>(nameof(IssueTrackerRules)); + + public AvaloniaList IssueTrackerRules + { + get => GetValue(IssueTrackerRulesProperty); + set => SetValue(IssueTrackerRulesProperty, value); + } + + public static readonly StyledProperty OnlyHighlightCurrentBranchProperty = + AvaloniaProperty.Register(nameof(OnlyHighlightCurrentBranch), true); + + public bool OnlyHighlightCurrentBranch + { + get => GetValue(OnlyHighlightCurrentBranchProperty); + set => SetValue(OnlyHighlightCurrentBranchProperty, value); + } + + public static readonly StyledProperty NavigationIdProperty = + AvaloniaProperty.Register(nameof(NavigationId)); + + public long NavigationId + { + get => GetValue(NavigationIdProperty); + set => SetValue(NavigationIdProperty, value); + } + + public Histories() + { + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == NavigationIdProperty) + { + if (DataContext is ViewModels.Histories) + { + var list = CommitListContainer; + if (list is { SelectedItems.Count: 1 }) + list.ScrollIntoView(list.SelectedIndex); + } + } + } + + private void OnCommitListLayoutUpdated(object _1, EventArgs _2) + { + var y = CommitListContainer.Scroll?.Offset.Y ?? 0; + var authorNameColumnWidth = AuthorNameColumnWidth.Value; + if (y != _lastScrollY || authorNameColumnWidth != _lastAuthorNameColumnWidth) + { + _lastScrollY = y; + _lastAuthorNameColumnWidth = authorNameColumnWidth; + CommitGraph.InvalidateVisual(); + } + } + + private void OnCommitListSelectionChanged(object _, SelectionChangedEventArgs e) + { + if (DataContext is ViewModels.Histories histories) + { + histories.Select(CommitListContainer.SelectedItems); + } + e.Handled = true; + } + + private void OnCommitListContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.Histories histories && sender is ListBox { SelectedItems.Count: > 0 } list) + { + var menu = histories.MakeContextMenu(list); + menu?.Open(list); + } + e.Handled = true; + } + + private void OnCommitListDoubleTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.Histories histories && sender is ListBox { SelectedItems.Count: 1 }) + { + var source = e.Source as Control; + var item = source.FindAncestorOfType(); + if (item is { DataContext: Models.Commit commit }) + histories.DoubleTapped(commit); + } + e.Handled = true; + } + + private void OnCommitListKeyDown(object sender, KeyEventArgs e) + { + if (!e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + return; + + // These shortcuts are not mentioned in the Shortcut Reference window. Is this expected? + if (sender is ListBox { SelectedItems: { Count: > 0 } selected }) + { + // CTRL/COMMAND + C -> Copy selected commit SHA and subject. + if (e.Key == Key.C) + { + var builder = new StringBuilder(); + foreach (var item in selected) + { + if (item is Models.Commit commit) + builder.AppendLine($"{commit.SHA.AsSpan(0, 10)} - {commit.Subject}"); + } + + App.CopyText(builder.ToString()); + e.Handled = true; + return; + } + + // CTRL/COMMAND + B -> shows Create Branch pop-up at selected commit. + if (e.Key == Key.B) + { + var repoView = this.FindAncestorOfType(); + if (repoView == null) + return; + + var repo = repoView.DataContext as ViewModels.Repository; + if (repo == null || !repo.CanCreatePopup()) + return; + + if (selected.Count == 1 && selected[0] is Models.Commit commit) + { + repo.ShowPopup(new ViewModels.CreateBranch(repo, commit)); + e.Handled = true; + } + } + } + } + + private double _lastScrollY = 0; + private double _lastAuthorNameColumnWidth = 0; + } +} diff --git a/src/Views/Hotkeys.axaml b/src/Views/Hotkeys.axaml new file mode 100644 index 00000000..d87ccd14 --- /dev/null +++ b/src/Views/Hotkeys.axaml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Hotkeys.axaml.cs b/src/Views/Hotkeys.axaml.cs new file mode 100644 index 00000000..ea293b0a --- /dev/null +++ b/src/Views/Hotkeys.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class Hotkeys : ChromelessWindow + { + public Hotkeys() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (!e.Handled && e.Key == Key.Escape) + Close(); + } + } +} diff --git a/src/Views/ImageContainer.cs b/src/Views/ImageContainer.cs new file mode 100644 index 00000000..995f269b --- /dev/null +++ b/src/Views/ImageContainer.cs @@ -0,0 +1,364 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Styling; + +namespace SourceGit.Views +{ + public class ImageContainer : Control + { + public override void Render(DrawingContext context) + { + if (_bgBrush == null) + { + var maskBrush = new SolidColorBrush(ActualThemeVariant == ThemeVariant.Dark ? 0xFF404040 : 0xFFBBBBBB); + var bg = new DrawingGroup() + { + Children = + { + new GeometryDrawing() { Brush = maskBrush, Geometry = new RectangleGeometry(new Rect(0, 0, 12, 12)) }, + new GeometryDrawing() { Brush = maskBrush, Geometry = new RectangleGeometry(new Rect(12, 12, 12, 12)) }, + } + }; + + _bgBrush = new DrawingBrush(bg) + { + AlignmentX = AlignmentX.Left, + AlignmentY = AlignmentY.Top, + DestinationRect = new RelativeRect(new Size(24, 24), RelativeUnit.Absolute), + Stretch = Stretch.None, + TileMode = TileMode.Tile, + }; + } + + context.FillRectangle(_bgBrush, new Rect(Bounds.Size)); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property.Name == "ActualThemeVariant") + { + _bgBrush = null; + InvalidateVisual(); + } + } + + private DrawingBrush _bgBrush = null; + } + + public class ImageView : ImageContainer + { + public static readonly StyledProperty ImageProperty = + AvaloniaProperty.Register(nameof(Image)); + + public Bitmap Image + { + get => GetValue(ImageProperty); + set => SetValue(ImageProperty, value); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (Image is { } image) + context.DrawImage(image, new Rect(0, 0, Bounds.Width, Bounds.Height)); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ImageProperty) + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (Image is { } image) + { + var imageSize = image.Size; + var scaleW = availableSize.Width / imageSize.Width; + var scaleH = availableSize.Height / imageSize.Height; + var scale = Math.Min(1, Math.Min(scaleW, scaleH)); + return new Size(scale * imageSize.Width, scale * imageSize.Height); + } + + return availableSize; + } + } + + public class ImageSwipeControl : ImageContainer + { + public static readonly StyledProperty AlphaProperty = + AvaloniaProperty.Register(nameof(Alpha), 0.5); + + public double Alpha + { + get => GetValue(AlphaProperty); + set => SetValue(AlphaProperty, value); + } + + public static readonly StyledProperty OldImageProperty = + AvaloniaProperty.Register(nameof(OldImage)); + + public Bitmap OldImage + { + get => GetValue(OldImageProperty); + set => SetValue(OldImageProperty, value); + } + + public static readonly StyledProperty NewImageProperty = + AvaloniaProperty.Register(nameof(NewImage)); + + public Bitmap NewImage + { + get => GetValue(NewImageProperty); + set => SetValue(NewImageProperty, value); + } + + static ImageSwipeControl() + { + AffectsMeasure(OldImageProperty, NewImageProperty); + AffectsRender(AlphaProperty); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var alpha = Alpha; + var w = Bounds.Width; + var h = Bounds.Height; + var x = w * alpha; + + if (OldImage is { } left && alpha > 0) + RenderSingleSide(context, left, new Rect(0, 0, x, h)); + + if (NewImage is { } right && alpha < 1) + RenderSingleSide(context, right, new Rect(x, 0, w - x, h)); + + context.DrawLine(new Pen(Brushes.DarkGreen, 2), new Point(x, 0), new Point(x, Bounds.Height)); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + var p = e.GetPosition(this); + var hitbox = new Rect(Math.Max(Bounds.Width * Alpha - 2, 0), 0, 4, Bounds.Height); + var pointer = e.GetCurrentPoint(this); + if (pointer.Properties.IsLeftButtonPressed && hitbox.Contains(p)) + { + _pressedOnSlider = true; + Cursor = new Cursor(StandardCursorType.SizeWestEast); + e.Pointer.Capture(this); + e.Handled = true; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + _pressedOnSlider = false; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + var w = Bounds.Width; + var p = e.GetPosition(this); + + if (_pressedOnSlider) + { + SetCurrentValue(AlphaProperty, Math.Clamp(p.X, 0, w) / w); + } + else + { + var hitbox = new Rect(Math.Max(w * Alpha - 2, 0), 0, 4, Bounds.Height); + if (hitbox.Contains(p)) + { + if (!_lastInSlider) + { + _lastInSlider = true; + Cursor = new Cursor(StandardCursorType.SizeWestEast); + } + } + else + { + if (_lastInSlider) + { + _lastInSlider = false; + Cursor = null; + } + } + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var left = OldImage; + var right = NewImage; + + if (left == null) + return right == null ? availableSize : GetDesiredSize(right.Size, availableSize); + + if (right == null) + return GetDesiredSize(left.Size, availableSize); + + var ls = GetDesiredSize(left.Size, availableSize); + var rs = GetDesiredSize(right.Size, availableSize); + return ls.Width > rs.Width ? ls : rs; + } + + private Size GetDesiredSize(Size img, Size available) + { + var sw = available.Width / img.Width; + var sh = available.Height / img.Height; + var scale = Math.Min(1, Math.Min(sw, sh)); + return new Size(scale * img.Width, scale * img.Height); + } + + private void RenderSingleSide(DrawingContext context, Bitmap img, Rect clip) + { + var w = Bounds.Width; + var h = Bounds.Height; + + var imgW = img.Size.Width; + var imgH = img.Size.Height; + var scale = Math.Min(1, Math.Min(w / imgW, h / imgH)); + + var scaledW = img.Size.Width * scale; + var scaledH = img.Size.Height * scale; + + var src = new Rect(0, 0, imgW, imgH); + var dst = new Rect((w - scaledW) * 0.5, (h - scaledH) * 0.5, scaledW, scaledH); + + using (context.PushClip(clip)) + context.DrawImage(img, src, dst); + } + + private bool _pressedOnSlider = false; + private bool _lastInSlider = false; + } + + public class ImageBlendControl : ImageContainer + { + public static readonly StyledProperty AlphaProperty = + AvaloniaProperty.Register(nameof(Alpha), 1.0); + + public double Alpha + { + get => GetValue(AlphaProperty); + set => SetValue(AlphaProperty, value); + } + + public static readonly StyledProperty OldImageProperty = + AvaloniaProperty.Register(nameof(OldImage)); + + public Bitmap OldImage + { + get => GetValue(OldImageProperty); + set => SetValue(OldImageProperty, value); + } + + public static readonly StyledProperty NewImageProperty = + AvaloniaProperty.Register(nameof(NewImage)); + + public Bitmap NewImage + { + get => GetValue(NewImageProperty); + set => SetValue(NewImageProperty, value); + } + + static ImageBlendControl() + { + AffectsMeasure(OldImageProperty, NewImageProperty); + AffectsRender(AlphaProperty); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var alpha = Alpha; + var left = OldImage; + var right = NewImage; + var drawLeft = left != null && alpha < 1.0; + var drawRight = right != null && alpha > 0; + + if (drawLeft && drawRight) + { + using (var rt = new RenderTargetBitmap(new PixelSize((int)Bounds.Width, (int)Bounds.Height), right.Dpi)) + { + using (var dc = rt.CreateDrawingContext()) + { + using (dc.PushRenderOptions(RO_SRC)) + RenderSingleSide(dc, left, rt.Size.Width, rt.Size.Height, 1 - alpha); + + using (dc.PushRenderOptions(RO_DST)) + RenderSingleSide(dc, right, rt.Size.Width, rt.Size.Height, alpha); + } + + context.DrawImage(rt, new Rect(0, 0, Bounds.Width, Bounds.Height)); + } + } + else if (drawLeft) + { + RenderSingleSide(context, left, Bounds.Width, Bounds.Height, 1 - alpha); + } + else if (drawRight) + { + RenderSingleSide(context, right, Bounds.Width, Bounds.Height, alpha); + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var left = OldImage; + var right = NewImage; + + if (left == null) + return right == null ? availableSize : GetDesiredSize(right.Size, availableSize); + + if (right == null) + return GetDesiredSize(left.Size, availableSize); + + var ls = GetDesiredSize(left.Size, availableSize); + var rs = GetDesiredSize(right.Size, availableSize); + return ls.Width > rs.Width ? ls : rs; + } + + private Size GetDesiredSize(Size img, Size available) + { + var sw = available.Width / img.Width; + var sh = available.Height / img.Height; + var scale = Math.Min(1, Math.Min(sw, sh)); + return new Size(scale * img.Width, scale * img.Height); + } + + private void RenderSingleSide(DrawingContext context, Bitmap img, double w, double h, double alpha) + { + var imgW = img.Size.Width; + var imgH = img.Size.Height; + var scale = Math.Min(1, Math.Min(w / imgW, h / imgH)); + + var scaledW = img.Size.Width * scale; + var scaledH = img.Size.Height * scale; + + var src = new Rect(0, 0, imgW, imgH); + var dst = new Rect((w - scaledW) * 0.5, (h - scaledH) * 0.5, scaledW, scaledH); + + using (context.PushOpacity(alpha)) + context.DrawImage(img, src, dst); + } + + private static readonly RenderOptions RO_SRC = new RenderOptions() { BitmapBlendingMode = BitmapBlendingMode.Source, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; + private static readonly RenderOptions RO_DST = new RenderOptions() { BitmapBlendingMode = BitmapBlendingMode.Plus, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; + } +} diff --git a/src/Views/ImageDiffView.axaml b/src/Views/ImageDiffView.axaml new file mode 100644 index 00000000..54a20628 --- /dev/null +++ b/src/Views/ImageDiffView.axaml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ImageDiffView.axaml.cs b/src/Views/ImageDiffView.axaml.cs new file mode 100644 index 00000000..7e32c91a --- /dev/null +++ b/src/Views/ImageDiffView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ImageDiffView : UserControl + { + public ImageDiffView() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Init.axaml b/src/Views/Init.axaml new file mode 100644 index 00000000..10aac9b0 --- /dev/null +++ b/src/Views/Init.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/src/Views/Init.axaml.cs b/src/Views/Init.axaml.cs new file mode 100644 index 00000000..0e197294 --- /dev/null +++ b/src/Views/Init.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Init : UserControl + { + public Init() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/InitGitFlow.axaml b/src/Views/InitGitFlow.axaml new file mode 100644 index 00000000..75fe696c --- /dev/null +++ b/src/Views/InitGitFlow.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/InitGitFlow.axaml.cs b/src/Views/InitGitFlow.axaml.cs new file mode 100644 index 00000000..2fbde79a --- /dev/null +++ b/src/Views/InitGitFlow.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class InitGitFlow : UserControl + { + public InitGitFlow() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/InteractiveRebase.axaml b/src/Views/InteractiveRebase.axaml new file mode 100644 index 00000000..f9f69e88 --- /dev/null +++ b/src/Views/InteractiveRebase.axaml @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LFSLocks.axaml.cs b/src/Views/LFSLocks.axaml.cs new file mode 100644 index 00000000..695341f4 --- /dev/null +++ b/src/Views/LFSLocks.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class LFSLocks : ChromelessWindow + { + public LFSLocks() + { + InitializeComponent(); + } + + private void OnUnlockButtonClicked(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.LFSLocks vm && sender is Button button) + vm.Unlock(button.DataContext as Models.LFSLock, false); + + e.Handled = true; + } + + private void OnForceUnlockButtonClicked(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.LFSLocks vm && sender is Button button) + vm.Unlock(button.DataContext as Models.LFSLock, true); + + e.Handled = true; + } + } +} diff --git a/src/Views/LFSPrune.axaml b/src/Views/LFSPrune.axaml new file mode 100644 index 00000000..a8ada710 --- /dev/null +++ b/src/Views/LFSPrune.axaml @@ -0,0 +1,16 @@ + + + + + + diff --git a/src/Views/LFSPrune.axaml.cs b/src/Views/LFSPrune.axaml.cs new file mode 100644 index 00000000..dbb4a376 --- /dev/null +++ b/src/Views/LFSPrune.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class LFSPrune : UserControl + { + public LFSPrune() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/LFSPull.axaml b/src/Views/LFSPull.axaml new file mode 100644 index 00000000..39b92c4a --- /dev/null +++ b/src/Views/LFSPull.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LFSPull.axaml.cs b/src/Views/LFSPull.axaml.cs new file mode 100644 index 00000000..db71afe6 --- /dev/null +++ b/src/Views/LFSPull.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class LFSPull : UserControl + { + public LFSPull() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/LFSPush.axaml b/src/Views/LFSPush.axaml new file mode 100644 index 00000000..f67f4aca --- /dev/null +++ b/src/Views/LFSPush.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LFSPush.axaml.cs b/src/Views/LFSPush.axaml.cs new file mode 100644 index 00000000..5641dfef --- /dev/null +++ b/src/Views/LFSPush.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class LFSPush : UserControl + { + public LFSPush() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/LFSTrackCustomPattern.axaml b/src/Views/LFSTrackCustomPattern.axaml new file mode 100644 index 00000000..f60304d5 --- /dev/null +++ b/src/Views/LFSTrackCustomPattern.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/src/Views/LFSTrackCustomPattern.axaml.cs b/src/Views/LFSTrackCustomPattern.axaml.cs new file mode 100644 index 00000000..2e66f55a --- /dev/null +++ b/src/Views/LFSTrackCustomPattern.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class LFSTrackCustomPattern : UserControl + { + public LFSTrackCustomPattern() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Launcher.axaml b/src/Views/Launcher.axaml new file mode 100644 index 00000000..bfe03fd5 --- /dev/null +++ b/src/Views/Launcher.axaml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Launcher.axaml.cs b/src/Views/Launcher.axaml.cs new file mode 100644 index 00000000..08620f83 --- /dev/null +++ b/src/Views/Launcher.axaml.cs @@ -0,0 +1,338 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class Launcher : ChromelessWindow + { + public static readonly StyledProperty CaptionHeightProperty = + AvaloniaProperty.Register(nameof(CaptionHeight)); + + public GridLength CaptionHeight + { + get => GetValue(CaptionHeightProperty); + set => SetValue(CaptionHeightProperty, value); + } + + public static readonly StyledProperty HasLeftCaptionButtonProperty = + AvaloniaProperty.Register(nameof(HasLeftCaptionButton)); + + public bool HasLeftCaptionButton + { + get => GetValue(HasLeftCaptionButtonProperty); + set => SetValue(HasLeftCaptionButtonProperty, value); + } + + public bool HasRightCaptionButton + { + get + { + if (OperatingSystem.IsLinux()) + return !Native.OS.UseSystemWindowFrame; + + return OperatingSystem.IsWindows(); + } + } + + public Launcher() + { + var layout = ViewModels.Preferences.Instance.Layout; + if (layout.LauncherWindowState != WindowState.Maximized) + { + Width = layout.LauncherWidth; + Height = layout.LauncherHeight; + } + + if (OperatingSystem.IsMacOS()) + { + HasLeftCaptionButton = true; + CaptionHeight = new GridLength(34); + ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome | ExtendClientAreaChromeHints.OSXThickTitleBar; + } + else if (UseSystemWindowFrame) + { + CaptionHeight = new GridLength(30); + } + else + { + CaptionHeight = new GridLength(38); + } + + InitializeComponent(); + } + + public void BringToTop() + { + if (WindowState == WindowState.Minimized) + WindowState = _lastWindowState; + else + Activate(); + } + + public bool HasKeyModifier(KeyModifiers modifier) + { + return _unhandledModifiers.HasFlag(modifier); + } + + public void ClearKeyModifier() + { + _unhandledModifiers = KeyModifiers.None; + } + + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + var state = ViewModels.Preferences.Instance.Layout.LauncherWindowState; + if (state == WindowState.Maximized || state == WindowState.FullScreen) + WindowState = WindowState.Maximized; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == WindowStateProperty) + { + _lastWindowState = (WindowState)change.OldValue!; + + var state = (WindowState)change.NewValue!; + if (!OperatingSystem.IsMacOS() && !UseSystemWindowFrame) + CaptionHeight = new GridLength(state == WindowState.Maximized ? 30 : 38); + + if (OperatingSystem.IsMacOS()) + HasLeftCaptionButton = state != WindowState.FullScreen; + + ViewModels.Preferences.Instance.Layout.LauncherWindowState = state; + } + } + + protected override void OnKeyDown(KeyEventArgs e) + { + var vm = DataContext as ViewModels.Launcher; + if (vm == null) + return; + + // We should clear all unhandled key modifiers. + _unhandledModifiers = KeyModifiers.None; + + // Check for AltGr (which is detected as Ctrl+Alt) + bool isAltGr = e.KeyModifiers.HasFlag(KeyModifiers.Control) && + e.KeyModifiers.HasFlag(KeyModifiers.Alt); + + // Skip hotkey processing if AltGr is pressed + if (isAltGr) + { + base.OnKeyDown(e); + return; + } + + // Ctrl+, opens preference dialog (macOS use hotkeys in system menu bar) + if (!OperatingSystem.IsMacOS() && e is { KeyModifiers: KeyModifiers.Control, Key: Key.OemComma }) + { + App.ShowWindow(new Preferences(), true); + e.Handled = true; + return; + } + + // F1 opens preference dialog (macOS use hotkeys in system menu bar) + if (!OperatingSystem.IsMacOS() && e.Key == Key.F1) + { + App.ShowWindow(new Hotkeys(), true); + return; + } + + // Ctrl+Q quits the application (macOS use hotkeys in system menu bar) + if (!OperatingSystem.IsMacOS() && e is { KeyModifiers: KeyModifiers.Control, Key: Key.Q }) + { + App.Quit(0); + return; + } + + if (e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.P) + { + vm.OpenWorkspaceSwitcher(); + e.Handled = true; + return; + } + + if (e.Key == Key.P) + { + vm.OpenTabSwitcher(); + e.Handled = true; + return; + } + + if (e.Key == Key.W) + { + vm.CloseTab(null); + e.Handled = true; + return; + } + + if (e.Key == Key.T) + { + vm.AddNewTab(); + e.Handled = true; + return; + } + + if (e.Key == Key.N) + { + if (vm.ActivePage.Data is not ViewModels.Welcome) + vm.AddNewTab(); + + ViewModels.Welcome.Instance.Clone(); + e.Handled = true; + return; + } + + if ((OperatingSystem.IsMacOS() && e.KeyModifiers.HasFlag(KeyModifiers.Alt) && e.Key == Key.Right) || + (!OperatingSystem.IsMacOS() && !e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.Tab)) + { + vm.GotoNextTab(); + e.Handled = true; + return; + } + + if ((OperatingSystem.IsMacOS() && e.KeyModifiers.HasFlag(KeyModifiers.Alt) && e.Key == Key.Left) || + (!OperatingSystem.IsMacOS() && e.KeyModifiers.HasFlag(KeyModifiers.Shift) && e.Key == Key.Tab)) + { + vm.GotoPrevTab(); + e.Handled = true; + return; + } + + if (vm.ActivePage.Data is ViewModels.Repository repo) + { + if (e.Key == Key.D1 || e.Key == Key.NumPad1) + { + repo.SelectedViewIndex = 0; + e.Handled = true; + return; + } + + if (e.Key == Key.D2 || e.Key == Key.NumPad2) + { + repo.SelectedViewIndex = 1; + e.Handled = true; + return; + } + + if (e.Key == Key.D3 || e.Key == Key.NumPad3) + { + repo.SelectedViewIndex = 2; + e.Handled = true; + return; + } + + if (e.Key == Key.F) + { + repo.IsSearching = true; + e.Handled = true; + return; + } + + if (e.Key == Key.H && e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + repo.IsSearching = false; + e.Handled = true; + return; + } + } + else + { + var welcome = this.FindDescendantOfType(); + if (welcome != null) + { + if (e.Key == Key.F) + { + welcome.SearchBox.Focus(); + e.Handled = true; + return; + } + } + } + } + else if (e.Key == Key.Escape) + { + if (vm.Switcher != null) + vm.CancelSwitcher(); + else + vm.ActivePage.CancelPopup(); + + e.Handled = true; + return; + } + else if (e.Key == Key.F5) + { + if (vm.ActivePage.Data is ViewModels.Repository repo) + { + repo.RefreshAll(); + e.Handled = true; + return; + } + } + + base.OnKeyDown(e); + + // Record unhandled key modifiers. + if (!e.Handled) + { + _unhandledModifiers = e.KeyModifiers; + + if (!_unhandledModifiers.HasFlag(KeyModifiers.Alt) && e.Key is Key.LeftAlt or Key.RightAlt) + _unhandledModifiers |= KeyModifiers.Alt; + + if (!_unhandledModifiers.HasFlag(KeyModifiers.Control) && e.Key is Key.LeftCtrl or Key.RightCtrl) + _unhandledModifiers |= KeyModifiers.Control; + + if (!_unhandledModifiers.HasFlag(KeyModifiers.Shift) && e.Key is Key.LeftShift or Key.RightShift) + _unhandledModifiers |= KeyModifiers.Shift; + } + } + + protected override void OnKeyUp(KeyEventArgs e) + { + base.OnKeyUp(e); + _unhandledModifiers = KeyModifiers.None; + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + base.OnClosing(e); + + if (!Design.IsDesignMode && DataContext is ViewModels.Launcher launcher) + launcher.Quit(Width, Height); + } + + private void OnOpenWorkspaceMenu(object sender, RoutedEventArgs e) + { + if (sender is Button btn && DataContext is ViewModels.Launcher launcher) + { + var menu = launcher.CreateContextForWorkspace(); + menu?.Open(btn); + } + + e.Handled = true; + } + + private void OnCancelSwitcher(object sender, PointerPressedEventArgs e) + { + if (e.Source == sender) + (DataContext as ViewModels.Launcher)?.CancelSwitcher(); + e.Handled = true; + } + + private KeyModifiers _unhandledModifiers = KeyModifiers.None; + private WindowState _lastWindowState = WindowState.Normal; + } +} diff --git a/src/Views/Launcher.xaml b/src/Views/Launcher.xaml deleted file mode 100644 index d3fef695..00000000 --- a/src/Views/Launcher.xaml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Launcher.xaml.cs b/src/Views/Launcher.xaml.cs deleted file mode 100644 index 0e3cee7e..00000000 --- a/src/Views/Launcher.xaml.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.ComponentModel; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Shapes; - -namespace SourceGit.Views { - - /// - /// 主窗体 - /// - public partial class Launcher : Controls.Window { - - public Launcher() { - Models.Watcher.Opened += OpenRepository; - InitializeComponent(); - tabs.Add(); - } - - private void OnClosing(object sender, CancelEventArgs e) { - var restore = Models.Preference.Instance.Restore; - if (!restore.IsEnabled) return; - - restore.Opened.Clear(); - restore.Actived = null; - - foreach (var tab in tabs.Tabs) { - if (!tab.IsRepository) continue; - - // 仅支持恢复加入管理的仓库页,Submodules等未加入管理的不支持 - var repo = Models.Preference.Instance.FindRepository(tab.Id); - if (repo != null) restore.Opened.Add(tab.Id); - } - - if (restore.Opened.Count > 0) { - if (restore.Opened.IndexOf(tabs.Current) >= 0) { - restore.Actived = tabs.Current; - } else { - restore.Actived = restore.Opened[0]; - } - } - - Models.Preference.Save(); - } - - #region OPEN_REPO - private void OpenRepository(Models.Repository repo) { - if (tabs.Goto(repo.Path)) return; - - Task.Run(() => { - var cmd = new Commands.Config(repo.Path); - repo.GitFlow.Feature = cmd.Get("gitflow.prefix.feature"); - repo.GitFlow.Release = cmd.Get("gitflow.prefix.release"); - repo.GitFlow.Hotfix = cmd.Get("gitflow.prefix.hotfix"); - }); - - Commands.AutoFetch.Start(repo.Path); - - var page = new Widgets.Dashboard(repo); - container.Add(repo.Path, page); - Controls.PopupWidget.RegisterContainer(repo.Path, page); - - var front = container.Get(tabs.Current); - if (front == null || front is Widgets.Dashboard) { - tabs.Add(repo.Name, repo.Path, repo.Bookmark); - } else { - tabs.Replace(tabs.Current, repo.Name, repo.Path, repo.Bookmark); - } - - foreach (var tab in tabs.Tabs) { - if (tab.IsRepository) continue; - var dirty = container.Get(tabs.Current) as Widgets.Welcome; - if (dirty != null) dirty.UpdateVisibles(); - } - } - #endregion - - #region OPERATIONS - private void FillMenu(ContextMenu menu, string icon, string header, RoutedEventHandler onClick) { - var iconMode = new Path(); - iconMode.Width = 12; - iconMode.Height = 12; - iconMode.Data = FindResource(icon) as Geometry; - iconMode.SetResourceReference(Path.FillProperty, "Brush.FG1"); - - var item = new MenuItem(); - item.Icon = iconMode; - item.Header = App.Text(header); - item.Click += onClick; - - menu.Items.Add(item); - } - - private void ToggleMainMenu(object sender, RoutedEventArgs e) { - var btn = (sender as Button); - if (btn.ContextMenu != null) { - btn.ContextMenu.IsOpen = true; - e.Handled = true; - return; - } - - var menu = new ContextMenu(); - menu.Placement = PlacementMode.Bottom; - menu.PlacementTarget = btn; - menu.StaysOpen = false; - menu.Focusable = true; - - FillMenu(menu, "Icon.Preference", "Preference", (o, ev) => { - var dialog = new Preference() { Owner = this }; - dialog.ShowDialog(); - }); - - FillMenu(menu, "Icon.Help", "About", (o, ev) => { - var dialog = new About() { Owner = this }; - dialog.ShowDialog(); - }); - - btn.ContextMenu = menu; - btn.ContextMenu.IsOpen = true; - e.Handled = true; - } - - private void Minimize(object sender, RoutedEventArgs e) { - SystemCommands.MinimizeWindow(this); - } - - private void Quit(object sender, RoutedEventArgs e) { - Application.Current.Shutdown(); - } - #endregion - - #region TAB_OPERATION - private void OnTabAdding(object sender, Widgets.PageTabBar.TabEventArgs e) { - var page = new Widgets.Welcome(); - container.Add(e.TabId, page); - } - - private void OnTabSelected(object sender, Widgets.PageTabBar.TabEventArgs e) { - container.Goto(e.TabId); - Controls.PopupWidget.SetCurrentContainer(e.TabId); - } - - private void OnTabClosed(object sender, Widgets.PageTabBar.TabEventArgs e) { - Controls.PopupWidget.UnregisterContainer(e.TabId); - Models.Watcher.Close(e.TabId); - Commands.AutoFetch.Stop(e.TabId); - container.Remove(e.TabId); - GC.Collect(); - } - #endregion - - #region HOTKEYS - protected override void OnPreviewKeyDown(KeyEventArgs e) { - if (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) { - if (Keyboard.IsKeyDown(Key.Tab)) { - tabs.Next(); - e.Handled = true; - return; - } - - if (Keyboard.IsKeyDown(Key.W)) { - tabs.CloseCurrent(); - e.Handled = true; - return; - } - - if (Keyboard.IsKeyDown(Key.T)) { - tabs.Add(); - e.Handled = true; - return; - } - - if (Keyboard.IsKeyDown(Key.F)) { - var dashboard = container.Get(tabs.Current) as Widgets.Dashboard; - if (dashboard != null) { - dashboard.OpenSearch(null, null); - e.Handled = true; - return; - } - } - - for (int i = 0; i < 9; i++) { - if (Keyboard.IsKeyDown(Key.D1 + i) || Keyboard.IsKeyDown(Key.NumPad1 + i)) { - if (tabs.Tabs.Count > i) { - tabs.Goto(tabs.Tabs[i].Id); - e.Handled = true; - return; - } - } - } - } - - if (Keyboard.IsKeyDown(Key.F5)) { - var dashboard = container.Get(tabs.Current) as Widgets.Dashboard; - if (dashboard != null) dashboard.Refresh(); - e.Handled = true; - return; - } - - if (Keyboard.IsKeyDown(Key.Escape)) { - var popup = container.Get(tabs.Current) as Controls.IPopupContainer; - popup?.ClosePopups(false); - } - } - #endregion - } -} diff --git a/src/Views/LauncherPage.axaml b/src/Views/LauncherPage.axaml new file mode 100644 index 00000000..36ca39f0 --- /dev/null +++ b/src/Views/LauncherPage.axaml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LauncherPage.axaml.cs b/src/Views/LauncherPage.axaml.cs new file mode 100644 index 00000000..b2c5affe --- /dev/null +++ b/src/Views/LauncherPage.axaml.cs @@ -0,0 +1,84 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class LauncherPage : UserControl + { + public LauncherPage() + { + InitializeComponent(); + } + + private void OnPopupSure(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.LauncherPage page) + page.ProcessPopup(); + + e.Handled = true; + } + + private void OnPopupCancel(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.LauncherPage page) + page.CancelPopup(); + + e.Handled = true; + } + + private void OnMaskClicked(object sender, PointerPressedEventArgs e) + { + OnPopupCancel(sender, e); + } + + private void OnCopyNotification(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: Models.Notification notice }) + App.CopyText(notice.Message); + + e.Handled = true; + } + + private void OnDismissNotification(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: Models.Notification notice } && + DataContext is ViewModels.LauncherPage page) + page.Notifications.Remove(notice); + + e.Handled = true; + } + + private void OnPopupDataContextChanged(object sender, EventArgs e) + { + if (sender is ContentPresenter presenter) + { + if (presenter.DataContext == null || presenter.DataContext is not ViewModels.Popup) + { + presenter.Content = null; + return; + } + + var dataTypeName = presenter.DataContext.GetType().FullName; + if (string.IsNullOrEmpty(dataTypeName)) + { + presenter.Content = null; + return; + } + + var viewTypeName = dataTypeName.Replace(".ViewModels.", ".Views."); + var viewType = Type.GetType(viewTypeName); + if (viewType == null) + { + presenter.Content = null; + return; + } + + var view = Activator.CreateInstance(viewType); + presenter.Content = view; + } + } + } +} diff --git a/src/Views/LauncherPageSwitcher.axaml b/src/Views/LauncherPageSwitcher.axaml new file mode 100644 index 00000000..b52feef0 --- /dev/null +++ b/src/Views/LauncherPageSwitcher.axaml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LauncherPageSwitcher.axaml.cs b/src/Views/LauncherPageSwitcher.axaml.cs new file mode 100644 index 00000000..9bc0bf2d --- /dev/null +++ b/src/Views/LauncherPageSwitcher.axaml.cs @@ -0,0 +1,48 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class LauncherPageSwitcher : UserControl + { + public LauncherPageSwitcher() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (e.Key == Key.Enter && DataContext is ViewModels.LauncherPageSwitcher switcher) + { + switcher.Switch(); + e.Handled = true; + } + } + + private void OnItemDoubleTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.LauncherPageSwitcher switcher) + { + switcher.Switch(); + e.Handled = true; + } + } + + private void OnSearchBoxKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Down && PagesListBox.ItemCount > 0) + { + PagesListBox.Focus(NavigationMethod.Directional); + + if (PagesListBox.SelectedIndex < 0) + PagesListBox.SelectedIndex = 0; + else if (PagesListBox.SelectedIndex < PagesListBox.ItemCount) + PagesListBox.SelectedIndex++; + + e.Handled = true; + } + } + } +} diff --git a/src/Views/LauncherTabBar.axaml b/src/Views/LauncherTabBar.axaml new file mode 100644 index 00000000..01711afd --- /dev/null +++ b/src/Views/LauncherTabBar.axaml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/LauncherTabBar.axaml.cs b/src/Views/LauncherTabBar.axaml.cs new file mode 100644 index 00000000..5bab6d80 --- /dev/null +++ b/src/Views/LauncherTabBar.axaml.cs @@ -0,0 +1,369 @@ +using System; + +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public partial class LauncherTabBar : UserControl + { + public static readonly StyledProperty IsScrollerVisibleProperty = + AvaloniaProperty.Register(nameof(IsScrollerVisible)); + + public bool IsScrollerVisible + { + get => GetValue(IsScrollerVisibleProperty); + set => SetValue(IsScrollerVisibleProperty, value); + } + + public static readonly StyledProperty SearchFilterProperty = + AvaloniaProperty.Register(nameof(SearchFilter)); + + public string SearchFilter + { + get => GetValue(SearchFilterProperty); + set => SetValue(SearchFilterProperty, value); + } + + public AvaloniaList SelectablePages + { + get; + } = []; + + public LauncherTabBar() + { + InitializeComponent(); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (LauncherTabsList == null || LauncherTabsList.SelectedIndex == -1) + return; + + var startX = LauncherTabsScroller.Offset.X; + var endX = startX + LauncherTabsScroller.Viewport.Width; + var height = LauncherTabsScroller.Viewport.Height; + + var selectedIdx = LauncherTabsList.SelectedIndex; + var count = LauncherTabsList.ItemCount; + var separatorPen = new Pen(this.FindResource("Brush.FG2") as IBrush, 0.5); + var separatorY = (height - 20) * 0.5; + for (var i = 0; i < count; i++) + { + if (i == selectedIdx || i == selectedIdx - 1) + continue; + + var container = LauncherTabsList.ContainerFromIndex(i); + if (container == null) + continue; + + var containerEndX = container.Bounds.Right; + if (containerEndX < startX || containerEndX > endX) + continue; + + if (IsScrollerVisible && i == count - 1) + break; + + var separatorX = containerEndX - startX + LauncherTabsScroller.Bounds.X; + context.DrawLine(separatorPen, new Point(separatorX, separatorY), new Point(separatorX, separatorY + 20)); + } + + var selected = LauncherTabsList.ContainerFromIndex(selectedIdx); + if (selected == null) + return; + + var activeStartX = selected.Bounds.X; + var activeEndX = activeStartX + selected.Bounds.Width; + if (activeStartX > endX + 5 || activeEndX < startX - 5) + return; + + var geo = new StreamGeometry(); + var angle = Math.PI / 2; + var y = height + 0.5; + using (var ctx = geo.Open()) + { + double x; + + var drawLeftX = activeStartX - startX + LauncherTabsScroller.Bounds.X; + var drawRightX = activeEndX - startX + LauncherTabsScroller.Bounds.X; + if (drawLeftX < LauncherTabsScroller.Bounds.X) + { + x = LauncherTabsScroller.Bounds.X; + ctx.BeginFigure(new Point(x, y), true); + y = 1; + ctx.LineTo(new Point(x, y)); + } + else + { + x = drawLeftX - 5; + ctx.BeginFigure(new Point(x, y), true); + x = drawLeftX; + y -= 5; + ctx.ArcTo(new Point(x, y), new Size(5, 5), angle, false, SweepDirection.CounterClockwise); + y = 6; + ctx.LineTo(new Point(x, y)); + x += 6; + y = 1; + ctx.ArcTo(new Point(x, y), new Size(6, 6), angle, false, SweepDirection.Clockwise); + } + + x = drawRightX - 6; + + if (drawRightX <= LauncherTabsScroller.Bounds.Right) + { + ctx.LineTo(new Point(x, y)); + x = drawRightX; + y = 6; + ctx.ArcTo(new Point(x, y), new Size(6, 6), angle, false, SweepDirection.Clockwise); + y = height + 0.5 - 5; + ctx.LineTo(new Point(x, y)); + x += 5; + y = height + 0.5; + ctx.ArcTo(new Point(x, y), new Size(5, 5), angle, false, SweepDirection.CounterClockwise); + } + else + { + x = LauncherTabsScroller.Bounds.Right; + ctx.LineTo(new Point(x, y)); + y = height + 0.5; + ctx.LineTo(new Point(x, y)); + } + } + + var fill = this.FindResource("Brush.ToolBar") as IBrush; + var stroke = new Pen(this.FindResource("Brush.Border0") as IBrush); + context.DrawGeometry(fill, stroke, geo); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SearchFilterProperty) + UpdateSelectablePages(); + } + + private void ScrollTabs(object _, PointerWheelEventArgs e) + { + if (!e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + if (e.Delta.Y < 0) + LauncherTabsScroller.LineRight(); + else if (e.Delta.Y > 0) + LauncherTabsScroller.LineLeft(); + e.Handled = true; + } + } + + private void ScrollTabsLeft(object _, RoutedEventArgs e) + { + LauncherTabsScroller.LineLeft(); + e.Handled = true; + } + + private void ScrollTabsRight(object _, RoutedEventArgs e) + { + LauncherTabsScroller.LineRight(); + e.Handled = true; + } + + private void OnTabsLayoutUpdated(object _1, EventArgs _2) + { + SetCurrentValue(IsScrollerVisibleProperty, LauncherTabsScroller.Extent.Width > LauncherTabsScroller.Viewport.Width); + InvalidateVisual(); + } + + private void OnTabsSelectionChanged(object _1, SelectionChangedEventArgs _2) + { + InvalidateVisual(); + } + + private void SetupDragAndDrop(object sender, RoutedEventArgs e) + { + if (sender is Border border) + { + DragDrop.SetAllowDrop(border, true); + border.AddHandler(DragDrop.DropEvent, DropTab); + } + e.Handled = true; + } + + private void OnPointerPressedTab(object sender, PointerPressedEventArgs e) + { + if (sender is Border border) + { + var point = e.GetCurrentPoint(border); + if (point.Properties.IsMiddleButtonPressed && border.DataContext is ViewModels.LauncherPage page) + { + (DataContext as ViewModels.Launcher)?.CloseTab(page); + e.Handled = true; + } + else + { + _pressedTab = true; + _startDragTab = false; + _pressedTabPosition = e.GetPosition(border); + } + } + } + + private void OnPointerReleasedTab(object _1, PointerReleasedEventArgs _2) + { + _pressedTab = false; + _startDragTab = false; + } + + private void OnPointerMovedOverTab(object sender, PointerEventArgs e) + { + if (_pressedTab && !_startDragTab && sender is Border { DataContext: ViewModels.LauncherPage page } border) + { + var delta = e.GetPosition(border) - _pressedTabPosition; + var sizeSquired = delta.X * delta.X + delta.Y * delta.Y; + if (sizeSquired < 64) + return; + + _startDragTab = true; + + var data = new DataObject(); + data.Set("MovedTab", page); + DragDrop.DoDragDrop(e, data, DragDropEffects.Move); + } + e.Handled = true; + } + + private void DropTab(object sender, DragEventArgs e) + { + if (e.Data.Contains("MovedTab") && + e.Data.Get("MovedTab") is ViewModels.LauncherPage moved && + sender is Border { DataContext: ViewModels.LauncherPage to } && + to != moved) + { + (DataContext as ViewModels.Launcher)?.MoveTab(moved, to); + } + + _pressedTab = false; + _startDragTab = false; + e.Handled = true; + } + + private void OnTabContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is Border border && DataContext is ViewModels.Launcher vm) + { + var menu = vm.CreateContextForPageTab(border.DataContext as ViewModels.LauncherPage); + menu?.Open(border); + } + + e.Handled = true; + } + + private void OnCloseTab(object sender, RoutedEventArgs e) + { + if (sender is Button btn && DataContext is ViewModels.Launcher vm) + vm.CloseTab(btn.DataContext as ViewModels.LauncherPage); + + e.Handled = true; + } + + private void OnTabsDropdownOpened(object sender, EventArgs e) + { + UpdateSelectablePages(); + } + + private void OnTabsDropdownClosed(object sender, EventArgs e) + { + SelectablePages.Clear(); + SearchFilter = string.Empty; + } + + private void OnTabsDropdownKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + PageSelector.Flyout?.Hide(); + e.Handled = true; + } + else if (e.Key == Key.Enter) + { + if (TabsDropdownList.SelectedItem is ViewModels.LauncherPage page && + DataContext is ViewModels.Launcher vm) + { + vm.ActivePage = page; + PageSelector.Flyout?.Hide(); + e.Handled = true; + } + } + } + + private void OnTabsDropdownSearchBoxKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Down && TabsDropdownList.ItemCount > 0) + { + TabsDropdownList.Focus(NavigationMethod.Directional); + + if (TabsDropdownList.SelectedIndex < 0) + TabsDropdownList.SelectedIndex = 0; + else if (TabsDropdownList.SelectedIndex < TabsDropdownList.ItemCount) + TabsDropdownList.SelectedIndex++; + + e.Handled = true; + } + } + + private void OnTabsDropdownLostFocus(object sender, RoutedEventArgs e) + { + if (sender is Control { IsFocused: false, IsKeyboardFocusWithin: false }) + PageSelector.Flyout?.Hide(); + } + + private void OnClearSearchFilter(object sender, RoutedEventArgs e) + { + SearchFilter = string.Empty; + } + + private void OnTabsDropdownItemTapped(object sender, TappedEventArgs e) + { + if (sender is Control { DataContext: ViewModels.LauncherPage page } && + DataContext is ViewModels.Launcher vm) + { + vm.ActivePage = page; + PageSelector.Flyout?.Hide(); + e.Handled = true; + } + } + + private void UpdateSelectablePages() + { + if (DataContext is not ViewModels.Launcher vm) + return; + + SelectablePages.Clear(); + + var pages = vm.Pages; + var filter = SearchFilter?.Trim() ?? ""; + if (string.IsNullOrEmpty(filter)) + { + SelectablePages.AddRange(pages); + return; + } + + foreach (var page in pages) + { + var node = page.Node; + if (node.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || + (node.IsRepository && node.Id.Contains(filter, StringComparison.OrdinalIgnoreCase))) + SelectablePages.Add(page); + } + } + + private bool _pressedTab = false; + private Point _pressedTabPosition = new Point(); + private bool _startDragTab = false; + } +} diff --git a/src/Views/LoadingIcon.axaml b/src/Views/LoadingIcon.axaml new file mode 100644 index 00000000..02178df7 --- /dev/null +++ b/src/Views/LoadingIcon.axaml @@ -0,0 +1,7 @@ + + diff --git a/src/Views/LoadingIcon.axaml.cs b/src/Views/LoadingIcon.axaml.cs new file mode 100644 index 00000000..28f4cc9b --- /dev/null +++ b/src/Views/LoadingIcon.axaml.cs @@ -0,0 +1,56 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class LoadingIcon : UserControl + { + public LoadingIcon() + { + IsHitTestVisible = false; + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (IsVisible) + StartAnim(); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + StopAnim(); + base.OnUnloaded(e); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsVisibleProperty) + { + if (IsVisible) + StartAnim(); + else + StopAnim(); + } + } + + private void StartAnim() + { + Content = new Path() { Classes = { "rotating" } }; + } + + private void StopAnim() + { + if (Content is Path path) + path.Classes.Clear(); + + Content = null; + } + } +} diff --git a/src/Views/MenuItemExtension.cs b/src/Views/MenuItemExtension.cs new file mode 100644 index 00000000..1c23b2ea --- /dev/null +++ b/src/Views/MenuItemExtension.cs @@ -0,0 +1,12 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace SourceGit.Views +{ + public class MenuItemExtension : AvaloniaObject + { + public static readonly AttachedProperty CommandProperty = + AvaloniaProperty.RegisterAttached("Command", string.Empty, false, BindingMode.OneWay); + } +} diff --git a/src/Views/Merge.axaml b/src/Views/Merge.axaml new file mode 100644 index 00000000..33d07f02 --- /dev/null +++ b/src/Views/Merge.axaml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Merge.axaml.cs b/src/Views/Merge.axaml.cs new file mode 100644 index 00000000..8fecbbac --- /dev/null +++ b/src/Views/Merge.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Merge : UserControl + { + public Merge() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/MergeMultiple.axaml b/src/Views/MergeMultiple.axaml new file mode 100644 index 00000000..332d9fef --- /dev/null +++ b/src/Views/MergeMultiple.axaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/MergeMultiple.axaml.cs b/src/Views/MergeMultiple.axaml.cs new file mode 100644 index 00000000..c0997067 --- /dev/null +++ b/src/Views/MergeMultiple.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class MergeMultiple : UserControl + { + public MergeMultiple() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/MoveRepositoryNode.axaml b/src/Views/MoveRepositoryNode.axaml new file mode 100644 index 00000000..7c408487 --- /dev/null +++ b/src/Views/MoveRepositoryNode.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/MoveRepositoryNode.axaml.cs b/src/Views/MoveRepositoryNode.axaml.cs new file mode 100644 index 00000000..494f4f30 --- /dev/null +++ b/src/Views/MoveRepositoryNode.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class MoveRepositoryNode : UserControl + { + public MoveRepositoryNode() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/NameHighlightedTextBlock.cs b/src/Views/NameHighlightedTextBlock.cs new file mode 100644 index 00000000..49f245dd --- /dev/null +++ b/src/Views/NameHighlightedTextBlock.cs @@ -0,0 +1,111 @@ +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class NameHighlightedTextBlock : Control + { + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public static readonly StyledProperty FontFamilyProperty = + TextBlock.FontFamilyProperty.AddOwner(); + + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + public static readonly StyledProperty FontSizeProperty = + TextBlock.FontSizeProperty.AddOwner(); + + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + public static readonly StyledProperty ForegroundProperty = + TextBlock.ForegroundProperty.AddOwner(); + + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + static NameHighlightedTextBlock() + { + AffectsMeasure(TextProperty); + } + + protected override Size MeasureOverride(Size availableSize) + { + var text = Text; + if (string.IsNullOrEmpty(text)) + return base.MeasureOverride(availableSize); + + var trimmed = text.Replace("$", ""); + var typeface = new Typeface(FontFamily); + var formatted = new FormattedText( + trimmed, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + FontSize, + Foreground); + + return new Size(formatted.Width, formatted.Height); + } + + public override void Render(DrawingContext context) + { + var text = Text; + if (string.IsNullOrEmpty(text)) + return; + + var normalTypeface = new Typeface(FontFamily); + var underlinePen = new Pen(Foreground); + var offsetX = 0.0; + + var parts = text.Split('$'); + var isName = false; + foreach (var part in parts) + { + if (string.IsNullOrEmpty(part)) + { + isName = !isName; + continue; + } + + var formatted = new FormattedText( + part, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + normalTypeface, + FontSize, + Foreground); + context.DrawText(formatted, new Point(offsetX, 0)); + + if (isName) + { + var lineY = formatted.Baseline + 2; + context.DrawLine(underlinePen, new Point(offsetX, lineY), new Point(offsetX + formatted.Width, lineY)); + } + + offsetX += formatted.WidthIncludingTrailingWhitespace; + isName = !isName; + } + } + } +} diff --git a/src/Views/PopupRunningStatus.axaml b/src/Views/PopupRunningStatus.axaml new file mode 100644 index 00000000..6a0dbdb4 --- /dev/null +++ b/src/Views/PopupRunningStatus.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/src/Views/PopupRunningStatus.axaml.cs b/src/Views/PopupRunningStatus.axaml.cs new file mode 100644 index 00000000..05258799 --- /dev/null +++ b/src/Views/PopupRunningStatus.axaml.cs @@ -0,0 +1,68 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class PopupRunningStatus : UserControl + { + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public string Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public PopupRunningStatus() + { + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + _isUnloading = false; + if (IsVisible) + StartAnim(); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + _isUnloading = true; + base.OnUnloaded(e); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsVisibleProperty) + { + if (IsVisible && !_isUnloading) + StartAnim(); + else + StopAnim(); + } + } + + private void StartAnim() + { + Icon.Content = new Path() { Classes = { "waiting" } }; + ProgressBar.IsIndeterminate = true; + } + + private void StopAnim() + { + if (Icon.Content is Path path) + path.Classes.Clear(); + Icon.Content = null; + ProgressBar.IsIndeterminate = false; + } + + private bool _isUnloading = false; + } +} diff --git a/src/Views/Popups/AddSubTree.xaml b/src/Views/Popups/AddSubTree.xaml deleted file mode 100644 index b4fea84c..00000000 --- a/src/Views/Popups/AddSubTree.xaml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/AddSubTree.xaml.cs b/src/Views/Popups/AddSubTree.xaml.cs deleted file mode 100644 index 0086a0a2..00000000 --- a/src/Views/Popups/AddSubTree.xaml.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - - /// - /// 添加子树面板 - /// - public partial class AddSubTree : Controls.PopupWidget { - private Models.Repository repo = null; - - public string Source { get; set; } - public string Ref { get; set; } - public string Prefix { get; set; } - - public AddSubTree(Models.Repository repo) { - this.repo = repo; - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("AddSubTree"); - } - - public override Task Start() { - txtSource.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtSource)) return null; - - txtPrefix.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtPrefix)) return null; - - txtRef.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtRef)) return null; - - var squash = chkSquash.IsChecked == true; - if (repo.SubTrees.FindIndex(x => x.Prefix == Prefix) >= 0) { - Models.Exception.Raise($"Subtree add failed. Prefix({Prefix}) already exists!"); - return null; - } - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo.Path, false); - var succ = new Commands.SubTree(repo.Path).Add(Prefix, Source, Ref, squash, UpdateProgress); - if (succ) { - repo.SubTrees.Add(new Models.SubTree() { - Prefix = Prefix, - Remote = Source, - }); - Models.Preference.Save(); - Models.Watcher.Get(repo.Path)?.RefreshSubTrees(); - } - Models.Watcher.SetEnabled(repo.Path, true); - return succ; - }); - } - } -} diff --git a/src/Views/Popups/AddSubmodule.xaml b/src/Views/Popups/AddSubmodule.xaml deleted file mode 100644 index e80e0968..00000000 --- a/src/Views/Popups/AddSubmodule.xaml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/AddSubmodule.xaml.cs b/src/Views/Popups/AddSubmodule.xaml.cs deleted file mode 100644 index 3cc65b99..00000000 --- a/src/Views/Popups/AddSubmodule.xaml.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 新增子模块面板 - /// - public partial class AddSubmodule : Controls.PopupWidget { - private string repo = null; - - public string URL { get; set; } - public string Path { get; set; } - - public AddSubmodule(string repo) { - this.repo = repo; - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("Submodule.Add"); - } - - public override Task Start() { - txtURL.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtURL)) return null; - - txtPath.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtPath)) return null; - - var recursive = chkNested.IsChecked == true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - var succ = new Commands.Submodule(repo).Add(URL, Path, recursive, UpdateProgress); - Models.Watcher.SetEnabled(repo, true); - return succ; - }); - } - } -} diff --git a/src/Views/Popups/Apply.xaml b/src/Views/Popups/Apply.xaml deleted file mode 100644 index 55218d87..00000000 --- a/src/Views/Popups/Apply.xaml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Apply.xaml.cs b/src/Views/Popups/Apply.xaml.cs deleted file mode 100644 index 19e44337..00000000 --- a/src/Views/Popups/Apply.xaml.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Win32; -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 应用补丁 - /// - public partial class Apply : Controls.PopupWidget { - private string repo = null; - public string File { get; set; } - - public Apply(string repo) { - this.repo = repo; - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("Apply.Title"); - } - - public override Task Start() { - txtPath.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtPath)) return null; - - var ignoreWS = chkIngoreWS.IsChecked == true; - var wsMode = (cmbWSOption.SelectedItem as Models.WhitespaceOption).Arg; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - var succ = new Commands.Apply(repo, File, ignoreWS, wsMode).Exec(); - Models.Watcher.SetEnabled(repo, true); - return succ; - }); - } - - private void OpenFileBrowser(object sender, System.Windows.RoutedEventArgs e) { - var dialog = new OpenFileDialog(); - dialog.Filter = "Patch File|*.patch"; - dialog.Title = App.Text("Apply.File.Placeholder"); - dialog.InitialDirectory = repo; - dialog.CheckFileExists = true; - - if (dialog.ShowDialog() == true) { - File = dialog.FileName; - txtPath.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - } - } - } -} diff --git a/src/Views/Popups/Archive.xaml b/src/Views/Popups/Archive.xaml deleted file mode 100644 index 16b2ffe4..00000000 --- a/src/Views/Popups/Archive.xaml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Archive.xaml.cs b/src/Views/Popups/Archive.xaml.cs deleted file mode 100644 index fef229b1..00000000 --- a/src/Views/Popups/Archive.xaml.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.Win32; -using System.IO; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; - -namespace SourceGit.Views.Popups { - - /// - /// 存档操作面板 - /// - public partial class Archive : Controls.PopupWidget { - private string repo; - private string revision; - - public string SaveTo { get; set; } - - public Archive(string repo, Models.Branch branch) { - this.repo = repo; - this.revision = branch.Head; - this.SaveTo = $"archive-{Path.GetFileNameWithoutExtension(branch.Name)}.zip"; - - InitializeComponent(); - - iconBased.Data = FindResource("Icon.Branch") as Geometry; - txtBased.Text = branch.IsLocal ? branch.Name : $"{branch.Remote}/{branch.Name}"; - } - - public Archive(string repo, Models.Commit revision) { - this.repo = repo; - this.revision = revision.SHA; - this.SaveTo = $"archive-{revision.ShortSHA}.zip"; - - InitializeComponent(); - - iconBased.Data = FindResource("Icon.Commit") as Geometry; - txtSHA.Text = revision.ShortSHA; - badgeSHA.Visibility = Visibility.Visible; - txtBased.Text = revision.Subject; - } - - public Archive(string repo, Models.Tag tag) { - this.repo = repo; - this.revision = tag.SHA; - this.SaveTo = $"archive-{tag.Name}.zip"; - - InitializeComponent(); - - iconBased.Data = FindResource("Icon.Tag") as Geometry; - txtBased.Text = tag.Name; - } - - public override string GetTitle() { - return App.Text("Archive.Title"); - } - - public override Task Start() { - txtSaveTo.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtSaveTo)) return null; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - var succ = new Commands.Archive(repo, revision, SaveTo, UpdateProgress).Exec(); - Models.Watcher.SetEnabled(repo, true); - return succ; - }); - } - - private void OpenFileBrowser(object sender, RoutedEventArgs e) { - var dialog = new OpenFileDialog(); - dialog.Filter = "ZIP|*.zip"; - dialog.Title = App.Text("Archive.File.Placeholder"); - dialog.InitialDirectory = repo; - dialog.CheckFileExists = false; - - if (dialog.ShowDialog() == true) { - SaveTo = dialog.FileName; - txtSaveTo.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - } - } - } -} diff --git a/src/Views/Popups/Checkout.xaml b/src/Views/Popups/Checkout.xaml deleted file mode 100644 index 811c4088..00000000 --- a/src/Views/Popups/Checkout.xaml +++ /dev/null @@ -1,10 +0,0 @@ - - diff --git a/src/Views/Popups/Checkout.xaml.cs b/src/Views/Popups/Checkout.xaml.cs deleted file mode 100644 index 6a531dfd..00000000 --- a/src/Views/Popups/Checkout.xaml.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - - /// - /// 切换分支 - /// - public partial class Checkout : Controls.PopupWidget { - private string repo; - private string branch; - - public Checkout(string repo, string branch) { - this.repo = repo; - this.branch = branch; - - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("BranchCM.Checkout", branch); - } - - public override Task Start() { - UpdateProgress(GetTitle()); - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Checkout(repo).Branch(branch, UpdateProgress); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/CherryPick.xaml b/src/Views/Popups/CherryPick.xaml deleted file mode 100644 index c4ba0644..00000000 --- a/src/Views/Popups/CherryPick.xaml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/CherryPick.xaml.cs b/src/Views/Popups/CherryPick.xaml.cs deleted file mode 100644 index 8bcdf553..00000000 --- a/src/Views/Popups/CherryPick.xaml.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 遴选面板 - /// - public partial class CherryPick : Controls.PopupWidget { - private string repo = null; - private string commit = null; - - public CherryPick(string repo, Models.Commit commit) { - this.repo = repo; - this.commit = commit.SHA; - - InitializeComponent(); - - txtSHA.Text = commit.ShortSHA; - txtCommit.Text = commit.Subject; - } - - public override string GetTitle() { - return App.Text("CherryPick.Title"); - } - - public override Task Start() { - var noCommits = chkCommit.IsChecked != true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.CherryPick(repo, commit, noCommits).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Cleanup.xaml b/src/Views/Popups/Cleanup.xaml deleted file mode 100644 index ede090f8..00000000 --- a/src/Views/Popups/Cleanup.xaml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/src/Views/Popups/Cleanup.xaml.cs b/src/Views/Popups/Cleanup.xaml.cs deleted file mode 100644 index 52a8a154..00000000 --- a/src/Views/Popups/Cleanup.xaml.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 清理仓库 - /// - public partial class Cleanup : Controls.PopupWidget { - private string repo; - - public Cleanup(string repo) { - this.repo = repo; - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("Dashboard.Clean"); - } - - public override Task Start() { - UpdateProgress(GetTitle()); - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.GC(repo, UpdateProgress).Exec(); - - var lfs = new Commands.LFS(repo); - if (lfs.IsEnabled()) lfs.Prune(UpdateProgress); - - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Configure.xaml b/src/Views/Popups/Configure.xaml deleted file mode 100644 index 58ed4519..00000000 --- a/src/Views/Popups/Configure.xaml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Configure.xaml.cs b/src/Views/Popups/Configure.xaml.cs deleted file mode 100644 index 2619ce16..00000000 --- a/src/Views/Popups/Configure.xaml.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 仓库配置 - /// - public partial class Configure : Controls.PopupWidget { - private string repo = null; - - public string UserName { get; set; } - public string UserEmail { get; set; } - public bool GPGSigningEnabled { get; set; } - public string GPGUserSigningKey { get; set; } - public string Proxy { get; set; } - - public Configure(string repo) { - this.repo = repo; - - var cmd = new Commands.Config(repo); - UserName = cmd.Get("user.name"); - UserEmail = cmd.Get("user.email"); - GPGSigningEnabled = cmd.Get("commit.gpgsign") == "true"; - GPGUserSigningKey = cmd.Get("user.signingkey"); - Proxy = cmd.Get("http.proxy"); - - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("Configure"); - } - - public override Task Start() { - return Task.Run(() => { - var cmd = new Commands.Config(repo); - - var oldUser = cmd.Get("user.name"); - if (oldUser != UserName) cmd.Set("user.name", UserName); - var oldEmail = cmd.Get("user.email"); - if (oldEmail != UserEmail) cmd.Set("user.email", UserEmail); - var oldProxy = cmd.Get("http.proxy"); - if (oldProxy != Proxy) cmd.Set("http.proxy", Proxy); - var oldGPGSigningEnabled = cmd.Get("commit.gpgsign") == "true"; - if (oldGPGSigningEnabled != GPGSigningEnabled) cmd.Set("commit.gpgsign", GPGSigningEnabled ? "true" : "false"); - var oldGPGUserSigningKey = cmd.Get("user.signingkey"); - if (oldGPGUserSigningKey != GPGUserSigningKey) cmd.Set("user.signingkey", GPGUserSigningKey); - - return true; - }); - } - } -} diff --git a/src/Views/Popups/CreateBranch.xaml b/src/Views/Popups/CreateBranch.xaml deleted file mode 100644 index 1fad3fa6..00000000 --- a/src/Views/Popups/CreateBranch.xaml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/CreateBranch.xaml.cs b/src/Views/Popups/CreateBranch.xaml.cs deleted file mode 100644 index a4ef1731..00000000 --- a/src/Views/Popups/CreateBranch.xaml.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; - -namespace SourceGit.Views.Popups { - /// - /// 新建分支面板 - /// - public partial class CreateBranch : Controls.PopupWidget { - private string repo = null; - private string basedOn = null; - - public string BranchName { get; set; } = ""; - public bool AutoStash { get; set; } = true; - - public CreateBranch(Models.Repository repo, Models.Branch branch) { - this.repo = repo.Path; - this.basedOn = branch.FullName; - - if (!branch.IsLocal) BranchName = branch.Name; - - InitializeComponent(); - - ruleBranch.Repo = repo; - iconBased.Data = FindResource("Icon.Branch") as Geometry; - txtBased.Text = !string.IsNullOrEmpty(branch.Remote) ? $"{branch.Remote}/{branch.Name}" : branch.Name; - } - - public CreateBranch(Models.Repository repo, Models.Commit commit) { - this.repo = repo.Path; - this.basedOn = commit.SHA; - - InitializeComponent(); - - ruleBranch.Repo = repo; - iconBased.Data = FindResource("Icon.Commit") as Geometry; - txtSHA.Text = commit.ShortSHA; - txtBased.Text = commit.Subject; - badgeSHA.Visibility = Visibility.Visible; - } - - public CreateBranch(Models.Repository repo, Models.Tag tag) { - this.repo = repo.Path; - this.basedOn = tag.Name; - - InitializeComponent(); - - ruleBranch.Repo = repo; - iconBased.Data = FindResource("Icon.Tag") as Geometry; - txtBased.Text = tag.Name; - } - - public override string GetTitle() { - return App.Text("CreateBranch"); - } - - public override Task Start() { - txtBranchName.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtBranchName)) return null; - - var checkout = chkCheckout.IsChecked == true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - if (checkout) { - var changes = new Commands.LocalChanges(repo).Result(); - if (changes.Count > 0) { - if (AutoStash) { - if (!new Commands.Stash(repo).Push(changes, "NEWBRANCH_AUTO_STASH", true)) { - return false; - } - } else { - new Commands.Discard(repo).Whole(); - } - } else { - AutoStash = false; - } - - UpdateProgress($"Create new branch '{BranchName}'"); - new Commands.Checkout(repo).Branch(BranchName, basedOn, UpdateProgress); - if (AutoStash) new Commands.Stash(repo).Pop("stash@{0}"); - } else { - new Commands.Branch(repo, BranchName).Create(basedOn); - } - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/CreateTag.xaml b/src/Views/Popups/CreateTag.xaml deleted file mode 100644 index 21b969f3..00000000 --- a/src/Views/Popups/CreateTag.xaml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/CreateTag.xaml.cs b/src/Views/Popups/CreateTag.xaml.cs deleted file mode 100644 index 0cdbacfc..00000000 --- a/src/Views/Popups/CreateTag.xaml.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; -using System.Windows.Media; - -namespace SourceGit.Views.Popups { - - /// - /// 创建分支面板 - /// - public partial class CreateTag : Controls.PopupWidget { - private string repo = null; - private string basedOn = null; - - public string TagName { get; set; } - public string Message { get; set; } - - public CreateTag(Models.Repository repo, Models.Branch branch) { - this.repo = repo.Path; - this.basedOn = branch.Head; - - InitializeComponent(); - - ruleTag.Tags = new Commands.Tags(repo.Path).Result(); - iconBased.Data = FindResource("Icon.Branch") as Geometry; - txtBased.Text = !string.IsNullOrEmpty(branch.Remote) ? $"{branch.Remote}/{branch.Name}" : branch.Name; - } - - public CreateTag(Models.Repository repo, Models.Commit commit) { - this.repo = repo.Path; - this.basedOn = commit.SHA; - - InitializeComponent(); - - ruleTag.Tags = new Commands.Tags(repo.Path).Result(); - iconBased.Data = FindResource("Icon.Commit") as Geometry; - txtSHA.Text = commit.ShortSHA; - txtBased.Text = commit.Subject; - badgeSHA.Visibility = System.Windows.Visibility.Visible; - } - - public override string GetTitle() { - return App.Text("CreateTag"); - } - - public override Task Start() { - txtTagName.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtTagName)) return null; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Tag(repo).Add(TagName, basedOn, Message); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/DeleteBranch.xaml b/src/Views/Popups/DeleteBranch.xaml deleted file mode 100644 index 9acdda19..00000000 --- a/src/Views/Popups/DeleteBranch.xaml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/DeleteBranch.xaml.cs b/src/Views/Popups/DeleteBranch.xaml.cs deleted file mode 100644 index 7d1b5bcb..00000000 --- a/src/Views/Popups/DeleteBranch.xaml.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 删除分支确认 - /// - public partial class DeleteBranch : Controls.PopupWidget { - private string repo = null; - private string branch = null; - private string remote = null; - private Action finishHandler = null; - - public DeleteBranch(string repo, string branch, string remote = null) { - this.repo = repo; - this.branch = branch; - this.remote = remote; - - InitializeComponent(); - - if (string.IsNullOrEmpty(remote)) txtTarget.Text = branch; - else txtTarget.Text = $"{remote}/{branch}"; - } - - public DeleteBranch Then(Action handler) { - this.finishHandler = handler; - return this; - } - - public override string GetTitle() { - return App.Text("DeleteBranch"); - } - - public override Task Start() { - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - - var full = branch; - if (string.IsNullOrEmpty(remote)) { - full = $"refs/heads/{branch}"; - new Commands.Branch(repo, branch).Delete(); - } else { - full = $"refs/remotes/{remote}/{branch}"; - new Commands.Push(repo, remote, branch).Exec(); - } - - finishHandler?.Invoke(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/DeleteRemote.xaml b/src/Views/Popups/DeleteRemote.xaml deleted file mode 100644 index d6078619..00000000 --- a/src/Views/Popups/DeleteRemote.xaml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/DeleteRemote.xaml.cs b/src/Views/Popups/DeleteRemote.xaml.cs deleted file mode 100644 index cbfb5986..00000000 --- a/src/Views/Popups/DeleteRemote.xaml.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - - /// - /// 删除远程确认 - /// - public partial class DeleteRemote : Controls.PopupWidget { - private string repo = null; - private string remote = null; - - public DeleteRemote(string repo, string remote) { - this.repo = repo; - this.remote = remote; - - InitializeComponent(); - txtTarget.Text = remote; - } - - public override string GetTitle() { - return App.Text("DeleteRemote"); - } - - public override Task Start() { - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Remote(repo).Delete(remote); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/DeleteSubmodule.xaml b/src/Views/Popups/DeleteSubmodule.xaml deleted file mode 100644 index ebfe8942..00000000 --- a/src/Views/Popups/DeleteSubmodule.xaml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/DeleteSubmodule.xaml.cs b/src/Views/Popups/DeleteSubmodule.xaml.cs deleted file mode 100644 index 18e6b4d5..00000000 --- a/src/Views/Popups/DeleteSubmodule.xaml.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 删除子模块面板 - /// - public partial class DeleteSubmodule : Controls.PopupWidget { - private string repo = null; - private string submodule = null; - - public DeleteSubmodule(string repo, string submodule) { - this.repo = repo; - this.submodule = submodule; - - InitializeComponent(); - - txtPath.Text = submodule; - } - - public override string GetTitle() { - return App.Text("DeleteSubmodule"); - } - - public override Task Start() { - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Submodule(repo).Delete(submodule); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/DeleteTag.xaml b/src/Views/Popups/DeleteTag.xaml deleted file mode 100644 index 90716202..00000000 --- a/src/Views/Popups/DeleteTag.xaml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/DeleteTag.xaml.cs b/src/Views/Popups/DeleteTag.xaml.cs deleted file mode 100644 index 05e6895c..00000000 --- a/src/Views/Popups/DeleteTag.xaml.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 删除标签 - /// - public partial class DeleteTag : Controls.PopupWidget { - private string repo = null; - private string tag = null; - - public DeleteTag(string repo, string tag) { - this.repo = repo; - this.tag = tag; - - InitializeComponent(); - - txtTag.Text = tag; - } - - public override string GetTitle() { - return App.Text("DeleteTag"); - } - - public override Task Start() { - var push = chkPush.IsChecked == true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Tag(repo).Delete(tag, push); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Discard.xaml b/src/Views/Popups/Discard.xaml deleted file mode 100644 index 001c5468..00000000 --- a/src/Views/Popups/Discard.xaml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Discard.xaml.cs b/src/Views/Popups/Discard.xaml.cs deleted file mode 100644 index da855147..00000000 --- a/src/Views/Popups/Discard.xaml.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Windows.Media; - -namespace SourceGit.Views.Popups { - /// - /// 忽略变更 - /// - public partial class Discard : Controls.PopupWidget { - private string repo = null; - private List changes = null; - - public Discard(string repo, List changes) { - this.repo = repo; - this.changes = changes; - - InitializeComponent(); - - if (changes == null || changes.Count == 0) { - icon.Data = FindResource("Icon.Folder") as Geometry; - txtTip.Text = App.Text("Discard.All"); - } else if (changes.Count == 1) { - txtTip.Text = changes[0].Path; - } else { - txtTip.Text = App.Text("Discard.Total", changes.Count); - } - } - - public override string GetTitle() { - return App.Text("Discard"); - } - - public override Task Start() { - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - var cmd = new Commands.Discard(repo); - if (changes == null || changes.Count == 0) { - cmd.Whole(); - } else { - cmd.Changes(changes); - } - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/EditSubTree.xaml b/src/Views/Popups/EditSubTree.xaml deleted file mode 100644 index b08aef60..00000000 --- a/src/Views/Popups/EditSubTree.xaml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/EditSubTree.xaml.cs b/src/Views/Popups/EditSubTree.xaml.cs deleted file mode 100644 index 32e2d538..00000000 --- a/src/Views/Popups/EditSubTree.xaml.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 编辑子树 - /// - public partial class EditSubTree : Controls.PopupWidget { - private Models.Repository repo; - private Models.SubTree subtree; - - public string Source { - get { return subtree.Remote; } - set { subtree.Remote = value; } - } - - public EditSubTree(Models.Repository repo, string prefix) { - this.repo = repo; - this.subtree = repo.SubTrees.Find(x => x.Prefix == prefix); - InitializeComponent(); - txtPrefix.Text = prefix; - } - - public override string GetTitle() { - return App.Text("EditSubTree"); - } - - public override Task Start() { - txtSource.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtSource)) return null; - return Task.Run(() => true); - } - } -} diff --git a/src/Views/Popups/FastForwardWithoutCheckout.xaml b/src/Views/Popups/FastForwardWithoutCheckout.xaml deleted file mode 100644 index e7b36ec7..00000000 --- a/src/Views/Popups/FastForwardWithoutCheckout.xaml +++ /dev/null @@ -1,10 +0,0 @@ - - \ No newline at end of file diff --git a/src/Views/Popups/FastForwardWithoutCheckout.xaml.cs b/src/Views/Popups/FastForwardWithoutCheckout.xaml.cs deleted file mode 100644 index 37efa5b5..00000000 --- a/src/Views/Popups/FastForwardWithoutCheckout.xaml.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 对于不是当前分支的本地分支,Fast-Forward - /// - public partial class FastForwardWithoutCheckout : Controls.PopupWidget { - private string repo = null; - private string remote = null; - private string localBranch = null; - private string remoteBranch = null; - private bool isValid = false; - - public FastForwardWithoutCheckout(string repo, string branch, string upstream) { - int idx = upstream.IndexOf('/'); - if (idx < 0 || idx == upstream.Length - 1) { - Models.Exception.Raise($"Invalid upstream: {upstream}"); - return; - } - - this.repo = repo; - this.remote = upstream.Substring(0, idx); - this.localBranch = branch; - this.remoteBranch = upstream.Substring(idx+1); - this.isValid = true; - - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("Fetch.Title"); - } - - public override Task Start() { - return Task.Run(() => { - if (isValid) { - Models.Watcher.SetEnabled(repo, false); - new Commands.Fetch(repo, remote, localBranch, remoteBranch, UpdateProgress).Exec(); - Models.Watcher.SetEnabled(repo, true); - } - - return true; - }); - } - } -} diff --git a/src/Views/Popups/Fetch.xaml b/src/Views/Popups/Fetch.xaml deleted file mode 100644 index f70b5b04..00000000 --- a/src/Views/Popups/Fetch.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Fetch.xaml.cs b/src/Views/Popups/Fetch.xaml.cs deleted file mode 100644 index a900b33b..00000000 --- a/src/Views/Popups/Fetch.xaml.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - - /// - /// 拉取更新 - /// - public partial class Fetch : Controls.PopupWidget { - private string repo = null; - - public Fetch(Models.Repository repo, string preferRemote) { - this.repo = repo.Path; - InitializeComponent(); - remotes.ItemsSource = repo.Remotes; - if (preferRemote != null) { - remotes.SelectedIndex = repo.Remotes.FindIndex(x => x.Name == preferRemote); - chkFetchAll.IsChecked = false; - } else { - remotes.SelectedIndex = 0; - chkFetchAll.IsChecked = true; - } - } - - public override string GetTitle() { - return App.Text("Fetch.Title"); - } - - public override Task Start() { - var prune = chkPrune.IsChecked == true; - var remote = (remotes.SelectedItem as Models.Remote).Name; - var all = chkFetchAll.IsChecked == true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - - if (all) { - foreach (var r in remotes.ItemsSource) { - new Commands.Fetch(repo, (r as Models.Remote).Name, prune, UpdateProgress).Exec(); - } - } else { - new Commands.Fetch(repo, remote, prune, UpdateProgress).Exec(); - } - - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/GitFlowFinish.xaml b/src/Views/Popups/GitFlowFinish.xaml deleted file mode 100644 index 848024de..00000000 --- a/src/Views/Popups/GitFlowFinish.xaml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/GitFlowFinish.xaml.cs b/src/Views/Popups/GitFlowFinish.xaml.cs deleted file mode 100644 index 3ea28f84..00000000 --- a/src/Views/Popups/GitFlowFinish.xaml.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 完成GitFlow分支开发 - /// - public partial class GitFlowFinish : Controls.PopupWidget { - private string repo = null; - private string name = null; - private Models.GitFlowBranchType type = Models.GitFlowBranchType.None; - - public GitFlowFinish(Models.Repository repo, string branch, Models.GitFlowBranchType type) { - this.repo = repo.Path; - this.type = type; - - InitializeComponent(); - - txtName.Text = branch; - switch (type) { - case Models.GitFlowBranchType.Feature: - txtPrefix.Text = App.Text("GitFlow.Feature"); - name = branch.Substring(repo.GitFlow.Feature.Length); - break; - case Models.GitFlowBranchType.Release: - txtPrefix.Text = App.Text("GitFlow.Release"); - name = branch.Substring(repo.GitFlow.Release.Length); - break; - case Models.GitFlowBranchType.Hotfix: - txtPrefix.Text = App.Text("GitFlow.Hotfix"); - name = branch.Substring(repo.GitFlow.Hotfix.Length); - break; - } - } - - public override string GetTitle() { - switch (type) { - case Models.GitFlowBranchType.Feature: - return App.Text("GitFlow.FinishFeature"); - case Models.GitFlowBranchType.Release: - return App.Text("GitFlow.FinishRelease"); - case Models.GitFlowBranchType.Hotfix: - return App.Text("GitFlow.FinishHotfix"); - default: - return ""; - } - } - - public override Task Start() { - var keepBranch = chkKeep.IsChecked == true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.GitFlow(repo).Finish(type, name, keepBranch); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/GitFlowStart.xaml b/src/Views/Popups/GitFlowStart.xaml deleted file mode 100644 index ec023911..00000000 --- a/src/Views/Popups/GitFlowStart.xaml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/GitFlowStart.xaml.cs b/src/Views/Popups/GitFlowStart.xaml.cs deleted file mode 100644 index fa518ad4..00000000 --- a/src/Views/Popups/GitFlowStart.xaml.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// Git-Flow start命令操作面板 - /// - public partial class GitFlowStart : Controls.PopupWidget { - private string repo = null; - private Models.GitFlowBranchType type = Models.GitFlowBranchType.None; - - public string BranchName { get; set; } - - public GitFlowStart(Models.Repository repo, Models.GitFlowBranchType type) { - this.repo = repo.Path; - this.type = type; - - InitializeComponent(); - - ruleBranch.Repo = repo; - switch (type) { - case Models.GitFlowBranchType.Feature: - ruleBranch.Prefix = repo.GitFlow.Feature; - txtPrefix.Text = repo.GitFlow.Feature; - break; - case Models.GitFlowBranchType.Release: - ruleBranch.Prefix = repo.GitFlow.Release; - txtPrefix.Text = repo.GitFlow.Release; - break; - case Models.GitFlowBranchType.Hotfix: - ruleBranch.Prefix = repo.GitFlow.Hotfix; - txtPrefix.Text = repo.GitFlow.Hotfix; - break; - } - } - - public override string GetTitle() { - switch (type) { - case Models.GitFlowBranchType.Feature: - return App.Text("GitFlow.StartFeatureTitle"); - case Models.GitFlowBranchType.Release: - return App.Text("GitFlow.StartReleaseTitle"); - case Models.GitFlowBranchType.Hotfix: - return App.Text("GitFlow.StartHotfixTitle"); - default: - return ""; - } - } - - public override Task Start() { - txtBranchName.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtBranchName)) return null; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.GitFlow(repo).Start(type, BranchName); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Init.xaml b/src/Views/Popups/Init.xaml deleted file mode 100644 index d9e487a3..00000000 --- a/src/Views/Popups/Init.xaml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Init.xaml.cs b/src/Views/Popups/Init.xaml.cs deleted file mode 100644 index 9f50aee6..00000000 --- a/src/Views/Popups/Init.xaml.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.IO; -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - - /// - /// 初始化Git仓库确认框 - /// - public partial class Init : Controls.PopupWidget { - public string WorkDir { get; set; } - - public Init(string dir) { - WorkDir = dir; - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("Init"); - } - - public override Task Start() { - return Task.Run(() => { - var succ = new Commands.Init(WorkDir).Exec(); - if (!succ) return false; - - var gitDir = Path.GetFullPath(Path.Combine(WorkDir, ".git")); - var repo = Models.Preference.Instance.AddRepository(WorkDir, gitDir); - Dispatcher.Invoke(() => Models.Watcher.Open(repo)); - return true; - }); - } - } -} diff --git a/src/Views/Popups/InitGitFlow.xaml b/src/Views/Popups/InitGitFlow.xaml deleted file mode 100644 index adf09916..00000000 --- a/src/Views/Popups/InitGitFlow.xaml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/InitGitFlow.xaml.cs b/src/Views/Popups/InitGitFlow.xaml.cs deleted file mode 100644 index 2062d9ae..00000000 --- a/src/Views/Popups/InitGitFlow.xaml.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 初始化Git-Flow - /// - public partial class InitGitFlow : Controls.PopupWidget { - private Models.Repository repo = null; - - public InitGitFlow(Models.Repository repo) { - this.repo = repo; - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("GitFlow.Init"); - } - - public override Task Start() { - var master = txtMaster.Text; - var dev = txtDevelop.Text; - var feature = txtFeature.Text; - var release = txtRelease.Text; - var hotfix = txtHotfix.Text; - var version = txtTag.Text; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo.Path, false); - var succ = new Commands.GitFlow(repo.Path).Init(master, dev, feature, release, hotfix, version); - var cmd = new Commands.Config(repo.Path); - if (succ) { - repo.GitFlow.Feature = cmd.Get("gitflow.prefix.feature"); - repo.GitFlow.Release = cmd.Get("gitflow.prefix.release"); - repo.GitFlow.Hotfix = cmd.Get("gitflow.prefix.hotfix"); - } else { - cmd.Set("gitflow.branch.master", null); - cmd.Set("gitflow.branch.develop", null); - cmd.Set("gitflow.prefix.feature", null); - cmd.Set("gitflow.prefix.bugfix", null); - cmd.Set("gitflow.prefix.release", null); - cmd.Set("gitflow.prefix.hotfix", null); - cmd.Set("gitflow.prefix.support", null); - cmd.Set("gitflow.prefix.versiontag", null); - } - Models.Watcher.SetEnabled(repo.Path, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Merge.xaml b/src/Views/Popups/Merge.xaml deleted file mode 100644 index 420b9199..00000000 --- a/src/Views/Popups/Merge.xaml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Merge.xaml.cs b/src/Views/Popups/Merge.xaml.cs deleted file mode 100644 index dd3f2f98..00000000 --- a/src/Views/Popups/Merge.xaml.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 合并操作界面 - /// - public partial class Merge : Controls.PopupWidget { - private string repo = null; - private string source = null; - - public Merge(string repo, string source, string dest) { - this.repo = repo; - this.source = source; - - InitializeComponent(); - - txtSource.Text = source; - txtInto.Text = dest; - } - - public override string GetTitle() { - return App.Text("Merge"); - } - - public override Task Start() { - var mode = (cmbMode.SelectedItem as Models.MergeOption).Arg; - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Merge(repo, source, mode, UpdateProgress).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Prune.xaml b/src/Views/Popups/Prune.xaml deleted file mode 100644 index 2a207bd7..00000000 --- a/src/Views/Popups/Prune.xaml +++ /dev/null @@ -1,10 +0,0 @@ - - diff --git a/src/Views/Popups/Prune.xaml.cs b/src/Views/Popups/Prune.xaml.cs deleted file mode 100644 index cca1e137..00000000 --- a/src/Views/Popups/Prune.xaml.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 清理远程已删除分支 - /// - public partial class Prune : Controls.PopupWidget { - private string repo = null; - private string remote = null; - - public Prune(string repo, string remote) { - this.repo = repo; - this.remote = remote; - InitializeComponent(); - } - - public override string GetTitle() { - return App.Text("RemoteCM.Prune"); - } - - public override Task Start() { - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - var succ = new Commands.Remote(repo).Prune(remote); - Models.Watcher.SetEnabled(repo, true); - return succ; - }); - } - } -} diff --git a/src/Views/Popups/Pull.xaml b/src/Views/Popups/Pull.xaml deleted file mode 100644 index 0a867590..00000000 --- a/src/Views/Popups/Pull.xaml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Pull.xaml.cs b/src/Views/Popups/Pull.xaml.cs deleted file mode 100644 index 866d3fdc..00000000 --- a/src/Views/Popups/Pull.xaml.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - - /// - /// 拉回 - /// - public partial class Pull : Controls.PopupWidget { - private Models.Repository repo = null; - private Models.Branch prefered = null; - - public Pull(Models.Repository repo, Models.Branch preferRemoteBranch) { - this.repo = repo; - this.prefered = preferRemoteBranch; - - InitializeComponent(); - - var current = repo.Branches.Find(x => x.IsCurrent); - if (current == null) return; - - txtInto.Text = current.Name; - - if (prefered == null && !string.IsNullOrEmpty(current.Upstream)) { - prefered = repo.Branches.Find(x => x.FullName == current.Upstream); - } - - cmbRemotes.ItemsSource = repo.Remotes; - if (prefered != null) { - cmbRemotes.SelectedItem = repo.Remotes.Find(x => x.Name == prefered.Remote); - } else { - cmbRemotes.SelectedItem = repo.Remotes[0]; - } - } - - public override string GetTitle() { - return App.Text("Pull.Title"); - } - - public override Task Start() { - var branch = cmbBranches.SelectedItem as Models.Branch; - if (branch == null) return null; - - var rebase = Models.Preference.Instance.Window.UseRebaseOnPull; - var autoStash = Models.Preference.Instance.Window.UseAutoStashOnPull; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo.Path, false); - var succ = new Commands.Pull(repo.Path, branch.Remote, branch.Name, rebase, autoStash, UpdateProgress).Run(); - Models.Watcher.SetEnabled(repo.Path, true); - return succ; - }); - } - - private void OnRemoteSelectionChanged(object sender, SelectionChangedEventArgs e) { - var remote = cmbRemotes.SelectedItem as Models.Remote; - if (remote == null) return; - - var branches = repo.Branches.Where(x => x.Remote == remote.Name).ToList(); - cmbBranches.ItemsSource = branches; - - if (branches.Count == 0) return; - - if (prefered != null && remote.Name == prefered.Remote) { - cmbBranches.SelectedItem = branches.Find(x => x.FullName == prefered.FullName); - } else { - cmbBranches.SelectedItem = branches[0]; - } - } - } -} diff --git a/src/Views/Popups/Push.xaml b/src/Views/Popups/Push.xaml deleted file mode 100644 index f7f2a2bd..00000000 --- a/src/Views/Popups/Push.xaml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Push.xaml.cs b/src/Views/Popups/Push.xaml.cs deleted file mode 100644 index 60e41dc2..00000000 --- a/src/Views/Popups/Push.xaml.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 推送 - /// - public partial class Push : Controls.PopupWidget { - private Models.Repository repo = null; - - public Push(Models.Repository repo, Models.Branch localBranch) { - this.repo = repo; - - InitializeComponent(); - - var localBranches = repo.Branches.Where(x => x.IsLocal).ToList(); - cmbLocalBranches.ItemsSource = localBranches; - if (localBranch != null) cmbLocalBranches.SelectedItem = localBranch; - else cmbLocalBranches.SelectedItem = localBranches.Find(x => x.IsCurrent); - } - - public override string GetTitle() { - return App.Text("Push.Title"); - } - - public override Task Start() { - var localBranch = cmbLocalBranches.SelectedItem as Models.Branch; - if (localBranch == null) return null; - - var remoteBranch = cmbRemoteBranches.SelectedItem as Models.Branch; - if (remoteBranch == null) return null; - - var withTags = chkAllTags.IsChecked == true; - var force = chkForce.IsChecked == true; - var track = string.IsNullOrEmpty(localBranch.Upstream); - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo.Path, false); - var succ = new Commands.Push( - repo.Path, - localBranch.Name, - remoteBranch.Remote, - remoteBranch.Name.Replace(" (new)", ""), - withTags, - force, - track, - UpdateProgress).Exec(); - Models.Watcher.SetEnabled(repo.Path, true); - return succ; - }); - } - - private void OnLocalSelectionChanged(object sender, SelectionChangedEventArgs e) { - var local = cmbLocalBranches.SelectedItem as Models.Branch; - if (local == null) return; - - cmbRemotes.ItemsSource = null; - cmbRemotes.ItemsSource = repo.Remotes; - - if (!string.IsNullOrEmpty(local.Upstream)) { - cmbRemotes.SelectedItem = repo.Remotes.Find(x => local.Upstream.StartsWith($"refs/remotes/{x.Name}/")); - } else { - cmbRemotes.SelectedItem = repo.Remotes[0]; - } - } - - private void OnRemoteSelectionChanged(object sender, SelectionChangedEventArgs e) { - var local = cmbLocalBranches.SelectedItem as Models.Branch; - if (local == null) return; - - var remote = cmbRemotes.SelectedItem as Models.Remote; - if (remote == null) return; - - var remoteBranches = new List(); - remoteBranches.AddRange(repo.Branches.Where(x => x.Remote == remote.Name)); - cmbRemoteBranches.ItemsSource = null; - - if (!string.IsNullOrEmpty(local.Upstream)) { - foreach (var b in remoteBranches) { - if (b.FullName == local.Upstream) { - cmbRemoteBranches.ItemsSource = remoteBranches; - cmbRemoteBranches.SelectedItem = b; - return; - } - } - } - - var match = $"refs/remotes/{remote.Name}/{local.Name}"; - foreach (var b in remoteBranches) { - if (b.FullName == match) { - cmbRemoteBranches.ItemsSource = remoteBranches; - cmbRemoteBranches.SelectedItem = b; - return; - } - } - - var prefer = new Models.Branch() { - Remote = remote.Name, - Name = $"{local.Name} (new)" - }; - remoteBranches.Add(prefer); - cmbRemoteBranches.ItemsSource = remoteBranches; - cmbRemoteBranches.SelectedItem = prefer; - } - } -} diff --git a/src/Views/Popups/PushTag.xaml b/src/Views/Popups/PushTag.xaml deleted file mode 100644 index 7674adba..00000000 --- a/src/Views/Popups/PushTag.xaml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/PushTag.xaml.cs b/src/Views/Popups/PushTag.xaml.cs deleted file mode 100644 index e47d453b..00000000 --- a/src/Views/Popups/PushTag.xaml.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 推送标签确认面板 - /// - public partial class PushTag : Controls.PopupWidget { - private string repo = null; - private string tag = null; - - public PushTag(Models.Repository repo, string tag) { - this.repo = repo.Path; - this.tag = tag; - - InitializeComponent(); - - txtTag.Text = tag; - cmbRemotes.ItemsSource = repo.Remotes; - cmbRemotes.SelectedIndex = 0; - } - - public override string GetTitle() { - return App.Text("PushTag"); - } - - public override Task Start() { - var remote = cmbRemotes.SelectedItem as Models.Remote; - if (remote == null) return null; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Push(repo, remote.Name, tag, false).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Rebase.xaml b/src/Views/Popups/Rebase.xaml deleted file mode 100644 index 11091776..00000000 --- a/src/Views/Popups/Rebase.xaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Rebase.xaml.cs b/src/Views/Popups/Rebase.xaml.cs deleted file mode 100644 index 0e5f76db..00000000 --- a/src/Views/Popups/Rebase.xaml.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Media; - -namespace SourceGit.Views.Popups { - /// - /// 变基操作面板 - /// - public partial class Rebase : Controls.PopupWidget { - private string repo = null; - private string on = null; - - public Rebase(string repo, string current, Models.Branch branch) { - this.repo = repo; - this.on = branch.Head; - - InitializeComponent(); - - txtCurrent.Text = current; - txtOn.Text = !string.IsNullOrEmpty(branch.Remote) ? $"{branch.Remote}/{branch.Name}" : branch.Name; - iconBased.Data = FindResource("Icon.Branch") as Geometry; - } - - public Rebase(string repo, string current, Models.Commit commit) { - this.repo = repo; - this.on = commit.SHA; - - InitializeComponent(); - - txtCurrent.Text = current; - txtSHA.Text = commit.ShortSHA; - txtOn.Text = commit.Subject; - badgeSHA.Visibility = System.Windows.Visibility.Visible; - iconBased.Data = FindResource("Icon.Commit") as Geometry; - } - - public override string GetTitle() { - return App.Text("Rebase"); - } - - public override Task Start() { - var autoStash = chkAutoStash.IsChecked == true; - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Rebase(repo, on, autoStash).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Remote.xaml b/src/Views/Popups/Remote.xaml deleted file mode 100644 index 62a80f74..00000000 --- a/src/Views/Popups/Remote.xaml +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Remote.xaml.cs b/src/Views/Popups/Remote.xaml.cs deleted file mode 100644 index 6054d2a2..00000000 --- a/src/Views/Popups/Remote.xaml.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.Win32; -using System; -using System.IO; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 远程信息编辑面板 - /// - public partial class Remote : Controls.PopupWidget { - private Models.Repository repo = null; - private Models.Remote remote = null; - - public string RemoteName { get; set; } - public string RemoteURL { get; set; } - - public Remote(Models.Repository repo, Models.Remote remote) { - this.repo = repo; - this.remote = remote; - - if (remote != null) { - RemoteName = remote.Name; - RemoteURL = remote.URL; - } - - InitializeComponent(); - - ruleName.Repo = repo; - if (Validations.GitURL.IsSSH(RemoteURL)) { - txtSSHKey.Text = new Commands.Config(repo.Path).Get($"remote.{remote.Name}.sshkey"); - } else { - txtSSHKey.Text = ""; - } - } - - public override string GetTitle() { - return App.Text(remote == null ? "Remote.AddTitle" : "Remote.EditTitle"); - } - - public override Task Start() { - if (remote == null || remote.Name != RemoteName) { - txtName.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtName)) return null; - } - - txtUrl.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtUrl)) return null; - - var sshKey = txtSSHKey.Text; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo.Path, false); - - if (string.IsNullOrEmpty(sshKey)) { - new Commands.Config(repo.Path).Set($"remote.{RemoteName}.sshkey", null); - } else { - new Commands.Config(repo.Path).Set($"remote.{RemoteName}.sshkey", sshKey); - } - - if (remote == null) { - var succ = new Commands.Remote(repo.Path).Add(RemoteName, RemoteURL); - if (succ) new Commands.Fetch(repo.Path, RemoteName, true, UpdateProgress).Exec(); - } else { - if (remote.URL != RemoteURL) { - var succ = new Commands.Remote(repo.Path).SetURL(remote.Name, RemoteURL); - if (succ) remote.URL = RemoteURL; - } - - if (remote.Name != RemoteName) { - var succ = new Commands.Remote(repo.Path).Rename(remote.Name, RemoteName); - if (succ) remote.Name = RemoteName; - } - } - - Models.Watcher.SetEnabled(repo.Path, true); - return true; - }); - } - - private void OnSelectSSHKey(object sender, RoutedEventArgs e) { - var initPath = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "..", ".ssh")); - if (!Directory.Exists(initPath)) Directory.CreateDirectory(initPath); - - var dialog = new OpenFileDialog(); - dialog.Filter = $"SSH Private Key|*"; - dialog.Title = App.Text("SSHKey"); - dialog.InitialDirectory = initPath; - dialog.CheckFileExists = true; - dialog.Multiselect = false; - - if (dialog.ShowDialog() == true) txtSSHKey.Text = dialog.FileName; - } - - private void OnUrlChanged(object sender, TextChangedEventArgs e) { - if (Validations.GitURL.IsSSH(txtUrl.Text)) { - rowSSHKey.Height = new GridLength(32, GridUnitType.Pixel); - } else { - rowSSHKey.Height = new GridLength(0, GridUnitType.Pixel); - } - } - } -} diff --git a/src/Views/Popups/RenameBranch.xaml b/src/Views/Popups/RenameBranch.xaml deleted file mode 100644 index c998d86a..00000000 --- a/src/Views/Popups/RenameBranch.xaml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/RenameBranch.xaml.cs b/src/Views/Popups/RenameBranch.xaml.cs deleted file mode 100644 index 2856a35f..00000000 --- a/src/Views/Popups/RenameBranch.xaml.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 本地分支改名 - /// - public partial class RenameBranch : Controls.PopupWidget { - private string repo = null; - private string target = null; - - public string NewName { get; set; } - - public RenameBranch(Models.Repository repo, string target) { - this.repo = repo.Path; - this.target = target; - - InitializeComponent(); - - ruleBranch.Repo = repo; - txtTarget.Text = target; - } - - public override string GetTitle() { - return App.Text("RenameBranch"); - } - - public override Task Start() { - txtNewName.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtNewName)) return null; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Branch(repo, target).Rename(NewName); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Reset.xaml b/src/Views/Popups/Reset.xaml deleted file mode 100644 index f4137ccf..00000000 --- a/src/Views/Popups/Reset.xaml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Reset.xaml.cs b/src/Views/Popups/Reset.xaml.cs deleted file mode 100644 index c95f06ab..00000000 --- a/src/Views/Popups/Reset.xaml.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 重置面板 - /// - public partial class Reset : Controls.PopupWidget { - private string repo = null; - private string revision = null; - - public Reset(string repo, string current, Models.Commit to) { - this.repo = repo; - this.revision = to.SHA; - - InitializeComponent(); - - txtCurrent.Text = current; - txtSHA.Text = to.ShortSHA; - txtMoveTo.Text = to.Subject; - } - - public override string GetTitle() { - return App.Text("Reset"); - } - - public override Task Start() { - var mode = cmbMode.SelectedItem as Models.ResetMode; - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Reset(repo, revision, mode.Arg).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Revert.xaml b/src/Views/Popups/Revert.xaml deleted file mode 100644 index b4401f40..00000000 --- a/src/Views/Popups/Revert.xaml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Revert.xaml.cs b/src/Views/Popups/Revert.xaml.cs deleted file mode 100644 index 166c6827..00000000 --- a/src/Views/Popups/Revert.xaml.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 撤销面板 - /// - public partial class Revert : Controls.PopupWidget { - private string repo = null; - private string commit = null; - - public Revert(string repo, Models.Commit commit) { - this.repo = repo; - this.commit = commit.SHA; - - InitializeComponent(); - - txtSHA.Text = commit.ShortSHA; - txtCommit.Text = commit.Subject; - } - - public override string GetTitle() { - return App.Text("Revert"); - } - - public override Task Start() { - var commitChanges = chkCommit.IsChecked == true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Revert(repo, commit, commitChanges).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Reword.xaml b/src/Views/Popups/Reword.xaml deleted file mode 100644 index 681140f4..00000000 --- a/src/Views/Popups/Reword.xaml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Reword.xaml.cs b/src/Views/Popups/Reword.xaml.cs deleted file mode 100644 index 803883d8..00000000 --- a/src/Views/Popups/Reword.xaml.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 编辑HEAD的提交描述 - /// - public partial class Reword : Controls.PopupWidget { - private string repo = null; - private string old = null; - - public string Msg { get; set; } - - public Reword(string repo, Models.Commit commit) { - this.repo = repo; - this.old = $"{commit.Subject}\n{commit.Message}".Trim(); - this.Msg = old; - InitializeComponent(); - - txtSHA.Text = commit.ShortSHA; - txtCurrent.Text = commit.Subject; - } - - public override string GetTitle() { - return App.Text("Reword"); - } - - public override Task Start() { - txtMsg.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtMsg)) return null; - - return Task.Run(() => { - if (old == Msg) return true; - - Models.Watcher.SetEnabled(repo, false); - new Commands.Reword(repo, Msg).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Squash.xaml b/src/Views/Popups/Squash.xaml deleted file mode 100644 index be38aa2b..00000000 --- a/src/Views/Popups/Squash.xaml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Squash.xaml.cs b/src/Views/Popups/Squash.xaml.cs deleted file mode 100644 index e2f8d844..00000000 --- a/src/Views/Popups/Squash.xaml.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 合并当前HEAD提交到上一个 - /// - public partial class Squash : Controls.PopupWidget { - private string repo = null; - private string to = null; - - public string Msg { get; set; } - - public Squash(string repo, Models.Commit head, Models.Commit parent) { - this.repo = repo; - this.to = parent.SHA; - this.Msg = $"{parent.Subject}\n{parent.Message}".Trim(); - InitializeComponent(); - - txtHeadSHA.Text = head.ShortSHA; - txtHead.Text = head.Subject; - txtParentSHA.Text = parent.ShortSHA; - txtParent.Text = parent.Subject; - } - - public override string GetTitle() { - return App.Text("Squash"); - } - - public override Task Start() { - txtMsg.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtMsg)) return null; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - var succ = new Commands.Reset(repo, to, "--soft").Exec(); - if (succ) new Commands.Commit(repo, Msg, true).Exec(); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/Stash.xaml b/src/Views/Popups/Stash.xaml deleted file mode 100644 index 6f696139..00000000 --- a/src/Views/Popups/Stash.xaml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/Stash.xaml.cs b/src/Views/Popups/Stash.xaml.cs deleted file mode 100644 index cd03790b..00000000 --- a/src/Views/Popups/Stash.xaml.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - - /// - /// 贮藏 - /// - public partial class Stash : Controls.PopupWidget { - private string repo = null; - private List changes = null; - - public Stash(string repo, List changes) { - this.repo = repo; - this.changes = changes; - - InitializeComponent(); - chkIncludeUntracked.IsEnabled = changes == null || changes.Count == 0; - } - - public override string GetTitle() { - return App.Text("Stash.Title"); - } - - public override Task Start() { - var includeUntracked = chkIncludeUntracked.IsChecked == true; - var message = txtMessage.Text; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - - var bFull = changes == null || changes.Count == 0; - if (bFull) { - changes = new Commands.LocalChanges(repo).Result(); - } - - var jobs = new List(); - foreach (var c in changes) { - if (c.WorkTree == Models.Change.Status.Added || c.WorkTree == Models.Change.Status.Untracked) { - if (includeUntracked) jobs.Add(c); - } else { - jobs.Add(c); - } - } - - if (jobs.Count > 0) new Commands.Stash(repo).Push(changes, message, bFull); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/StashDropConfirm.xaml b/src/Views/Popups/StashDropConfirm.xaml deleted file mode 100644 index dfc1def2..00000000 --- a/src/Views/Popups/StashDropConfirm.xaml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/StashDropConfirm.xaml.cs b/src/Views/Popups/StashDropConfirm.xaml.cs deleted file mode 100644 index 1becaafc..00000000 --- a/src/Views/Popups/StashDropConfirm.xaml.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 确认丢弃选中的贮藏 - /// - public partial class StashDropConfirm : Controls.PopupWidget { - private string repo; - private string stash; - - public StashDropConfirm(string repo, string stash, string msg) { - this.repo = repo; - this.stash = stash; - - InitializeComponent(); - - txtTarget.Text = stash + " - " + msg; - } - - public override string GetTitle() { - return App.Text("StashDropConfirm"); - } - - public override Task Start() { - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.Stash(repo).Drop(stash); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/SubTreePull.xaml b/src/Views/Popups/SubTreePull.xaml deleted file mode 100644 index 279adf41..00000000 --- a/src/Views/Popups/SubTreePull.xaml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/SubTreePull.xaml.cs b/src/Views/Popups/SubTreePull.xaml.cs deleted file mode 100644 index 52149368..00000000 --- a/src/Views/Popups/SubTreePull.xaml.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 拉取 - /// - public partial class SubTreePull : Controls.PopupWidget { - private string repo; - private Models.SubTree subtree; - - public string Branch { - get { return subtree.Branch; } - set { subtree.Branch = value; } - } - - public SubTreePull(string repo, Models.SubTree subtree) { - this.repo = repo; - this.subtree = subtree; - InitializeComponent(); - txtPrefix.Text = subtree.Prefix; - txtSource.Text = subtree.Remote; - } - - public override string GetTitle() { - return App.Text("SubTreePullOrPush.Pull"); - } - - public override Task Start() { - txtBranch.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtBranch)) return null; - - var squash = chkSquash.IsChecked == true; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.SubTree(repo).Pull(subtree.Prefix, subtree.Remote, Branch, squash, UpdateProgress); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/SubTreePush.xaml b/src/Views/Popups/SubTreePush.xaml deleted file mode 100644 index 3fbcb735..00000000 --- a/src/Views/Popups/SubTreePush.xaml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/SubTreePush.xaml.cs b/src/Views/Popups/SubTreePush.xaml.cs deleted file mode 100644 index 7cef2871..00000000 --- a/src/Views/Popups/SubTreePush.xaml.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Popups { - /// - /// 推送 - /// - public partial class SubTreePush : Controls.PopupWidget { - private string repo; - private Models.SubTree subtree; - - public string Branch { - get { return subtree.Branch; } - set { subtree.Branch = value; } - } - - public SubTreePush(string repo, Models.SubTree subtree) { - this.repo = repo; - this.subtree = subtree; - InitializeComponent(); - txtPrefix.Text = subtree.Prefix; - txtSource.Text = subtree.Remote; - } - - public override string GetTitle() { - return App.Text("SubTreePullOrPush.Push"); - } - - public override Task Start() { - txtBranch.GetBindingExpression(TextBox.TextProperty).UpdateSource(); - if (Validation.GetHasError(txtBranch)) return null; - - return Task.Run(() => { - Models.Watcher.SetEnabled(repo, false); - new Commands.SubTree(repo).Push(subtree.Prefix, subtree.Remote, Branch, UpdateProgress); - Models.Watcher.SetEnabled(repo, true); - return true; - }); - } - } -} diff --git a/src/Views/Popups/UnlinkSubTree.xaml b/src/Views/Popups/UnlinkSubTree.xaml deleted file mode 100644 index eb200ff1..00000000 --- a/src/Views/Popups/UnlinkSubTree.xaml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Popups/UnlinkSubTree.xaml.cs b/src/Views/Popups/UnlinkSubTree.xaml.cs deleted file mode 100644 index 372bf305..00000000 --- a/src/Views/Popups/UnlinkSubTree.xaml.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.Views.Popups { - /// - /// 删除子树 - /// - public partial class UnlinkSubTree : Controls.PopupWidget { - private Models.Repository repo; - private string prefix; - - public UnlinkSubTree(Models.Repository repo, string prefix) { - this.repo = repo; - this.prefix = prefix; - InitializeComponent(); - txtPrefix.Text = prefix; - } - - public override string GetTitle() { - return App.Text("UnlinkSubTree"); - } - - public override Task Start() { - return Task.Run(() => { - var idx = repo.SubTrees.FindIndex(x => x.Prefix == prefix); - if (idx >= 0) { - repo.SubTrees.RemoveAt(idx); - Models.Preference.Save(); - Models.Watcher.Get(repo.Path)?.RefreshSubTrees(); - } - return true; - }); - } - } -} diff --git a/src/Views/Preference.xaml b/src/Views/Preference.xaml deleted file mode 100644 index ff615c6c..00000000 --- a/src/Views/Preference.xaml +++ /dev/null @@ -1,433 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Preference.xaml.cs b/src/Views/Preference.xaml.cs deleted file mode 100644 index df3edd05..00000000 --- a/src/Views/Preference.xaml.cs +++ /dev/null @@ -1,173 +0,0 @@ -using Microsoft.Win32; -using System; -using System.IO; -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views { - - /// - /// 设置面板 - /// - public partial class Preference : Controls.Window { - - public string User { get; set; } - public string Email { get; set; } - public string CRLF { get; set; } - public string Version { get; set; } - public string GPGExec { get; set; } - public bool GPGSigningEnabled { get; set; } - public string GPGUserSigningKey { get; set; } - - public Preference() { - UpdateGitInfo(false); - InitializeComponent(); - } - - private bool UpdateGitInfo(bool updateUi) { - var isReady = Models.Preference.Instance.IsReady; - if (isReady) { - var cmd = new Commands.Config(); - User = cmd.Get("user.name"); - Email = cmd.Get("user.email"); - CRLF = cmd.Get("core.autocrlf"); - Version = new Commands.Version().Query(); - if (string.IsNullOrEmpty(CRLF)) CRLF = "false"; - GPGExec = cmd.Get("gpg.program"); - if (string.IsNullOrEmpty(GPGExec)) { - string gitInstallFolder = Path.GetDirectoryName(Models.Preference.Instance.Git.Path); - string defaultGPG = Path.GetFullPath(gitInstallFolder + "/../usr/bin/gpg.exe"); - if (File.Exists(defaultGPG)) GPGExec = defaultGPG; - } - GPGSigningEnabled = cmd.Get("commit.gpgsign") == "true"; - GPGUserSigningKey = cmd.Get("user.signingkey"); - } else { - User = ""; - Email = ""; - CRLF = "false"; - Version = "Unknown"; - GPGExec = ""; - GPGSigningEnabled = false; - GPGUserSigningKey = ""; - } - if (updateUi) { - editGitUser?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - editGitEmail?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - editGitCrlf?.GetBindingExpression(ComboBox.SelectedValueProperty).UpdateTarget(); - textGitVersion?.GetBindingExpression(TextBlock.TextProperty).UpdateTarget(); - txtGPGExec?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - } - return isReady; - } - - #region EVENTS - private void LocaleChanged(object sender, SelectionChangedEventArgs e) { - Models.Locale.Change(); - } - - private void ChangeTheme(object sender, RoutedEventArgs e) { - Models.Theme.Change(); - } - - private void SelectGitPath(object sender, RoutedEventArgs e) { - var initDir = Models.ExecutableFinder.Find("git.exe"); - if (initDir == null) initDir = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - else initDir = Path.GetDirectoryName(initDir); - - var dialog = new OpenFileDialog { - Filter = "Git Executable|git.exe", - FileName = "git.exe", - Title = App.Text("Preference.Dialog.GitExe"), - InitialDirectory = initDir, - CheckFileExists = true, - }; - - if (dialog.ShowDialog() == true) { - Models.Preference.Instance.Git.Path = dialog.FileName; - editGitPath?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - UpdateGitInfo(true); - } - } - - private void SelectGitCloneDir(object sender, RoutedEventArgs e) { - var dialog = new System.Windows.Forms.FolderBrowserDialog(); - dialog.ShowNewFolderButton = true; - if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) { - Models.Preference.Instance.Git.DefaultCloneDir = dialog.SelectedPath; - txtGitCloneDir?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - } - } - - private void SelectGPGExec(object sender, RoutedEventArgs e) { - var dialog = new OpenFileDialog(); - dialog.Filter = $"GPG Executable|gpg.exe"; - dialog.Title = App.Text("GPG.Path.Placeholder"); - dialog.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - dialog.CheckFileExists = true; - - if (dialog.ShowDialog() == true) { - GPGExec = dialog.FileName; - txtGPGExec?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - } - } - - private void SelectMergeTool(object sender, RoutedEventArgs e) { - var type = Models.Preference.Instance.MergeTool.Type; - var tool = Models.MergeTool.Supported.Find(x => x.Type == type); - - if (tool == null || tool.Type == 0) return; - - var dialog = new OpenFileDialog(); - dialog.Filter = $"{tool.Name} Executable|{tool.Exec}"; - dialog.Title = App.Text("Preference.Dialog.Merger", tool.Name); - dialog.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - dialog.CheckFileExists = true; - - if (dialog.ShowDialog() == true) { - Models.Preference.Instance.MergeTool.Path = dialog.FileName; - txtMergeExec?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - } - } - - private void MergeToolChanged(object sender, SelectionChangedEventArgs e) { - var type = (int)(sender as ComboBox).SelectedValue; - var tool = Models.MergeTool.Supported.Find(x => x.Type == type); - if (tool == null) return; - - if (IsLoaded) { - Models.Preference.Instance.MergeTool.Path = tool.Finder(); - txtMergeExec?.GetBindingExpression(TextBox.TextProperty).UpdateTarget(); - } - - e.Handled = true; - } - - private void Quit(object sender, RoutedEventArgs e) { - if (Models.Preference.Instance.IsReady) { - var cmd = new Commands.Config(); - var oldUser = cmd.Get("user.name"); - if (oldUser != User) cmd.Set("user.name", User); - - var oldEmail = cmd.Get("user.email"); - if (oldEmail != Email) cmd.Set("user.email", Email); - - var oldCRLF = cmd.Get("core.autocrlf"); - if (oldCRLF != CRLF) cmd.Set("core.autocrlf", CRLF); - - var oldGPGExec = cmd.Get("gpg.program"); - if (oldGPGExec != GPGExec) cmd.Set("gpg.program", GPGExec); - - var oldGPGSigningEnabledStr = cmd.Get("commit.gpgsign"); - var oldGPGSigningEnabled = "true" == oldGPGSigningEnabledStr; - if (oldGPGSigningEnabled != GPGSigningEnabled) cmd.Set("commit.gpgsign", GPGSigningEnabled ? "true" : "false"); - - var oldGPGUserSigningKey = cmd.Get("user.signingkey"); - if (oldGPGUserSigningKey != GPGUserSigningKey) cmd.Set("user.signingkey", GPGUserSigningKey); - } - - Models.Preference.Save(); - Close(); - } - #endregion - } -} diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml new file mode 100644 index 00000000..beb228b6 --- /dev/null +++ b/src/Views/Preferences.axaml @@ -0,0 +1,784 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + Dark + Light + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Preferences.axaml.cs b/src/Views/Preferences.axaml.cs new file mode 100644 index 00000000..6856fbce --- /dev/null +++ b/src/Views/Preferences.axaml.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class Preferences : ChromelessWindow + { + public string DefaultUser + { + get; + set; + } + + public string DefaultEmail + { + get; + set; + } + + public Models.CRLFMode CRLFMode + { + get; + set; + } = null; + + public bool EnablePruneOnFetch + { + get; + set; + } + + public static readonly StyledProperty GitVersionProperty = + AvaloniaProperty.Register(nameof(GitVersion)); + + public string GitVersion + { + get => GetValue(GitVersionProperty); + set => SetValue(GitVersionProperty, value); + } + + public static readonly StyledProperty ShowGitVersionWarningProperty = + AvaloniaProperty.Register(nameof(ShowGitVersionWarning)); + + public bool ShowGitVersionWarning + { + get => GetValue(ShowGitVersionWarningProperty); + set => SetValue(ShowGitVersionWarningProperty, value); + } + + public bool EnableGPGCommitSigning + { + get; + set; + } + + public bool EnableGPGTagSigning + { + get; + set; + } + + public static readonly StyledProperty GPGFormatProperty = + AvaloniaProperty.Register(nameof(GPGFormat), Models.GPGFormat.Supported[0]); + + public Models.GPGFormat GPGFormat + { + get => GetValue(GPGFormatProperty); + set => SetValue(GPGFormatProperty, value); + } + + public static readonly StyledProperty GPGExecutableFileProperty = + AvaloniaProperty.Register(nameof(GPGExecutableFile)); + + public string GPGExecutableFile + { + get => GetValue(GPGExecutableFileProperty); + set => SetValue(GPGExecutableFileProperty, value); + } + + public string GPGUserKey + { + get; + set; + } + + public bool EnableHTTPSSLVerify + { + get; + set; + } = false; + + public static readonly StyledProperty SelectedOpenAIServiceProperty = + AvaloniaProperty.Register(nameof(SelectedOpenAIService)); + + public Models.OpenAIService SelectedOpenAIService + { + get => GetValue(SelectedOpenAIServiceProperty); + set => SetValue(SelectedOpenAIServiceProperty, value); + } + + public static readonly StyledProperty SelectedCustomActionProperty = + AvaloniaProperty.Register(nameof(SelectedCustomAction)); + + public Models.CustomAction SelectedCustomAction + { + get => GetValue(SelectedCustomActionProperty); + set => SetValue(SelectedCustomActionProperty, value); + } + + public Preferences() + { + var pref = ViewModels.Preferences.Instance; + DataContext = pref; + + if (pref.IsGitConfigured()) + { + var config = new Commands.Config(null).ListAll(); + + if (config.TryGetValue("user.name", out var name)) + DefaultUser = name; + if (config.TryGetValue("user.email", out var email)) + DefaultEmail = email; + if (config.TryGetValue("user.signingkey", out var signingKey)) + GPGUserKey = signingKey; + if (config.TryGetValue("core.autocrlf", out var crlf)) + CRLFMode = Models.CRLFMode.Supported.Find(x => x.Value == crlf); + if (config.TryGetValue("fetch.prune", out var pruneOnFetch)) + EnablePruneOnFetch = (pruneOnFetch == "true"); + if (config.TryGetValue("commit.gpgsign", out var gpgCommitSign)) + EnableGPGCommitSigning = (gpgCommitSign == "true"); + if (config.TryGetValue("tag.gpgsign", out var gpgTagSign)) + EnableGPGTagSigning = (gpgTagSign == "true"); + if (config.TryGetValue("gpg.format", out var gpgFormat)) + GPGFormat = Models.GPGFormat.Supported.Find(x => x.Value == gpgFormat) ?? Models.GPGFormat.Supported[0]; + + if (GPGFormat.Value == "openpgp" && config.TryGetValue("gpg.program", out var openpgp)) + GPGExecutableFile = openpgp; + else if (config.TryGetValue($"gpg.{GPGFormat.Value}.program", out var gpgProgram)) + GPGExecutableFile = gpgProgram; + + if (config.TryGetValue("http.sslverify", out var sslVerify)) + EnableHTTPSSLVerify = sslVerify == "true"; + else + EnableHTTPSSLVerify = true; + } + + UpdateGitVersion(); + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == GPGFormatProperty) + { + var config = new Commands.Config(null).ListAll(); + if (GPGFormat.Value == "openpgp" && config.TryGetValue("gpg.program", out var openpgp)) + GPGExecutableFile = openpgp; + else if (config.TryGetValue($"gpg.{GPGFormat.Value}.program", out var gpgProgram)) + GPGExecutableFile = gpgProgram; + } + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + base.OnClosing(e); + + if (Design.IsDesignMode) + return; + + var config = new Commands.Config(null).ListAll(); + SetIfChanged(config, "user.name", DefaultUser, ""); + SetIfChanged(config, "user.email", DefaultEmail, ""); + SetIfChanged(config, "user.signingkey", GPGUserKey, ""); + SetIfChanged(config, "core.autocrlf", CRLFMode?.Value, null); + SetIfChanged(config, "fetch.prune", EnablePruneOnFetch ? "true" : "false", "false"); + SetIfChanged(config, "commit.gpgsign", EnableGPGCommitSigning ? "true" : "false", "false"); + SetIfChanged(config, "tag.gpgsign", EnableGPGTagSigning ? "true" : "false", "false"); + SetIfChanged(config, "http.sslverify", EnableHTTPSSLVerify ? "" : "false", ""); + SetIfChanged(config, "gpg.format", GPGFormat.Value, "openpgp"); + + if (!GPGFormat.Value.Equals("ssh", StringComparison.Ordinal)) + { + var oldGPG = string.Empty; + if (GPGFormat.Value == "openpgp" && config.TryGetValue("gpg.program", out var openpgp)) + oldGPG = openpgp; + else if (config.TryGetValue($"gpg.{GPGFormat.Value}.program", out var gpgProgram)) + oldGPG = gpgProgram; + + bool changed = false; + if (!string.IsNullOrEmpty(oldGPG)) + changed = oldGPG != GPGExecutableFile; + else if (!string.IsNullOrEmpty(GPGExecutableFile)) + changed = true; + + if (changed) + new Commands.Config(null).Set($"gpg.{GPGFormat.Value}.program", GPGExecutableFile); + } + + ViewModels.Preferences.Instance.Save(); + } + + private async void SelectThemeOverrideFile(object _, RoutedEventArgs e) + { + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType("Theme Overrides File") { Patterns = ["*.json"] }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + { + ViewModels.Preferences.Instance.ThemeOverrides = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + + private async void SelectGitExecutable(object _, RoutedEventArgs e) + { + var pattern = OperatingSystem.IsWindows() ? "git.exe" : "git"; + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType("Git Executable") { Patterns = [pattern] }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + { + ViewModels.Preferences.Instance.GitInstallPath = selected[0].Path.LocalPath; + UpdateGitVersion(); + } + + e.Handled = true; + } + + private async void SelectDefaultCloneDir(object _, RoutedEventArgs e) + { + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + try + { + var selected = await StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder?.Path.ToString(); + ViewModels.Preferences.Instance.GitDefaultCloneDir = folderPath; + } + } + catch (Exception ex) + { + App.RaiseException(string.Empty, $"Failed to select default clone directory: {ex.Message}"); + } + + e.Handled = true; + } + + private async void SelectGPGExecutable(object _, RoutedEventArgs e) + { + var patterns = new List(); + if (OperatingSystem.IsWindows()) + patterns.Add($"{GPGFormat.Program}.exe"); + else + patterns.Add(GPGFormat.Program); + + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType("GPG Program") { Patterns = patterns }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + { + GPGExecutableFile = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + + private async void SelectShellOrTerminal(object _, RoutedEventArgs e) + { + var type = ViewModels.Preferences.Instance.ShellOrTerminal; + if (type == -1) + return; + + var shell = Models.ShellOrTerminal.Supported[type]; + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType(shell.Name) { Patterns = [shell.Exec] }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + { + ViewModels.Preferences.Instance.ShellOrTerminalPath = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + + private async void SelectExternalMergeTool(object _, RoutedEventArgs e) + { + var type = ViewModels.Preferences.Instance.ExternalMergeToolType; + if (type < 0 || type >= Models.ExternalMerger.Supported.Count) + { + ViewModels.Preferences.Instance.ExternalMergeToolType = 0; + e.Handled = true; + return; + } + + var tool = Models.ExternalMerger.Supported[type]; + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType(tool.Name) { Patterns = tool.GetPatterns() }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + { + ViewModels.Preferences.Instance.ExternalMergeToolPath = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + + private void SetIfChanged(Dictionary cached, string key, string value, string defValue) + { + bool changed = false; + if (cached.TryGetValue(key, out var old)) + changed = old != value; + else if (!string.IsNullOrEmpty(value) && value != defValue) + changed = true; + + if (changed) + new Commands.Config(null).Set(key, value); + } + + private void OnUseNativeWindowFrameChanged(object sender, RoutedEventArgs e) + { + if (sender is CheckBox box) + { + ViewModels.Preferences.Instance.UseSystemWindowFrame = box.IsChecked == true; + App.ShowWindow(new ConfirmRestart(), true); + } + + e.Handled = true; + } + + private void OnGitInstallPathChanged(object sender, TextChangedEventArgs e) + { + UpdateGitVersion(); + } + + private void OnAddOpenAIService(object sender, RoutedEventArgs e) + { + var service = new Models.OpenAIService() { Name = "Unnamed Service" }; + ViewModels.Preferences.Instance.OpenAIServices.Add(service); + SelectedOpenAIService = service; + + e.Handled = true; + } + + private void OnRemoveSelectedOpenAIService(object sender, RoutedEventArgs e) + { + if (SelectedOpenAIService == null) + return; + + ViewModels.Preferences.Instance.OpenAIServices.Remove(SelectedOpenAIService); + SelectedOpenAIService = null; + e.Handled = true; + } + + private void OnAddCustomAction(object sender, RoutedEventArgs e) + { + var action = new Models.CustomAction() { Name = "Unnamed Action (Global)" }; + ViewModels.Preferences.Instance.CustomActions.Add(action); + SelectedCustomAction = action; + + e.Handled = true; + } + + private async void SelectExecutableForCustomAction(object sender, RoutedEventArgs e) + { + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType("Executable file(script)") { Patterns = ["*.*"] }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1 && sender is Button { DataContext: Models.CustomAction action }) + action.Executable = selected[0].Path.LocalPath; + + e.Handled = true; + } + + private void OnRemoveSelectedCustomAction(object sender, RoutedEventArgs e) + { + if (SelectedCustomAction == null) + return; + + ViewModels.Preferences.Instance.CustomActions.Remove(SelectedCustomAction); + SelectedCustomAction = null; + e.Handled = true; + } + + private void OnMoveSelectedCustomActionUp(object sender, RoutedEventArgs e) + { + if (SelectedCustomAction == null) + return; + + var idx = ViewModels.Preferences.Instance.CustomActions.IndexOf(SelectedCustomAction); + if (idx > 0) + ViewModels.Preferences.Instance.CustomActions.Move(idx - 1, idx); + + e.Handled = true; + } + + private void OnMoveSelectedCustomActionDown(object sender, RoutedEventArgs e) + { + if (SelectedCustomAction == null) + return; + + var idx = ViewModels.Preferences.Instance.CustomActions.IndexOf(SelectedCustomAction); + if (idx < ViewModels.Preferences.Instance.CustomActions.Count - 1) + ViewModels.Preferences.Instance.CustomActions.Move(idx + 1, idx); + + e.Handled = true; + } + + private void UpdateGitVersion() + { + GitVersion = Native.OS.GitVersionString; + ShowGitVersionWarning = !string.IsNullOrEmpty(GitVersion) && Native.OS.GitVersion < Models.GitVersions.MINIMAL; + } + } +} diff --git a/src/Views/PruneRemote.axaml b/src/Views/PruneRemote.axaml new file mode 100644 index 00000000..fe110bd2 --- /dev/null +++ b/src/Views/PruneRemote.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/src/Views/PruneRemote.axaml.cs b/src/Views/PruneRemote.axaml.cs new file mode 100644 index 00000000..cc4cc282 --- /dev/null +++ b/src/Views/PruneRemote.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class PruneRemote : UserControl + { + public PruneRemote() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/PruneWorktrees.axaml b/src/Views/PruneWorktrees.axaml new file mode 100644 index 00000000..a3a0f770 --- /dev/null +++ b/src/Views/PruneWorktrees.axaml @@ -0,0 +1,15 @@ + + + + + + diff --git a/src/Views/PruneWorktrees.axaml.cs b/src/Views/PruneWorktrees.axaml.cs new file mode 100644 index 00000000..fbb5cf3c --- /dev/null +++ b/src/Views/PruneWorktrees.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class PruneWorktrees : UserControl + { + public PruneWorktrees() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Pull.axaml b/src/Views/Pull.axaml new file mode 100644 index 00000000..96d308b4 --- /dev/null +++ b/src/Views/Pull.axaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Pull.axaml.cs b/src/Views/Pull.axaml.cs new file mode 100644 index 00000000..c6b4923e --- /dev/null +++ b/src/Views/Pull.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Pull : UserControl + { + public Pull() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Push.axaml b/src/Views/Push.axaml new file mode 100644 index 00000000..743606ee --- /dev/null +++ b/src/Views/Push.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Push.axaml.cs b/src/Views/Push.axaml.cs new file mode 100644 index 00000000..9a0b2125 --- /dev/null +++ b/src/Views/Push.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Push : UserControl + { + public Push() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/PushTag.axaml b/src/Views/PushTag.axaml new file mode 100644 index 00000000..1181e4e8 --- /dev/null +++ b/src/Views/PushTag.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/PushTag.axaml.cs b/src/Views/PushTag.axaml.cs new file mode 100644 index 00000000..ed3ac194 --- /dev/null +++ b/src/Views/PushTag.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class PushTag : UserControl + { + public PushTag() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Rebase.axaml b/src/Views/Rebase.axaml new file mode 100644 index 00000000..91c1fab2 --- /dev/null +++ b/src/Views/Rebase.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Rebase.axaml.cs b/src/Views/Rebase.axaml.cs new file mode 100644 index 00000000..8bd712c1 --- /dev/null +++ b/src/Views/Rebase.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Rebase : UserControl + { + public Rebase() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/RemoveWorktree.axaml b/src/Views/RemoveWorktree.axaml new file mode 100644 index 00000000..736e6e40 --- /dev/null +++ b/src/Views/RemoveWorktree.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Views/RemoveWorktree.axaml.cs b/src/Views/RemoveWorktree.axaml.cs new file mode 100644 index 00000000..24b075af --- /dev/null +++ b/src/Views/RemoveWorktree.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class RemoveWorktree : UserControl + { + public RemoveWorktree() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/RenameBranch.axaml b/src/Views/RenameBranch.axaml new file mode 100644 index 00000000..efbbf323 --- /dev/null +++ b/src/Views/RenameBranch.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RenameBranch.axaml.cs b/src/Views/RenameBranch.axaml.cs new file mode 100644 index 00000000..39589dc6 --- /dev/null +++ b/src/Views/RenameBranch.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class RenameBranch : UserControl + { + public RenameBranch() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml new file mode 100644 index 00000000..f1f3fccc --- /dev/null +++ b/src/Views/Repository.axaml @@ -0,0 +1,865 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs new file mode 100644 index 00000000..8196b72f --- /dev/null +++ b/src/Views/Repository.axaml.cs @@ -0,0 +1,450 @@ +using System; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class CounterPresenter : Control + { + public static readonly StyledProperty CountProperty = + AvaloniaProperty.Register(nameof(Count), 0); + + public int Count + { + get => GetValue(CountProperty); + set => SetValue(CountProperty, value); + } + + public static readonly StyledProperty FontFamilyProperty = + TextBlock.FontFamilyProperty.AddOwner(); + + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + public static readonly StyledProperty FontSizeProperty = + TextBlock.FontSizeProperty.AddOwner(); + + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.White); + + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + public static readonly StyledProperty BackgroundProperty = + AvaloniaProperty.Register(nameof(Background), Brushes.White); + + public IBrush Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + static CounterPresenter() + { + AffectsMeasure( + FontSizeProperty, + FontFamilyProperty, + ForegroundProperty, + CountProperty); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (_label != null) + { + context.DrawRectangle(Background, null, new RoundedRect(new Rect(0, 0, _label.Width + 18, 18), new CornerRadius(9))); + context.DrawText(_label, new Point(9, 9 - _label.Height * 0.5)); + } + } + + protected override Size MeasureOverride(Size availableSize) + { + if (Count > 0) + { + _label = new FormattedText( + Count.ToString(), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(FontFamily), + FontSize, + Foreground); + } + else + { + _label = null; + } + + InvalidateVisual(); + return _label != null ? new Size(_label.Width + 18, 18) : new Size(0, 0); + } + + private FormattedText _label = null; + } + + public partial class Repository : UserControl + { + public Repository() + { + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + UpdateLeftSidebarLayout(); + } + + private void OnSearchCommitPanelPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == IsVisibleProperty && sender is Grid { IsVisible: true }) + TxtSearchCommitsBox.Focus(); + } + + private void OnSearchKeyDown(object _, KeyEventArgs e) + { + var repo = DataContext as ViewModels.Repository; + if (repo == null) + return; + + if (e.Key == Key.Enter) + { + if (!string.IsNullOrWhiteSpace(repo.SearchCommitFilter)) + repo.StartSearchCommits(); + + e.Handled = true; + } + else if (e.Key == Key.Down) + { + if (repo.MatchedFilesForSearching is { Count: > 0 }) + { + SearchSuggestionBox.Focus(NavigationMethod.Tab); + SearchSuggestionBox.SelectedIndex = 0; + } + + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + repo.ClearMatchedFilesForSearching(); + e.Handled = true; + } + } + + private void OnBranchTreeRowsChanged(object _, RoutedEventArgs e) + { + UpdateLeftSidebarLayout(); + e.Handled = true; + } + + private void OnLocalBranchTreeSelectionChanged(object _1, RoutedEventArgs _2) + { + RemoteBranchTree.UnselectAll(); + TagsList.UnselectAll(); + } + + private void OnRemoteBranchTreeSelectionChanged(object _1, RoutedEventArgs _2) + { + LocalBranchTree.UnselectAll(); + TagsList.UnselectAll(); + } + + private void OnTagsRowsChanged(object _, RoutedEventArgs e) + { + UpdateLeftSidebarLayout(); + e.Handled = true; + } + + private void OnTagsSelectionChanged(object _1, RoutedEventArgs _2) + { + LocalBranchTree.UnselectAll(); + RemoteBranchTree.UnselectAll(); + } + + private void OnSubmodulesRowsChanged(object _, RoutedEventArgs e) + { + UpdateLeftSidebarLayout(); + e.Handled = true; + } + + private void OnWorktreeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is ListBox { SelectedItem: Models.Worktree worktree } grid && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForWorktree(worktree); + menu?.Open(grid); + } + + e.Handled = true; + } + + private void OnDoubleTappedWorktree(object sender, TappedEventArgs e) + { + if (sender is ListBox { SelectedItem: Models.Worktree worktree } && DataContext is ViewModels.Repository repo) + { + repo.OpenWorktree(worktree); + } + + e.Handled = true; + } + + private void OnWorktreeListPropertyChanged(object _, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == ItemsControl.ItemsSourceProperty || e.Property == IsVisibleProperty) + UpdateLeftSidebarLayout(); + } + + private void OnLeftSidebarSizeChanged(object _, SizeChangedEventArgs e) + { + if (e.HeightChanged) + UpdateLeftSidebarLayout(); + } + + private void UpdateLeftSidebarLayout() + { + var vm = DataContext as ViewModels.Repository; + if (vm?.Settings == null) + return; + + if (!IsLoaded) + return; + + var leftHeight = LeftSidebarGroups.Bounds.Height - 28.0 * 5 - 4; + if (leftHeight <= 0) + return; + + var localBranchRows = vm.IsLocalBranchGroupExpanded ? LocalBranchTree.Rows.Count : 0; + var remoteBranchRows = vm.IsRemoteGroupExpanded ? RemoteBranchTree.Rows.Count : 0; + var desiredBranches = (localBranchRows + remoteBranchRows) * 24.0; + var desiredTag = vm.IsTagGroupExpanded ? 24.0 * TagsList.Rows : 0; + var desiredSubmodule = vm.IsSubmoduleGroupExpanded ? 24.0 * SubmoduleList.Rows : 0; + var desiredWorktree = vm.IsWorktreeGroupExpanded ? 24.0 * vm.Worktrees.Count : 0; + var desiredOthers = desiredTag + desiredSubmodule + desiredWorktree; + var hasOverflow = (desiredBranches + desiredOthers > leftHeight); + + if (vm.IsWorktreeGroupExpanded) + { + var height = desiredWorktree; + if (hasOverflow) + { + var test = leftHeight - desiredBranches - desiredTag - desiredSubmodule; + if (test < 0) + height = Math.Min(120, height); + else + height = Math.Max(120, test); + } + + leftHeight -= height; + WorktreeList.Height = height; + hasOverflow = (desiredBranches + desiredTag + desiredSubmodule) > leftHeight; + } + + if (vm.IsSubmoduleGroupExpanded) + { + var height = desiredSubmodule; + if (hasOverflow) + { + var test = leftHeight - desiredBranches - desiredTag; + if (test < 0) + height = Math.Min(120, height); + else + height = Math.Max(120, test); + } + + leftHeight -= height; + SubmoduleList.Height = height; + hasOverflow = (desiredBranches + desiredTag) > leftHeight; + } + + if (vm.IsTagGroupExpanded) + { + var height = desiredTag; + if (hasOverflow) + { + var test = leftHeight - desiredBranches; + if (test < 0) + height = Math.Min(120, height); + else + height = Math.Max(120, test); + } + + leftHeight -= height; + TagsList.Height = height; + } + + if (leftHeight > 0 && desiredBranches > leftHeight) + { + var local = localBranchRows * 24.0; + var remote = remoteBranchRows * 24.0; + var half = leftHeight / 2; + if (vm.IsLocalBranchGroupExpanded) + { + if (vm.IsRemoteGroupExpanded) + { + if (local < half) + { + LocalBranchTree.Height = local; + RemoteBranchTree.Height = leftHeight - local; + } + else if (remote < half) + { + RemoteBranchTree.Height = remote; + LocalBranchTree.Height = leftHeight - remote; + } + else + { + LocalBranchTree.Height = half; + RemoteBranchTree.Height = half; + } + } + else + { + LocalBranchTree.Height = leftHeight; + } + } + else if (vm.IsRemoteGroupExpanded) + { + RemoteBranchTree.Height = leftHeight; + } + } + else + { + if (vm.IsLocalBranchGroupExpanded) + { + var height = localBranchRows * 24; + LocalBranchTree.Height = height; + } + + if (vm.IsRemoteGroupExpanded) + { + var height = remoteBranchRows * 24; + RemoteBranchTree.Height = height; + } + } + } + + private void OnSearchSuggestionBoxKeyDown(object _, KeyEventArgs e) + { + var repo = DataContext as ViewModels.Repository; + if (repo == null) + return; + + if (e.Key == Key.Escape) + { + repo.ClearMatchedFilesForSearching(); + e.Handled = true; + } + else if (e.Key == Key.Enter && SearchSuggestionBox.SelectedItem is string content) + { + repo.SearchCommitFilter = content; + TxtSearchCommitsBox.CaretIndex = content.Length; + repo.StartSearchCommits(); + e.Handled = true; + } + } + + private void OnSearchSuggestionDoubleTapped(object sender, TappedEventArgs e) + { + var repo = DataContext as ViewModels.Repository; + if (repo == null) + return; + + var content = (sender as StackPanel)?.DataContext as string; + if (!string.IsNullOrEmpty(content)) + { + repo.SearchCommitFilter = content; + TxtSearchCommitsBox.CaretIndex = content.Length; + repo.StartSearchCommits(); + } + e.Handled = true; + } + + private void OnOpenAdvancedHistoriesOption(object sender, RoutedEventArgs e) + { + if (sender is Button button && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForHistoriesPage(); + menu?.Open(button); + } + + e.Handled = true; + } + + private void OnOpenSortLocalBranchMenu(object sender, RoutedEventArgs e) + { + if (sender is Button button && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForBranchSortMode(true); + menu?.Open(button); + } + + e.Handled = true; + } + + private void OnOpenSortRemoteBranchMenu(object sender, RoutedEventArgs e) + { + if (sender is Button button && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForBranchSortMode(false); + menu?.Open(button); + } + + e.Handled = true; + } + + private void OnOpenSortTagMenu(object sender, RoutedEventArgs e) + { + if (sender is Button button && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForTagSortMode(); + menu?.Open(button); + } + + e.Handled = true; + } + + private void OnSkipInProgress(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo) + repo.SkipMerge(); + + e.Handled = true; + } + + private void OnRemoveSelectedHistoriesFilter(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo && sender is Button { DataContext: Models.Filter filter }) + repo.RemoveHistoriesFilter(filter); + + e.Handled = true; + } + + private void OnBisectCommand(object sender, RoutedEventArgs e) + { + if (sender is Button button && + DataContext is ViewModels.Repository { IsBisectCommandRunning: false } repo && + repo.CanCreatePopup()) + repo.Bisect(button.Tag as string); + + e.Handled = true; + } + } +} diff --git a/src/Views/RepositoryConfigure.axaml b/src/Views/RepositoryConfigure.axaml new file mode 100644 index 00000000..5ded6f5c --- /dev/null +++ b/src/Views/RepositoryConfigure.axaml @@ -0,0 +1,532 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RepositoryConfigure.axaml.cs b/src/Views/RepositoryConfigure.axaml.cs new file mode 100644 index 00000000..2c80dd45 --- /dev/null +++ b/src/Views/RepositoryConfigure.axaml.cs @@ -0,0 +1,46 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class RepositoryConfigure : ChromelessWindow + { + public RepositoryConfigure() + { + InitializeComponent(); + } + + protected override void OnClosing(WindowClosingEventArgs e) + { + base.OnClosing(e); + + if (!Design.IsDesignMode && DataContext is ViewModels.RepositoryConfigure configure) + configure.Save(); + } + + private async void SelectExecutableForCustomAction(object sender, RoutedEventArgs e) + { + var options = new FilePickerOpenOptions() + { + FileTypeFilter = [new FilePickerFileType("Executable file(script)") { Patterns = ["*.*"] }], + AllowMultiple = false, + }; + + var selected = await StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1 && sender is Button { DataContext: Models.CustomAction action }) + action.Executable = selected[0].Path.LocalPath; + + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (!e.Handled && e.Key == Key.Escape) + Close(); + } + } +} diff --git a/src/Views/RepositoryToolbar.axaml b/src/Views/RepositoryToolbar.axaml new file mode 100644 index 00000000..08cfd308 --- /dev/null +++ b/src/Views/RepositoryToolbar.axaml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RepositoryToolbar.axaml.cs b/src/Views/RepositoryToolbar.axaml.cs new file mode 100644 index 00000000..cbd0041a --- /dev/null +++ b/src/Views/RepositoryToolbar.axaml.cs @@ -0,0 +1,165 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class RepositoryToolbar : UserControl + { + public RepositoryToolbar() + { + InitializeComponent(); + } + + private void OpenWithExternalTools(object sender, RoutedEventArgs e) + { + if (sender is Button button && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForExternalTools(); + menu?.Open(button); + e.Handled = true; + } + } + + private void OpenStatistics(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo) + { + App.ShowWindow(new ViewModels.Statistics(repo.FullPath), true); + e.Handled = true; + } + } + + private void OpenConfigure(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo) + { + App.ShowWindow(new ViewModels.RepositoryConfigure(repo), true); + e.Handled = true; + } + } + + private void Fetch(object _, RoutedEventArgs e) + { + var launcher = this.FindAncestorOfType(); + if (launcher is not null && DataContext is ViewModels.Repository repo) + { + var startDirectly = launcher.HasKeyModifier(KeyModifiers.Control); + launcher.ClearKeyModifier(); + repo.Fetch(startDirectly); + e.Handled = true; + } + } + + private void Pull(object _, RoutedEventArgs e) + { + var launcher = this.FindAncestorOfType(); + if (launcher is not null && DataContext is ViewModels.Repository repo) + { + if (repo.IsBare) + { + App.RaiseException(repo.FullPath, "Can't run `git pull` in bare repository!"); + return; + } + + var startDirectly = launcher.HasKeyModifier(KeyModifiers.Control); + launcher.ClearKeyModifier(); + repo.Pull(startDirectly); + e.Handled = true; + } + } + + private void Push(object _, RoutedEventArgs e) + { + var launcher = this.FindAncestorOfType(); + if (launcher is not null && DataContext is ViewModels.Repository repo) + { + var startDirectly = launcher.HasKeyModifier(KeyModifiers.Control); + launcher.ClearKeyModifier(); + repo.Push(startDirectly); + e.Handled = true; + } + } + + private void StashAll(object _, RoutedEventArgs e) + { + var launcher = this.FindAncestorOfType(); + if (launcher is not null && DataContext is ViewModels.Repository repo) + { + var startDirectly = launcher.HasKeyModifier(KeyModifiers.Control); + launcher.ClearKeyModifier(); + repo.StashAll(startDirectly); + e.Handled = true; + } + } + + private void OpenGitFlowMenu(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo && sender is Control control) + { + var menu = repo.CreateContextMenuForGitFlow(); + menu?.Open(control); + } + + e.Handled = true; + } + + private void OpenGitLFSMenu(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo && sender is Control control) + { + var menu = repo.CreateContextMenuForGitLFS(); + menu?.Open(control); + } + + e.Handled = true; + } + + private void StartBisect(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository { IsBisectCommandRunning: false } repo && + repo.InProgressContext == null && + repo.CanCreatePopup()) + { + if (repo.LocalChangesCount > 0) + App.RaiseException(repo.FullPath, "You have un-committed local changes. Please discard or stash them first."); + else if (repo.IsBisectCommandRunning || repo.BisectState != Models.BisectState.None) + App.RaiseException(repo.FullPath, "Bisect is running! Please abort it before starting a new one."); + else + repo.Bisect("start"); + } + + e.Handled = true; + } + + private void OpenCustomActionMenu(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo && sender is Control control) + { + var menu = repo.CreateContextMenuForCustomAction(); + menu?.Open(control); + } + + e.Handled = true; + } + + private void OpenGitLogs(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository repo) + { + App.ShowWindow(new ViewModels.ViewLogs(repo), true); + e.Handled = true; + } + } + + private void NavigateToHead(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Repository { CurrentBranch: { } } repo) + { + repo.NavigateToCommit(repo.CurrentBranch.Head); + e.Handled = true; + } + } + } +} diff --git a/src/Views/Reset.axaml b/src/Views/Reset.axaml new file mode 100644 index 00000000..bce9d747 --- /dev/null +++ b/src/Views/Reset.axaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Reset.axaml.cs b/src/Views/Reset.axaml.cs new file mode 100644 index 00000000..8c380538 --- /dev/null +++ b/src/Views/Reset.axaml.cs @@ -0,0 +1,38 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class Reset : UserControl + { + public Reset() + { + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + ResetMode.Focus(); + } + + private void OnResetModeKeyDown(object sender, KeyEventArgs e) + { + if (sender is ComboBox comboBox) + { + var key = e.Key.ToString(); + for (int i = 0; i < Models.ResetMode.Supported.Length; i++) + { + if (key.Equals(Models.ResetMode.Supported[i].Key, System.StringComparison.OrdinalIgnoreCase)) + { + comboBox.SelectedIndex = i; + e.Handled = true; + return; + } + } + } + } + } +} diff --git a/src/Views/ResetWithoutCheckout.axaml b/src/Views/ResetWithoutCheckout.axaml new file mode 100644 index 00000000..3808a8dd --- /dev/null +++ b/src/Views/ResetWithoutCheckout.axaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ResetWithoutCheckout.axaml.cs b/src/Views/ResetWithoutCheckout.axaml.cs new file mode 100644 index 00000000..9280c070 --- /dev/null +++ b/src/Views/ResetWithoutCheckout.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ResetWithoutCheckout : UserControl + { + public ResetWithoutCheckout() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Revert.axaml b/src/Views/Revert.axaml new file mode 100644 index 00000000..cafe1725 --- /dev/null +++ b/src/Views/Revert.axaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + diff --git a/src/Views/Revert.axaml.cs b/src/Views/Revert.axaml.cs new file mode 100644 index 00000000..7684d077 --- /dev/null +++ b/src/Views/Revert.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Revert : UserControl + { + public Revert() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/RevisionCompare.axaml b/src/Views/RevisionCompare.axaml new file mode 100644 index 00000000..6367c866 --- /dev/null +++ b/src/Views/RevisionCompare.axaml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RevisionCompare.axaml.cs b/src/Views/RevisionCompare.axaml.cs new file mode 100644 index 00000000..2c548240 --- /dev/null +++ b/src/Views/RevisionCompare.axaml.cs @@ -0,0 +1,56 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class RevisionCompare : UserControl + { + public RevisionCompare() + { + InitializeComponent(); + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.RevisionCompare vm && sender is ChangeCollectionView view) + { + var menu = vm.CreateChangeContextMenu(); + menu?.Open(view); + } + + e.Handled = true; + } + + private void OnPressedSHA(object sender, PointerPressedEventArgs e) + { + if (DataContext is ViewModels.RevisionCompare vm && sender is TextBlock block) + vm.NavigateTo(block.Text); + + e.Handled = true; + } + + private async void OnSaveAsPatch(object sender, RoutedEventArgs e) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + return; + + var vm = DataContext as ViewModels.RevisionCompare; + if (vm == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await topLevel.StorageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + vm.SaveAsPatch(storageFile.Path.LocalPath); + + e.Handled = true; + } + } +} diff --git a/src/Views/RevisionFileContentViewer.axaml b/src/Views/RevisionFileContentViewer.axaml new file mode 100644 index 00000000..3e8362c9 --- /dev/null +++ b/src/Views/RevisionFileContentViewer.axaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RevisionFileContentViewer.axaml.cs b/src/Views/RevisionFileContentViewer.axaml.cs new file mode 100644 index 00000000..5e9d5437 --- /dev/null +++ b/src/Views/RevisionFileContentViewer.axaml.cs @@ -0,0 +1,165 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Media; + +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.TextMate; + +namespace SourceGit.Views +{ + public class RevisionTextFileView : TextEditor + { + public static readonly StyledProperty TabWidthProperty = + AvaloniaProperty.Register(nameof(TabWidth), 4); + + public int TabWidth + { + get => GetValue(TabWidthProperty); + set => SetValue(TabWidthProperty, value); + } + + public static readonly StyledProperty UseSyntaxHighlightingProperty = + AvaloniaProperty.Register(nameof(UseSyntaxHighlighting)); + + public bool UseSyntaxHighlighting + { + get => GetValue(UseSyntaxHighlightingProperty); + set => SetValue(UseSyntaxHighlightingProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextEditor); + + public RevisionTextFileView() : base(new TextArea(), new TextDocument()) + { + IsReadOnly = true; + ShowLineNumbers = true; + WordWrap = false; + + Options.IndentationSize = TabWidth; + Options.EnableHyperlinks = false; + Options.EnableEmailHyperlinks = false; + + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + + TextArea.LeftMargins[0].Margin = new Thickness(8, 0); + TextArea.TextView.Margin = new Thickness(4, 0); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + TextArea.TextView.ContextRequested += OnTextViewContextRequested; + UpdateTextMate(); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + + TextArea.TextView.ContextRequested -= OnTextViewContextRequested; + + if (_textMate != null) + { + _textMate.Dispose(); + _textMate = null; + } + + GC.Collect(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + if (DataContext is Models.RevisionTextFile source) + { + Text = source.Content; + Models.TextMateHelper.SetGrammarByFileName(_textMate, source.FileName); + ScrollToHome(); + } + else + { + Text = string.Empty; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == TabWidthProperty) + Options.IndentationSize = TabWidth; + else if (change.Property == UseSyntaxHighlightingProperty) + UpdateTextMate(); + } + + private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) + { + var selected = SelectedText; + if (string.IsNullOrEmpty(selected)) + return; + + var copy = new MenuItem() { Header = App.Text("Copy") }; + copy.Click += (_, ev) => + { + App.CopyText(selected); + ev.Handled = true; + }; + + if (this.FindResource("Icons.Copy") is Geometry geo) + { + copy.Icon = new Avalonia.Controls.Shapes.Path() + { + Width = 10, + Height = 10, + Stretch = Stretch.Uniform, + Data = geo, + }; + } + + var menu = new ContextMenu(); + menu.Items.Add(copy); + menu.Open(TextArea.TextView); + + e.Handled = true; + } + + private void UpdateTextMate() + { + if (UseSyntaxHighlighting) + { + if (_textMate == null) + _textMate = Models.TextMateHelper.CreateForEditor(this); + + if (DataContext is Models.RevisionTextFile file) + Models.TextMateHelper.SetGrammarByFileName(_textMate, file.FileName); + } + else if (_textMate != null) + { + _textMate.Dispose(); + _textMate = null; + GC.Collect(); + + TextArea.TextView.Redraw(); + } + } + + private TextMate.Installation _textMate = null; + } + + public partial class RevisionFileContentViewer : UserControl + { + public RevisionFileContentViewer() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/RevisionFileTreeView.axaml b/src/Views/RevisionFileTreeView.axaml new file mode 100644 index 00000000..7266c429 --- /dev/null +++ b/src/Views/RevisionFileTreeView.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RevisionFileTreeView.axaml.cs b/src/Views/RevisionFileTreeView.axaml.cs new file mode 100644 index 00000000..569e121f --- /dev/null +++ b/src/Views/RevisionFileTreeView.axaml.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class RevisionFileTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.RevisionFileTreeNode { IsFolder: true } node) + { + var tree = this.FindAncestorOfType(); + tree?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class RevisionTreeNodeIcon : UserControl + { + public static readonly StyledProperty NodeProperty = + AvaloniaProperty.Register(nameof(Node)); + + public ViewModels.RevisionFileTreeNode Node + { + get => GetValue(NodeProperty); + set => SetValue(NodeProperty, value); + } + + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + static RevisionTreeNodeIcon() + { + NodeProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); + IsExpandedProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); + } + + private void UpdateContent() + { + var node = Node; + if (node?.Backend == null) + { + Content = null; + return; + } + + var obj = node.Backend; + switch (obj.Type) + { + case Models.ObjectType.Blob: + CreateContent("Icons.File", new Thickness(0, 0, 0, 0)); + break; + case Models.ObjectType.Commit: + CreateContent("Icons.Submodule", new Thickness(0, 0, 0, 0)); + break; + default: + CreateContent(node.IsExpanded ? "Icons.Folder.Open" : "Icons.Folder", new Thickness(0, 2, 0, 0), Brushes.Goldenrod); + break; + } + } + + private void CreateContent(string iconKey, Thickness margin, IBrush fill = null) + { + var geo = this.FindResource(iconKey) as StreamGeometry; + if (geo == null) + return; + + var icon = new Avalonia.Controls.Shapes.Path() + { + Width = 14, + Height = 14, + Margin = margin, + Stretch = Stretch.Uniform, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Data = geo, + }; + + if (fill != null) + icon.Fill = fill; + + Content = icon; + } + } + + public class RevisionFileRowsListBox : ListBox + { + protected override Type StyleKeyOverride => typeof(ListBox); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (SelectedItem is ViewModels.RevisionFileTreeNode { IsFolder: true } node && e.KeyModifiers == KeyModifiers.None) + { + if ((node.IsExpanded && e.Key == Key.Left) || (!node.IsExpanded && e.Key == Key.Right)) + { + this.FindAncestorOfType()?.ToggleNodeIsExpanded(node); + e.Handled = true; + } + } + + if (!e.Handled) + base.OnKeyDown(e); + } + } + + public partial class RevisionFileTreeView : UserControl + { + public static readonly StyledProperty RevisionProperty = + AvaloniaProperty.Register(nameof(Revision)); + + public string Revision + { + get => GetValue(RevisionProperty); + set => SetValue(RevisionProperty, value); + } + + public AvaloniaList Rows + { + get => _rows; + } + + public RevisionFileTreeView() + { + InitializeComponent(); + } + + public void SetSearchResult(string file) + { + _rows.Clear(); + _searchResult.Clear(); + + var rows = new List(); + if (string.IsNullOrEmpty(file)) + { + MakeRows(rows, _tree, 0); + } + else + { + var vm = DataContext as ViewModels.CommitDetail; + if (vm?.Commit == null) + return; + + var objects = vm.GetRevisionFilesUnderFolder(file); + if (objects == null || objects.Count != 1) + return; + + var routes = file.Split('/', StringSplitOptions.None); + if (routes.Length == 1) + { + _searchResult.Add(new ViewModels.RevisionFileTreeNode + { + Backend = objects[0] + }); + } + else + { + var last = _searchResult; + var prefix = string.Empty; + for (var i = 0; i < routes.Length - 1; i++) + { + var folder = new ViewModels.RevisionFileTreeNode + { + Backend = new Models.Object + { + Type = Models.ObjectType.Tree, + Path = prefix + routes[i], + }, + IsExpanded = true, + }; + + last.Add(folder); + last = folder.Children; + prefix = folder.Backend + "/"; + } + + last.Add(new ViewModels.RevisionFileTreeNode + { + Backend = objects[0] + }); + } + + MakeRows(rows, _searchResult, 0); + } + + _rows.AddRange(rows); + GC.Collect(); + } + + public void ToggleNodeIsExpanded(ViewModels.RevisionFileTreeNode node) + { + _disableSelectionChangingEvent = true; + node.IsExpanded = !node.IsExpanded; + + var depth = node.Depth; + var idx = _rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subtree = GetChildrenOfTreeNode(node); + if (subtree != null && subtree.Count > 0) + { + var subrows = new List(); + MakeRows(subrows, subtree, depth + 1); + _rows.InsertRange(idx + 1, subrows); + } + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < _rows.Count; i++) + { + var row = _rows[i]; + if (row.Depth <= depth) + break; + + removeCount++; + } + _rows.RemoveRange(idx + 1, removeCount); + } + + _disableSelectionChangingEvent = false; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == RevisionProperty) + { + _tree.Clear(); + _rows.Clear(); + _searchResult.Clear(); + + var vm = DataContext as ViewModels.CommitDetail; + if (vm?.Commit == null) + { + GC.Collect(); + return; + } + + var objects = vm.GetRevisionFilesUnderFolder(null); + if (objects == null || objects.Count == 0) + { + GC.Collect(); + return; + } + + foreach (var obj in objects) + _tree.Add(new ViewModels.RevisionFileTreeNode { Backend = obj }); + + SortNodes(_tree); + + var topTree = new List(); + MakeRows(topTree, _tree, 0); + _rows.AddRange(topTree); + GC.Collect(); + } + } + + private void OnTreeNodeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail vm && + sender is Grid { DataContext: ViewModels.RevisionFileTreeNode { Backend: { } obj } } grid) + { + if (obj.Type != Models.ObjectType.Tree) + { + var menu = vm.CreateRevisionFileContextMenu(obj); + menu?.Open(grid); + } + } + + e.Handled = true; + } + + private void OnTreeNodeDoubleTapped(object sender, TappedEventArgs e) + { + if (sender is Grid { DataContext: ViewModels.RevisionFileTreeNode { IsFolder: true } node }) + { + var posX = e.GetPosition(this).X; + if (posX < node.Depth * 16 + 16) + return; + + ToggleNodeIsExpanded(node); + } + } + + private void OnRowsSelectionChanged(object sender, SelectionChangedEventArgs _) + { + if (_disableSelectionChangingEvent || DataContext is not ViewModels.CommitDetail vm) + return; + + if (sender is ListBox { SelectedItem: ViewModels.RevisionFileTreeNode { IsFolder: false } node }) + vm.ViewRevisionFile(node.Backend); + else + vm.ViewRevisionFile(null); + } + + private List GetChildrenOfTreeNode(ViewModels.RevisionFileTreeNode node) + { + if (!node.IsFolder) + return null; + + if (node.Children.Count > 0) + return node.Children; + + var vm = DataContext as ViewModels.CommitDetail; + if (vm == null) + return null; + + var objects = vm.GetRevisionFilesUnderFolder(node.Backend.Path + "/"); + if (objects == null || objects.Count == 0) + return null; + + foreach (var obj in objects) + node.Children.Add(new ViewModels.RevisionFileTreeNode() { Backend = obj }); + + SortNodes(node.Children); + return node.Children; + } + + private void MakeRows(List rows, List nodes, int depth) + { + foreach (var node in nodes) + { + node.Depth = depth; + rows.Add(node); + + if (!node.IsExpanded || !node.IsFolder) + continue; + + MakeRows(rows, node.Children, depth + 1); + } + } + + private void SortNodes(List nodes) + { + nodes.Sort((l, r) => + { + if (l.IsFolder == r.IsFolder) + return Models.NumericSort.Compare(l.Name, r.Name); + return l.IsFolder ? -1 : 1; + }); + } + + private List _tree = []; + private AvaloniaList _rows = []; + private bool _disableSelectionChangingEvent = false; + private List _searchResult = []; + } +} diff --git a/src/Views/RevisionFiles.axaml b/src/Views/RevisionFiles.axaml new file mode 100644 index 00000000..5b512060 --- /dev/null +++ b/src/Views/RevisionFiles.axaml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/RevisionFiles.axaml.cs b/src/Views/RevisionFiles.axaml.cs new file mode 100644 index 00000000..3208fbb8 --- /dev/null +++ b/src/Views/RevisionFiles.axaml.cs @@ -0,0 +1,84 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class RevisionFiles : UserControl + { + public RevisionFiles() + { + InitializeComponent(); + } + + private void OnSearchBoxKeyDown(object _, KeyEventArgs e) + { + var vm = DataContext as ViewModels.CommitDetail; + if (vm == null) + return; + + if (e.Key == Key.Enter) + { + FileTree.SetSearchResult(vm.RevisionFileSearchFilter); + e.Handled = true; + } + else if (e.Key == Key.Down || e.Key == Key.Up) + { + if (vm.RevisionFileSearchSuggestion.Count > 0) + { + SearchSuggestionBox.Focus(NavigationMethod.Tab); + SearchSuggestionBox.SelectedIndex = 0; + } + + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + vm.CancelRevisionFileSuggestions(); + e.Handled = true; + } + } + + private void OnSearchBoxTextChanged(object _, TextChangedEventArgs e) + { + if (string.IsNullOrEmpty(TxtSearchRevisionFiles.Text)) + FileTree.SetSearchResult(null); + } + + private void OnSearchSuggestionBoxKeyDown(object _, KeyEventArgs e) + { + var vm = DataContext as ViewModels.CommitDetail; + if (vm == null) + return; + + if (e.Key == Key.Escape) + { + vm.CancelRevisionFileSuggestions(); + e.Handled = true; + } + else if (e.Key == Key.Enter && SearchSuggestionBox.SelectedItem is string content) + { + vm.RevisionFileSearchFilter = content; + TxtSearchRevisionFiles.CaretIndex = content.Length; + FileTree.SetSearchResult(vm.RevisionFileSearchFilter); + e.Handled = true; + } + } + + private void OnSearchSuggestionDoubleTapped(object sender, TappedEventArgs e) + { + var vm = DataContext as ViewModels.CommitDetail; + if (vm == null) + return; + + var content = (sender as StackPanel)?.DataContext as string; + if (!string.IsNullOrEmpty(content)) + { + vm.RevisionFileSearchFilter = content; + TxtSearchRevisionFiles.CaretIndex = content.Length; + FileTree.SetSearchResult(vm.RevisionFileSearchFilter); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/Reword.axaml b/src/Views/Reword.axaml new file mode 100644 index 00000000..3ea1ad98 --- /dev/null +++ b/src/Views/Reword.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/src/Views/Reword.axaml.cs b/src/Views/Reword.axaml.cs new file mode 100644 index 00000000..f05f708a --- /dev/null +++ b/src/Views/Reword.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class Reword : UserControl + { + public Reword() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/ScanRepositories.axaml b/src/Views/ScanRepositories.axaml new file mode 100644 index 00000000..90274700 --- /dev/null +++ b/src/Views/ScanRepositories.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/src/Views/ScanRepositories.axaml.cs b/src/Views/ScanRepositories.axaml.cs new file mode 100644 index 00000000..4848112a --- /dev/null +++ b/src/Views/ScanRepositories.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ScanRepositories : UserControl + { + public ScanRepositories() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/SelfUpdate.axaml b/src/Views/SelfUpdate.axaml new file mode 100644 index 00000000..2d5990e7 --- /dev/null +++ b/src/Views/SelfUpdate.axaml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/StashesPage.axaml.cs b/src/Views/StashesPage.axaml.cs new file mode 100644 index 00000000..d152a12f --- /dev/null +++ b/src/Views/StashesPage.axaml.cs @@ -0,0 +1,59 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class StashesPage : UserControl + { + public StashesPage() + { + InitializeComponent(); + } + + private void OnMainLayoutSizeChanged(object sender, SizeChangedEventArgs e) + { + var grid = sender as Grid; + if (grid == null) + return; + + var layout = ViewModels.Preferences.Instance.Layout; + var width = grid.Bounds.Width; + var maxLeft = width - 304; + + if (layout.StashesLeftWidth.Value - maxLeft > 1.0) + layout.StashesLeftWidth = new GridLength(maxLeft, GridUnitType.Pixel); + } + + private void OnStashListKeyDown(object sender, KeyEventArgs e) + { + if (e.Key is not (Key.Delete or Key.Back)) + return; + + if (DataContext is not ViewModels.StashesPage vm) + return; + + vm.Drop(vm.SelectedStash); + e.Handled = true; + } + + private void OnStashContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.StashesPage vm && sender is Border border) + { + var menu = vm.MakeContextMenu(border.DataContext as Models.Stash); + menu?.Open(border); + } + e.Handled = true; + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.StashesPage vm && sender is Grid grid) + { + var menu = vm.MakeContextMenuForChange(grid.DataContext as Models.Change); + menu?.Open(grid); + } + e.Handled = true; + } + } +} diff --git a/src/Views/Statistics.axaml b/src/Views/Statistics.axaml new file mode 100644 index 00000000..163ce031 --- /dev/null +++ b/src/Views/Statistics.axaml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Statistics.axaml.cs b/src/Views/Statistics.axaml.cs new file mode 100644 index 00000000..4ebf9016 --- /dev/null +++ b/src/Views/Statistics.axaml.cs @@ -0,0 +1,10 @@ +namespace SourceGit.Views +{ + public partial class Statistics : ChromelessWindow + { + public Statistics() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Statistics.xaml b/src/Views/Statistics.xaml deleted file mode 100644 index da3ba1c4..00000000 --- a/src/Views/Statistics.xaml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Statistics.xaml.cs b/src/Views/Statistics.xaml.cs deleted file mode 100644 index 7df3c86d..00000000 --- a/src/Views/Statistics.xaml.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Windows; - -namespace SourceGit.Views { - /// - /// 提交统计 - /// - public partial class Statistics : Controls.Window { - private string repo = null; - - public Statistics(string repo) { - this.repo = repo; - InitializeComponent(); - Task.Run(Refresh); - } - - private void Quit(object sender, RoutedEventArgs e) { - Close(); - } - - private void Refresh() { - var mapsWeek = new Dictionary(); - for (int i = 0; i < 7; i++) { - mapsWeek.Add(i, new Models.StatisticSample { - Name = App.Text($"Weekday.{i}"), - Index = i, - Count = 0, - }); - } - - var mapsMonth = new Dictionary(); - var today = DateTime.Now; - var maxDays = DateTime.DaysInMonth(today.Year, today.Month); - for (int i = 1; i <= maxDays; i++) { - mapsMonth.Add(i, new Models.StatisticSample { - Name = $"{i}", - Index = i, - Count = 0, - }); - } - - var mapsYear = new Dictionary(); - for (int i = 1; i <= 12; i++) { - mapsYear.Add(i, new Models.StatisticSample { - Name = App.Text($"Month.{i}"), - Index = i, - Count = 0, - }); - } - - var mapCommitterWeek = new Dictionary(); - var mapCommitterMonth = new Dictionary(); - var mapCommitterYear = new Dictionary(); - - var weekStart = today.AddSeconds(-(int)today.DayOfWeek * 3600 * 24 - today.Hour * 3600 - today.Minute * 60 - today.Second); - var weekEnd = weekStart.AddDays(7); - var month = today.Month; - var utcStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); - - var limits = $"--branches --remotes --since=\"{today.ToString("yyyy-01-01 00:00:00")}\""; - var commits = new Commands.Commits(repo, limits).Result(); - var totalCommitsWeek = 0; - var totalCommitsMonth = 0; - var totalCommitsYear = commits.Count; - foreach (var c in commits) { - var commitTime = utcStart.AddSeconds(c.CommitterTime); - if (commitTime.CompareTo(weekStart) >= 0 && commitTime.CompareTo(weekEnd) < 0) { - mapsWeek[(int)commitTime.DayOfWeek].Count++; - totalCommitsWeek++; - - if (mapCommitterWeek.ContainsKey(c.Committer.Name)) { - mapCommitterWeek[c.Committer.Name].Count++; - } else { - mapCommitterWeek[c.Committer.Name] = new Models.StatisticSample { - Name = c.Committer.Name, - Count = 1, - }; - } - } - - if (commitTime.Month == month) { - mapsMonth[commitTime.Day].Count++; - totalCommitsMonth++; - - if (mapCommitterMonth.ContainsKey(c.Committer.Name)) { - mapCommitterMonth[c.Committer.Name].Count++; - } else { - mapCommitterMonth[c.Committer.Name] = new Models.StatisticSample { - Name = c.Committer.Name, - Count = 1, - }; - } - } - - mapsYear[commitTime.Month].Count++; - if (mapCommitterYear.ContainsKey(c.Committer.Name)) { - mapCommitterYear[c.Committer.Name].Count++; - } else { - mapCommitterYear[c.Committer.Name] = new Models.StatisticSample { - Name = c.Committer.Name, - Count = 1, - }; - } - } - - SetPage(pageWeek, mapCommitterWeek.Values.ToList(), mapsWeek.Values.ToList(), totalCommitsWeek); - SetPage(pageMonth, mapCommitterMonth.Values.ToList(), mapsMonth.Values.ToList(), totalCommitsMonth); - SetPage(pageYear, mapCommitterYear.Values.ToList(), mapsYear.Values.ToList(), totalCommitsYear); - - mapsMonth.Clear(); - mapsWeek.Clear(); - mapsYear.Clear(); - mapCommitterMonth.Clear(); - mapCommitterWeek.Clear(); - mapCommitterYear.Clear(); - commits.Clear(); - - Dispatcher.Invoke(() => { - loading.IsAnimating = false; - loading.Visibility = Visibility.Collapsed; - }); - } - - private void SetPage(Widgets.StatisticsPage page, List committers, List commits, int total) { - committers.Sort((x, y) => y.Count - x.Count); - commits.Sort((x, y) => x.Index - y.Index); - page.SetData(committers, commits, total); - } - } -} diff --git a/src/Views/SubmodulesView.axaml b/src/Views/SubmodulesView.axaml new file mode 100644 index 00000000..b8147384 --- /dev/null +++ b/src/Views/SubmodulesView.axaml @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/SubmodulesView.axaml.cs b/src/Views/SubmodulesView.axaml.cs new file mode 100644 index 00000000..81ccdc5d --- /dev/null +++ b/src/Views/SubmodulesView.axaml.cs @@ -0,0 +1,182 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class SubmoduleTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.SubmoduleTreeNode { IsFolder: true } node) + { + var view = this.FindAncestorOfType(); + view?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class SubmoduleTreeNodeIcon : UserControl + { + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsExpandedProperty) + UpdateContent(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + UpdateContent(); + } + + private void UpdateContent() + { + if (DataContext is not ViewModels.SubmoduleTreeNode node) + { + Content = null; + return; + } + + if (node.Module != null) + CreateContent(new Thickness(0, 0, 0, 0), "Icons.Submodule"); + else if (node.IsExpanded) + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open"); + else + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder"); + } + + private void CreateContent(Thickness margin, string iconKey) + { + var geo = this.FindResource(iconKey) as StreamGeometry; + if (geo == null) + return; + + Content = new Avalonia.Controls.Shapes.Path() + { + Width = 12, + Height = 12, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = margin, + Data = geo, + }; + } + } + + public partial class SubmodulesView : UserControl + { + public static readonly RoutedEvent RowsChangedEvent = + RoutedEvent.Register(nameof(RowsChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler RowsChanged + { + add { AddHandler(RowsChangedEvent, value); } + remove { RemoveHandler(RowsChangedEvent, value); } + } + + public int Rows + { + get; + private set; + } + + public SubmodulesView() + { + InitializeComponent(); + } + + public void ToggleNodeIsExpanded(ViewModels.SubmoduleTreeNode node) + { + if (Content is ViewModels.SubmoduleCollectionAsTree tree) + { + tree.ToggleExpand(node); + Rows = tree.Rows.Count; + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ContentProperty) + { + if (Content is ViewModels.SubmoduleCollectionAsTree tree) + Rows = tree.Rows.Count; + else if (Content is ViewModels.SubmoduleCollectionAsList list) + Rows = list.Submodules.Count; + else + Rows = 0; + + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + else if (change.Property == IsVisibleProperty) + { + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + private void OnItemDoubleTapped(object sender, TappedEventArgs e) + { + if (sender is Control control && DataContext is ViewModels.Repository repo) + { + if (control.DataContext is ViewModels.SubmoduleTreeNode node) + { + if (node.IsFolder) + ToggleNodeIsExpanded(node); + else if (node.Module.Status != Models.SubmoduleStatus.NotInited) + repo.OpenSubmodule(node.Module.Path); + } + else if (control.DataContext is Models.Submodule m && m.Status != Models.SubmoduleStatus.NotInited) + { + repo.OpenSubmodule(m.Path); + } + } + + e.Handled = true; + } + + private void OnItemContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is Control control && DataContext is ViewModels.Repository repo) + { + if (control.DataContext is ViewModels.SubmoduleTreeNode node && node.Module != null) + { + var menu = repo.CreateContextMenuForSubmodule(node.Module); + menu?.Open(control); + } + else if (control.DataContext is Models.Submodule m) + { + var menu = repo.CreateContextMenuForSubmodule(m); + menu?.Open(control); + } + } + + e.Handled = true; + } + } +} diff --git a/src/Views/TagsView.axaml b/src/Views/TagsView.axaml new file mode 100644 index 00000000..655d046a --- /dev/null +++ b/src/Views/TagsView.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/TagsView.axaml.cs b/src/Views/TagsView.axaml.cs new file mode 100644 index 00000000..1b384262 --- /dev/null +++ b/src/Views/TagsView.axaml.cs @@ -0,0 +1,232 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class TagTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.TagTreeNode { IsFolder: true } node) + { + var view = this.FindAncestorOfType(); + view?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class TagTreeNodeIcon : UserControl + { + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + UpdateContent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsExpandedProperty) + UpdateContent(); + } + + private void UpdateContent() + { + if (DataContext is not ViewModels.TagTreeNode node) + { + Content = null; + return; + } + + if (node.Tag != null) + CreateContent(new Thickness(0, 0, 0, 0), "Icons.Tag"); + else if (node.IsExpanded) + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open"); + else + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder"); + } + + private void CreateContent(Thickness margin, string iconKey) + { + var geo = this.FindResource(iconKey) as StreamGeometry; + if (geo == null) + return; + + Content = new Avalonia.Controls.Shapes.Path() + { + Width = 12, + Height = 12, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = margin, + Data = geo, + }; + } + } + + public partial class TagsView : UserControl + { + public static readonly RoutedEvent SelectionChangedEvent = + RoutedEvent.Register(nameof(SelectionChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler SelectionChanged + { + add { AddHandler(SelectionChangedEvent, value); } + remove { RemoveHandler(SelectionChangedEvent, value); } + } + + public static readonly RoutedEvent RowsChangedEvent = + RoutedEvent.Register(nameof(RowsChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler RowsChanged + { + add { AddHandler(RowsChangedEvent, value); } + remove { RemoveHandler(RowsChangedEvent, value); } + } + + public int Rows + { + get; + private set; + } + + public TagsView() + { + InitializeComponent(); + } + + public void UnselectAll() + { + var list = this.FindDescendantOfType(); + if (list != null) + list.SelectedItem = null; + } + + public void ToggleNodeIsExpanded(ViewModels.TagTreeNode node) + { + if (Content is ViewModels.TagCollectionAsTree tree) + { + tree.ToggleExpand(node); + Rows = tree.Rows.Count; + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ContentProperty) + { + if (Content is ViewModels.TagCollectionAsTree tree) + Rows = tree.Rows.Count; + else if (Content is ViewModels.TagCollectionAsList list) + Rows = list.Tags.Count; + else + Rows = 0; + + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + else if (change.Property == IsVisibleProperty) + { + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + private void OnItemDoubleTapped(object sender, TappedEventArgs e) + { + if (sender is Control { DataContext: ViewModels.TagTreeNode { IsFolder: true } node }) + ToggleNodeIsExpanded(node); + + e.Handled = true; + } + + private void OnItemPointerPressed(object sender, PointerPressedEventArgs e) + { + var p = e.GetCurrentPoint(this); + if (!p.Properties.IsLeftButtonPressed) + return; + + if (DataContext is not ViewModels.Repository repo) + return; + + if (sender is Control { DataContext: Models.Tag tag }) + repo.NavigateToCommit(tag.SHA); + else if (sender is Control { DataContext: ViewModels.TagTreeNode { Tag: { } nodeTag } }) + repo.NavigateToCommit(nodeTag.SHA); + } + + private void OnItemContextRequested(object sender, ContextRequestedEventArgs e) + { + var control = sender as Control; + if (control == null) + return; + + Models.Tag selected; + if (control.DataContext is ViewModels.TagTreeNode node) + selected = node.Tag; + else if (control.DataContext is Models.Tag tag) + selected = tag; + else + selected = null; + + if (selected != null && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForTag(selected); + menu?.Open(control); + } + + e.Handled = true; + } + + private void OnSelectionChanged(object sender, SelectionChangedEventArgs _) + { + var selected = (sender as ListBox)?.SelectedItem; + var selectedTag = null as Models.Tag; + if (selected is ViewModels.TagTreeNode node) + selectedTag = node.Tag; + else if (selected is Models.Tag tag) + selectedTag = tag; + + if (selectedTag != null) + RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); + } + + private void OnKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.Repository repo) + return; + + var selected = (sender as ListBox)?.SelectedItem; + if (selected is ViewModels.TagTreeNode { Tag: { } tagInNode }) + repo.DeleteTag(tagInNode); + else if (selected is Models.Tag tag) + repo.DeleteTag(tag); + + e.Handled = true; + } + } +} diff --git a/src/Views/TextDiffView.axaml b/src/Views/TextDiffView.axaml new file mode 100644 index 00000000..ec3475fa --- /dev/null +++ b/src/Views/TextDiffView.axaml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/TextDiffView.axaml.cs b/src/Views/TextDiffView.axaml.cs new file mode 100644 index 00000000..b6235ac8 --- /dev/null +++ b/src/Views/TextDiffView.axaml.cs @@ -0,0 +1,2013 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Text; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; + +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using AvaloniaEdit.TextMate; +using AvaloniaEdit.Utils; + +namespace SourceGit.Views +{ + public class TextDiffViewChunk + { + public double Y { get; set; } = 0.0; + public double Height { get; set; } = 0.0; + public int StartIdx { get; set; } = 0; + public int EndIdx { get; set; } = 0; + public bool Combined { get; set; } = true; + public bool IsOldSide { get; set; } = false; + + public bool ShouldReplace(TextDiffViewChunk old) + { + if (old == null) + return true; + + return Math.Abs(Y - old.Y) > 0.001 || + Math.Abs(Height - old.Height) > 0.001 || + StartIdx != old.StartIdx || + EndIdx != old.EndIdx || + Combined != old.Combined || + IsOldSide != old.IsOldSide; + } + } + + public record TextDiffViewRange + { + public int StartIdx { get; set; } = 0; + public int EndIdx { get; set; } = 0; + + public TextDiffViewRange(int startIdx, int endIdx) + { + StartIdx = startIdx; + EndIdx = endIdx; + } + } + + public class ThemedTextDiffPresenter : TextEditor + { + public class VerticalSeparatorMargin : AbstractMargin + { + public override void Render(DrawingContext context) + { + var presenter = this.FindAncestorOfType(); + if (presenter != null) + { + var pen = new Pen(presenter.LineBrush); + context.DrawLine(pen, new Point(0, 0), new Point(0, Bounds.Height)); + } + } + + protected override Size MeasureOverride(Size availableSize) + { + return new Size(1, 0); + } + } + + public class LineNumberMargin : AbstractMargin + { + public LineNumberMargin(bool usePresenter, bool isOld) + { + _usePresenter = usePresenter; + _isOld = isOld; + + Margin = new Thickness(8, 0); + ClipToBounds = true; + } + + public override void Render(DrawingContext context) + { + var presenter = this.FindAncestorOfType(); + if (presenter == null) + return; + + var isOld = _isOld; + if (_usePresenter) + isOld = presenter.IsOld; + + var lines = presenter.GetLines(); + var view = TextView; + if (view != null && view.VisualLinesValid) + { + var typeface = view.CreateTypeface(); + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var index = line.FirstDocumentLine.LineNumber; + if (index > lines.Count) + break; + + var info = lines[index - 1]; + var lineNumber = isOld ? info.OldLine : info.NewLine; + if (string.IsNullOrEmpty(lineNumber)) + continue; + + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; + var txt = new FormattedText( + lineNumber, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + presenter.FontSize, + presenter.Foreground); + context.DrawText(txt, new Point(Bounds.Width - txt.Width, y - (txt.Height * 0.5))); + } + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var presenter = this.FindAncestorOfType(); + if (presenter == null) + return new Size(32, 0); + + var maxLineNumber = presenter.GetMaxLineNumber(); + var typeface = TextView.CreateTypeface(); + var test = new FormattedText( + $"{maxLineNumber}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + presenter.FontSize, + Brushes.White); + return new Size(test.Width, 0); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + private bool _usePresenter = false; + private bool _isOld = false; + } + + public class LineModifyTypeMargin : AbstractMargin + { + public LineModifyTypeMargin() + { + Margin = new Thickness(1, 0); + ClipToBounds = true; + } + + public override void Render(DrawingContext context) + { + var presenter = this.FindAncestorOfType(); + if (presenter == null) + return; + + var lines = presenter.GetLines(); + var view = TextView; + if (view != null && view.VisualLinesValid) + { + var typeface = view.CreateTypeface(); + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var index = line.FirstDocumentLine.LineNumber; + if (index > lines.Count) + break; + + var info = lines[index - 1]; + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; + var indicator = null as FormattedText; + if (info.Type == Models.TextDiffLineType.Added) + { + indicator = new FormattedText( + "+", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + presenter.FontSize, + Brushes.Green); + } + else if (info.Type == Models.TextDiffLineType.Deleted) + { + indicator = new FormattedText( + "-", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + presenter.FontSize, + Brushes.Red); + } + + if (indicator != null) + context.DrawText(indicator, new Point(0, y - (indicator.Height * 0.5))); + } + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var presenter = this.FindAncestorOfType(); + if (presenter == null) + return new Size(0, 0); + + var typeface = TextView.CreateTypeface(); + var test = new FormattedText( + $"-", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + presenter.FontSize, + Brushes.White); + return new Size(test.Width, 0); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + } + + public class LineBackgroundRenderer : IBackgroundRenderer + { + public KnownLayer Layer => KnownLayer.Background; + + public LineBackgroundRenderer(ThemedTextDiffPresenter presenter) + { + _presenter = presenter; + } + + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (_presenter.Document == null || !textView.VisualLinesValid) + return; + + var changeBlock = _presenter.BlockNavigation?.GetCurrentBlock(); + Brush changeBlockBG = new SolidColorBrush(Colors.Gray, 0.25); + Pen changeBlockFG = new Pen(Brushes.Gray); + + var lines = _presenter.GetLines(); + var width = textView.Bounds.Width; + foreach (var line in textView.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var index = line.FirstDocumentLine.LineNumber; + if (index > lines.Count) + break; + + var info = lines[index - 1]; + + var startY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - textView.VerticalOffset; + var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - textView.VerticalOffset; + + var bg = GetBrushByLineType(info.Type); + if (bg != null) + { + drawingContext.DrawRectangle(bg, null, new Rect(0, startY, width, endY - startY)); + + if (info.Highlights.Count > 0) + { + var highlightBG = info.Type == Models.TextDiffLineType.Added ? _presenter.AddedHighlightBrush : _presenter.DeletedHighlightBrush; + var processingIdxStart = 0; + var processingIdxEnd = 0; + var nextHighlight = 0; + + foreach (var tl in line.TextLines) + { + processingIdxEnd += tl.Length; + + var y = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineTop) - textView.VerticalOffset; + var h = line.GetTextLineVisualYPosition(tl, VisualYPosition.LineBottom) - textView.VerticalOffset - y; + + while (nextHighlight < info.Highlights.Count) + { + var highlight = info.Highlights[nextHighlight]; + if (highlight.Start >= processingIdxEnd) + break; + + var start = line.GetVisualColumn(highlight.Start < processingIdxStart ? processingIdxStart : highlight.Start); + var end = line.GetVisualColumn(highlight.End >= processingIdxEnd ? processingIdxEnd : highlight.End + 1); + + var x = line.GetTextLineVisualXPosition(tl, start) - textView.HorizontalOffset; + var w = line.GetTextLineVisualXPosition(tl, end) - textView.HorizontalOffset - x; + var rect = new Rect(x, y, w, h); + drawingContext.DrawRectangle(highlightBG, null, rect); + + if (highlight.End >= processingIdxEnd) + break; + + nextHighlight++; + } + + processingIdxStart = processingIdxEnd; + } + } + } + + if (changeBlock != null && changeBlock.IsInRange(index)) + { + drawingContext.DrawRectangle(changeBlockBG, null, new Rect(0, startY, width, endY - startY)); + if (index == changeBlock.Start) + drawingContext.DrawLine(changeBlockFG, new Point(0, startY), new Point(width, startY)); + if (index == changeBlock.End) + drawingContext.DrawLine(changeBlockFG, new Point(0, endY), new Point(width, endY)); + } + } + } + + private IBrush GetBrushByLineType(Models.TextDiffLineType type) + { + switch (type) + { + case Models.TextDiffLineType.None: + return _presenter.EmptyContentBackground; + case Models.TextDiffLineType.Added: + return _presenter.AddedContentBackground; + case Models.TextDiffLineType.Deleted: + return _presenter.DeletedContentBackground; + default: + return null; + } + } + + private ThemedTextDiffPresenter _presenter = null; + } + + public class LineStyleTransformer : DocumentColorizingTransformer + { + public LineStyleTransformer(ThemedTextDiffPresenter presenter) + { + _presenter = presenter; + } + + protected override void ColorizeLine(DocumentLine line) + { + var lines = _presenter.GetLines(); + var idx = line.LineNumber; + if (idx > lines.Count) + return; + + var info = lines[idx - 1]; + if (info.Type == Models.TextDiffLineType.Indicator) + { + ChangeLinePart(line.Offset, line.EndOffset, v => + { + v.TextRunProperties.SetForegroundBrush(_presenter.IndicatorForeground); + v.TextRunProperties.SetTypeface(new Typeface(_presenter.FontFamily, FontStyle.Italic)); + }); + } + } + + private readonly ThemedTextDiffPresenter _presenter; + } + + public static readonly StyledProperty FileNameProperty = + AvaloniaProperty.Register(nameof(FileName), string.Empty); + + public string FileName + { + get => GetValue(FileNameProperty); + set => SetValue(FileNameProperty, value); + } + + public static readonly StyledProperty IsOldProperty = + AvaloniaProperty.Register(nameof(IsOld)); + + public bool IsOld + { + get => GetValue(IsOldProperty); + set => SetValue(IsOldProperty, value); + } + + public static readonly StyledProperty LineBrushProperty = + AvaloniaProperty.Register(nameof(LineBrush), new SolidColorBrush(Colors.DarkGray)); + + public IBrush LineBrush + { + get => GetValue(LineBrushProperty); + set => SetValue(LineBrushProperty, value); + } + + public static readonly StyledProperty EmptyContentBackgroundProperty = + AvaloniaProperty.Register(nameof(EmptyContentBackground), new SolidColorBrush(Color.FromArgb(60, 0, 0, 0))); + + public IBrush EmptyContentBackground + { + get => GetValue(EmptyContentBackgroundProperty); + set => SetValue(EmptyContentBackgroundProperty, value); + } + + public static readonly StyledProperty AddedContentBackgroundProperty = + AvaloniaProperty.Register(nameof(AddedContentBackground), new SolidColorBrush(Color.FromArgb(60, 0, 255, 0))); + + public IBrush AddedContentBackground + { + get => GetValue(AddedContentBackgroundProperty); + set => SetValue(AddedContentBackgroundProperty, value); + } + + public static readonly StyledProperty DeletedContentBackgroundProperty = + AvaloniaProperty.Register(nameof(DeletedContentBackground), new SolidColorBrush(Color.FromArgb(60, 255, 0, 0))); + + public IBrush DeletedContentBackground + { + get => GetValue(DeletedContentBackgroundProperty); + set => SetValue(DeletedContentBackgroundProperty, value); + } + + public static readonly StyledProperty AddedHighlightBrushProperty = + AvaloniaProperty.Register(nameof(AddedHighlightBrush), new SolidColorBrush(Color.FromArgb(90, 0, 255, 0))); + + public IBrush AddedHighlightBrush + { + get => GetValue(AddedHighlightBrushProperty); + set => SetValue(AddedHighlightBrushProperty, value); + } + + public static readonly StyledProperty DeletedHighlightBrushProperty = + AvaloniaProperty.Register(nameof(DeletedHighlightBrush), new SolidColorBrush(Color.FromArgb(80, 255, 0, 0))); + + public IBrush DeletedHighlightBrush + { + get => GetValue(DeletedHighlightBrushProperty); + set => SetValue(DeletedHighlightBrushProperty, value); + } + + public static readonly StyledProperty IndicatorForegroundProperty = + AvaloniaProperty.Register(nameof(IndicatorForeground), Brushes.Gray); + + public IBrush IndicatorForeground + { + get => GetValue(IndicatorForegroundProperty); + set => SetValue(IndicatorForegroundProperty, value); + } + + public static readonly StyledProperty UseSyntaxHighlightingProperty = + AvaloniaProperty.Register(nameof(UseSyntaxHighlighting)); + + public bool UseSyntaxHighlighting + { + get => GetValue(UseSyntaxHighlightingProperty); + set => SetValue(UseSyntaxHighlightingProperty, value); + } + + public static readonly StyledProperty ShowHiddenSymbolsProperty = + AvaloniaProperty.Register(nameof(ShowHiddenSymbols)); + + public bool ShowHiddenSymbols + { + get => GetValue(ShowHiddenSymbolsProperty); + set => SetValue(ShowHiddenSymbolsProperty, value); + } + + public static readonly StyledProperty TabWidthProperty = + AvaloniaProperty.Register(nameof(TabWidth), 4); + + public int TabWidth + { + get => GetValue(TabWidthProperty); + set => SetValue(TabWidthProperty, value); + } + + public static readonly StyledProperty EnableChunkSelectionProperty = + AvaloniaProperty.Register(nameof(EnableChunkSelection)); + + public bool EnableChunkSelection + { + get => GetValue(EnableChunkSelectionProperty); + set => SetValue(EnableChunkSelectionProperty, value); + } + + public static readonly StyledProperty SelectedChunkProperty = + AvaloniaProperty.Register(nameof(SelectedChunk)); + + public TextDiffViewChunk SelectedChunk + { + get => GetValue(SelectedChunkProperty); + set => SetValue(SelectedChunkProperty, value); + } + + public static readonly StyledProperty DisplayRangeProperty = + AvaloniaProperty.Register(nameof(DisplayRange), new TextDiffViewRange(0, 0)); + + public TextDiffViewRange DisplayRange + { + get => GetValue(DisplayRangeProperty); + set => SetValue(DisplayRangeProperty, value); + } + + public static readonly StyledProperty BlockNavigationProperty = + AvaloniaProperty.Register(nameof(BlockNavigation)); + + public ViewModels.BlockNavigation BlockNavigation + { + get => GetValue(BlockNavigationProperty); + set => SetValue(BlockNavigationProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextEditor); + + public ThemedTextDiffPresenter(TextArea area, TextDocument doc) : base(area, doc) + { + IsReadOnly = true; + ShowLineNumbers = false; + BorderThickness = new Thickness(0); + + Options.IndentationSize = TabWidth; + Options.EnableHyperlinks = false; + Options.EnableEmailHyperlinks = false; + + _lineStyleTransformer = new LineStyleTransformer(this); + + TextArea.TextView.Margin = new Thickness(2, 0); + TextArea.TextView.BackgroundRenderers.Add(new LineBackgroundRenderer(this)); + TextArea.TextView.LineTransformers.Add(_lineStyleTransformer); + } + + public virtual List GetLines() + { + return []; + } + + public virtual int GetMaxLineNumber() + { + return 0; + } + + public virtual void UpdateSelectedChunk(double y) + { + } + + public virtual void GotoFirstChange() + { + var blockNavigation = BlockNavigation; + if (blockNavigation != null) + { + var prev = blockNavigation.GotoFirst(); + if (prev != null) + { + TextArea.Caret.Line = prev.Start; + ScrollToLine(prev.Start); + } + } + } + + public virtual void GotoPrevChange() + { + var blockNavigation = BlockNavigation; + if (blockNavigation != null) + { + var prev = blockNavigation.GotoPrev(); + if (prev != null) + { + TextArea.Caret.Line = prev.Start; + ScrollToLine(prev.Start); + } + + return; + } + + var firstLineIdx = DisplayRange.StartIdx; + if (firstLineIdx <= 1) + return; + + var lines = GetLines(); + var firstLineType = lines[firstLineIdx].Type; + var prevLineType = lines[firstLineIdx - 1].Type; + var isChangeFirstLine = firstLineType != Models.TextDiffLineType.Normal && firstLineType != Models.TextDiffLineType.Indicator; + var isChangePrevLine = prevLineType != Models.TextDiffLineType.Normal && prevLineType != Models.TextDiffLineType.Indicator; + if (isChangeFirstLine && isChangePrevLine) + { + for (var i = firstLineIdx - 2; i >= 0; i--) + { + var prevType = lines[i].Type; + if (prevType == Models.TextDiffLineType.Normal || prevType == Models.TextDiffLineType.Indicator) + { + ScrollToLine(i + 2); + return; + } + } + } + + var findChange = false; + for (var i = firstLineIdx - 1; i >= 0; i--) + { + var prevType = lines[i].Type; + if (prevType == Models.TextDiffLineType.Normal || prevType == Models.TextDiffLineType.Indicator) + { + if (findChange) + { + ScrollToLine(i + 2); + return; + } + } + else if (!findChange) + { + findChange = true; + } + } + } + + public virtual void GotoNextChange() + { + var blockNavigation = BlockNavigation; + if (blockNavigation != null) + { + var next = blockNavigation.GotoNext(); + if (next != null) + { + TextArea.Caret.Line = next.Start; + ScrollToLine(next.Start); + } + + return; + } + + var lines = GetLines(); + var lastLineIdx = DisplayRange.EndIdx; + if (lastLineIdx >= lines.Count - 1) + return; + + var lastLineType = lines[lastLineIdx].Type; + var findNormalLine = lastLineType == Models.TextDiffLineType.Normal || lastLineType == Models.TextDiffLineType.Indicator; + for (var idx = lastLineIdx + 1; idx < lines.Count; idx++) + { + var nextType = lines[idx].Type; + if (nextType == Models.TextDiffLineType.None || + nextType == Models.TextDiffLineType.Added || + nextType == Models.TextDiffLineType.Deleted) + { + if (findNormalLine) + { + ScrollToLine(idx + 1); + return; + } + } + else if (!findNormalLine) + { + findNormalLine = true; + } + } + } + + public virtual void GotoLastChange() + { + var blockNavigation = BlockNavigation; + if (blockNavigation != null) + { + var next = blockNavigation.GotoLast(); + if (next != null) + { + TextArea.Caret.Line = next.Start; + ScrollToLine(next.Start); + } + } + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var chunk = SelectedChunk; + if (chunk == null || (!chunk.Combined && chunk.IsOldSide != IsOld)) + return; + + var color = (Color)this.FindResource("SystemAccentColor")!; + var brush = new SolidColorBrush(color, 0.1); + var pen = new Pen(color.ToUInt32()); + var rect = new Rect(0, chunk.Y, Bounds.Width, chunk.Height); + + context.DrawRectangle(brush, null, rect); + context.DrawLine(pen, rect.TopLeft, rect.TopRight); + context.DrawLine(pen, rect.BottomLeft, rect.BottomRight); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + TextArea.TextView.ContextRequested += OnTextViewContextRequested; + TextArea.TextView.PointerEntered += OnTextViewPointerChanged; + TextArea.TextView.PointerMoved += OnTextViewPointerChanged; + TextArea.TextView.PointerWheelChanged += OnTextViewPointerWheelChanged; + TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; + + TextArea.AddHandler(KeyDownEvent, OnTextAreaKeyDown, RoutingStrategies.Tunnel); + + UpdateTextMate(); + OnTextViewVisualLinesChanged(null, null); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + + TextArea.RemoveHandler(KeyDownEvent, OnTextAreaKeyDown); + + TextArea.TextView.ContextRequested -= OnTextViewContextRequested; + TextArea.TextView.PointerEntered -= OnTextViewPointerChanged; + TextArea.TextView.PointerMoved -= OnTextViewPointerChanged; + TextArea.TextView.PointerWheelChanged -= OnTextViewPointerWheelChanged; + TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged; + + if (_textMate != null) + { + _textMate.Dispose(); + _textMate = null; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UseSyntaxHighlightingProperty) + { + UpdateTextMate(); + } + else if (change.Property == ShowHiddenSymbolsProperty) + { + var val = ShowHiddenSymbols; + Options.ShowTabs = val; + Options.ShowSpaces = val; + Options.ShowEndOfLine = val; + } + else if (change.Property == TabWidthProperty) + { + Options.IndentationSize = TabWidth; + } + else if (change.Property == FileNameProperty) + { + Models.TextMateHelper.SetGrammarByFileName(_textMate, FileName); + } + else if (change.Property.Name == "ActualThemeVariant" && change.NewValue != null) + { + Models.TextMateHelper.SetThemeByApp(_textMate); + } + else if (change.Property == SelectedChunkProperty) + { + InvalidateVisual(); + } + else if (change.Property == BlockNavigationProperty) + { + if (change.OldValue is ViewModels.BlockNavigation oldValue) + oldValue.PropertyChanged -= OnBlockNavigationPropertyChanged; + + if (change.NewValue is ViewModels.BlockNavigation newValue) + newValue.PropertyChanged += OnBlockNavigationPropertyChanged; + + TextArea?.TextView?.Redraw(); + } + } + + private void OnTextAreaKeyDown(object sender, KeyEventArgs e) + { + if (e.KeyModifiers.Equals(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + if (e.Key == Key.C) + { + CopyWithoutIndicators(); + e.Handled = true; + } + } + + if (!e.Handled) + base.OnKeyDown(e); + } + + private void OnBlockNavigationPropertyChanged(object _1, PropertyChangedEventArgs e) + { + if (e.PropertyName == "Current") + TextArea?.TextView?.Redraw(); + } + + private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) + { + var selection = TextArea.Selection; + if (selection.IsEmpty) + return; + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = App.CreateMenuIcon("Icons.Copy"); + copy.Click += (_, ev) => + { + CopyWithoutIndicators(); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copy); + menu.Open(TextArea.TextView); + + e.Handled = true; + } + + private void OnTextViewPointerChanged(object sender, PointerEventArgs e) + { + if (EnableChunkSelection && sender is TextView view) + { + var selection = TextArea.Selection; + if (selection == null || selection.IsEmpty) + { + if (_lastSelectStart != _lastSelectEnd) + { + _lastSelectStart = TextLocation.Empty; + _lastSelectEnd = TextLocation.Empty; + } + + var chunk = SelectedChunk; + if (chunk != null) + { + var rect = new Rect(0, chunk.Y, Bounds.Width, chunk.Height); + if (rect.Contains(e.GetPosition(this))) + return; + } + + UpdateSelectedChunk(e.GetPosition(view).Y + view.VerticalOffset); + return; + } + + var start = selection.StartPosition.Location; + var end = selection.EndPosition.Location; + if (_lastSelectStart != start || _lastSelectEnd != end) + { + _lastSelectStart = start; + _lastSelectEnd = end; + UpdateSelectedChunk(e.GetPosition(view).Y + view.VerticalOffset); + return; + } + + if (SelectedChunk == null) + UpdateSelectedChunk(e.GetPosition(view).Y + view.VerticalOffset); + } + } + + private void OnTextViewPointerWheelChanged(object sender, PointerWheelEventArgs e) + { + if (EnableChunkSelection && sender is TextView view) + { + var y = e.GetPosition(view).Y + view.VerticalOffset; + Dispatcher.UIThread.Post(() => UpdateSelectedChunk(y)); + } + } + + private void OnTextViewVisualLinesChanged(object sender, EventArgs e) + { + if (!TextArea.TextView.VisualLinesValid) + { + SetCurrentValue(DisplayRangeProperty, new TextDiffViewRange(0, 0)); + return; + } + + var lines = GetLines(); + var start = int.MaxValue; + var count = 0; + foreach (var line in TextArea.TextView.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var index = line.FirstDocumentLine.LineNumber - 1; + if (index >= lines.Count) + continue; + + count++; + if (start > index) + start = index; + } + + SetCurrentValue(DisplayRangeProperty, new TextDiffViewRange(start, start + count)); + } + + protected void TrySetChunk(TextDiffViewChunk chunk) + { + var old = SelectedChunk; + if (chunk == null) + { + if (old != null) + SetCurrentValue(SelectedChunkProperty, null); + + return; + } + + if (chunk.ShouldReplace(old)) + SetCurrentValue(SelectedChunkProperty, chunk); + } + + protected (int, int) FindRangeByIndex(List lines, int lineIdx) + { + var startIdx = -1; + var endIdx = -1; + + var normalLineCount = 0; + var modifiedLineCount = 0; + + for (int i = lineIdx; i >= 0; i--) + { + var line = lines[i]; + if (line.Type == Models.TextDiffLineType.Indicator) + { + startIdx = i; + break; + } + + if (line.Type == Models.TextDiffLineType.Normal) + { + normalLineCount++; + if (normalLineCount >= 2) + { + startIdx = i; + break; + } + } + else + { + normalLineCount = 0; + modifiedLineCount++; + } + } + + normalLineCount = lines[lineIdx].Type == Models.TextDiffLineType.Normal ? 1 : 0; + for (int i = lineIdx + 1; i < lines.Count; i++) + { + var line = lines[i]; + if (line.Type == Models.TextDiffLineType.Indicator) + { + endIdx = i; + break; + } + + if (line.Type == Models.TextDiffLineType.Normal) + { + normalLineCount++; + if (normalLineCount >= 2) + { + endIdx = i; + break; + } + } + else + { + normalLineCount = 0; + modifiedLineCount++; + } + } + + if (endIdx == -1) + endIdx = lines.Count - 1; + + return modifiedLineCount > 0 ? (startIdx, endIdx) : (-1, -1); + } + + private void UpdateTextMate() + { + if (UseSyntaxHighlighting) + { + if (_textMate == null) + { + TextArea.TextView.LineTransformers.Remove(_lineStyleTransformer); + _textMate = Models.TextMateHelper.CreateForEditor(this); + TextArea.TextView.LineTransformers.Add(_lineStyleTransformer); + Models.TextMateHelper.SetGrammarByFileName(_textMate, FileName); + } + } + else + { + if (_textMate != null) + { + _textMate.Dispose(); + _textMate = null; + GC.Collect(); + + TextArea.TextView.Redraw(); + } + } + } + + private void CopyWithoutIndicators() + { + var selection = TextArea.Selection; + if (selection.IsEmpty) + { + App.CopyText(string.Empty); + return; + } + + var lines = GetLines(); + + var startPosition = selection.StartPosition; + var endPosition = selection.EndPosition; + + if (startPosition.Location > endPosition.Location) + (startPosition, endPosition) = (endPosition, startPosition); + + var startIdx = startPosition.Line - 1; + var endIdx = endPosition.Line - 1; + + if (startIdx == endIdx) + { + var line = lines[startIdx]; + if (line.Type == Models.TextDiffLineType.Indicator || + line.Type == Models.TextDiffLineType.None) + { + App.CopyText(string.Empty); + return; + } + + App.CopyText(SelectedText); + return; + } + + var builder = new StringBuilder(); + for (var i = startIdx; i <= endIdx && i <= lines.Count - 1; i++) + { + var line = lines[i]; + if (line.Type == Models.TextDiffLineType.Indicator || + line.Type == Models.TextDiffLineType.None) + continue; + + // The first selected line (partial selection) + if (i == startIdx && startPosition.Column > 1) + { + builder.Append(line.Content.AsSpan(startPosition.Column - 1)); + builder.Append(Environment.NewLine); + continue; + } + + // The selection range is larger than original source. + if (i == lines.Count - 1 && i < endIdx) + { + builder.Append(line.Content); + break; + } + + // For the last line (selection range is within original source) + if (i == endIdx) + { + if (endPosition.Column - 1 < line.Content.Length) + { + builder.Append(line.Content.AsSpan(0, endPosition.Column - 1)); + } + else + { + builder.Append(line.Content); + } + break; + } + + // Other lines. + builder.AppendLine(line.Content); + } + + App.CopyText(builder.ToString()); + } + + private TextMate.Installation _textMate = null; + private TextLocation _lastSelectStart = TextLocation.Empty; + private TextLocation _lastSelectEnd = TextLocation.Empty; + private LineStyleTransformer _lineStyleTransformer = null; + } + + public class CombinedTextDiffPresenter : ThemedTextDiffPresenter + { + public CombinedTextDiffPresenter() : base(new TextArea(), new TextDocument()) + { + TextArea.LeftMargins.Add(new LineNumberMargin(false, true)); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin()); + TextArea.LeftMargins.Add(new LineNumberMargin(false, false)); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin()); + TextArea.LeftMargins.Add(new LineModifyTypeMargin()); + } + + public override List GetLines() + { + if (DataContext is Models.TextDiff diff) + return diff.Lines; + return []; + } + + public override int GetMaxLineNumber() + { + if (DataContext is Models.TextDiff diff) + return diff.MaxLineNumber; + return 0; + } + + public override void UpdateSelectedChunk(double y) + { + var diff = DataContext as Models.TextDiff; + if (diff == null) + return; + + var view = TextArea.TextView; + var selection = TextArea.Selection; + if (!selection.IsEmpty) + { + var startIdx = Math.Min(selection.StartPosition.Line - 1, diff.Lines.Count - 1); + var endIdx = Math.Min(selection.EndPosition.Line - 1, diff.Lines.Count - 1); + + if (startIdx > endIdx) + (startIdx, endIdx) = (endIdx, startIdx); + + var hasChanges = false; + for (var i = startIdx; i <= endIdx; i++) + { + var line = diff.Lines[i]; + if (line.Type == Models.TextDiffLineType.Added || line.Type == Models.TextDiffLineType.Deleted) + { + hasChanges = true; + break; + } + } + + if (!hasChanges) + { + TrySetChunk(null); + return; + } + + var firstLineIdx = view.VisualLines[0].FirstDocumentLine.LineNumber - 1; + var lastLineIdx = view.VisualLines[^1].FirstDocumentLine.LineNumber - 1; + if (endIdx < firstLineIdx || startIdx > lastLineIdx) + { + TrySetChunk(null); + return; + } + + var startLine = view.GetVisualLine(startIdx + 1); + var endLine = view.GetVisualLine(endIdx + 1); + + var rectStartY = startLine != null ? + startLine.GetTextLineVisualYPosition(startLine.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset : + 0; + var rectEndY = endLine != null ? + endLine.GetTextLineVisualYPosition(endLine.TextLines[^1], VisualYPosition.TextBottom) - view.VerticalOffset : + view.Bounds.Height; + + TrySetChunk(new TextDiffViewChunk() + { + Y = rectStartY, + Height = rectEndY - rectStartY, + StartIdx = startIdx, + EndIdx = endIdx, + Combined = true, + IsOldSide = false, + }); + } + else + { + var lineIdx = -1; + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var index = line.FirstDocumentLine.LineNumber; + if (index > diff.Lines.Count) + break; + + var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.TextBottom); + if (endY > y) + { + lineIdx = index - 1; + break; + } + } + + if (lineIdx == -1) + { + TrySetChunk(null); + return; + } + + var (startIdx, endIdx) = FindRangeByIndex(diff.Lines, lineIdx); + if (startIdx == -1) + { + TrySetChunk(null); + return; + } + + var startLine = view.GetVisualLine(startIdx + 1); + var endLine = view.GetVisualLine(endIdx + 1); + + var rectStartY = startLine != null ? + startLine.GetTextLineVisualYPosition(startLine.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset : + 0; + var rectEndY = endLine != null ? + endLine.GetTextLineVisualYPosition(endLine.TextLines[^1], VisualYPosition.TextBottom) - view.VerticalOffset : + view.Bounds.Height; + + TrySetChunk(new TextDiffViewChunk() + { + Y = rectStartY, + Height = rectEndY - rectStartY, + StartIdx = startIdx, + EndIdx = endIdx, + Combined = true, + IsOldSide = false, + }); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + _scrollViewer = this.FindDescendantOfType(); + if (_scrollViewer != null) + { + _scrollViewer.Bind(ScrollViewer.OffsetProperty, new Binding("ScrollOffset", BindingMode.TwoWay)); + _scrollViewer.ScrollChanged += OnTextViewScrollChanged; + } + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + if (_scrollViewer != null) + _scrollViewer.ScrollChanged -= OnTextViewScrollChanged; + + base.OnUnloaded(e); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + if (DataContext is Models.TextDiff textDiff) + { + var builder = new StringBuilder(); + foreach (var line in textDiff.Lines) + { + if (line.Content.Length > 10000) + { + builder.Append(line.Content.AsSpan(0, 1000)); + builder.Append($"...({line.Content.Length - 1000} character trimmed)"); + } + else + { + builder.Append(line.Content); + } + + if (line.NoNewLineEndOfFile) + builder.Append("\u26D4"); + + builder.Append('\n'); + } + + Text = builder.ToString(); + } + else + { + Text = string.Empty; + } + + GC.Collect(); + } + + private void OnTextViewScrollChanged(object sender, ScrollChangedEventArgs e) + { + if (!TextArea.TextView.IsPointerOver) + TrySetChunk(null); + } + + private ScrollViewer _scrollViewer = null; + } + + public class SingleSideTextDiffPresenter : ThemedTextDiffPresenter + { + public SingleSideTextDiffPresenter() : base(new TextArea(), new TextDocument()) + { + TextArea.LeftMargins.Add(new LineNumberMargin(true, false)); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin()); + TextArea.LeftMargins.Add(new LineModifyTypeMargin()); + } + + public override List GetLines() + { + if (DataContext is ViewModels.TwoSideTextDiff diff) + return IsOld ? diff.Old : diff.New; + return []; + } + + public override int GetMaxLineNumber() + { + if (DataContext is ViewModels.TwoSideTextDiff diff) + return diff.MaxLineNumber; + return 0; + } + + public override void GotoFirstChange() + { + base.GotoFirstChange(); + DirectSyncScrollOffset(); + } + + public override void GotoPrevChange() + { + base.GotoPrevChange(); + DirectSyncScrollOffset(); + } + + public override void GotoNextChange() + { + base.GotoNextChange(); + DirectSyncScrollOffset(); + } + + public override void GotoLastChange() + { + base.GotoLastChange(); + DirectSyncScrollOffset(); + } + + public override void UpdateSelectedChunk(double y) + { + var diff = DataContext as ViewModels.TwoSideTextDiff; + if (diff == null) + return; + + var parent = this.FindAncestorOfType(); + if (parent == null) + return; + + var view = TextArea.TextView; + var lines = IsOld ? diff.Old : diff.New; + var selection = TextArea.Selection; + if (!selection.IsEmpty) + { + var startIdx = Math.Min(selection.StartPosition.Line - 1, lines.Count - 1); + var endIdx = Math.Min(selection.EndPosition.Line - 1, lines.Count - 1); + + if (startIdx > endIdx) + (startIdx, endIdx) = (endIdx, startIdx); + + var hasChanges = false; + for (var i = startIdx; i <= endIdx; i++) + { + var line = lines[i]; + if (line.Type == Models.TextDiffLineType.Added || line.Type == Models.TextDiffLineType.Deleted) + { + hasChanges = true; + break; + } + } + + if (!hasChanges) + { + TrySetChunk(null); + return; + } + + var firstLineIdx = view.VisualLines[0].FirstDocumentLine.LineNumber - 1; + var lastLineIdx = view.VisualLines[^1].FirstDocumentLine.LineNumber - 1; + if (endIdx < firstLineIdx || startIdx > lastLineIdx) + { + TrySetChunk(null); + return; + } + + var startLine = view.GetVisualLine(startIdx + 1); + var endLine = view.GetVisualLine(endIdx + 1); + + var rectStartY = startLine != null ? + startLine.GetTextLineVisualYPosition(startLine.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset : + 0; + var rectEndY = endLine != null ? + endLine.GetTextLineVisualYPosition(endLine.TextLines[^1], VisualYPosition.TextBottom) - view.VerticalOffset : + view.Bounds.Height; + + diff.ConvertsToCombinedRange(parent.DataContext as Models.TextDiff, ref startIdx, ref endIdx, IsOld); + + TrySetChunk(new TextDiffViewChunk() + { + Y = rectStartY, + Height = rectEndY - rectStartY, + StartIdx = startIdx, + EndIdx = endIdx, + Combined = false, + IsOldSide = IsOld, + }); + + return; + } + + if (this.FindAncestorOfType()?.DataContext is Models.TextDiff textDiff) + { + var lineIdx = -1; + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var index = line.FirstDocumentLine.LineNumber; + if (index > lines.Count) + break; + + var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.TextBottom); + if (endY > y) + { + lineIdx = index - 1; + break; + } + } + + if (lineIdx == -1) + { + TrySetChunk(null); + return; + } + + var (startIdx, endIdx) = FindRangeByIndex(lines, lineIdx); + if (startIdx == -1) + { + TrySetChunk(null); + return; + } + + var startLine = view.GetVisualLine(startIdx + 1); + var endLine = view.GetVisualLine(endIdx + 1); + + var rectStartY = startLine != null ? + startLine.GetTextLineVisualYPosition(startLine.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset : + 0; + var rectEndY = endLine != null ? + endLine.GetTextLineVisualYPosition(endLine.TextLines[^1], VisualYPosition.TextBottom) - view.VerticalOffset : + view.Bounds.Height; + + TrySetChunk(new TextDiffViewChunk() + { + Y = rectStartY, + Height = rectEndY - rectStartY, + StartIdx = textDiff.Lines.IndexOf(lines[startIdx]), + EndIdx = endIdx == lines.Count - 1 ? textDiff.Lines.Count - 1 : textDiff.Lines.IndexOf(lines[endIdx]), + Combined = true, + IsOldSide = false, + }); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + _scrollViewer = this.FindDescendantOfType(); + if (_scrollViewer != null) + { + _scrollViewer.ScrollChanged += OnTextViewScrollChanged; + _scrollViewer.Bind(ScrollViewer.OffsetProperty, new Binding("SyncScrollOffset", BindingMode.OneWay)); + } + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + if (_scrollViewer != null) + { + _scrollViewer.ScrollChanged -= OnTextViewScrollChanged; + _scrollViewer = null; + } + + base.OnUnloaded(e); + GC.Collect(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + if (DataContext is ViewModels.TwoSideTextDiff diff) + { + var builder = new StringBuilder(); + var lines = IsOld ? diff.Old : diff.New; + foreach (var line in lines) + { + if (line.Content.Length > 10000) + { + builder.Append(line.Content.AsSpan(0, 1000)); + builder.Append($"...({line.Content.Length - 1000} characters trimmed)"); + } + else + { + builder.Append(line.Content); + } + + if (line.NoNewLineEndOfFile) + builder.Append("\u26D4"); + + builder.Append('\n'); + } + + Text = builder.ToString(); + } + else + { + Text = string.Empty; + } + } + + private void OnTextViewScrollChanged(object sender, ScrollChangedEventArgs e) + { + if (IsPointerOver && DataContext is ViewModels.TwoSideTextDiff diff) + { + diff.SyncScrollOffset = _scrollViewer?.Offset ?? Vector.Zero; + + if (!TextArea.TextView.IsPointerOver) + TrySetChunk(null); + } + } + + private void DirectSyncScrollOffset() + { + if (_scrollViewer is not null && DataContext is ViewModels.TwoSideTextDiff diff) + diff.SyncScrollOffset = _scrollViewer?.Offset ?? Vector.Zero; + } + + private ScrollViewer _scrollViewer = null; + } + + public class TextDiffViewMinimap : Control + { + public static readonly StyledProperty AddedLineBrushProperty = + AvaloniaProperty.Register(nameof(AddedLineBrush), new SolidColorBrush(Color.FromArgb(60, 0, 255, 0))); + + public IBrush AddedLineBrush + { + get => GetValue(AddedLineBrushProperty); + set => SetValue(AddedLineBrushProperty, value); + } + + public static readonly StyledProperty DeletedLineBrushProperty = + AvaloniaProperty.Register(nameof(DeletedLineBrush), new SolidColorBrush(Color.FromArgb(60, 255, 0, 0))); + + public IBrush DeletedLineBrush + { + get => GetValue(DeletedLineBrushProperty); + set => SetValue(DeletedLineBrushProperty, value); + } + + public static readonly StyledProperty DisplayRangeProperty = + AvaloniaProperty.Register(nameof(DisplayRange), new TextDiffViewRange(0, 0)); + + public TextDiffViewRange DisplayRange + { + get => GetValue(DisplayRangeProperty); + set => SetValue(DisplayRangeProperty, value); + } + + public static readonly StyledProperty DisplayRangeColorProperty = + AvaloniaProperty.Register(nameof(DisplayRangeColor), Colors.RoyalBlue); + + public Color DisplayRangeColor + { + get => GetValue(DisplayRangeColorProperty); + set => SetValue(DisplayRangeColorProperty, value); + } + + static TextDiffViewMinimap() + { + AffectsRender( + AddedLineBrushProperty, + DeletedLineBrushProperty, + DisplayRangeProperty, + DisplayRangeColorProperty); + } + + public override void Render(DrawingContext context) + { + var total = 0; + if (DataContext is ViewModels.TwoSideTextDiff twoSideDiff) + { + var halfWidth = Bounds.Width * 0.5; + total = Math.Max(twoSideDiff.Old.Count, twoSideDiff.New.Count); + RenderSingleSide(context, twoSideDiff.Old, 0, halfWidth); + RenderSingleSide(context, twoSideDiff.New, halfWidth, halfWidth); + } + else if (DataContext is Models.TextDiff diff) + { + total = diff.Lines.Count; + RenderSingleSide(context, diff.Lines, 0, Bounds.Width); + } + + var range = DisplayRange; + if (range.EndIdx == 0) + return; + + var startY = range.StartIdx / (total * 1.0) * Bounds.Height; + var endY = range.EndIdx / (total * 1.0) * Bounds.Height; + var color = DisplayRangeColor; + var brush = new SolidColorBrush(color, 0.2); + var pen = new Pen(color.ToUInt32()); + var rect = new Rect(0, startY, Bounds.Width, endY - startY); + + context.DrawRectangle(brush, null, rect); + context.DrawLine(pen, rect.TopLeft, rect.TopRight); + context.DrawLine(pen, rect.BottomLeft, rect.BottomRight); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateVisual(); + } + + private void RenderSingleSide(DrawingContext context, List lines, double x, double width) + { + var total = lines.Count; + var lastLineType = Models.TextDiffLineType.Indicator; + var lastLineTypeStart = 0; + + for (int i = 0; i < total; i++) + { + var line = lines[i]; + if (line.Type != lastLineType) + { + RenderBlock(context, lastLineType, lastLineTypeStart, i - lastLineTypeStart, total, x, width); + + lastLineType = line.Type; + lastLineTypeStart = i; + } + } + + RenderBlock(context, lastLineType, lastLineTypeStart, total - lastLineTypeStart, total, x, width); + } + + private void RenderBlock(DrawingContext context, Models.TextDiffLineType type, int start, int count, int total, double x, double width) + { + if (type == Models.TextDiffLineType.Added || type == Models.TextDiffLineType.Deleted) + { + var brush = type == Models.TextDiffLineType.Added ? AddedLineBrush : DeletedLineBrush; + var y = start / (total * 1.0) * Bounds.Height; + var h = Math.Max(0.5, count / (total * 1.0) * Bounds.Height); + context.DrawRectangle(brush, null, new Rect(x, y, width, h)); + } + } + } + + public partial class TextDiffView : UserControl + { + public static readonly StyledProperty UseSideBySideDiffProperty = + AvaloniaProperty.Register(nameof(UseSideBySideDiff)); + + public bool UseSideBySideDiff + { + get => GetValue(UseSideBySideDiffProperty); + set => SetValue(UseSideBySideDiffProperty, value); + } + + public static readonly StyledProperty SelectedChunkProperty = + AvaloniaProperty.Register(nameof(SelectedChunk)); + + public TextDiffViewChunk SelectedChunk + { + get => GetValue(SelectedChunkProperty); + set => SetValue(SelectedChunkProperty, value); + } + + public static readonly StyledProperty IsUnstagedChangeProperty = + AvaloniaProperty.Register(nameof(IsUnstagedChange)); + + public bool IsUnstagedChange + { + get => GetValue(IsUnstagedChangeProperty); + set => SetValue(IsUnstagedChangeProperty, value); + } + + public static readonly StyledProperty EnableChunkSelectionProperty = + AvaloniaProperty.Register(nameof(EnableChunkSelection)); + + public bool EnableChunkSelection + { + get => GetValue(EnableChunkSelectionProperty); + set => SetValue(EnableChunkSelectionProperty, value); + } + + public static readonly StyledProperty UseBlockNavigationProperty = + AvaloniaProperty.Register(nameof(UseBlockNavigation)); + + public bool UseBlockNavigation + { + get => GetValue(UseBlockNavigationProperty); + set => SetValue(UseBlockNavigationProperty, value); + } + + public static readonly StyledProperty BlockNavigationProperty = + AvaloniaProperty.Register(nameof(BlockNavigation)); + + public ViewModels.BlockNavigation BlockNavigation + { + get => GetValue(BlockNavigationProperty); + set => SetValue(BlockNavigationProperty, value); + } + + public static readonly RoutedEvent BlockNavigationChangedEvent = + RoutedEvent.Register(nameof(BlockNavigationChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler BlockNavigationChanged + { + add { AddHandler(BlockNavigationChangedEvent, value); } + remove { RemoveHandler(BlockNavigationChangedEvent, value); } + } + + static TextDiffView() + { + UseSideBySideDiffProperty.Changed.AddClassHandler((v, _) => + { + v.RefreshContent(v.DataContext as Models.TextDiff, false); + }); + + UseBlockNavigationProperty.Changed.AddClassHandler((v, _) => + { + v.RefreshBlockNavigation(); + }); + + SelectedChunkProperty.Changed.AddClassHandler((v, _) => + { + var chunk = v.SelectedChunk; + if (chunk == null) + { + v.Popup.IsVisible = false; + return; + } + + var top = chunk.Y + (chunk.Height >= 36 ? 8 : 2); + var right = (chunk.Combined || !chunk.IsOldSide) ? 26 : (v.Bounds.Width * 0.5f) + 26; + v.Popup.Margin = new Thickness(0, top, right, 0); + v.Popup.IsVisible = true; + }); + } + + public TextDiffView() + { + InitializeComponent(); + } + + public void GotoFirstChange() + { + this.FindDescendantOfType()?.GotoFirstChange(); + TryRaiseBlockNavigationChanged(); + } + + public void GotoPrevChange() + { + this.FindDescendantOfType()?.GotoPrevChange(); + TryRaiseBlockNavigationChanged(); + } + + public void GotoNextChange() + { + this.FindDescendantOfType()?.GotoNextChange(); + TryRaiseBlockNavigationChanged(); + } + + public void GotoLastChange() + { + this.FindDescendantOfType()?.GotoLastChange(); + TryRaiseBlockNavigationChanged(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + RefreshContent(DataContext as Models.TextDiff); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + + if (SelectedChunk != null) + SetCurrentValue(SelectedChunkProperty, null); + } + + private void RefreshContent(Models.TextDiff diff, bool keepScrollOffset = true) + { + if (SelectedChunk != null) + SetCurrentValue(SelectedChunkProperty, null); + + if (diff == null) + { + Editor.Content = null; + GC.Collect(); + return; + } + + if (UseSideBySideDiff) + { + var previousContent = Editor.Content as ViewModels.TwoSideTextDiff; + Editor.Content = new ViewModels.TwoSideTextDiff(diff, keepScrollOffset ? previousContent : null); + } + else + { + if (!keepScrollOffset) + diff.ScrollOffset = Vector.Zero; + Editor.Content = diff; + } + + RefreshBlockNavigation(); + + IsUnstagedChange = diff.Option.IsUnstaged; + EnableChunkSelection = diff.Option.WorkingCopyChange != null; + } + + private void RefreshBlockNavigation() + { + if (UseBlockNavigation) + BlockNavigation = new ViewModels.BlockNavigation(Editor.Content); + else + BlockNavigation = null; + + TryRaiseBlockNavigationChanged(); + } + + private void OnStageChunk(object _1, RoutedEventArgs _2) + { + var chunk = SelectedChunk; + if (chunk == null) + return; + + var diff = DataContext as Models.TextDiff; + if (diff == null) + return; + + var change = diff.Option.WorkingCopyChange; + if (change == null) + return; + + var selection = diff.MakeSelection(chunk.StartIdx + 1, chunk.EndIdx + 1, chunk.Combined, chunk.IsOldSide); + if (!selection.HasChanges) + return; + + var repoView = this.FindAncestorOfType(); + if (repoView == null) + return; + + var repo = repoView.DataContext as ViewModels.Repository; + if (repo == null) + return; + + repo.SetWatcherEnabled(false); + + if (!selection.HasLeftChanges) + { + new Commands.Add(repo.FullPath, change).Exec(); + } + else + { + var tmpFile = Path.GetTempFileName(); + if (change.WorkTree == Models.ChangeState.Untracked) + { + diff.GenerateNewPatchFromSelection(change, null, selection, false, tmpFile); + } + else if (chunk.Combined) + { + var treeGuid = new Commands.QueryStagedFileBlobGuid(diff.Repo, change.Path).Result(); + diff.GeneratePatchFromSelection(change, treeGuid, selection, false, tmpFile); + } + else + { + var treeGuid = new Commands.QueryStagedFileBlobGuid(diff.Repo, change.Path).Result(); + diff.GeneratePatchFromSelectionSingleSide(change, treeGuid, selection, false, chunk.IsOldSide, tmpFile); + } + + new Commands.Apply(diff.Repo, tmpFile, true, "nowarn", "--cache --index").Exec(); + File.Delete(tmpFile); + } + + repo.MarkWorkingCopyDirtyManually(); + repo.SetWatcherEnabled(true); + } + + private void OnUnstageChunk(object _1, RoutedEventArgs _2) + { + var chunk = SelectedChunk; + if (chunk == null) + return; + + var diff = DataContext as Models.TextDiff; + if (diff == null) + return; + + var change = diff.Option.WorkingCopyChange; + if (change == null) + return; + + var selection = diff.MakeSelection(chunk.StartIdx + 1, chunk.EndIdx + 1, chunk.Combined, chunk.IsOldSide); + if (!selection.HasChanges) + return; + + var repoView = this.FindAncestorOfType(); + if (repoView == null) + return; + + var repo = repoView.DataContext as ViewModels.Repository; + if (repo == null) + return; + + repo.SetWatcherEnabled(false); + + if (!selection.HasLeftChanges) + { + if (change.DataForAmend != null) + new Commands.UnstageChangesForAmend(repo.FullPath, [change]).Exec(); + else + new Commands.Restore(repo.FullPath, change).Exec(); + } + else + { + var treeGuid = new Commands.QueryStagedFileBlobGuid(diff.Repo, change.Path).Result(); + var tmpFile = Path.GetTempFileName(); + if (change.Index == Models.ChangeState.Added) + diff.GenerateNewPatchFromSelection(change, treeGuid, selection, true, tmpFile); + else if (chunk.Combined) + diff.GeneratePatchFromSelection(change, treeGuid, selection, true, tmpFile); + else + diff.GeneratePatchFromSelectionSingleSide(change, treeGuid, selection, true, chunk.IsOldSide, tmpFile); + + new Commands.Apply(diff.Repo, tmpFile, true, "nowarn", "--cache --index --reverse").Exec(); + File.Delete(tmpFile); + } + + repo.MarkWorkingCopyDirtyManually(); + repo.SetWatcherEnabled(true); + } + + private void OnDiscardChunk(object _1, RoutedEventArgs _2) + { + var chunk = SelectedChunk; + if (chunk == null) + return; + + var diff = DataContext as Models.TextDiff; + if (diff == null) + return; + + var change = diff.Option.WorkingCopyChange; + if (change == null) + return; + + var selection = diff.MakeSelection(chunk.StartIdx + 1, chunk.EndIdx + 1, chunk.Combined, chunk.IsOldSide); + if (!selection.HasChanges) + return; + + var repoView = this.FindAncestorOfType(); + if (repoView == null) + return; + + var repo = repoView.DataContext as ViewModels.Repository; + if (repo == null) + return; + + repo.SetWatcherEnabled(false); + + if (!selection.HasLeftChanges) + { + Commands.Discard.Changes(repo.FullPath, [change], null); + } + else + { + var tmpFile = Path.GetTempFileName(); + if (change.Index == Models.ChangeState.Added) + { + diff.GenerateNewPatchFromSelection(change, null, selection, true, tmpFile); + } + else if (chunk.Combined) + { + var treeGuid = new Commands.QueryStagedFileBlobGuid(diff.Repo, change.Path).Result(); + diff.GeneratePatchFromSelection(change, treeGuid, selection, true, tmpFile); + } + else + { + var treeGuid = new Commands.QueryStagedFileBlobGuid(diff.Repo, change.Path).Result(); + diff.GeneratePatchFromSelectionSingleSide(change, treeGuid, selection, true, chunk.IsOldSide, tmpFile); + } + + new Commands.Apply(diff.Repo, tmpFile, true, "nowarn", "--reverse").Exec(); + File.Delete(tmpFile); + } + + repo.MarkWorkingCopyDirtyManually(); + repo.SetWatcherEnabled(true); + } + + private void TryRaiseBlockNavigationChanged() + { + if (UseBlockNavigation) + RaiseEvent(new RoutedEventArgs(BlockNavigationChangedEvent)); + } + } +} diff --git a/src/Views/UpdateSubmodules.axaml b/src/Views/UpdateSubmodules.axaml new file mode 100644 index 00000000..f189b80d --- /dev/null +++ b/src/Views/UpdateSubmodules.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/UpdateSubmodules.axaml.cs b/src/Views/UpdateSubmodules.axaml.cs new file mode 100644 index 00000000..165c809a --- /dev/null +++ b/src/Views/UpdateSubmodules.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class UpdateSubmodules : UserControl + { + public UpdateSubmodules() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Validations/ArchiveFile.cs b/src/Views/Validations/ArchiveFile.cs deleted file mode 100644 index eb347445..00000000 --- a/src/Views/Validations/ArchiveFile.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Globalization; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class ArchiveFile : ValidationRule { - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var path = value as string; - if (string.IsNullOrEmpty(path) || !path.EndsWith(".zip")) return new ValidationResult(false, App.Text("BadArchiveFile")); - return ValidationResult.ValidResult; - } - } -} \ No newline at end of file diff --git a/src/Views/Validations/BranchName.cs b/src/Views/Validations/BranchName.cs deleted file mode 100644 index 6de649ca..00000000 --- a/src/Views/Validations/BranchName.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Globalization; -using System.Text.RegularExpressions; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class BranchName : ValidationRule { - private static readonly Regex REG_FORMAT = new Regex(@"^[\w\-/\.]+$"); - - public Models.Repository Repo { get; set; } - public string Prefix { get; set; } = ""; - - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var name = value as string; - if (string.IsNullOrEmpty(name)) return new ValidationResult(false, App.Text("EmptyBranchName")); - if (!REG_FORMAT.IsMatch(name)) return new ValidationResult(false, App.Text("BadBranchName")); - - name = Prefix + name; - foreach (var t in Repo.Branches) { - var check = t.IsLocal ? t.Name : $"{t.Remote}/{t.Name}"; - if (check == name) { - return new ValidationResult(false, App.Text("DuplicatedBranchName")); - } - } - - return ValidationResult.ValidResult; - } - } -} \ No newline at end of file diff --git a/src/Views/Validations/CloneDir.cs b/src/Views/Validations/CloneDir.cs deleted file mode 100644 index 34f30a46..00000000 --- a/src/Views/Validations/CloneDir.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Globalization; -using System.IO; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class CloneDir : ValidationRule { - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - return Directory.Exists(value as string) - ? ValidationResult.ValidResult - : new ValidationResult(false, App.Text("BadCloneFolder")); - } - } -} diff --git a/src/Views/Validations/CommitMessage.cs b/src/Views/Validations/CommitMessage.cs deleted file mode 100644 index 444a2c20..00000000 --- a/src/Views/Validations/CommitMessage.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Globalization; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class CommitMessage : ValidationRule { - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var subject = value as string; - return string.IsNullOrWhiteSpace(subject) - ? new ValidationResult(false, App.Text("EmptyCommitMessage")) - : ValidationResult.ValidResult; - } - } -} diff --git a/src/Views/Validations/GitURL.cs b/src/Views/Validations/GitURL.cs deleted file mode 100644 index 97c5671c..00000000 --- a/src/Views/Validations/GitURL.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Globalization; -using System.Text.RegularExpressions; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - - public class GitURL : ValidationRule { - private static readonly Regex[] VALID_FORMATS = new Regex[] { - new Regex(@"^http[s]?://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/[\w\-]+/[\w\-\.]+\.git$"), - new Regex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:[\w\-]+/[\w\-\.]+\.git$"), - new Regex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/[\w\-]+/[\w\-\.]+\.git$"), - }; - - public static bool IsSSH(string url) { - if (string.IsNullOrEmpty(url)) return false; - - for (int i = 1; i < VALID_FORMATS.Length; i++) { - if (VALID_FORMATS[i].IsMatch(url)) return true; - } - - return false; - } - - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - string url = value as string; - if (!string.IsNullOrEmpty(url)) { - foreach (var format in VALID_FORMATS) { - if (format.IsMatch(url)) return ValidationResult.ValidResult; - } - } - - return new ValidationResult(false, App.Text("BadRemoteUri")); ; - } - } -} diff --git a/src/Views/Validations/LocalRepositoryName.cs b/src/Views/Validations/LocalRepositoryName.cs deleted file mode 100644 index 80615742..00000000 --- a/src/Views/Validations/LocalRepositoryName.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Globalization; -using System.Text.RegularExpressions; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class LocalRepositoryName : ValidationRule { - private static readonly Regex REG_FORMAT = new Regex(@"^[\w\-]+$"); - - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var name = value as string; - if (string.IsNullOrEmpty(name)) return ValidationResult.ValidResult; - if (!REG_FORMAT.IsMatch(name)) return new ValidationResult(false, App.Text("BadLocalName")); - return ValidationResult.ValidResult; - } - } -} diff --git a/src/Views/Validations/PatchFile.cs b/src/Views/Validations/PatchFile.cs deleted file mode 100644 index 7b6541dd..00000000 --- a/src/Views/Validations/PatchFile.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Globalization; -using System.IO; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class PatchFile : ValidationRule { - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - return File.Exists(value as string) - ? ValidationResult.ValidResult - : new ValidationResult(false, App.Text("BadPatchFile")); - } - } -} \ No newline at end of file diff --git a/src/Views/Validations/RelativePath.cs b/src/Views/Validations/RelativePath.cs deleted file mode 100644 index 4f7496a2..00000000 --- a/src/Views/Validations/RelativePath.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Globalization; -using System.Text.RegularExpressions; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class RelativePath : ValidationRule { - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var path = value as string; - if (string.IsNullOrEmpty(path)) return ValidationResult.ValidResult; - - var regex = new Regex(@"^[\w\-\._/]+$"); - var succ = regex.IsMatch(path.Trim()); - return !succ ? new ValidationResult(false, App.Text("BadRelativePath")) : ValidationResult.ValidResult; - } - } -} \ No newline at end of file diff --git a/src/Views/Validations/RemoteName.cs b/src/Views/Validations/RemoteName.cs deleted file mode 100644 index a2c44ae6..00000000 --- a/src/Views/Validations/RemoteName.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Globalization; -using System.Text.RegularExpressions; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class RemoteName : ValidationRule { - private static readonly Regex REG_FORMAT = new Regex(@"^[\w\-\.]+$"); - - public Models.Repository Repo { get; set; } - public bool IsOptional { get; set; } - - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var name = value as string; - if (string.IsNullOrEmpty(name)) { - return IsOptional ? ValidationResult.ValidResult : new ValidationResult(false, App.Text("EmptyRemoteName")); - } - - if (!REG_FORMAT.IsMatch(name)) return new ValidationResult(false, App.Text("BadRemoteName")); - - if (Repo != null) { - foreach (var t in Repo.Remotes) { - if (t.Name == name) { - return new ValidationResult(false, App.Text("DuplicatedRemoteName")); - } - } - } - - return ValidationResult.ValidResult; - } - } -} diff --git a/src/Views/Validations/Required.cs b/src/Views/Validations/Required.cs deleted file mode 100644 index 1106266b..00000000 --- a/src/Views/Validations/Required.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Globalization; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class Required : ValidationRule { - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var path = value as string; - return string.IsNullOrEmpty(path) ? - new ValidationResult(false, App.Text("Required")) : - ValidationResult.ValidResult; - } - } -} diff --git a/src/Views/Validations/TagName.cs b/src/Views/Validations/TagName.cs deleted file mode 100644 index 570fe737..00000000 --- a/src/Views/Validations/TagName.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Text.RegularExpressions; -using System.Windows.Controls; - -namespace SourceGit.Views.Validations { - public class TagName : ValidationRule { - private static readonly Regex REG_FORMAT = new Regex(@"^[\w\-\.]+$"); - - public List Tags { get; set; } - - public override ValidationResult Validate(object value, CultureInfo cultureInfo) { - var name = value as string; - if (string.IsNullOrEmpty(name)) return new ValidationResult(false, App.Text("EmptyTagName")); - if (!REG_FORMAT.IsMatch(name)) return new ValidationResult(false, App.Text("BadTagName")); - - foreach (var t in Tags) { - if (t.Name == name) { - return new ValidationResult(false, App.Text("DuplicatedTagName")); - } - } - - return ValidationResult.ValidResult; - } - } -} \ No newline at end of file diff --git a/src/Views/ViewLogs.axaml b/src/Views/ViewLogs.axaml new file mode 100644 index 00000000..a3b9e240 --- /dev/null +++ b/src/Views/ViewLogs.axaml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Welcome.axaml.cs b/src/Views/Welcome.axaml.cs new file mode 100644 index 00000000..521e4530 --- /dev/null +++ b/src/Views/Welcome.axaml.cs @@ -0,0 +1,285 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class RepositoryTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.RepositoryNode { IsRepository: false } node) + { + ViewModels.Welcome.Instance.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class RepositoryListBox : ListBox + { + protected override Type StyleKeyOverride => typeof(ListBox); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (SelectedItem is ViewModels.RepositoryNode node && e.KeyModifiers == KeyModifiers.None) + { + if (e.Key is Key.Delete or Key.Back) + { + node.Delete(); + e.Handled = true; + } + else if (node.IsRepository) + { + if (e.Key == Key.Enter) + { + var parent = this.FindAncestorOfType(); + if (parent is { DataContext: ViewModels.Launcher launcher }) + launcher.OpenRepositoryInTab(node, null); + + e.Handled = true; + } + } + else if ((node.IsExpanded && e.Key == Key.Left) || (!node.IsExpanded && e.Key == Key.Right) || e.Key == Key.Enter) + { + ViewModels.Welcome.Instance.ToggleNodeIsExpanded(node); + e.Handled = true; + } + } + + if (!e.Handled) + base.OnKeyDown(e); + } + } + + public partial class Welcome : UserControl + { + public Welcome() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (!e.Handled) + { + if (e.Key == Key.Down && ViewModels.Welcome.Instance.Rows.Count > 0) + { + TreeContainer.SelectedIndex = 0; + TreeContainer.Focus(NavigationMethod.Directional); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + ViewModels.Welcome.Instance.ClearSearchFilter(); + e.Handled = true; + } + } + } + + private void SetupTreeViewDragAndDrop(object sender, RoutedEventArgs _) + { + if (sender is ListBox view) + { + DragDrop.SetAllowDrop(view, true); + view.AddHandler(DragDrop.DragOverEvent, DragOverTreeView); + view.AddHandler(DragDrop.DropEvent, DropOnTreeView); + } + } + + private void SetupTreeNodeDragAndDrop(object sender, RoutedEventArgs _) + { + if (sender is Grid grid) + { + DragDrop.SetAllowDrop(grid, true); + grid.AddHandler(DragDrop.DragOverEvent, DragOverTreeNode); + grid.AddHandler(DragDrop.DropEvent, DropOnTreeNode); + } + } + + private void OnTreeNodeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is Grid { DataContext: ViewModels.RepositoryNode node } grid) + { + var menu = ViewModels.Welcome.Instance.CreateContextMenu(node); + menu?.Open(grid); + e.Handled = true; + } + } + + private void OnPointerPressedTreeNode(object sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(sender as Visual).Properties.IsLeftButtonPressed) + { + _pressedTreeNode = true; + _startDragTreeNode = false; + _pressedTreeNodePosition = e.GetPosition(sender as Grid); + } + else + { + _pressedTreeNode = false; + _startDragTreeNode = false; + } + } + + private void OnPointerReleasedOnTreeNode(object _1, PointerReleasedEventArgs _2) + { + _pressedTreeNode = false; + _startDragTreeNode = false; + } + + private void OnPointerMovedOverTreeNode(object sender, PointerEventArgs e) + { + if (_pressedTreeNode && !_startDragTreeNode && + sender is Grid { DataContext: ViewModels.RepositoryNode node } grid) + { + var delta = e.GetPosition(grid) - _pressedTreeNodePosition; + var sizeSquired = delta.X * delta.X + delta.Y * delta.Y; + if (sizeSquired < 64) + return; + + _startDragTreeNode = true; + + var data = new DataObject(); + data.Set("MovedRepositoryTreeNode", node); + DragDrop.DoDragDrop(e, data, DragDropEffects.Move); + } + } + + private void OnTreeViewLostFocus(object _1, RoutedEventArgs _2) + { + _pressedTreeNode = false; + _startDragTreeNode = false; + } + + private void DragOverTreeView(object sender, DragEventArgs e) + { + if (e.Data.Contains("MovedRepositoryTreeNode") || e.Data.Contains(DataFormats.Files)) + { + e.DragEffects = DragDropEffects.Move; + e.Handled = true; + } + else + { + e.DragEffects = DragDropEffects.None; + e.Handled = true; + } + } + + private void DropOnTreeView(object sender, DragEventArgs e) + { + if (e.Data.Contains("MovedRepositoryTreeNode") && e.Data.Get("MovedRepositoryTreeNode") is ViewModels.RepositoryNode moved) + { + e.Handled = true; + ViewModels.Welcome.Instance.MoveNode(moved, null); + } + else if (e.Data.Contains(DataFormats.Files)) + { + e.Handled = true; + + var items = e.Data.GetFiles(); + if (items != null) + { + foreach (var item in items) + { + ViewModels.Welcome.Instance.OpenOrInitRepository(item.Path.LocalPath, null, true); + break; + } + } + } + + _pressedTreeNode = false; + _startDragTreeNode = false; + } + + private void DragOverTreeNode(object sender, DragEventArgs e) + { + if (e.Data.Contains("MovedRepositoryTreeNode") || e.Data.Contains(DataFormats.Files)) + { + var grid = sender as Grid; + if (grid == null) + return; + + var to = grid.DataContext as ViewModels.RepositoryNode; + if (to == null) + return; + + e.DragEffects = to.IsRepository ? DragDropEffects.None : DragDropEffects.Move; + e.Handled = true; + } + } + + private void DropOnTreeNode(object sender, DragEventArgs e) + { + if (sender is not Grid grid) + return; + + var to = grid.DataContext as ViewModels.RepositoryNode; + if (to == null || to.IsRepository) + { + e.Handled = true; + return; + } + + if (e.Data.Contains("MovedRepositoryTreeNode") && + e.Data.Get("MovedRepositoryTreeNode") is ViewModels.RepositoryNode moved) + { + e.Handled = true; + + if (to != moved) + ViewModels.Welcome.Instance.MoveNode(moved, to); + } + else if (e.Data.Contains(DataFormats.Files)) + { + e.Handled = true; + + var items = e.Data.GetFiles(); + if (items != null) + { + foreach (var item in items) + { + ViewModels.Welcome.Instance.OpenOrInitRepository(item.Path.LocalPath, to, true); + break; + } + } + } + + _pressedTreeNode = false; + _startDragTreeNode = false; + } + + private void OnDoubleTappedTreeNode(object sender, TappedEventArgs e) + { + if (sender is Grid { DataContext: ViewModels.RepositoryNode node }) + { + if (node.IsRepository) + { + var parent = this.FindAncestorOfType(); + if (parent is { DataContext: ViewModels.Launcher launcher }) + launcher.OpenRepositoryInTab(node, null); + } + else + { + ViewModels.Welcome.Instance.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + private bool _pressedTreeNode = false; + private Point _pressedTreeNodePosition = new Point(); + private bool _startDragTreeNode = false; + } +} diff --git a/src/Views/WelcomeToolbar.axaml b/src/Views/WelcomeToolbar.axaml new file mode 100644 index 00000000..3ed99ce6 --- /dev/null +++ b/src/Views/WelcomeToolbar.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Views/WelcomeToolbar.axaml.cs b/src/Views/WelcomeToolbar.axaml.cs new file mode 100644 index 00000000..2918a570 --- /dev/null +++ b/src/Views/WelcomeToolbar.axaml.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; + +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class WelcomeToolbar : UserControl + { + public WelcomeToolbar() + { + InitializeComponent(); + } + + private async void OpenLocalRepository(object _1, RoutedEventArgs e) + { + var activePage = App.GetLauncher().ActivePage; + if (activePage == null || !activePage.CanCreatePopup()) + return; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + if (Directory.Exists(ViewModels.Preferences.Instance.GitDefaultCloneDir)) + { + var folder = await topLevel.StorageProvider.TryGetFolderFromPathAsync(ViewModels.Preferences.Instance.GitDefaultCloneDir); + options.SuggestedStartLocation = folder; + } + + try + { + var selected = await topLevel.StorageProvider.OpenFolderPickerAsync(options); + if (selected.Count == 1) + { + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder?.Path.ToString(); + ViewModels.Welcome.Instance.OpenOrInitRepository(folderPath, null, false); + } + } + catch (Exception exception) + { + App.RaiseException(string.Empty, $"Failed to open repository: {exception.Message}"); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/Widgets/Bookmark.xaml b/src/Views/Widgets/Bookmark.xaml deleted file mode 100644 index 7a8aa295..00000000 --- a/src/Views/Widgets/Bookmark.xaml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/Views/Widgets/Bookmark.xaml.cs b/src/Views/Widgets/Bookmark.xaml.cs deleted file mode 100644 index 41c0547d..00000000 --- a/src/Views/Widgets/Bookmark.xaml.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Widgets { - /// - /// 仓库书签 - /// - public partial class Bookmark : UserControl { - /// - /// 颜色属性 - /// - public static readonly DependencyProperty ColorProperty = DependencyProperty.Register( - "Color", - typeof(int), - typeof(Bookmark), - new PropertyMetadata(0)); - - /// - /// 颜色 - /// - public int Color { - get { return (int)GetValue(ColorProperty); } - set { SetValue(ColorProperty, value); } - } - - /// - /// 构造函数 - /// - public Bookmark() { - InitializeComponent(); - } - } -} diff --git a/src/Views/Widgets/CommitChanges.xaml b/src/Views/Widgets/CommitChanges.xaml deleted file mode 100644 index f5684b09..00000000 --- a/src/Views/Widgets/CommitChanges.xaml +++ /dev/null @@ -1,190 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/CommitChanges.xaml.cs b/src/Views/Widgets/CommitChanges.xaml.cs deleted file mode 100644 index 8dcf0b05..00000000 --- a/src/Views/Widgets/CommitChanges.xaml.cs +++ /dev/null @@ -1,367 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Widgets { - /// - /// 显示提交中的变更列表 - /// - public partial class CommitChanges : UserControl { - private string repo = null; - private List range = new List(); - private List cachedChanges = new List(); - private string filter = null; - private bool isSelecting = false; - private bool isLFSEnabled = false; - - public class ChangeNode { - public string Path { get; set; } = ""; - public Models.Change Change { get; set; } = null; - public bool IsExpanded { get; set; } = false; - public bool IsFolder => Change == null; - public List Children { get; set; } = new List(); - } - - public CommitChanges() { - InitializeComponent(); - } - - public void CleanUp() { - range.Clear(); - cachedChanges.Clear(); - modeTree.ItemsSource = new List(); - modeGrid.ItemsSource = new List(); - modeList.ItemsSource = new List(); - } - - public void SetData(string repo, List range, List changes) { - this.repo = repo; - this.range = range; - this.cachedChanges = changes; - this.isLFSEnabled = new Commands.LFS(repo).IsEnabled(); - - UpdateVisible(); - } - - public void Select(Models.Change change) { - isSelecting = true; - - switch (modeSwitcher.Mode) { - case Models.Change.DisplayMode.Tree: - var node = FindNodeByChange(modeTree.ItemsSource as List, change); - modeTree.Select(node); - break; - case Models.Change.DisplayMode.List: - modeList.SelectedItem = change; - modeList.ScrollIntoView(change); - break; - case Models.Change.DisplayMode.Grid: - modeGrid.SelectedItem = change; - modeGrid.ScrollIntoView(change); - break; - } - - isSelecting = false; - } - - private void UpdateVisible() { - Task.Run(() => { - // 筛选出可见的列表 - List visible; - if (string.IsNullOrEmpty(filter)) { - visible = cachedChanges; - } else { - visible = cachedChanges.Where(x => x.Path.ToUpper().Contains(filter)).ToList(); - } - - // 排序 - visible.Sort((l, r) => l.Path.CompareTo(r.Path)); - - // 生成树节点 - var nodes = new List(); - var folders = new Dictionary(); - var expanded = visible.Count <= 50; - - foreach (var c in visible) { - var sepIdx = c.Path.IndexOf('/'); - if (sepIdx == -1) { - nodes.Add(new ChangeNode() { - Path = c.Path, - Change = c, - IsExpanded = false - }); - } else { - ChangeNode lastFolder = null; - var start = 0; - - while (sepIdx != -1) { - var folder = c.Path.Substring(0, sepIdx); - if (folders.ContainsKey(folder)) { - lastFolder = folders[folder]; - } else if (lastFolder == null) { - lastFolder = new ChangeNode() { - Path = folder, - Change = null, - IsExpanded = expanded - }; - nodes.Add(lastFolder); - folders.Add(folder, lastFolder); - } else { - var cur = new ChangeNode() { - Path = folder, - Change = null, - IsExpanded = expanded - }; - folders.Add(folder, cur); - lastFolder.Children.Add(cur); - lastFolder = cur; - } - - start = sepIdx + 1; - sepIdx = c.Path.IndexOf('/', start); - } - - lastFolder.Children.Add(new ChangeNode() { - Path = c.Path, - Change = c, - IsExpanded = false - }); - } - } - - folders.Clear(); - SortFileNodes(nodes); - - Dispatcher.Invoke(() => { - modeTree.ItemsSource = nodes; - modeList.ItemsSource = visible; - modeGrid.ItemsSource = visible; - - UpdateMode(); - }); - }); - } - - private ChangeNode FindNodeByChange(List nodes, Models.Change change) { - if (nodes == null || nodes.Count == 0) return null; - - foreach (var node in nodes) { - if (node.IsFolder) { - var found = FindNodeByChange(node.Children, change); - if (found != null) return found; - } else if (node.Change == change) { - return node; - } - } - - return null; - } - - private void SortFileNodes(List nodes) { - nodes.Sort((l, r) => { - if (l.IsFolder == r.IsFolder) { - return l.Path.CompareTo(r.Path); - } else { - return l.IsFolder ? -1 : 1; - } - }); - - foreach (var node in nodes) { - if (node.Children.Count > 1) SortFileNodes(node.Children); - } - } - - private void UpdateMode() { - var mode = modeSwitcher.Mode; - - if (modeTree != null) { - if (mode == Models.Change.DisplayMode.Tree) { - modeTree.Visibility = Visibility.Visible; - } else { - modeTree.Visibility = Visibility.Collapsed; - } - } - - if (modeList != null) { - if (mode == Models.Change.DisplayMode.List) { - modeList.Visibility = Visibility.Visible; - modeList.Columns[1].Width = DataGridLength.SizeToCells; - modeList.Columns[1].Width = DataGridLength.Auto; - } else { - modeList.Visibility = Visibility.Collapsed; - } - } - - if (modeGrid != null) { - if (mode == Models.Change.DisplayMode.Grid) { - modeGrid.Visibility = Visibility.Visible; - modeGrid.Columns[1].Width = DataGridLength.SizeToCells; - modeGrid.Columns[1].Width = DataGridLength.Auto; - modeGrid.Columns[2].Width = DataGridLength.SizeToCells; - modeGrid.Columns[2].Width = DataGridLength.Auto; - } else { - modeGrid.Visibility = Visibility.Collapsed; - } - } - } - - private void OpenChangeDiff(Models.Change change) { - var revisions = new string[] { "", "" }; - if (range.Count == 2) { - revisions[0] = range[0].SHA; - revisions[1] = range[1].SHA; - } else { - revisions[0] = $"{range[0].SHA}^"; - revisions[1] = range[0].SHA; - if (range[0].Parents.Count == 0) revisions[0] = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - } - - diffViewer.Diff(repo, new DiffViewer.Option() { - RevisionRange = revisions, - Path = change.Path, - OrgPath = change.OriginalPath, - UseLFS = isLFSEnabled, - }); - } - - private void OpenChangeContextMenu(Models.Change change, UIElement placement) { - var menu = new ContextMenu() { PlacementTarget = placement }; - var path = change.Path; - - if (change.Index != Models.Change.Status.Deleted) { - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Click += (o, ev) => { - var viewer = new FileHistories(repo, path); - viewer.Show(); - ev.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Visibility = range.Count == 1 ? Visibility.Visible : Visibility.Collapsed; - blame.Click += (obj, ev) => { - var viewer = new Blame(repo, path, range[0].SHA); - viewer.Show(); - ev.Handled = true; - }; - - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Click += (o, ev) => { - var full = Path.GetFullPath(repo + "\\" + path); - Process.Start("explorer", $"/select,{full}"); - ev.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(explore); - } - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Click += (obj, ev) => { - Clipboard.SetDataObject(path, true); - }; - - menu.Items.Add(copyPath); - menu.IsOpen = true; - } - - private void OnDisplayModeChanged(object sender, RoutedEventArgs e) { - UpdateMode(); - } - - private void SearchFilterChanged(object sender, TextChangedEventArgs e) { - var edit = sender as Controls.TextEdit; - filter = edit.Text.ToUpper(); - UpdateVisible(); - } - - private void OnRequestBringIntoView(object sender, RequestBringIntoViewEventArgs e) { - if (!isSelecting) e.Handled = true; - } - - private void OnTreeSelectionChanged(object sender, RoutedEventArgs e) { - if (modeSwitcher.Mode != Models.Change.DisplayMode.Tree) return; - - diffViewer.Reset(); - if (modeTree.Selected.Count == 0) return; - - var change = (modeTree.Selected[0] as ChangeNode).Change; - if (change == null) return; - - OpenChangeDiff(change); - } - - private void OnListSelectionChanged(object sender, SelectionChangedEventArgs e) { - if (modeSwitcher.Mode != Models.Change.DisplayMode.List) return; - - diffViewer.Reset(); - - var change = (sender as DataGrid).SelectedItem as Models.Change; - if (change == null) return; - - OpenChangeDiff(change); - } - - private void OnGridSelectionChanged(object sender, SelectionChangedEventArgs e) { - if (modeSwitcher.Mode != Models.Change.DisplayMode.Grid) return; - - diffViewer.Reset(); - - var change = (sender as DataGrid).SelectedItem as Models.Change; - if (change == null) return; - - OpenChangeDiff(change); - } - - private void OnTreeContextMenuOpening(object sender, ContextMenuEventArgs e) { - var item = sender as Controls.TreeItem; - if (item == null) return; - - var node = item.DataContext as ChangeNode; - if (node == null || node.IsFolder) return; - - OpenChangeContextMenu(node.Change, item); - e.Handled = true; - } - - private void OnDataGridContextMenuOpening(object sender, ContextMenuEventArgs e) { - var row = sender as DataGridRow; - if (row == null) return; - - var change = row.Item as Models.Change; - if (change == null) return; - - OpenChangeContextMenu(change, row); - e.Handled = true; - } - - private void OnListSizeChanged(object sender, SizeChangedEventArgs e) { - if (modeSwitcher.Mode != Models.Change.DisplayMode.List) return; - - int last = modeList.Columns.Count - 1; - double offset = 0; - for (int i = 0; i < last; i++) offset += modeList.Columns[i].ActualWidth; - modeList.Columns[last].MinWidth = Math.Max(layerChanges.ActualWidth - offset, 10); - modeList.UpdateLayout(); - } - - private void OnGridSizeChanged(object sender, SizeChangedEventArgs e) { - if (modeSwitcher.Mode != Models.Change.DisplayMode.Grid) return; - - int last = modeGrid.Columns.Count - 1; - double offset = 0; - for (int i = 0; i < last; i++) offset += modeGrid.Columns[i].ActualWidth; - modeGrid.Columns[last].MinWidth = Math.Max(layerChanges.ActualWidth - offset, 10); - modeGrid.UpdateLayout(); - } - } -} diff --git a/src/Views/Widgets/CommitDetail.xaml b/src/Views/Widgets/CommitDetail.xaml deleted file mode 100644 index c3d4fb8c..00000000 --- a/src/Views/Widgets/CommitDetail.xaml +++ /dev/null @@ -1,234 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/CommitDetail.xaml.cs b/src/Views/Widgets/CommitDetail.xaml.cs deleted file mode 100644 index 765b6d13..00000000 --- a/src/Views/Widgets/CommitDetail.xaml.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Navigation; - -namespace SourceGit.Views.Widgets { - - /// - /// 提交详情 - /// - public partial class CommitDetail : UserControl { - private string repo = null; - private Models.Commit commit = null; - private Commands.Context cancelToken = new Commands.Context(); - - public CommitDetail() { - InitializeComponent(); - - Unloaded += (o, e) => { - changeList.ItemsSource = new List(); - changeContainer.CleanUp(); - revisionFiles.Cleanup(); - }; - } - - public void SetData(string repo, Models.Commit commit) { - cancelToken.IsCancelRequested = true; - cancelToken = new Commands.Context(); - - this.repo = repo; - this.commit = commit; - - revisionFiles.SetData(repo, commit.SHA, cancelToken); - UpdateInformation(commit); - UpdateChanges(); - } - - #region DATA - private void UpdateInformation(Models.Commit commit) { - txtSHA.Text = commit.SHA; - txtMessage.Text = (commit.Subject + "\n\n" + commit.Message.Trim()).Trim(); - - avatarAuthor.Email = commit.Author.Email; - avatarAuthor.FallbackLabel = commit.Author.Name; - txtAuthorName.Text = commit.Author.Name; - txtAuthorEmail.Text = commit.Author.Email; - txtAuthorTime.Text = commit.AuthorTimeStr; - - if (commit.Committer.Equals(commit.Author) && commit.CommitterTime == commit.AuthorTime) { - avatarCommitter.Visibility = Visibility.Hidden; - committerInfoPanel.Visibility = Visibility.Hidden; - } else { - avatarCommitter.Visibility = Visibility.Visible; - committerInfoPanel.Visibility = Visibility.Visible; - - avatarCommitter.Email = commit.Committer.Email; - avatarCommitter.FallbackLabel = commit.Committer.Name; - txtCommitterName.Text = commit.Committer.Name; - txtCommitterEmail.Text = commit.Committer.Email; - txtCommitterTime.Text = commit.CommitterTimeStr; - } - - if (commit.Parents.Count == 0) { - rowParents.Height = new GridLength(0); - } else { - rowParents.Height = GridLength.Auto; - var shortPIDs = new List(); - foreach (var p in commit.Parents) shortPIDs.Add(p.Substring(0, 10)); - listParents.ItemsSource = shortPIDs; - } - - if (!commit.HasDecorators) { - rowRefs.Height = new GridLength(0); - } else { - rowRefs.Height = GridLength.Auto; - listRefs.ItemsSource = commit.Decorators; - } - } - - private void UpdateChanges() { - var cmd = new Commands.CommitChanges(repo, commit.SHA) { Ctx = cancelToken }; - Task.Run(() => { - var changes = cmd.Result(); - if (cmd.Ctx.IsCancelRequested) return; - - Dispatcher.Invoke(() => { - changeList.ItemsSource = changes; - changeContainer.SetData(repo, new List() { commit }, changes); - }); - }); - } - #endregion - - #region EVENTS - private void OnNavigateParent(object sender, RequestNavigateEventArgs e) { - Models.Watcher.Get(repo)?.NavigateTo(e.Uri.OriginalString); - } - - private void OnChangeListMouseDoubleClick(object sender, MouseButtonEventArgs e) { - var row = sender as DataGridRow; - if (row == null) return; - - var change = row.DataContext as Models.Change; - if (change == null) return; - - body.SelectedIndex = 1; - changeContainer.Select(change); - e.Handled = true; - } - - private void OnChangeListContextMenuOpening(object sender, ContextMenuEventArgs e) { - var row = sender as DataGridRow; - if (row == null) return; - - if (!row.IsSelected) { - changeList.UnselectAll(); - row.IsSelected = true; - } - - var selectedCount = changeList.SelectedItems.Count; - var menu = new ContextMenu() { PlacementTarget = row }; - if (selectedCount == 1) { - var change = changeList.SelectedItems[0] as Models.Change; - if (change == null) return; - - if (change.Index != Models.Change.Status.Deleted) { - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.IsEnabled = change.Index != Models.Change.Status.Deleted; - history.Click += (_, ev) => { - var viewer = new FileHistories(repo, change.Path); - viewer.Show(); - ev.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.IsEnabled = change.Index != Models.Change.Status.Deleted; - blame.Click += (_, ev) => { - var viewer = new Blame(repo, change.Path, commit.SHA); - viewer.Show(); - ev.Handled = true; - }; - - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.IsEnabled = change.Index != Models.Change.Status.Deleted; - explore.Click += (_, ev) => { - var full = Path.GetFullPath(repo + "\\" + change.Path); - Process.Start("explorer", $"/select,{full}"); - ev.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(explore); - } - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Click += (_, ev) => { - Clipboard.SetDataObject(change.Path, true); - ev.Handled = true; - }; - - menu.Items.Add(copyPath); - } else { - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Click += (_, ev) => { - var builder = new StringBuilder(); - foreach (var obj in changeList.SelectedItems) { - builder.Append((obj as Models.Change).Path); - builder.Append("\n"); - } - Clipboard.SetDataObject(builder.ToString(), true); - ev.Handled = true; - }; - - menu.Items.Add(copyPath); - } - - menu.IsOpen = true; - e.Handled = true; - } - #endregion - } -} diff --git a/src/Views/Widgets/Dashboard.xaml b/src/Views/Widgets/Dashboard.xaml deleted file mode 100644 index 6b44bd6f..00000000 --- a/src/Views/Widgets/Dashboard.xaml +++ /dev/null @@ -1,596 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/PageTabBar.xaml.cs b/src/Views/Widgets/PageTabBar.xaml.cs deleted file mode 100644 index 72cc2306..00000000 --- a/src/Views/Widgets/PageTabBar.xaml.cs +++ /dev/null @@ -1,403 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Media; - -namespace SourceGit.Views.Widgets { - - /// - /// 主窗体标题栏的标签页容器控件 - /// - public partial class PageTabBar : UserControl { - - /// - /// 标签数据 - /// - public class Tab : INotifyPropertyChanged { - public event PropertyChangedEventHandler PropertyChanged; - - public string Id { get; set; } - public bool IsRepository { get; set; } - - private string title; - public string Title { - get => title; - set => SetProperty(ref title, value); - } - - public string Tooltip { get; set; } - - private int bookmark = 0; - public int Bookmark { - get => bookmark; - set => SetProperty(ref bookmark, value); - } - - private bool isSeperatorVisible = false; - public bool IsSeperatorVisible { - get => isSeperatorVisible; - set => SetProperty(ref isSeperatorVisible, value); - } - - public void SetProperty(ref T storage, T value, [CallerMemberName] string propName = null) { - if (Equals(storage, value)) return; - storage = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName)); - } - } - - /// - /// 标签相关事件参数 - /// - public class TabEventArgs : RoutedEventArgs { - public string TabId { get; set; } - public TabEventArgs(RoutedEvent e, object o, string id) : base(e, o) { TabId = id; } - } - - public static readonly RoutedEvent TabAddEvent = EventManager.RegisterRoutedEvent( - "TabAdd", - RoutingStrategy.Bubble, - typeof(EventHandler), - typeof(PageTabBar)); - - public event RoutedEventHandler TabAdd { - add { AddHandler(TabAddEvent, value); } - remove { RemoveHandler(TabAddEvent, value); } - } - - public static readonly RoutedEvent TabSelectedEvent = EventManager.RegisterRoutedEvent( - "TabSelected", - RoutingStrategy.Bubble, - typeof(EventHandler), - typeof(PageTabBar)); - - public event RoutedEventHandler TabSelected { - add { AddHandler(TabSelectedEvent, value); } - remove { RemoveHandler(TabSelectedEvent, value); } - } - - public static readonly RoutedEvent TabClosedEvent = EventManager.RegisterRoutedEvent( - "TabClosed", - RoutingStrategy.Bubble, - typeof(EventHandler), - typeof(PageTabBar)); - - public event RoutedEventHandler TabClosed { - add { AddHandler(TabClosedEvent, value); } - remove { RemoveHandler(TabClosedEvent, value); } - } - - public ObservableCollection Tabs { - get; - private set; - } - - public string Current { - get { return (container.SelectedItem as Tab).Id; } - } - - public PageTabBar() { - Tabs = new ObservableCollection(); - InitializeComponent(); - - Models.Watcher.BookmarkChanged += (repoPath, bookmark) => { - foreach (var tab in Tabs) { - if (tab.Id == repoPath) { - tab.Bookmark = bookmark; - break; - } - } - }; - } - - public void Add() { - NewTab(null, null); - } - - public void Add(string title, string repo, int bookmark) { - var tab = new Tab() { - Id = repo, - IsRepository = true, - Title = title, - Tooltip = repo, - Bookmark = bookmark, - }; - - Tabs.Add(tab); - container.SelectedItem = tab; - } - - public void Replace(string id, string title, string repo, int bookmark) { - var tab = null as Tab; - var curTab = container.SelectedItem as Tab; - - foreach (var one in Tabs) { - if (one.Id == id) { - tab = one; - break; - } - } - - if (tab == null) return; - - var idx = Tabs.IndexOf(tab); - Tabs.RemoveAt(idx); - RaiseEvent(new TabEventArgs(TabClosedEvent, this, tab.Id)); - - var replaced = new Tab() { - Id = repo, - IsRepository = true, - Title = title, - Tooltip = repo, - Bookmark = bookmark, - }; - - Tabs.Insert(idx, replaced); - if (curTab.Id == id) container.SelectedItem = replaced; - } - - public void Update(string id, int bookmark, string title) { - foreach (var one in Tabs) { - if (one.Id == id) { - one.Bookmark = bookmark; - one.Title = title; - break; - } - } - } - - public bool Goto(string id) { - foreach (var tab in Tabs) { - if (tab.Id == id) { - container.SelectedItem = tab; - return true; - } - } - - return false; - } - - public void Next() { - container.SelectedIndex = (container.SelectedIndex + 1) % Tabs.Count; - } - - public void CloseCurrent() { - var curTab = container.SelectedItem as Tab; - var idx = container.SelectedIndex; - Tabs.Remove(curTab); - if (Tabs.Count == 0) { - Application.Current.Shutdown(); - } else { - var last = Tabs.Count - 1; - var next = idx > last ? Tabs[last] : Tabs[idx]; - container.SelectedItem = next; - RaiseEvent(new TabEventArgs(TabClosedEvent, this, curTab.Id)); - RaiseEvent(new TabEventArgs(TabSelectedEvent, this, next.Id)); - } - } - - private void CalcScrollerVisibilty(object sender, SizeChangedEventArgs e) { - if ((sender as StackPanel).ActualWidth > scroller.ActualWidth) { - startSeperator.Visibility = Visibility.Hidden; - leftScroller.Visibility = Visibility.Visible; - rightScroller.Visibility = Visibility.Visible; - } else { - leftScroller.Visibility = Visibility.Collapsed; - rightScroller.Visibility = Visibility.Collapsed; - if (container.SelectedIndex == 0) { - startSeperator.Visibility = Visibility.Hidden; - } else { - startSeperator.Visibility = Visibility.Visible; - } - } - } - - private void NewTab(object sender, RoutedEventArgs e) { - var id = Guid.NewGuid().ToString(); - var tab = new Tab() { - Id = id, - IsRepository = false, - Title = App.Text("PageTabBar.Welcome.Title"), - Tooltip = App.Text("PageTabBar.Welcome.Tip"), - Bookmark = 0, - }; - - Tabs.Add(tab); - RaiseEvent(new TabEventArgs(TabAddEvent, this, id)); - container.SelectedItem = tab; - } - - private void ScrollLeft(object sender, RoutedEventArgs e) { - scroller.LineLeft(); - } - - private void ScrollRight(object sender, RoutedEventArgs e) { - scroller.LineRight(); - } - - private void SelectionChanged(object sender, SelectionChangedEventArgs e) { - var tab = container.SelectedItem as Tab; - if (tab == null) return; - UpdateSeperators(tab); - RaiseEvent(new TabEventArgs(TabSelectedEvent, this, tab.Id)); - } - - private void CloseTab(object sender, RoutedEventArgs e) { - var tab = (sender as Button).DataContext as Tab; - if (tab == null) return; - CloseTab(tab); - } - - private void CloseTab(Tab tab) { - var curTab = container.SelectedItem as Tab; - if (curTab != null && tab.Id == curTab.Id) { - var idx = Tabs.IndexOf(tab); - Tabs.Remove(tab); - - if (Tabs.Count == 0) { - Application.Current.Shutdown(); - return; - } - - var last = Tabs.Count - 1; - var next = idx > last ? Tabs[last] : Tabs[idx]; - container.SelectedItem = next; - RaiseEvent(new TabEventArgs(TabSelectedEvent, this, next.Id)); - } else { - Tabs.Remove(tab); - UpdateSeperators(curTab); - } - RaiseEvent(new TabEventArgs(TabClosedEvent, this, tab.Id)); - } - - private void OnMouseMove(object sender, MouseEventArgs e) { - var item = sender as ListBoxItem; - if (item == null) return; - - var tab = item.DataContext as Tab; - if (tab == null || tab != container.SelectedItem) return; - - if (e.LeftButton == MouseButtonState.Pressed) { - DragDrop.DoDragDrop(item, item.DataContext, DragDropEffects.Move); - } - } - - private void OnGiveFeedback(object sender, GiveFeedbackEventArgs e) { - if (e.Effects == DragDropEffects.Move) { - e.UseDefaultCursors = false; - Mouse.SetCursor(Cursors.Hand); - } else { - e.UseDefaultCursors = true; - } - - e.Handled = true; - } - - private void OnDragOver(object sender, DragEventArgs e) { - OnDrop(sender, e); - } - - private void OnDrop(object sender, DragEventArgs e) { - var tabSrc = e.Data.GetData(typeof(Tab)) as Tab; - if (tabSrc == null) return; - - var dst = e.Source as FrameworkElement; - if (dst == null) return; - - var tabDst = dst.DataContext as Tab; - if (tabSrc.Id == tabDst.Id) return; - - int dstIdx = Tabs.IndexOf(tabDst); - Tabs.Remove(tabSrc); - Tabs.Insert(dstIdx, tabSrc); - container.SelectedItem = tabSrc; - e.Handled = true; - } - - private void OnTabContextMenuOpening(object sender, ContextMenuEventArgs e) { - var tab = (sender as ListBoxItem).DataContext as Tab; - if (tab == null) return; - - var menu = new ContextMenu() { PlacementTarget = sender as UIElement }; - - var close = new MenuItem(); - close.Header = App.Text("PageTabBar.Tab.Close"); - close.Click += (_, __) => { - CloseTab(tab); - }; - - var closeOther = new MenuItem(); - closeOther.Header = App.Text("PageTabBar.Tab.CloseOther"); - closeOther.Click += (_, __) => { - Tabs.ToList().ForEach(t => { if (tab != t) CloseTab(t); }); - }; - - var closeRight = new MenuItem(); - closeRight.Header = App.Text("PageTabBar.Tab.CloseRight"); - closeRight.Click += (_, __) => { - var tabs = Tabs.ToList(); - tabs.RemoveRange(0, tabs.IndexOf(tab) + 1); - tabs.ForEach(t => CloseTab(t)); - }; - - menu.Items.Add(close); - menu.Items.Add(closeOther); - menu.Items.Add(closeRight); - - if (tab.IsRepository) { - var bookmark = new MenuItem(); - bookmark.Header = App.Text("PageTabBar.Tab.Bookmark"); - for (int i = 0; i < Converters.IntToBookmarkBrush.COLORS.Length; i++) { - var mark = new MenuItem(); - mark.Icon = new Bookmark() { Color = i, Width = 14, Height = 14 }; - mark.Header = $"{i}"; - - var refIdx = i; - mark.Click += (o, ev) => { - var repo = Models.Preference.Instance.FindRepository(tab.Id); - if (repo != null) repo.Bookmark = refIdx; - ev.Handled = true; - }; - bookmark.Items.Add(mark); - } - menu.Items.Add(new Separator()); - menu.Items.Add(bookmark); - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("PageTabBar.Tab.CopyPath"); - copyPath.Click += (_, __) => { - Clipboard.SetDataObject(tab.Id); - }; - menu.Items.Add(new Separator()); - menu.Items.Add(copyPath); - } - - menu.IsOpen = true; - e.Handled = true; - } - - private void UpdateSeperators(Tab actived) { - int curIdx = 0; - for (int i = 0; i < Tabs.Count; i++) { - if (Tabs[i] == actived) { - curIdx = i; - actived.IsSeperatorVisible = false; - if (i > 0) Tabs[i - 1].IsSeperatorVisible = false; - } else { - Tabs[i].IsSeperatorVisible = true; - } - } - - if (leftScroller.Visibility == Visibility.Visible || curIdx == 0) { - startSeperator.Visibility = Visibility.Hidden; - } else { - startSeperator.Visibility = Visibility.Visible; - } - } - } -} diff --git a/src/Views/Widgets/RevisionCompare.xaml b/src/Views/Widgets/RevisionCompare.xaml deleted file mode 100644 index 4e66a372..00000000 --- a/src/Views/Widgets/RevisionCompare.xaml +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/RevisionCompare.xaml.cs b/src/Views/Widgets/RevisionCompare.xaml.cs deleted file mode 100644 index 75ce957b..00000000 --- a/src/Views/Widgets/RevisionCompare.xaml.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Windows.Controls; - -namespace SourceGit.Views.Widgets { - /// - /// 展示两个提交之间的变更 - /// - public partial class RevisionCompare : UserControl { - - public RevisionCompare() { - InitializeComponent(); - } - - public void SetData(string repo, Models.Commit start, Models.Commit end) { - avatarStart.Email = start.Committer.Email; - avatarStart.FallbackLabel = start.Committer.Name; - avatarStart.ToolTip = start.Committer.Name; - txtStartSHA.Text = start.ShortSHA; - txtStartTime.Text = start.CommitterTimeStr; - txtStartSubject.Text = start.Subject; - - avatarEnd.Email = end.Committer.Email; - avatarEnd.FallbackLabel = end.Committer.Name; - avatarEnd.ToolTip = end.Committer.Name; - txtEndSHA.Text = end.ShortSHA; - txtEndTime.Text = end.CommitterTimeStr; - txtEndSubject.Text = end.Subject; - - Task.Run(() => { - var changes = new Commands.CommitRangeChanges(repo, start.SHA, end.SHA).Result(); - Dispatcher.Invoke(() => { - changesContainer.SetData(repo, new List() { start, end }, changes); - }); - }); - } - } -} diff --git a/src/Views/Widgets/RevisionFiles.xaml b/src/Views/Widgets/RevisionFiles.xaml deleted file mode 100644 index d7c7ded1..00000000 --- a/src/Views/Widgets/RevisionFiles.xaml +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/RevisionFiles.xaml.cs b/src/Views/Widgets/RevisionFiles.xaml.cs deleted file mode 100644 index d4c407ed..00000000 --- a/src/Views/Widgets/RevisionFiles.xaml.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; - -namespace SourceGit.Views.Widgets { - /// - /// 提交信息面板中的文件列表分页 - /// - public partial class RevisionFiles : UserControl { - private string repo = null; - private string sha = null; - private bool isLFSEnabled = false; - private List cached = new List(); - private string filter = null; - - /// - /// 文件列表树节点 - /// - public class FileNode { - public Models.ObjectType Type { get; set; } = Models.ObjectType.None; - public string Path { get; set; } = ""; - public string SHA { get; set; } = null; - public bool IsExpanded { get; set; } = false; - public bool IsFolder => Type == Models.ObjectType.None; - public List Children { get; set; } = new List(); - } - - public RevisionFiles() { - InitializeComponent(); - } - - public void SetData(string repo, string sha, Commands.Context cancelToken) { - this.repo = repo; - this.sha = sha; - this.isLFSEnabled = new Commands.LFS(repo).IsEnabled(); - - var cmd = new Commands.RevisionObjects(repo, sha) { Ctx = cancelToken }; - Task.Run(() => { - var objects = cmd.Result(); - if (cmd.Ctx.IsCancelRequested) return; - - cached = objects; - ShowVisibles(); - }); - } - - public void Cleanup() { - treeFiles.ItemsSource = new List(); - cached = new List(); - } - - private void ShowVisibles() { - var nodes = new List(); - var folders = new Dictionary(); - var visibles = new List(); - - if (string.IsNullOrEmpty(filter)) { - visibles.AddRange(cached); - } else { - foreach (var obj in cached) { - if (obj.Path.ToUpper().Contains(filter)) visibles.Add(obj); - } - } - - var expanded = visibles.Count <= 50; - - foreach (var obj in visibles) { - var sepIdx = obj.Path.IndexOf('/'); - if (sepIdx == -1) { - nodes.Add(new FileNode() { - Type = obj.Type, - Path = obj.Path, - SHA = obj.SHA, - }); - } else { - FileNode lastFolder = null; - var start = 0; - - while (sepIdx != -1) { - var folder = obj.Path.Substring(0, sepIdx); - if (folders.ContainsKey(folder)) { - lastFolder = folders[folder]; - } else if (lastFolder == null) { - lastFolder = new FileNode() { - Type = Models.ObjectType.None, - Path = folder, - SHA = null, - IsExpanded = expanded, - }; - nodes.Add(lastFolder); - folders.Add(folder, lastFolder); - } else { - var cur = new FileNode() { - Type = Models.ObjectType.None, - Path = folder, - SHA = null, - IsExpanded = expanded, - }; - folders.Add(folder, cur); - lastFolder.Children.Add(cur); - lastFolder = cur; - } - - start = sepIdx + 1; - sepIdx = obj.Path.IndexOf('/', start); - } - - lastFolder.Children.Add(new FileNode() { - Type = obj.Type, - Path = obj.Path, - SHA = obj.SHA, - }); - } - } - - folders.Clear(); - visibles.Clear(); - - SortFileNodes(nodes); - - Dispatcher.Invoke(() => { - treeFiles.ItemsSource = nodes; - GC.Collect(); - }); - } - - private void SortFileNodes(List nodes) { - nodes.Sort((l, r) => { - if (l.IsFolder == r.IsFolder) { - return l.Path.CompareTo(r.Path); - } else { - return l.IsFolder ? -1 : 1; - } - }); - - foreach (var node in nodes) { - if (node.Children.Count > 1) SortFileNodes(node.Children); - } - } - - private bool IsImageFile(string path) { - return path.EndsWith(".png") || - path.EndsWith(".jpg") || - path.EndsWith(".jpeg") || - path.EndsWith(".ico") || - path.EndsWith(".bmp") || - path.EndsWith(".tiff") || - path.EndsWith(".gif"); - } - - #region EVENTS - private void LayoutTextPreview(List lines) { - var maxLineNumber = $"{lines.Count + 1}"; - var formatted = new FormattedText( - maxLineNumber, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - new Typeface(txtPreviewData.FontFamily, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), - 12.0, - Brushes.Black, - VisualTreeHelper.GetDpi(this).PixelsPerDip); - - var offset = formatted.Width + 16; - if (lines.Count * 16 > layerTextPreview.ActualHeight) offset += 8; - - txtPreviewData.ItemsSource = lines; - txtPreviewData.Columns[0].Width = new DataGridLength(formatted.Width + 16, DataGridLengthUnitType.Pixel); - txtPreviewData.Columns[1].Width = DataGridLength.Auto; - txtPreviewData.Columns[1].Width = DataGridLength.SizeToCells; - txtPreviewData.Columns[1].MinWidth = layerTextPreview.ActualWidth - offset; - - txtPreviewSplitter.Margin = new Thickness(formatted.Width + 15, 0, 0, 0); - } - - private void OnTextPreviewSizeChanged(object sender, SizeChangedEventArgs e) { - if (txtPreviewData == null) return; - - var offset = txtPreviewData.NonFrozenColumnsViewportHorizontalOffset; - if (txtPreviewData.Items.Count * 16 > layerTextPreview.ActualHeight) offset += 8; - - txtPreviewData.Columns[1].Width = DataGridLength.Auto; - txtPreviewData.Columns[1].Width = DataGridLength.SizeToCells; - txtPreviewData.Columns[1].MinWidth = layerTextPreview.ActualWidth - offset; - txtPreviewData.UpdateLayout(); - } - - private void OnTextPreviewContextMenuOpening(object sender, ContextMenuEventArgs e) { - var grid = sender as DataGrid; - if (grid == null) return; - - var menu = new ContextMenu() { PlacementTarget = grid }; - - var copyIcon = new System.Windows.Shapes.Path(); - copyIcon.Data = FindResource("Icon.Copy") as Geometry; - copyIcon.Width = 10; - - var copy = new MenuItem(); - copy.Header = "Copy"; - copy.Icon = copyIcon; - copy.Click += (o, ev) => { - var items = grid.SelectedItems; - if (items.Count == 0) return; - - var builder = new StringBuilder(); - foreach (var item in items) { - var line = item as Models.TextLine; - if (line == null) continue; - - builder.Append(line.Data); - builder.AppendLine(); - } - - Clipboard.SetDataObject(builder.ToString(), true); - }; - menu.Items.Add(copy); - menu.IsOpen = true; - e.Handled = true; - } - - private void OnFilesSelectionChanged(object sender, RoutedEventArgs e) { - layerTextPreview.Visibility = Visibility.Collapsed; - layerImagePreview.Visibility = Visibility.Collapsed; - layerRevisionPreview.Visibility = Visibility.Collapsed; - layerBinaryPreview.Visibility = Visibility.Collapsed; - txtPreviewData.ItemsSource = null; - - if (treeFiles.Selected.Count == 0) return; - - var node = treeFiles.Selected[0] as FileNode; - switch (node.Type) { - case Models.ObjectType.Blob: - if (IsImageFile(node.Path)) { - var tmp = Path.GetTempFileName(); - new Commands.SaveRevisionFile(repo, node.Path, sha, tmp).Exec(); - - layerImagePreview.Visibility = Visibility.Visible; - imgPreviewData.Source = new BitmapImage(new Uri(tmp, UriKind.Absolute)); - } else if (isLFSEnabled && new Commands.LFS(repo).IsFiltered(node.Path)) { - var lfs = new Commands.QueryLFSObject(repo, sha, node.Path).Result(); - layerRevisionPreview.Visibility = Visibility.Visible; - iconRevisionPreview.Data = FindResource("Icon.LFS") as Geometry; - txtRevisionPreview.Text = "LFS SIZE: " + App.Text("Bytes", lfs.Size); - } else if (new Commands.IsBinaryFile(repo, sha, node.Path).Result()) { - layerBinaryPreview.Visibility = Visibility.Visible; - } else { - layerTextPreview.Visibility = Visibility.Visible; - Task.Run(() => { - var lines = new Commands.QueryFileContent(repo, sha, node.Path).Result(); - Dispatcher.Invoke(() => LayoutTextPreview(lines)); - }); - } - break; - case Models.ObjectType.Tag: - layerRevisionPreview.Visibility = Visibility.Visible; - iconRevisionPreview.Data = FindResource("Icon.Tag") as Geometry; - txtRevisionPreview.Text = "TAG: " + node.SHA; - break; - case Models.ObjectType.Commit: - layerRevisionPreview.Visibility = Visibility.Visible; - iconRevisionPreview.Data = FindResource("Icon.Submodule") as Geometry; - txtRevisionPreview.Text = "SUBMODULE: " + node.SHA; - break; - case Models.ObjectType.Tree: - layerRevisionPreview.Visibility = Visibility.Visible; - iconRevisionPreview.Data = FindResource("Icon.Tree") as Geometry; - txtRevisionPreview.Text = "TREE: " + node.SHA; - break; - default: - return; - } - } - - private void OnFilesContextMenuOpening(object sender, ContextMenuEventArgs e) { - var item = sender as Controls.TreeItem; - if (item == null) return; - - var node = item.DataContext as FileNode; - if (node == null || node.IsFolder) return; - - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Click += (o, ev) => { - var viewer = new FileHistories(repo, node.Path); - viewer.Show(); - ev.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Click += (obj, ev) => { - var viewer = new Blame(repo, node.Path, sha); - viewer.Show(); - ev.Handled = true; - }; - - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Click += (o, ev) => { - var full = Path.GetFullPath(repo + "\\" + node.Path); - Process.Start("explorer", $"/select,{full}"); - ev.Handled = true; - }; - - var saveAs = new MenuItem(); - saveAs.Header = App.Text("SaveAs"); - saveAs.IsEnabled = node.Type == Models.ObjectType.Blob; - saveAs.Click += (obj, ev) => { - var dialog = new System.Windows.Forms.FolderBrowserDialog(); - dialog.ShowNewFolderButton = true; - if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) { - var full = Path.Combine(dialog.SelectedPath, Path.GetFileName(node.Path)); - new Commands.SaveRevisionFile(repo, node.Path, sha, full).Exec(); - } - ev.Handled = true; - }; - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Click += (obj, ev) => { - Clipboard.SetDataObject(node.Path, true); - ev.Handled = true; - }; - - var menu = new ContextMenu() { PlacementTarget = item }; - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(explore); - menu.Items.Add(saveAs); - menu.Items.Add(copyPath); - - menu.IsOpen = true; - e.Handled = true; - } - - private void OnRequestBringIntoView(object sender, RequestBringIntoViewEventArgs e) { - e.Handled = true; - } - - private void OnSearchFilterChanged(object sender, TextChangedEventArgs e) { - var edit = sender as Controls.TextEdit; - filter = edit.Text.ToUpper(); - Task.Run(() => ShowVisibles()); - e.Handled = true; - } - #endregion - } -} diff --git a/src/Views/Widgets/Stashes.xaml b/src/Views/Widgets/Stashes.xaml deleted file mode 100644 index 6faf5a6c..00000000 --- a/src/Views/Widgets/Stashes.xaml +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/Stashes.xaml.cs b/src/Views/Widgets/Stashes.xaml.cs deleted file mode 100644 index 19abe242..00000000 --- a/src/Views/Widgets/Stashes.xaml.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; - -namespace SourceGit.Views.Widgets { - - /// - /// 贮藏管理 - /// - public partial class Stashes : UserControl { - private string repo = null; - private string selected = null; - private bool isLFSEnabled = false; - - public Stashes(string repo) { - this.repo = repo; - this.isLFSEnabled = new Commands.LFS(repo).IsEnabled(); - InitializeComponent(); - } - - public void SetData(List data) { - stashList.ItemsSource = data; - changeList.ItemsSource = null; - } - - private void ClearAll(object sender, RoutedEventArgs e) { - var confirmDialog = new ConfirmDialog( - App.Text("Apply.Warn"), - App.Text("ConfirmClearStashes"), - async () => { - waiting.Visibility = Visibility.Visible; - waiting.IsAnimating = true; - Models.Watcher.SetEnabled(repo, false); - await Task.Run(() => { - new Commands.Command() { - Cwd = repo, - Args = "stash clear", - }.Exec(); - }); - Models.Watcher.SetEnabled(repo, true); - waiting.Visibility = Visibility.Collapsed; - waiting.IsAnimating = false; - }); - confirmDialog.ShowDialog(); - e.Handled = true; - } - - private async void OnStashSelectionChanged(object sender, SelectionChangedEventArgs e) { - changeList.ItemsSource = null; - selected = null; - - var stash = stashList.SelectedItem as Models.Stash; - if (stash == null) return; - - selected = stash.SHA; - diffViewer.Reset(); - - var changes = await Task.Run(() => new Commands.StashChanges(repo, selected).Result()); - changeList.ItemsSource = changes; - } - - private void OnChangeSelectionChanged(object sender, SelectionChangedEventArgs e) { - var change = changeList.SelectedItem as Models.Change; - if (change == null) return; - - diffViewer.Diff(repo, new DiffViewer.Option() { - RevisionRange = new string[] { selected + "^", selected }, - Path = change.Path, - OrgPath = change.OriginalPath, - UseLFS = isLFSEnabled, - }); - } - - private void OnStashContextMenuOpening(object sender, ContextMenuEventArgs ev) { - var stash = (sender as Border).DataContext as Models.Stash; - if (stash == null) return; - - var apply = new MenuItem(); - apply.Header = App.Text("StashCM.Apply"); - apply.Click += (o, e) => Start(() => new Commands.Stash(repo).Apply(stash.Name)); - - var pop = new MenuItem(); - pop.Header = App.Text("StashCM.Pop"); - pop.Click += (o, e) => Start(() => new Commands.Stash(repo).Pop(stash.Name)); - - var delete = new MenuItem(); - delete.Header = App.Text("StashCM.Drop"); - delete.Click += (o, e) => new Popups.StashDropConfirm(repo, stash.Name, stash.Message).Show(); - - var menu = new ContextMenu() { PlacementTarget = sender as UIElement }; - menu.Items.Add(apply); - menu.Items.Add(pop); - menu.Items.Add(delete); - menu.IsOpen = true; - ev.Handled = true; - } - - private async void Start(Func job) { - waiting.Visibility = Visibility.Visible; - waiting.IsAnimating = true; - Models.Watcher.SetEnabled(repo, false); - await Task.Run(job); - Models.Watcher.SetEnabled(repo, true); - waiting.Visibility = Visibility.Collapsed; - waiting.IsAnimating = false; - } - } -} diff --git a/src/Views/Widgets/StatisticsPage.xaml b/src/Views/Widgets/StatisticsPage.xaml deleted file mode 100644 index 23559ae9..00000000 --- a/src/Views/Widgets/StatisticsPage.xaml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/StatisticsPage.xaml.cs b/src/Views/Widgets/StatisticsPage.xaml.cs deleted file mode 100644 index 5790a4f6..00000000 --- a/src/Views/Widgets/StatisticsPage.xaml.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using System.Windows.Controls; - -namespace SourceGit.Views.Widgets { - /// - /// 统计内容 - /// - public partial class StatisticsPage : UserControl { - - public StatisticsPage() { - InitializeComponent(); - } - - public void SetData(List committers, List commits, int totalCommits) { - Dispatcher.Invoke(() => { - txtMemberCount.Text = App.Text("Statistics.TotalCommitterCount", committers.Count); - txtCommitCount.Text = App.Text("Statistics.TotalCommitsCount", totalCommits); - - lstCommitters.ItemsSource = committers; - chartCommits.SetData(commits); - }); - } - } -} diff --git a/src/Views/Widgets/Welcome.xaml b/src/Views/Widgets/Welcome.xaml deleted file mode 100644 index 9c0b3949..00000000 --- a/src/Views/Widgets/Welcome.xaml +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Views/Widgets/Welcome.xaml.cs b/src/Views/Widgets/Welcome.xaml.cs deleted file mode 100644 index fcc3de42..00000000 --- a/src/Views/Widgets/Welcome.xaml.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Input; -using System.Windows.Media; - -namespace SourceGit.Views.Widgets { - - /// - /// 新标签页 - /// - public partial class Welcome : UserControl { - - public Welcome() { - InitializeComponent(); - UpdateVisibles(); - - Models.Theme.AddListener(this, UpdateVisibles); - Models.Watcher.BookmarkChanged += (_, __) => { UpdateVisibles(); }; - } - - #region FUNC_EVENTS - private void OnOpenClicked(object sender, RoutedEventArgs e) { - var dialog = new System.Windows.Forms.FolderBrowserDialog(); - dialog.ShowNewFolderButton = true; - if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) CheckAndOpen(dialog.SelectedPath); - } - - private void OnOpenTerminalClicked(object sender, RoutedEventArgs e) { - if (MakeSureReady()) { - var bash = Path.Combine(Models.Preference.Instance.Git.Path, "..", "bash.exe"); - if (!File.Exists(bash)) { - Models.Exception.Raise(App.Text("MissingBash")); - return; - } - - Process.Start(new ProcessStartInfo { - FileName = bash, - UseShellExecute = true, - }); - - e.Handled = true; - } - } - - private void OnCloneClicked(object sender, RoutedEventArgs e) { - if (MakeSureReady()) { - var dialog = new Clone(); - dialog.Owner = App.Current.MainWindow; - dialog.ShowDialog(); - } - } - - private void FillSortMenu(ContextMenu menu, Models.Preference.SortMethod desired, string label) { - var item = new MenuItem(); - item.Header = App.Text(label); - item.Click += (s, ev) => { - Models.Preference.Instance.General.SortBy = desired; - UpdateVisibles(); - }; - - if (Models.Preference.Instance.General.SortBy == desired) { - var icon = new System.Windows.Shapes.Path(); - icon.Data = FindResource("Icon.Check") as Geometry; - icon.Fill = FindResource("Brush.FG1") as Brush; - icon.Width = 12; - item.Icon = icon; - } - - menu.Items.Add(item); - } - - private void OnSortMethodClicked(object sender, RoutedEventArgs e) { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.Bottom; - menu.PlacementTarget = sender as Button; - menu.StaysOpen = false; - menu.Focusable = true; - - FillSortMenu(menu, Models.Preference.SortMethod.ByName, "Sort.Name"); - FillSortMenu(menu, Models.Preference.SortMethod.ByRecentlyOpened, "Sort.RecentlyOpened"); - FillSortMenu(menu, Models.Preference.SortMethod.ByBookmark, "Sort.Bookmark"); - - menu.IsOpen = true; - e.Handled = true; - } - - private void OnRemoveRepository(object sender, RoutedEventArgs e) { - var repo = (sender as Control).DataContext as Models.Repository; - if (repo == null) return; - - var confirmDialog = new ConfirmDialog( - App.Text("Apply.Warn"), - App.Text("ConfirmRemoveRepo", repo.Path), - () => { - Models.Preference.Instance.RemoveRepository(repo.Path); - UpdateVisibles(); - }); - confirmDialog.ShowDialog(); - e.Handled = true; - } - - private void OnDoubleClickRepository(object sender, MouseButtonEventArgs e) { - OnOpenRepository(sender, e); - } - - private void OnRepositoryContextMenuOpening(object sender, ContextMenuEventArgs e) { - var control = sender as Control; - if (control == null) return; - - var repo = control.DataContext as Models.Repository; - if (repo == null) return; - - var menu = new ContextMenu(); - menu.Placement = PlacementMode.MousePoint; - menu.PlacementTarget = control; - menu.StaysOpen = false; - menu.Focusable = true; - - var open = new MenuItem(); - open.Header = App.Text("RepoCM.Open"); - open.Click += OnOpenRepository; - menu.Items.Add(open); - menu.Items.Add(new Separator()); - - var bookmark = new MenuItem(); - bookmark.Header = App.Text("PageTabBar.Tab.Bookmark"); - for (int i = 0; i < Converters.IntToBookmarkBrush.COLORS.Length; i++) { - var mark = new MenuItem(); - mark.Icon = new Bookmark() { Color = i, Width = 14, Height = 14 }; - mark.Header = $"{i}"; - - var refIdx = i; - mark.Click += (o, ev) => { - repo.Bookmark = refIdx; - ev.Handled = true; - }; - bookmark.Items.Add(mark); - } - menu.Items.Add(bookmark); - menu.Items.Add(new Separator()); - - var remove = new MenuItem(); - remove.Header = App.Text("Welcome.Delete"); - remove.Click += OnRemoveRepository; - menu.Items.Add(remove); - - menu.IsOpen = true; - e.Handled = true; - } - - private void OnOpenRepository(object sender, RoutedEventArgs e) { - var repo = (sender as Control).DataContext as Models.Repository; - if (repo == null) return; - - CheckAndOpen(repo.Path); - e.Handled = true; - } - - private void OnExploreRepository(object sender, RoutedEventArgs e) { - var repo = (sender as Control).DataContext as Models.Repository; - if (repo == null) return; - - Process.Start("explorer", repo.Path); - e.Handled = true; - } - - private void OnOpenRepositoryTerminal(object sender, RoutedEventArgs e) { - var repo = (sender as Control).DataContext as Models.Repository; - if (repo == null) return; - - var bash = Path.Combine(Models.Preference.Instance.Git.Path, "..", "bash.exe"); - if (!File.Exists(bash)) { - Models.Exception.Raise(App.Text("MissingBash")); - return; - } - - Process.Start(new ProcessStartInfo { - WorkingDirectory = repo.Path, - FileName = bash, - UseShellExecute = true, - }); - } - - private void OnSearchFilterChanged(object sender, TextChangedEventArgs e) { - UpdateVisibles(); - } - - private void OnPageDrop(object sender, DragEventArgs e) { - bool rebuild = false; - - if (e.Data.GetDataPresent(DataFormats.FileDrop)) { - if (!MakeSureReady()) return; - - var paths = e.Data.GetData(DataFormats.FileDrop) as string[]; - foreach (var path in paths) { - var dir = new Commands.QueryGitDir(path).Result(); - if (dir != null) { - var root = new Commands.GetRepositoryRootPath(path).Result(); - Models.Preference.Instance.AddRepository(root, dir); - rebuild = true; - } - } - } - - if (rebuild) UpdateVisibles(); - } - #endregion - - #region DATA - public void UpdateVisibles() { - var visibles = new List(); - var curFilter = filter.Text.ToLower(); - - if (string.IsNullOrEmpty(curFilter)) { - visibles.AddRange(Models.Preference.Instance.Repositories); - } else { - foreach (var repo in Models.Preference.Instance.Repositories) { - if (repo.Name.ToLower().IndexOf(curFilter, StringComparison.Ordinal) >= 0 || - repo.Path.ToLower().IndexOf(curFilter, StringComparison.Ordinal) >= 0) { - visibles.Add(repo); - } - } - } - - switch (Models.Preference.Instance.General.SortBy) { - case Models.Preference.SortMethod.ByName: - visibles.Sort((l, r) => l.Name.CompareTo(r.Name)); - break; - case Models.Preference.SortMethod.ByRecentlyOpened: - visibles.Sort((l, r) => r.LastOpenTime.CompareTo(l.LastOpenTime)); - break; - default: - visibles.Sort((l, r) => r.Bookmark - l.Bookmark); - break; - } - - mask.Visibility = visibles.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - repoList.ItemsSource = visibles; - } - - private bool MakeSureReady() { - if (!Models.Preference.Instance.IsReady) { - Models.Exception.Raise(App.Text("NotConfigured")); - return false; - } - return true; - } - - private void CheckAndOpen(string path) { - if (!MakeSureReady()) return; - - if (!Directory.Exists(path)) { - Models.Exception.Raise(App.Text("PathNotFound", path)); - return; - } - - var root = new Commands.GetRepositoryRootPath(path).Result(); - if (root == null) { - new Popups.Init(path).Show(); - return; - } - - var gitDir = new Commands.QueryGitDir(root).Result(); - var repo = Models.Preference.Instance.AddRepository(root, gitDir); - Models.Watcher.Open(repo); - UpdateVisibles(); - } - #endregion - } -} diff --git a/src/Views/Widgets/WorkingCopy.xaml b/src/Views/Widgets/WorkingCopy.xaml deleted file mode 100644 index f95b6fb0..00000000 --- a/src/Views/Widgets/WorkingCopy.xaml +++ /dev/null @@ -1,267 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/WorkingCopy.axaml.cs b/src/Views/WorkingCopy.axaml.cs new file mode 100644 index 00000000..ca2ebbb7 --- /dev/null +++ b/src/Views/WorkingCopy.axaml.cs @@ -0,0 +1,126 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class WorkingCopy : UserControl + { + public WorkingCopy() + { + InitializeComponent(); + } + + private void OnMainLayoutSizeChanged(object sender, SizeChangedEventArgs e) + { + var grid = sender as Grid; + if (grid == null) + return; + + var layout = ViewModels.Preferences.Instance.Layout; + var width = grid.Bounds.Width; + var maxLeft = width - 304; + + if (layout.WorkingCopyLeftWidth.Value - maxLeft > 1.0) + layout.WorkingCopyLeftWidth = new GridLength(maxLeft, GridUnitType.Pixel); + } + + private void OnUnstagedContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm && sender is Control control) + { + var menu = vm.CreateContextMenuForUnstagedChanges(); + menu?.Open(control); + e.Handled = true; + } + } + + private void OnStagedContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm && sender is Control control) + { + var menu = vm.CreateContextMenuForStagedChanges(); + menu?.Open(control); + e.Handled = true; + } + } + + private void OnUnstagedChangeDoubleTapped(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm) + { + var next = UnstagedChangesView.GetNextChangeWithoutSelection(); + vm.StageSelected(next); + UnstagedChangesView.TakeFocus(); + e.Handled = true; + } + } + + private void OnStagedChangeDoubleTapped(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm) + { + var next = StagedChangesView.GetNextChangeWithoutSelection(); + vm.UnstageSelected(next); + StagedChangesView.TakeFocus(); + e.Handled = true; + } + } + + private void OnUnstagedKeyDown(object _, KeyEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm) + { + if (e.Key is Key.Space or Key.Enter) + { + var next = UnstagedChangesView.GetNextChangeWithoutSelection(); + vm.StageSelected(next); + UnstagedChangesView.TakeFocus(); + e.Handled = true; + return; + } + + if (e.Key is Key.Delete or Key.Back && vm.SelectedUnstaged is { Count: > 0 } selected) + { + vm.Discard(selected); + e.Handled = true; + } + } + } + + private void OnStagedKeyDown(object _, KeyEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm && e.Key is Key.Space or Key.Enter) + { + var next = StagedChangesView.GetNextChangeWithoutSelection(); + vm.UnstageSelected(next); + StagedChangesView.TakeFocus(); + e.Handled = true; + } + } + + private void OnStageSelectedButtonClicked(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm) + { + var next = UnstagedChangesView.GetNextChangeWithoutSelection(); + vm.StageSelected(next); + UnstagedChangesView.TakeFocus(); + } + + e.Handled = true; + } + + private void OnUnstageSelectedButtonClicked(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm) + { + var next = StagedChangesView.GetNextChangeWithoutSelection(); + vm.UnstageSelected(next); + StagedChangesView.TakeFocus(); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/WorkspaceSwitcher.axaml b/src/Views/WorkspaceSwitcher.axaml new file mode 100644 index 00000000..4b2691de --- /dev/null +++ b/src/Views/WorkspaceSwitcher.axaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/WorkspaceSwitcher.axaml.cs b/src/Views/WorkspaceSwitcher.axaml.cs new file mode 100644 index 00000000..87743d9f --- /dev/null +++ b/src/Views/WorkspaceSwitcher.axaml.cs @@ -0,0 +1,48 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class WorkspaceSwitcher : UserControl + { + public WorkspaceSwitcher() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (e.Key == Key.Enter && DataContext is ViewModels.WorkspaceSwitcher switcher) + { + switcher.Switch(); + e.Handled = true; + } + } + + private void OnItemDoubleTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.WorkspaceSwitcher switcher) + { + switcher.Switch(); + e.Handled = true; + } + } + + private void OnSearchBoxKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Down && WorkspaceListBox.ItemCount > 0) + { + WorkspaceListBox.Focus(NavigationMethod.Directional); + + if (WorkspaceListBox.SelectedIndex < 0) + WorkspaceListBox.SelectedIndex = 0; + else if (WorkspaceListBox.SelectedIndex < WorkspaceListBox.ItemCount) + WorkspaceListBox.SelectedIndex++; + + e.Handled = true; + } + } + } +}