diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..89d2090c3 --- /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 +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 000000000..bd1dfea9f --- /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 000000000..b3dd9d5ed --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,81 @@ +name: Build +on: + workflow_call: +jobs: + build: + strategy: + matrix: + include: + - name: Windows x64 + os: windows-2022 + runtime: win-x64 + - name: Windows ARM64 + os: windows-2022 + runtime: win-arm64 + - name: macOS (Intel) + os: macos-15-intel + 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@v6 + with: + submodules: true + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.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@v7 + with: + name: sourcegit.${{ matrix.runtime }} + path: publish/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..3204df528 --- /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@v6 + - 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/format-check.yml b/.github/workflows/format-check.yml new file mode 100644 index 000000000..0640d19e9 --- /dev/null +++ b/.github/workflows/format-check.yml @@ -0,0 +1,26 @@ +name: Format Check +on: + push: + branches: [develop] + pull_request: + branches: [develop] + workflow_dispatch: + workflow_call: + +jobs: + format-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: true + + - name: Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Run formatting check + run: dotnet format --verify-no-changes src/SourceGit.csproj diff --git a/.github/workflows/homebrew-notify.yml b/.github/workflows/homebrew-notify.yml new file mode 100644 index 000000000..d0538b37a --- /dev/null +++ b/.github/workflows/homebrew-notify.yml @@ -0,0 +1,22 @@ +name: Notify Homebrew Tap + +on: + release: + types: [published] + +jobs: + notify-homebrew: + runs-on: ubuntu-latest + steps: + - name: Notify Homebrew tap + env: + TAG: ${{ github.event.release.tag_name }} + HOMEBREW_TAP_REPO_TOKEN: ${{ secrets.HOMEBREW_TAP_REPO_TOKEN }} + run: | + echo "📢 Notifying Homebrew tap of new release $TAG..." + curl -X POST \ + -H "Authorization: token $HOMEBREW_TAP_REPO_TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/ybeapps/homebrew-sourcegit/dispatches \ + -d "{\"event_type\":\"new-sourcegit-release\",\"client_payload\":{\"version\":\"$TAG\"}}" + echo "✅ Homebrew tap notified successfully" diff --git a/.github/workflows/localization-check.yml b/.github/workflows/localization-check.yml new file mode 100644 index 000000000..76d7be77f --- /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@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.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 000000000..0845774fb --- /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-2022 + strategy: + matrix: + runtime: [win-x64, win-arm64] + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - name: Download build + uses: actions/download-artifact@v8 + with: + name: sourcegit.${{ matrix.runtime }} + path: build/SourceGit + - name: Package + shell: pwsh + env: + VERSION: ${{ inputs.version }} + RUNTIME: ${{ matrix.runtime }} + run: ./build/scripts/package.win.ps1 + - name: Upload package artifact + uses: actions/upload-artifact@v7 + with: + name: package.${{ matrix.runtime }} + path: build/sourcegit_*.zip + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v6 + 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@v6 + - name: Download build + uses: actions/download-artifact@v8 + 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@v7 + with: + name: package.${{ matrix.runtime }} + path: build/sourcegit_*.zip + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v6 + 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@v6 + - 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@v8 + 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@v7 + with: + name: package.${{ matrix.runtime }} + path: | + build/sourcegit-*.AppImage + build/sourcegit_*.deb + build/sourcegit-*.rpm + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v6 + with: + name: sourcegit.${{ matrix.runtime }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..816870a02 --- /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@v6 + - 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@v8 + 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 d9b7b7812..5ca0482f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,47 @@ -.idea -.vs -.vscode -bin -build -obj +.vs/ +.vscode/ +.idea/ + +*.sln.docstates *.user +*.suo +*.code-workspace +*.tss + .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 + + +# Flatpak +!build/resources/flatpak + +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 +*.lscache diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..1ef4a5fcd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "depends/AvaloniaEdit"] + path = depends/AvaloniaEdit + url = https://github.com/love-linger/AvaloniaEdit.git diff --git a/.issuetracker b/.issuetracker new file mode 100644 index 000000000..fd1aa1694 --- /dev/null +++ b/.issuetracker @@ -0,0 +1,3 @@ +[issuetracker "Github ISSUE"] + regex = "#(\\d+)" + url = https://github.com/sourcegit-scm/sourcegit/issues/$1 diff --git a/LICENSE b/LICENSE index dceab2d83..4d00388b9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2024 sourcegit +Copyright (c) 2026 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 7ca23b6a4..38a7e99fa 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,301 @@ -# SourceGit - -Opensouce Git GUI client. - -## High-lights - -* Supports Windows/macOS -* Opensource/Free -* 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 - * Archive - * Diff - * Save as patch/apply - * File histories - * Blame - * Revision Diffs -* GitFlow support - -## How to use - -**To use this tool, you need to install Git first.** - -Download pre-build binaries:[Releases](https://github.com/sourcegit-scm/sourcegit/releases) - -For **macOS** users: - -* Download `SourceGit.macOS.zip`. -* Choose the app that fits your system's CPU architecture and copy it to Applications. `x64` for Intel and `arm64` for Apple Silicon. -* Make sure your mac trusts all software from anywhere. For more information, search `spctl --master-disable`. -* You may need to run `sudo xattr -cr /Applications/SourceGit.app` to make sure the software works. - -## 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) + +## 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. + +## Highlights + +* Supports Windows/macOS/Linux +* Opensource/Free +* Fast +* Deutsch/English/Español/Bahasa Indonesia/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 +* Create PR on GitHub/Gitlab/Gitea/Gitee/Bitbucket... +* Using AI to generate commit message +* Built-in conventional commit message helper. + +> [!WARNING] +> **Linux** only tested on **Debian 12** on both **X11** & **Wayland**. + +## 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, which is platform-dependent, to store user settings, downloaded avatars and crash logs. + +| OS | PATH | +|---------|-------------------------------------------| +| Windows | `%APPDATA%\SourceGit` | +| Linux | `~/.sourcegit` | +| macOS | `~/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 with Windows packages and Linux AppImages. + +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 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) + +> [!NOTE] +> `git-flow` is no longer shipped with **Git for Windows** since `2.51.1`. You can use it by following these steps: +> * Download [git-flow-next](https://github.com/gittower/git-flow-next/releases) +> * Unzip & Rename the `git-flow-next` to `git-flow` +> * Copy to `$GIT_INSTALL_DIR/cmd` or just add its path to you `PATH` directly + +For **macOS** users: + +* Thanks [@ybeapps](https://github.com/ybeapps) for making `SourceGit` available on `Homebrew`: + ```shell + brew install --cask 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 + ``` +> [!NOTE] +> macOS packages in the `Release` page of this project are all unsigned. If you are worried about potential security issues with the above command, you can download the signed package from the [distribution repository](https://github.com/ybeapps/homebrew-sourcegit/releases) provided by [@ybeapps](https://github.com/ybeapps) (there is no need to execute the above command while installing `SourceGit`). + +* 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 + sudo mkdir -p /etc/apt/keyrings + 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. + +* Thanks [@gadfly3173](https://github.com/gadfly3173) for providing `deb` repository, hosted on https://deb-repo.gadfly.vip + + ```shell + # Import GPG key + curl -fsSL https://deb-repo.gadfly.vip/public.key | sudo gpg --dearmor -o /usr/share/keyrings/deb-repo.gpg + + # Add repository (DEB822 format, recommended for Bookworm+ / 22.04+) + echo "Types: deb + URIs: https://deb-repo.gadfly.vip + Suites: stable + Components: main + Architectures: $(dpkg --print-architecture) + Signed-By: /usr/share/keyrings/deb-repo.gpg" | sudo tee /etc/apt/sources.list.d/deb-repo.sources + + # Or use one‑line format for older releases: + # echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/deb-repo.gpg] https://deb-repo.gadfly.vip stable main" | sudo tee / etc/apt/sources.list.d/deb-repo.list + + # Update and install + sudo apt update + sudo apt install sourcegit + ``` + +* `AOSC OS`: In addition to AMD64 and ARM64, support for LoongArch and RISC-V has been added. + ```shell + sudo oma install sourcegit + ``` + +> [!NOTE] +> RISC-V support is untested. + +* `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) or [git-credential-libsecret](https://pkgs.org/search/?q=git-credential-libsecret) 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`. + +## Commandline arguments + +Users can also launcher `SourceGit` from commandline. Usage: + +``` + // Open repository in existing `SourceGit` instance or a new one + --history // Launch `SourceGit` to see the history of a file or dir + --blame // Launch `SourceGit` to blame a file (HEAD version only) +``` + +## 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 | +| Cursor | YES | YES | YES | +| Sublime Text | YES | YES | YES | +| Zed | YES | 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. +> User can also exclude some editors by using `external_editors.json`. + +The format of `external_editors.json`: +```json +{ + "tools": { + "Visual Studio Code": "D:\\VSCode\\Code.exe" + }, + "excludes": [ + "Visual Studio Community 2019" + ] +} +``` + +> [!NOTE] +> This app also supports a lot of `JetBrains` IDEs, installing `JetBrains Toolbox` will help this app to find them. + +## Conventional Commit Helper + +You can define your own conventional commit types (per-repository) by following steps: + +1. Create a json file with your own conventional commit type definitions. For example: +```json +[ + { + "Name": "New Feature", + "Type": "Feature", + "Description": "Adding a new feature", + "PrefillShortDesc": "this is a test" + }, + { + "Name": "Bug Fixes", + "Type": "Fix", + "Description": "Fixing a bug" + } +] +``` +2. Configure the `Conventional Commit Types` in repository configuration window. + +## 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`. + +This project has a submodule in `depends/AvaloniaEdit` which is a custom fork of [Official AvaloniaEdit](https://github.com/AvaloniaUI/AvaloniaEdit). +Please make sure it is initialized - enable `--recurse-submodules` option while cloning or run `git submodule update --init` after cloned. + +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) + +## Translation Status + +You can find the current translation status in [TRANSLATION.md](https://github.com/sourcegit-scm/sourcegit/blob/develop/TRANSLATION.md) + +### Translate Utility Script + +A script that assists with translations by reading the target language, comparing it with the base language, and going through missing keys one by one, so the translator can provide the translations interactively without needing to check each key manually. + +#### Usage + +Check for a given language (e.g., `pt_BR`) and optionally check for missing translations: + +```bash +python translate_helper.py pt_BR [--check] +``` + +- `pt_BR` is the target language code (change as needed), it should correspond to a file named `pt_BR.axaml` in the `src/Resources/Locales/` directory, so you can replace it with any other language code you want to translate, e.g., `de_DE`, `es_ES`, etc. +- `--check` is an optional flag used to only check for missing keys without prompting for translations, useful for getting a list of missing translations. + +The script will read the base language file (`en_US.axaml`) and the target language file (e.g., `pt_BR.axaml`), identify missing keys, and prompt you to provide translations for those keys. If the `--check` flag is used, it will only list the missing keys without prompting for translations. + +## Third-Party Components + +For detailed license information, see [THIRD-PARTY-LICENSES.md](THIRD-PARTY-LICENSES.md). diff --git a/SourceGit.slnx b/SourceGit.slnx new file mode 100644 index 000000000..9d6f8ab09 --- /dev/null +++ b/SourceGit.slnx @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md new file mode 100644 index 000000000..e2600fb4f --- /dev/null +++ b/THIRD-PARTY-LICENSES.md @@ -0,0 +1,114 @@ +# Third-Party Licenses + +The project uses the following third-party libraries or assets + +## Packages + +### AvaloniaUI + +- **Source**: https://github.com/AvaloniaUI/Avalonia +- **Version**: 12.0.5 +- **License**: MIT License +- **License Link**: https://github.com/AvaloniaUI/Avalonia/blob/master/licence.md + +### Avalonia.Controls.DataGrid + +- **Source**: https://github.com/AvaloniaUI/Avalonia.Controls.DataGrid +- **Version**: 12.0.1 +- **License**: MIT License +- **License Link**: https://github.com/AvaloniaUI/Avalonia.Controls.DataGrid/blob/master/licence.md + +### AvaloniaEdit + +- **Official Source**: https://github.com/AvaloniaUI/AvaloniaEdit +- **Fork (Modified)**: https://github.com/love-linger/AvaloniaEdit +- **Version**: Based on 12.0.x +- **License**: MIT License +- **License Link**: https://github.com/AvaloniaUI/AvaloniaEdit/blob/master/LICENSE + +### TextMateSharp + +- **Source**: https://github.com/danipen/TextMateSharp +- **Version**: 2.0.2 +- **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.10.0 +- **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.9.0-beta.1 +- **License**: MIT License +- **License Link**: https://github.com/Azure/azure-sdk-for-net/blob/main/LICENSE.txt + +### BitMiracle.LibTiff.NET + +- **Source**: https://github.com/BitMiracle/libtiff.net +- **Version**: 2.4.660 +- **License**: New BSD License +- **License Link**: https://github.com/BitMiracle/libtiff.net/blob/master/license.txt + +### Pfim + +- **Source**: https://github.com/nickbabcock/Pfim +- **Version**: 0.11.4 +- **License**: MIT License +- **License Link**: https://github.com/nickbabcock/Pfim/blob/master/LICENSE.txt + +### StbImageSharp + +- **Source**: https://github.com/StbSharp/StbImageSharp +- **Version**: 2.30.15 +- **License**: Public Domain + +## 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 + +### vuejs-language-tools + +- **Source**: https://github.com/vuejs/language-tools +- **Commit**: 68d98dc57f8486c2946ae28dc86bf8e91d45da4d +- **License**: MIT License +- **License Link**: https://github.com/vuejs/language-tools/blob/68d98dc57f8486c2946ae28dc86bf8e91d45da4d/LICENSE diff --git a/TRANSLATION.md b/TRANSLATION.md new file mode 100644 index 000000000..47960c547 --- /dev/null +++ b/TRANSLATION.md @@ -0,0 +1,1561 @@ +# 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-89.52%25-yellow) + +
+Missing keys in de_DE.axaml + +- Text.AIAssistant.Use +- Text.App.HideOthers +- Text.Apply.3Way +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.BranchCM.CompareWithSpecial +- Text.CheckoutBranchFromStash +- Text.CheckoutBranchFromStash.Branch +- Text.CheckoutBranchFromStash.Stash +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Clone.Bookmark +- Text.Clone.Group +- Text.CommandPalette.Branches +- Text.CommandPalette.BranchesAndTags +- Text.CommandPalette.RepositoryActions +- Text.CommandPalette.RevisionFiles +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitterTime +- Text.CommitDetail.CollapseToBottom +- Text.CommitMessageTextBox.Column +- Text.Compare.Changes +- Text.Compare.Commits +- Text.Compare.Commits.LeftOnly +- Text.Compare.Commits.RightOnly +- Text.Compare.Commits.Tips +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.ConfigureCustomActionControls.StringFormatter +- Text.ConfigureCustomActionControls.StringFormatter.Tip +- Text.ConfigureCustomActionControls.UseFriendlyName +- Text.ConfirmEmptyCommit.StageSelectedThenCommit +- Text.CopyAsPatch +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.Diff.Submodule.UncommittedChanges +- Text.Discard.IncludeModified +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.GotoRevisionSelector +- Text.Histories.HighlightsInGraph +- Text.Histories.HighlightsInGraph.All +- Text.Histories.HighlightsInGraph.CurrentBranchOnly +- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits +- Text.Histories.HighlightsInGraph.SelectedCommitsOnly +- Text.HistoriesDetailsStandalone +- Text.HistoriesDetailsStandalone.CommitDetail +- Text.HistoriesDetailsStandalone.RevisionCompare +- Text.Hotkeys.Global.OpenLocalRepository +- Text.Hotkeys.Repo.CreateBranch +- Text.Hotkeys.Repo.GoToChild +- Text.Hotkeys.Repo.ToggleHistoriesDetailPanel +- Text.Init.CommandTip +- Text.Init.ErrorMessageTip +- Text.InteractiveRebase.NoVerify +- Text.Launcher.NewVersion +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.OpenLocalRepository +- Text.OpenLocalRepository.Bookmark +- Text.OpenLocalRepository.Group +- Text.OpenLocalRepository.Path +- Text.Preferences.AI.AdditionalPrompt +- Text.Preferences.AI.Model +- Text.Preferences.AI.Model.AutoFetchAvailableModels +- Text.Preferences.General.ShowRelativeTimeInGraph +- Text.Preferences.General.Use24Hours +- Text.Preferences.General.UseCompactBranchNames +- Text.Preferences.Git.UseStashAndReapplyByDefault +- Text.Rebase.NoVerify +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.RemoteCM.EnableAutoFetch +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.StashCM.ApplyFileChanges +- Text.StashCM.Branch +- Text.SubmoduleRevisionCompare +- Text.SubmoduleRevisionCompare.OpenDetails +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges +- Text.Worktree.Branch +- Text.Worktree.Head +- Text.Worktree.Path + +
+ +### ![el__GR](https://img.shields.io/badge/el__GR-99.51%25-yellow) + +
+Missing keys in el_GR.axaml + +- Text.Launcher.NewVersion +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.WorkingCopy.FilterChanges + +
+ +### ![es__ES](https://img.shields.io/badge/es__ES-99.90%25-yellow) + +
+Missing keys in es_ES.axaml + +- Text.WorkingCopy.FilterChanges + +
+ +### ![fr__FR](https://img.shields.io/badge/fr__FR-95.49%25-yellow) + +
+Missing keys in fr_FR.axaml + +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitterTime +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.CopyAsPatch +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.Launcher.NewVersion +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.Preferences.General.UseCompactBranchNames +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges + +
+ +### ![he__IL](https://img.shields.io/badge/he__IL-95.49%25-yellow) + +
+Missing keys in he_IL.axaml + +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitterTime +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.CopyAsPatch +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.Launcher.NewVersion +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.Preferences.General.UseCompactBranchNames +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges + +
+ +### ![id__ID](https://img.shields.io/badge/id__ID-99.90%25-yellow) + +
+Missing keys in id_ID.axaml + +- Text.WorkingCopy.FilterChanges + +
+ +### ![it__IT](https://img.shields.io/badge/it__IT-89.03%25-yellow) + +
+Missing keys in it_IT.axaml + +- Text.AIAssistant.Use +- Text.App.HideOthers +- Text.Apply.3Way +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.BranchCM.CompareWithSpecial +- Text.ChangeCM.ResetFileTo +- Text.CheckoutBranchFromStash +- Text.CheckoutBranchFromStash.Branch +- Text.CheckoutBranchFromStash.Stash +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Clone.Bookmark +- Text.Clone.Group +- Text.CommandPalette.Branches +- Text.CommandPalette.BranchesAndTags +- Text.CommandPalette.RepositoryActions +- Text.CommandPalette.RevisionFiles +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitterTime +- Text.CommitDetail.CollapseToBottom +- Text.CommitMessageTextBox.Column +- Text.Compare.Changes +- Text.Compare.Commits +- Text.Compare.Commits.LeftOnly +- Text.Compare.Commits.RightOnly +- Text.Compare.Commits.Tips +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.ConfigureCustomActionControls.StringFormatter +- Text.ConfigureCustomActionControls.StringFormatter.Tip +- Text.ConfigureCustomActionControls.UseFriendlyName +- Text.ConfirmEmptyCommit.StageSelectedThenCommit +- Text.CopyAsPatch +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.Diff.Submodule.UncommittedChanges +- Text.Discard.IncludeModified +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.GotoRevisionSelector +- Text.Histories.HighlightsInGraph +- Text.Histories.HighlightsInGraph.All +- Text.Histories.HighlightsInGraph.CurrentBranchOnly +- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits +- Text.Histories.HighlightsInGraph.SelectedCommitsOnly +- Text.Histories.ShowColumns +- Text.HistoriesDetailsStandalone +- Text.HistoriesDetailsStandalone.CommitDetail +- Text.HistoriesDetailsStandalone.RevisionCompare +- Text.Hotkeys.Global.OpenLocalRepository +- Text.Hotkeys.Repo.CreateBranch +- Text.Hotkeys.Repo.GoToChild +- Text.Hotkeys.Repo.GoToParent +- Text.Hotkeys.Repo.ToggleHistoriesDetailPanel +- Text.Init.CommandTip +- Text.Init.ErrorMessageTip +- Text.InteractiveRebase.NoVerify +- Text.Launcher.NewVersion +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.OpenLocalRepository +- Text.OpenLocalRepository.Bookmark +- Text.OpenLocalRepository.Group +- Text.OpenLocalRepository.Path +- Text.Preferences.AI.AdditionalPrompt +- Text.Preferences.AI.Model +- Text.Preferences.AI.Model.AutoFetchAvailableModels +- Text.Preferences.General.ShowRelativeTimeInGraph +- Text.Preferences.General.Use24Hours +- Text.Preferences.General.UseCompactBranchNames +- Text.Preferences.Git.UseStashAndReapplyByDefault +- Text.Rebase.NoVerify +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.RemoteCM.EnableAutoFetch +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.SelfUpdate.CurrentVersion +- Text.SelfUpdate.ReleaseDate +- Text.StashCM.ApplyFileChanges +- Text.StashCM.Branch +- Text.SubmoduleRevisionCompare +- Text.SubmoduleRevisionCompare.OpenDetails +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges +- Text.Worktree.Branch +- Text.Worktree.Head +- Text.Worktree.Path + +
+ +### ![ja__JP](https://img.shields.io/badge/ja__JP-99.51%25-yellow) + +
+Missing keys in ja_JP.axaml + +- Text.Launcher.NewVersion +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.WorkingCopy.FilterChanges + +
+ +### ![ko__KR](https://img.shields.io/badge/ko__KR-96.96%25-yellow) + +
+Missing keys in ko_KR.axaml + +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.Diff.EmptyFile +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.Launcher.NewVersion +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.TagCM.Checkout +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.FilterChanges + +
+ +### ![pt__BR](https://img.shields.io/badge/pt__BR-62.78%25-red) + +
+Missing keys in pt_BR.axaml + +- Text.AIAssistant.Use +- Text.App.HideOthers +- Text.Apply.3Way +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.Blame.BlameOnPreviousRevision +- Text.BranchCM.CompareWithSpecial +- Text.BranchCM.InteractiveRebase.Manually +- Text.BranchTree.AheadBehind +- Text.BranchTree.Behind +- Text.BranchTree.Tracking +- Text.BranchTree.URL +- Text.BranchTree.Worktree +- Text.ChangeCM.Merge +- Text.ChangeCM.MergeExternal +- Text.ChangeCM.ResetFileTo +- Text.ChangeSubmoduleUrl +- Text.ChangeSubmoduleUrl.Submodule +- Text.ChangeSubmoduleUrl.URL +- Text.Checkout.WarnLostCommits +- Text.Checkout.WarnUpdatingSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CheckoutBranchFromStash +- Text.CheckoutBranchFromStash.Branch +- Text.CheckoutBranchFromStash.Stash +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Clone.Bookmark +- Text.Clone.Group +- Text.Clone.RecurseSubmodules +- Text.CommandPalette.Branches +- Text.CommandPalette.BranchesAndTags +- Text.CommandPalette.RepositoryActions +- Text.CommandPalette.RevisionFiles +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitMessage +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopyCommitterTime +- Text.CommitCM.CopySubject +- Text.CommitCM.InteractiveRebase +- Text.CommitCM.InteractiveRebase.Drop +- Text.CommitCM.InteractiveRebase.Edit +- Text.CommitCM.InteractiveRebase.Fixup +- Text.CommitCM.InteractiveRebase.Manually +- Text.CommitCM.InteractiveRebase.Reword +- Text.CommitCM.InteractiveRebase.Squash +- Text.CommitCM.MergeMultiple +- Text.CommitCM.PushRevision +- Text.CommitCM.Rebase +- Text.CommitCM.Reset +- Text.CommitDetail.Changes.Count +- Text.CommitDetail.CollapseToBottom +- Text.CommitDetail.Files.Search +- Text.CommitDetail.Info.CopyEmail +- Text.CommitDetail.Info.CopyName +- Text.CommitDetail.Info.CopyNameAndEmail +- Text.CommitDetail.Info.Key +- Text.CommitDetail.Info.Signer +- Text.CommitMessageTextBox.Column +- Text.CommitMessageTextBox.Placeholder +- Text.CommitMessageTextBox.SubjectCount +- Text.Compare.Changes +- Text.Compare.Commits +- Text.Compare.Commits.LeftOnly +- Text.Compare.Commits.RightOnly +- Text.Compare.Commits.Tips +- Text.Configure.CommitMessageTemplate.BuiltinVars +- Text.Configure.CustomAction.Arguments.Tip +- Text.Configure.CustomAction.InputControls +- Text.Configure.CustomAction.InputControls.Edit +- Text.Configure.CustomAction.Scope.Branch +- Text.Configure.CustomAction.Scope.File +- Text.Configure.CustomAction.Scope.Remote +- Text.Configure.CustomAction.Scope.Tag +- Text.Configure.CustomAction.WaitForExit +- Text.Configure.Git.AskBeforeAutoUpdatingSubmodules +- Text.Configure.Git.ConventionalTypesOverride +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.Configure.Git.PreferredMergeMode +- Text.Configure.IssueTracker.AddSampleGerritChangeIdCommit +- Text.Configure.IssueTracker.AddSampleGiteeIssue +- Text.Configure.IssueTracker.AddSampleGiteePullRequest +- Text.Configure.IssueTracker.Share +- Text.ConfigureCustomActionControls +- Text.ConfigureCustomActionControls.CheckedValue +- Text.ConfigureCustomActionControls.CheckedValue.Tip +- Text.ConfigureCustomActionControls.Description +- Text.ConfigureCustomActionControls.DefaultValue +- Text.ConfigureCustomActionControls.IsFolder +- Text.ConfigureCustomActionControls.Label +- Text.ConfigureCustomActionControls.Options +- Text.ConfigureCustomActionControls.Options.Tip +- Text.ConfigureCustomActionControls.StringFormatter +- Text.ConfigureCustomActionControls.StringFormatter.Tip +- Text.ConfigureCustomActionControls.StringValue.Tip +- Text.ConfigureCustomActionControls.Type +- Text.ConfigureCustomActionControls.UseFriendlyName +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.StageSelectedThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.ConfirmRestart.Title +- Text.ConfirmRestart.Message +- Text.CopyAsPatch +- Text.CopyFullPath +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.DeleteMultiTags +- Text.DeleteMultiTags.DeleteFromRemotes +- Text.DeleteMultiTags.Tip +- Text.DeleteRepositoryNode.Path +- Text.DeleteRepositoryNode.TipForGroup +- Text.DeleteRepositoryNode.TipForRepository +- Text.Diff.EmptyFile +- Text.Diff.First +- Text.Diff.Image.Blend +- Text.Diff.Image.Difference +- Text.Diff.Image.SideBySide +- Text.Diff.Image.Swipe +- Text.Diff.Last +- Text.Diff.New +- Text.Diff.Old +- Text.Diff.Submodule.Deleted +- Text.Diff.Submodule.UncommittedChanges +- Text.DirHistories +- Text.DirtyState.HasLocalChanges +- Text.DirtyState.HasPendingPullOrPush +- Text.DirtyState.UpToDate +- Text.Discard.IncludeModified +- Text.Discard.IncludeUntracked +- Text.EditBranchDescription +- Text.EditBranchDescription.Target +- Text.ExecuteCustomAction.Target +- Text.ExecuteCustomAction.Repository +- Text.Fetch.Force +- Text.FileCM.CustomAction +- Text.FileCM.ResolveUsing +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.FinishWithSquash +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.GitLFS.Locks.UnlockAllMyLocks +- Text.GitLFS.Locks.UnlockAllMyLocks.Confirm +- Text.GotoRevisionSelector +- Text.Histories.HighlightsInGraph +- Text.Histories.HighlightsInGraph.All +- Text.Histories.HighlightsInGraph.CurrentBranchOnly +- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits +- Text.Histories.HighlightsInGraph.SelectedCommitsOnly +- Text.Histories.ShowColumns +- Text.HistoriesDetailsStandalone +- Text.HistoriesDetailsStandalone.CommitDetail +- Text.HistoriesDetailsStandalone.RevisionCompare +- Text.Hotkeys.Global.Clone +- Text.Hotkeys.Global.OpenLocalRepository +- Text.Hotkeys.Global.ShowWorkspaceDropdownMenu +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.Global.Zoom +- Text.Hotkeys.Repo.CreateBranch +- Text.Hotkeys.Repo.GoToChild +- Text.Hotkeys.Repo.GoToParent +- Text.Hotkeys.Repo.OpenCommandPalette +- Text.Hotkeys.Repo.ToggleHistoriesDetailPanel +- Text.Init.CommandTip +- Text.Init.ErrorMessageTip +- Text.InProgress.CherryPick.Head +- Text.InProgress.Merge.Operating +- Text.InProgress.Rebase.StoppedAt +- Text.InProgress.Revert.Head +- Text.InteractiveRebase.NoVerify +- Text.InteractiveRebase.ReorderTip +- Text.Launcher.Commands +- Text.Launcher.NewVersion +- Text.Launcher.OpenRepository +- Text.Launcher.Pages +- Text.Launcher.Workspaces +- Text.Merge.Edit +- Text.Merge.Source +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.MergeConflictEditor.AcceptBoth.MineFirst +- Text.MergeConflictEditor.AcceptBoth.TheirsFirst +- Text.MergeConflictEditor.UseBoth +- Text.MergeConflictEditor.AllResolved +- Text.MergeConflictEditor.ConflictsRemaining +- Text.MergeConflictEditor.Mine +- Text.MergeConflictEditor.NextConflict +- Text.MergeConflictEditor.PrevConflict +- Text.MergeConflictEditor.Result +- Text.MergeConflictEditor.SaveAndStage +- Text.MergeConflictEditor.Theirs +- Text.MergeConflictEditor.Title +- Text.MergeConflictEditor.UnsavedChanges +- Text.MergeConflictEditor.UseMine +- Text.MergeConflictEditor.UseTheirs +- Text.MergeConflictEditor.Undo +- Text.MergeMultiple +- Text.MergeMultiple.CommitChanges +- Text.MergeMultiple.Strategy +- Text.MergeMultiple.Targets +- Text.MoveSubmodule +- Text.MoveSubmodule.MoveTo +- Text.MoveSubmodule.Submodule +- Text.No +- Text.Open +- Text.Open.SystemDefaultEditor +- Text.OpenFile +- Text.OpenLocalRepository +- Text.OpenLocalRepository.Bookmark +- Text.OpenLocalRepository.Group +- Text.OpenLocalRepository.Path +- Text.PageTabBar.Tab.MoveToWorkspace +- Text.PageTabBar.Tab.Refresh +- Text.Preferences.AI.AdditionalPrompt +- Text.Preferences.AI.Model +- Text.Preferences.AI.Model.AutoFetchAvailableModels +- Text.Preferences.AI.ReadApiKeyFromEnv +- Text.Preferences.Appearance.EditorTabWidth +- Text.Preferences.Appearance.UseAutoHideScrollBars +- Text.Preferences.DiffMerge.DiffArgs +- Text.Preferences.DiffMerge.DiffArgs.Tip +- Text.Preferences.DiffMerge.MergeArgs +- Text.Preferences.DiffMerge.MergeArgs.Tip +- Text.Preferences.General.DateFormat +- Text.Preferences.General.EnableCompactFolders +- Text.Preferences.General.ShowChangesPageByDefault +- Text.Preferences.General.ShowChangesTabInCommitDetailByDefault +- Text.Preferences.General.ShowRelativeTimeInGraph +- Text.Preferences.General.ShowTagsInGraph +- Text.Preferences.General.Use24Hours +- Text.Preferences.General.UseCompactBranchNames +- Text.Preferences.General.UseGitHubStyleAvatar +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Preferences.Git.SSLVerify +- Text.Preferences.Git.UseLibsecret +- Text.Preferences.Git.UseStashAndReapplyByDefault +- Text.Preferences.Shell.Args +- Text.Preferences.Shell.Args.Tip +- Text.Push.New +- Text.Push.Revision +- Text.Push.Revision.Title +- Text.PushToNewBranch +- Text.PushToNewBranch.Title +- Text.Rebase.NoVerify +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.RemoteCM.CustomAction +- Text.RemoteCM.EnableAutoFetch +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Dashboard +- Text.Repository.FilterCommits +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.Repository.HistoriesLayout +- Text.Repository.HistoriesLayout.Horizontal +- Text.Repository.HistoriesLayout.Vertical +- Text.Repository.HistoriesOrder +- Text.Repository.MoreOptions +- Text.Repository.Notifications.Clear +- Text.Repository.OpenAsFolder +- Text.Repository.Resolve +- Text.Repository.Search.ByContent +- Text.Repository.Search.ByPath +- Text.Repository.ShowDecoratedCommitsOnly +- Text.Repository.ShowFirstParentOnly +- Text.Repository.ShowFlags +- Text.Repository.ShowLostCommits +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.Skip +- Text.Repository.Tags.OrderByCreatorDate +- Text.Repository.Tags.OrderByName +- Text.Repository.Tags.Sort +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.ScanRepositories.UseCustomDir +- Text.SelfUpdate.CurrentVersion +- Text.SelfUpdate.ReleaseDate +- Text.SetSubmoduleBranch +- Text.SetSubmoduleBranch.Submodule +- Text.SetSubmoduleBranch.Current +- Text.SetSubmoduleBranch.New +- Text.SetSubmoduleBranch.New.Tip +- Text.SetUpstream +- Text.SetUpstream.Local +- Text.SetUpstream.Unset +- Text.SetUpstream.Upstream +- Text.SHALinkCM.NavigateTo +- Text.Stash.Mode +- Text.StashCM.ApplyFileChanges +- Text.StashCM.Branch +- Text.StashCM.CopyMessage +- Text.StashCM.SaveAsPatch +- Text.Submodule.Branch +- Text.Submodule.CopyBranch +- Text.Submodule.Deinit +- Text.Submodule.Histories +- Text.Submodule.Move +- Text.Submodule.SetBranch +- Text.Submodule.SetURL +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.Update +- Text.Submodule.URL +- Text.SubmoduleRevisionCompare +- Text.SubmoduleRevisionCompare.OpenDetails +- Text.Tag.Tagger +- Text.Tag.Time +- Text.TagCM.Checkout +- Text.TagCM.CompareTwo +- Text.TagCM.CompareWith +- Text.TagCM.CompareWithHead +- Text.TagCM.Copy.Message +- Text.TagCM.Copy.Name +- Text.TagCM.Copy.Tagger +- Text.TagCM.CopyName +- Text.TagCM.CustomAction +- Text.TagCM.DeleteMultiple +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.AddToGitIgnore.InFolder +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.ClearCommitHistories +- Text.WorkingCopy.ClearCommitHistories.Confirm +- Text.WorkingCopy.CommitToEdit +- Text.WorkingCopy.ConfirmCommitWithDetachedHead +- Text.WorkingCopy.ConfirmCommitWithFilter +- Text.WorkingCopy.Conflicts.Merge +- Text.WorkingCopy.Conflicts.MergeExternal +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.FilterChanges +- Text.WorkingCopy.NoVerify +- Text.WorkingCopy.ResetAuthor +- Text.WorkingCopy.SignOff +- Text.Worktree.Branch +- Text.Worktree.Head +- Text.Worktree.Open +- Text.Worktree.Path +- Text.Yes + +
+ +### ![ru__RU](https://img.shields.io/badge/ru__RU-%E2%88%9A-brightgreen) + +### ![ta__IN](https://img.shields.io/badge/ta__IN-64.45%25-red) + +
+Missing keys in ta_IN.axaml + +- Text.About.ReleaseDate +- Text.About.ReleaseNotes +- Text.AddToIgnore +- Text.AddToIgnore.Pattern +- Text.AddToIgnore.Storage +- Text.AIAssistant.Use +- Text.App.Hide +- Text.App.HideOthers +- Text.App.ShowAll +- Text.Apply.3Way +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Askpass.Passphrase +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.Bisect.WaitingForFirstGood +- Text.Bisect.WaitingForMark +- Text.Blame.BlameOnPreviousRevision +- Text.Blame.IgnoreWhitespace +- Text.BranchCM.CompareTwo +- Text.BranchCM.CompareWith +- Text.BranchCM.CompareWithHead +- Text.BranchCM.CompareWithSpecial +- Text.BranchCM.CreatePR +- Text.BranchCM.CreatePRForUpstream +- Text.BranchCM.EditDescription +- Text.BranchCM.InteractiveRebase.Manually +- Text.BranchCM.ResetToSelectedCommit +- Text.BranchCM.SwitchToWorktree +- Text.BranchTree.Ahead +- Text.BranchTree.AheadBehind +- Text.BranchTree.Behind +- Text.BranchTree.InvalidUpstream +- Text.BranchTree.Remote +- Text.BranchTree.Status +- Text.BranchTree.Tracking +- Text.BranchTree.URL +- Text.BranchTree.Worktree +- Text.ChangeCM.Merge +- Text.ChangeCM.MergeExternal +- Text.ChangeCM.ResetFileTo +- Text.ChangeSubmoduleUrl +- Text.ChangeSubmoduleUrl.Submodule +- Text.ChangeSubmoduleUrl.URL +- Text.Checkout.WarnLostCommits +- Text.Checkout.WarnUpdatingSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CheckoutBranchFromStash +- Text.CheckoutBranchFromStash.Branch +- Text.CheckoutBranchFromStash.Stash +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Clone.Bookmark +- Text.Clone.Group +- Text.CommandPalette.Branches +- Text.CommandPalette.BranchesAndTags +- Text.CommandPalette.RepositoryActions +- Text.CommandPalette.RevisionFiles +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitMessage +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopyCommitterTime +- Text.CommitCM.CopySubject +- Text.CommitCM.InteractiveRebase +- Text.CommitCM.InteractiveRebase.Drop +- Text.CommitCM.InteractiveRebase.Edit +- Text.CommitCM.InteractiveRebase.Fixup +- Text.CommitCM.InteractiveRebase.Manually +- Text.CommitCM.InteractiveRebase.Reword +- Text.CommitCM.InteractiveRebase.Squash +- Text.CommitCM.PushRevision +- Text.CommitCM.Rebase +- Text.CommitCM.Reset +- Text.CommitDetail.Changes.Count +- Text.CommitDetail.CollapseToBottom +- Text.CommitDetail.Info.CopyEmail +- Text.CommitDetail.Info.CopyName +- Text.CommitDetail.Info.CopyNameAndEmail +- Text.CommitDetail.Info.Key +- Text.CommitDetail.Info.Signer +- Text.CommitMessageTextBox.Column +- Text.CommitMessageTextBox.Placeholder +- Text.CommitMessageTextBox.SubjectCount +- Text.Compare.Changes +- Text.Compare.Commits +- Text.Compare.Commits.LeftOnly +- Text.Compare.Commits.RightOnly +- Text.Compare.Commits.Tips +- Text.Configure.CommitMessageTemplate.BuiltinVars +- Text.Configure.CustomAction.Arguments.Tip +- Text.Configure.CustomAction.InputControls +- Text.Configure.CustomAction.InputControls.Edit +- Text.Configure.CustomAction.Scope.File +- Text.Configure.CustomAction.Scope.Remote +- Text.Configure.CustomAction.Scope.Tag +- Text.Configure.Git.AskBeforeAutoUpdatingSubmodules +- Text.Configure.Git.ConventionalTypesOverride +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.Configure.Git.PreferredMergeMode +- Text.Configure.IssueTracker.AddSampleGerritChangeIdCommit +- Text.Configure.IssueTracker.Share +- Text.ConfigureCustomActionControls +- Text.ConfigureCustomActionControls.CheckedValue +- Text.ConfigureCustomActionControls.CheckedValue.Tip +- Text.ConfigureCustomActionControls.Description +- Text.ConfigureCustomActionControls.DefaultValue +- Text.ConfigureCustomActionControls.IsFolder +- Text.ConfigureCustomActionControls.Label +- Text.ConfigureCustomActionControls.Options +- Text.ConfigureCustomActionControls.Options.Tip +- Text.ConfigureCustomActionControls.StringFormatter +- Text.ConfigureCustomActionControls.StringFormatter.Tip +- Text.ConfigureCustomActionControls.StringValue.Tip +- Text.ConfigureCustomActionControls.Type +- Text.ConfigureCustomActionControls.UseFriendlyName +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.StageSelectedThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.ConfirmRestart.Title +- Text.ConfirmRestart.Message +- Text.CopyAsPatch +- Text.CreateBranch.OverwriteExisting +- Text.DealWithLocalChanges.DoNothing +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.DeleteMultiTags +- Text.DeleteMultiTags.DeleteFromRemotes +- Text.DeleteMultiTags.Tip +- Text.Diff.EmptyFile +- Text.Diff.Image.Blend +- Text.Diff.Image.Difference +- Text.Diff.Image.SideBySide +- Text.Diff.Image.Swipe +- Text.Diff.New +- Text.Diff.Old +- Text.Diff.Submodule.Deleted +- Text.Diff.Submodule.UncommittedChanges +- Text.DirHistories +- Text.DirtyState.HasLocalChanges +- Text.DirtyState.HasPendingPullOrPush +- Text.DirtyState.UpToDate +- Text.Discard.IncludeModified +- Text.Discard.IncludeUntracked +- Text.EditBranchDescription +- Text.EditBranchDescription.Target +- Text.ExecuteCustomAction.Target +- Text.ExecuteCustomAction.Repository +- Text.FileCM.CustomAction +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.FinishWithSquash +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.GitLFS.Locks.UnlockAllMyLocks +- Text.GitLFS.Locks.UnlockAllMyLocks.Confirm +- Text.GotoRevisionSelector +- Text.Histories.HighlightsInGraph +- Text.Histories.HighlightsInGraph.All +- Text.Histories.HighlightsInGraph.CurrentBranchOnly +- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits +- Text.Histories.HighlightsInGraph.SelectedCommitsOnly +- Text.Histories.ShowColumns +- Text.HistoriesDetailsStandalone +- Text.HistoriesDetailsStandalone.CommitDetail +- Text.HistoriesDetailsStandalone.RevisionCompare +- Text.Hotkeys.Global.OpenLocalRepository +- Text.Hotkeys.Global.ShowWorkspaceDropdownMenu +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.Global.Zoom +- Text.Hotkeys.Repo.CreateBranch +- Text.Hotkeys.Repo.GoToChild +- Text.Hotkeys.Repo.GoToParent +- Text.Hotkeys.Repo.OpenCommandPalette +- Text.Hotkeys.Repo.ToggleHistoriesDetailPanel +- Text.Init.CommandTip +- Text.Init.ErrorMessageTip +- Text.InteractiveRebase.NoVerify +- Text.InteractiveRebase.ReorderTip +- Text.Launcher.Commands +- Text.Launcher.NewVersion +- Text.Launcher.OpenRepository +- Text.Launcher.Pages +- Text.Launcher.Workspaces +- Text.Merge.Edit +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.MergeConflictEditor.AcceptBoth.MineFirst +- Text.MergeConflictEditor.AcceptBoth.TheirsFirst +- Text.MergeConflictEditor.UseBoth +- Text.MergeConflictEditor.AllResolved +- Text.MergeConflictEditor.ConflictsRemaining +- Text.MergeConflictEditor.Mine +- Text.MergeConflictEditor.NextConflict +- Text.MergeConflictEditor.PrevConflict +- Text.MergeConflictEditor.Result +- Text.MergeConflictEditor.SaveAndStage +- Text.MergeConflictEditor.Theirs +- Text.MergeConflictEditor.Title +- Text.MergeConflictEditor.UnsavedChanges +- Text.MergeConflictEditor.UseMine +- Text.MergeConflictEditor.UseTheirs +- Text.MergeConflictEditor.Undo +- Text.MoveSubmodule +- Text.MoveSubmodule.MoveTo +- Text.MoveSubmodule.Submodule +- Text.No +- Text.Open +- Text.Open.SystemDefaultEditor +- Text.OpenFile +- Text.OpenLocalRepository +- Text.OpenLocalRepository.Bookmark +- Text.OpenLocalRepository.Group +- Text.OpenLocalRepository.Path +- Text.PageTabBar.Tab.MoveToWorkspace +- Text.PageTabBar.Tab.Refresh +- Text.Preferences.AI.AdditionalPrompt +- Text.Preferences.AI.Model +- Text.Preferences.AI.Model.AutoFetchAvailableModels +- Text.Preferences.AI.ReadApiKeyFromEnv +- Text.Preferences.Appearance.UseAutoHideScrollBars +- Text.Preferences.DiffMerge.DiffArgs +- Text.Preferences.DiffMerge.DiffArgs.Tip +- Text.Preferences.DiffMerge.MergeArgs +- Text.Preferences.DiffMerge.MergeArgs.Tip +- Text.Preferences.General.EnableCompactFolders +- Text.Preferences.General.ShowChangesPageByDefault +- Text.Preferences.General.ShowChangesTabInCommitDetailByDefault +- Text.Preferences.General.ShowRelativeTimeInGraph +- Text.Preferences.General.Use24Hours +- Text.Preferences.General.UseCompactBranchNames +- Text.Preferences.General.UseGitHubStyleAvatar +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Preferences.Git.UseLibsecret +- Text.Preferences.Git.UseStashAndReapplyByDefault +- Text.Preferences.Shell.Args +- Text.Preferences.Shell.Args.Tip +- Text.Push.New +- Text.Push.Revision +- Text.Push.Revision.Title +- Text.PushToNewBranch +- Text.PushToNewBranch.Title +- Text.Rebase.NoVerify +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.RemoteCM.CustomAction +- Text.RemoteCM.EnableAutoFetch +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Dashboard +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.Repository.MoreOptions +- Text.Repository.OpenAsFolder +- Text.Repository.Resolve +- Text.Repository.Search.ByContent +- Text.Repository.Search.ByPath +- Text.Repository.ShowDecoratedCommitsOnly +- Text.Repository.ShowFirstParentOnly +- Text.Repository.ShowFlags +- Text.Repository.ShowLostCommits +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.ScanRepositories.UseCustomDir +- Text.SelfUpdate.CurrentVersion +- Text.SelfUpdate.ReleaseDate +- Text.SetSubmoduleBranch +- Text.SetSubmoduleBranch.Submodule +- Text.SetSubmoduleBranch.Current +- Text.SetSubmoduleBranch.New +- Text.SetSubmoduleBranch.New.Tip +- Text.Stash.Mode +- Text.StashCM.ApplyFileChanges +- Text.StashCM.Branch +- Text.StashCM.CopyMessage +- Text.Submodule.Branch +- Text.Submodule.CopyBranch +- Text.Submodule.Deinit +- Text.Submodule.Histories +- Text.Submodule.Move +- Text.Submodule.SetBranch +- Text.Submodule.SetURL +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.Update +- Text.Submodule.URL +- Text.SubmoduleRevisionCompare +- Text.SubmoduleRevisionCompare.OpenDetails +- Text.Tag.Tagger +- Text.Tag.Time +- Text.TagCM.Checkout +- Text.TagCM.CompareTwo +- Text.TagCM.CompareWith +- Text.TagCM.CompareWithHead +- Text.TagCM.Copy.Message +- Text.TagCM.Copy.Name +- Text.TagCM.Copy.Tagger +- Text.TagCM.CopyName +- Text.TagCM.CustomAction +- Text.TagCM.DeleteMultiple +- Text.TagCM.Merge +- Text.UpdateSubmodules.Target +- Text.UpdateSubmodules.Recursive +- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.AddToGitIgnore.InFolder +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.ClearCommitHistories +- Text.WorkingCopy.ClearCommitHistories.Confirm +- Text.WorkingCopy.ConfirmCommitWithDetachedHead +- Text.WorkingCopy.Conflicts.Merge +- Text.WorkingCopy.Conflicts.MergeExternal +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.FilterChanges +- Text.WorkingCopy.NoVerify +- Text.WorkingCopy.ResetAuthor +- Text.Worktree.Branch +- Text.Worktree.Head +- Text.Worktree.Open +- Text.Worktree.Path +- Text.Yes + +
+ +### ![uk__UA](https://img.shields.io/badge/uk__UA-65.23%25-red) + +
+Missing keys in uk_UA.axaml + +- Text.About.ReleaseDate +- Text.About.ReleaseNotes +- Text.AddToIgnore +- Text.AddToIgnore.Pattern +- Text.AddToIgnore.Storage +- Text.AIAssistant.Use +- Text.App.Hide +- Text.App.HideOthers +- Text.App.ShowAll +- Text.Apply.3Way +- Text.Apply.Source +- Text.Apply.Source.File +- Text.Apply.Source.Clipboard +- Text.Askpass.Passphrase +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForCheckoutAnother +- Text.Bisect.WaitingForFirstBad +- Text.Bisect.WaitingForFirstGood +- Text.Bisect.WaitingForMark +- Text.Blame.BlameOnPreviousRevision +- Text.Blame.IgnoreWhitespace +- Text.BranchCM.CompareTwo +- Text.BranchCM.CompareWith +- Text.BranchCM.CompareWithHead +- Text.BranchCM.CompareWithSpecial +- Text.BranchCM.CreatePR +- Text.BranchCM.CreatePRForUpstream +- Text.BranchCM.EditDescription +- Text.BranchCM.InteractiveRebase.Manually +- Text.BranchCM.ResetToSelectedCommit +- Text.BranchCM.SwitchToWorktree +- Text.BranchTree.Ahead +- Text.BranchTree.AheadBehind +- Text.BranchTree.Behind +- Text.BranchTree.InvalidUpstream +- Text.BranchTree.Remote +- Text.BranchTree.Status +- Text.BranchTree.Tracking +- Text.BranchTree.URL +- Text.BranchTree.Worktree +- Text.ChangeCM.Merge +- Text.ChangeCM.MergeExternal +- Text.ChangeCM.ResetFileTo +- Text.ChangeSubmoduleUrl +- Text.ChangeSubmoduleUrl.Submodule +- Text.ChangeSubmoduleUrl.URL +- Text.Checkout.WarnLostCommits +- Text.Checkout.WarnUpdatingSubmodules +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- Text.CheckoutBranchFromStash +- Text.CheckoutBranchFromStash.Branch +- Text.CheckoutBranchFromStash.Stash +- Text.CheckoutDetached +- Text.CheckoutDetached.Target +- Text.CheckoutDetached.Warning +- Text.Clone.Bookmark +- Text.Clone.Group +- Text.CommandPalette.Branches +- Text.CommandPalette.BranchesAndTags +- Text.CommandPalette.RepositoryActions +- Text.CommandPalette.RevisionFiles +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyAuthorTime +- Text.CommitCM.CopyCommitMessage +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopyCommitterTime +- Text.CommitCM.CopySubject +- Text.CommitCM.InteractiveRebase +- Text.CommitCM.InteractiveRebase.Drop +- Text.CommitCM.InteractiveRebase.Edit +- Text.CommitCM.InteractiveRebase.Fixup +- Text.CommitCM.InteractiveRebase.Manually +- Text.CommitCM.InteractiveRebase.Reword +- Text.CommitCM.InteractiveRebase.Squash +- Text.CommitCM.PushRevision +- Text.CommitCM.Rebase +- Text.CommitCM.Reset +- Text.CommitDetail.Changes.Count +- Text.CommitDetail.CollapseToBottom +- Text.CommitDetail.Info.CopyEmail +- Text.CommitDetail.Info.CopyName +- Text.CommitDetail.Info.CopyNameAndEmail +- Text.CommitDetail.Info.Key +- Text.CommitDetail.Info.Signer +- Text.CommitMessageTextBox.Column +- Text.CommitMessageTextBox.Placeholder +- Text.CommitMessageTextBox.SubjectCount +- Text.Compare.Changes +- Text.Compare.Commits +- Text.Compare.Commits.LeftOnly +- Text.Compare.Commits.RightOnly +- Text.Compare.Commits.Tips +- Text.Configure.CommitMessageTemplate.BuiltinVars +- Text.Configure.CustomAction.Arguments.Tip +- Text.Configure.CustomAction.InputControls +- Text.Configure.CustomAction.InputControls.Edit +- Text.Configure.CustomAction.Scope.File +- Text.Configure.CustomAction.Scope.Remote +- Text.Configure.CustomAction.Scope.Tag +- Text.Configure.Git.AskBeforeAutoUpdatingSubmodules +- Text.Configure.Git.ConventionalTypesOverride +- Text.Configure.Git.EnableRecursiveWhenAutoUpdatingSubmodules +- Text.Configure.IssueTracker.AddSampleGerritChangeIdCommit +- Text.Configure.IssueTracker.Share +- Text.ConfigureCustomActionControls +- Text.ConfigureCustomActionControls.CheckedValue +- Text.ConfigureCustomActionControls.CheckedValue.Tip +- Text.ConfigureCustomActionControls.Description +- Text.ConfigureCustomActionControls.DefaultValue +- Text.ConfigureCustomActionControls.IsFolder +- Text.ConfigureCustomActionControls.Label +- Text.ConfigureCustomActionControls.Options +- Text.ConfigureCustomActionControls.Options.Tip +- Text.ConfigureCustomActionControls.StringFormatter +- Text.ConfigureCustomActionControls.StringFormatter.Tip +- Text.ConfigureCustomActionControls.StringValue.Tip +- Text.ConfigureCustomActionControls.Type +- Text.ConfigureCustomActionControls.UseFriendlyName +- Text.ConfigureWorkspace.Name +- Text.ConfirmEmptyCommit.StageSelectedThenCommit +- Text.ConfirmRestart.Title +- Text.ConfirmRestart.Message +- Text.CopyAsPatch +- Text.CreateBranch.OverwriteExisting +- Text.DealWithLocalChanges.DoNothing +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.DeleteBranch.AskForRemote +- Text.DeleteBranch.Force +- Text.DeleteMultiTags +- Text.DeleteMultiTags.DeleteFromRemotes +- Text.DeleteMultiTags.Tip +- Text.Diff.EmptyFile +- Text.Diff.Image.Blend +- Text.Diff.Image.Difference +- Text.Diff.Image.SideBySide +- Text.Diff.Image.Swipe +- Text.Diff.New +- Text.Diff.Old +- Text.Diff.Submodule.Deleted +- Text.Diff.Submodule.UncommittedChanges +- Text.DirHistories +- Text.DirtyState.HasLocalChanges +- Text.DirtyState.HasPendingPullOrPush +- Text.DirtyState.UpToDate +- Text.Discard.IncludeModified +- Text.Discard.IncludeUntracked +- Text.EditBranchDescription +- Text.EditBranchDescription.Target +- Text.ExecuteCustomAction.Target +- Text.ExecuteCustomAction.Repository +- Text.FileCM.CustomAction +- Text.FileModeChange +- Text.FileModeChange.Deleted +- Text.FileModeChange.Directory +- Text.FileModeChange.Executable +- Text.FileModeChange.New +- Text.FileModeChange.Normal +- Text.FileModeChange.Submodule +- Text.FileModeChange.Symlink +- Text.FileModeChange.Unknown +- Text.GitFlow.Finish +- Text.GitFlow.FinishWithRebase +- Text.GitFlow.FinishWithSquash +- Text.GitFlow.StartAt +- Text.GitFlow.StartName +- Text.GitLFS.Locks.UnlockAllMyLocks +- Text.GitLFS.Locks.UnlockAllMyLocks.Confirm +- Text.GotoRevisionSelector +- Text.Histories.HighlightsInGraph +- Text.Histories.HighlightsInGraph.All +- Text.Histories.HighlightsInGraph.CurrentBranchOnly +- Text.Histories.HighlightsInGraph.CurrentBranchAndSelectedCommits +- Text.Histories.HighlightsInGraph.SelectedCommitsOnly +- Text.Histories.ShowColumns +- Text.HistoriesDetailsStandalone +- Text.HistoriesDetailsStandalone.CommitDetail +- Text.HistoriesDetailsStandalone.RevisionCompare +- Text.Hotkeys.Global.OpenLocalRepository +- Text.Hotkeys.Global.ShowWorkspaceDropdownMenu +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.Global.Zoom +- Text.Hotkeys.Repo.CreateBranch +- Text.Hotkeys.Repo.GoToChild +- Text.Hotkeys.Repo.GoToParent +- Text.Hotkeys.Repo.OpenCommandPalette +- Text.Hotkeys.Repo.ToggleHistoriesDetailPanel +- Text.Init.CommandTip +- Text.Init.ErrorMessageTip +- Text.InteractiveRebase.NoVerify +- Text.InteractiveRebase.ReorderTip +- Text.Launcher.Commands +- Text.Launcher.NewVersion +- Text.Launcher.OpenRepository +- Text.Launcher.Pages +- Text.Launcher.Workspaces +- Text.Merge.Edit +- Text.Merge.Test +- Text.Merge.Test.NoConflicts +- Text.Merge.Test.UnknownError +- Text.Merge.Test.WillCauseConflicts +- Text.MergeConflictEditor.AcceptBoth.MineFirst +- Text.MergeConflictEditor.AcceptBoth.TheirsFirst +- Text.MergeConflictEditor.UseBoth +- Text.MergeConflictEditor.AllResolved +- Text.MergeConflictEditor.ConflictsRemaining +- Text.MergeConflictEditor.Mine +- Text.MergeConflictEditor.NextConflict +- Text.MergeConflictEditor.PrevConflict +- Text.MergeConflictEditor.Result +- Text.MergeConflictEditor.SaveAndStage +- Text.MergeConflictEditor.Theirs +- Text.MergeConflictEditor.Title +- Text.MergeConflictEditor.UnsavedChanges +- Text.MergeConflictEditor.UseMine +- Text.MergeConflictEditor.UseTheirs +- Text.MergeConflictEditor.Undo +- Text.MoveSubmodule +- Text.MoveSubmodule.MoveTo +- Text.MoveSubmodule.Submodule +- Text.No +- Text.Open +- Text.Open.SystemDefaultEditor +- Text.OpenFile +- Text.OpenLocalRepository +- Text.OpenLocalRepository.Bookmark +- Text.OpenLocalRepository.Group +- Text.OpenLocalRepository.Path +- Text.PageTabBar.Tab.MoveToWorkspace +- Text.PageTabBar.Tab.Refresh +- Text.Preferences.AI.AdditionalPrompt +- Text.Preferences.AI.Model +- Text.Preferences.AI.Model.AutoFetchAvailableModels +- Text.Preferences.AI.ReadApiKeyFromEnv +- Text.Preferences.Appearance.UseAutoHideScrollBars +- Text.Preferences.DiffMerge.DiffArgs +- Text.Preferences.DiffMerge.DiffArgs.Tip +- Text.Preferences.DiffMerge.MergeArgs +- Text.Preferences.DiffMerge.MergeArgs.Tip +- Text.Preferences.General.EnableCompactFolders +- Text.Preferences.General.ShowChangesPageByDefault +- Text.Preferences.General.ShowChangesTabInCommitDetailByDefault +- Text.Preferences.General.ShowRelativeTimeInGraph +- Text.Preferences.General.Use24Hours +- Text.Preferences.General.UseCompactBranchNames +- Text.Preferences.General.UseGitHubStyleAvatar +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Preferences.Git.UseLibsecret +- Text.Preferences.Git.UseStashAndReapplyByDefault +- Text.Preferences.Shell.Args +- Text.Preferences.Shell.Args.Tip +- Text.Push.New +- Text.Push.Revision +- Text.Push.Revision.Title +- Text.PushToNewBranch +- Text.PushToNewBranch.Title +- Text.Rebase.NoVerify +- Text.Rebase.Test +- Text.Rebase.Test.OK +- Text.Rebase.Test.UnknownError +- Text.Rebase.Test.WillCauseConflicts +- Text.RemoteCM.CustomAction +- Text.RemoteCM.EnableAutoFetch +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Dashboard +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.Repository.MoreOptions +- Text.Repository.OpenAsFolder +- Text.Repository.Resolve +- Text.Repository.Search.ByContent +- Text.Repository.Search.ByPath +- Text.Repository.ShowDecoratedCommitsOnly +- Text.Repository.ShowFirstParentOnly +- Text.Repository.ShowFlags +- Text.Repository.ShowLostCommits +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.ScanRepositories.UseCustomDir +- Text.SelfUpdate.CurrentVersion +- Text.SelfUpdate.ReleaseDate +- Text.SetSubmoduleBranch +- Text.SetSubmoduleBranch.Submodule +- Text.SetSubmoduleBranch.Current +- Text.SetSubmoduleBranch.New +- Text.SetSubmoduleBranch.New.Tip +- Text.Stash.Mode +- Text.StashCM.ApplyFileChanges +- Text.StashCM.Branch +- Text.StashCM.CopyMessage +- Text.Submodule.Branch +- Text.Submodule.CopyBranch +- Text.Submodule.Deinit +- Text.Submodule.Histories +- Text.Submodule.Move +- Text.Submodule.SetBranch +- Text.Submodule.SetURL +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.Update +- Text.Submodule.URL +- Text.SubmoduleRevisionCompare +- Text.SubmoduleRevisionCompare.OpenDetails +- Text.Tag.Tagger +- Text.Tag.Time +- Text.TagCM.Checkout +- Text.TagCM.CompareTwo +- Text.TagCM.CompareWith +- Text.TagCM.CompareWithHead +- Text.TagCM.Copy.Message +- Text.TagCM.Copy.Name +- Text.TagCM.Copy.Tagger +- Text.TagCM.CopyName +- Text.TagCM.CustomAction +- Text.TagCM.DeleteMultiple +- Text.TagCM.Merge +- Text.UpdateSubmodules.Recursive +- Text.UpdateSubmodules.UpdateToRemoteTrackingBranch +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.AddToGitIgnore.InFolder +- Text.WorkingCopy.AddToGitIgnore.UntrackedInSameFolder +- Text.WorkingCopy.ClearCommitHistories +- Text.WorkingCopy.ClearCommitHistories.Confirm +- Text.WorkingCopy.ConfirmCommitWithDetachedHead +- Text.WorkingCopy.Conflicts.Merge +- Text.WorkingCopy.Conflicts.MergeExternal +- Text.WorkingCopy.FilterChanges +- Text.WorkingCopy.NoVerify +- Text.WorkingCopy.ResetAuthor +- Text.Worktree.Branch +- Text.Worktree.Head +- Text.Worktree.Open +- Text.Worktree.Path +- Text.Yes + +
+ +### ![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 000000000..054d00cdb --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2026.17 \ No newline at end of file diff --git a/build/README.md b/build/README.md new file mode 100644 index 000000000..0698a8fbc --- /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 10](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 000000000..966b1d65f --- /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 %u +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 000000000..8cdcd3a87 Binary files /dev/null and b/build/resources/_common/icons/sourcegit.png differ diff --git a/src/App.icns b/build/resources/app/App.icns similarity index 100% rename from src/App.icns rename to build/resources/app/App.icns diff --git a/build/resources/app/App.plist b/build/resources/app/App.plist new file mode 100644 index 000000000..d20efa5e2 --- /dev/null +++ b/build/resources/app/App.plist @@ -0,0 +1,41 @@ + + + + + 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 + CFBundleDocumentTypes + + + CFBundleTypeName + Supported Folder + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.folder + + + + NSHighResolutionCapable + + + diff --git a/build/resources/appimage/sourcegit.appdata.xml b/build/resources/appimage/sourcegit.appdata.xml new file mode 100644 index 000000000..617737a11 --- /dev/null +++ b/build/resources/appimage/sourcegit.appdata.xml @@ -0,0 +1,72 @@ + + + com.sourcegit_scm.SourceGit + MIT + MIT + SourceGit + Open-source and Free Git GUI Client + +

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 using commitollama
  • +
+
+ https://github.com/sourcegit-scm/sourcegit/issues + https://github.com/sourcegit-scm/sourcegit/issues + https://sourcegit-scm.github.io + https://github.com/sourcegit-scm/sourcegit + com.sourcegit_scm.SourceGit.desktop + + sourcegit-scm + + + #f15336 + #f15336 + + + + https://sourcegit-scm.github.io/images/theme_dark.png + Dark Theme + + + https://sourcegit-scm.github.io/images/theme_light.png + Light Theme + + + +
diff --git a/build/resources/deb/DEBIAN/control b/build/resources/deb/DEBIAN/control new file mode 100755 index 000000000..3f944e3ab --- /dev/null +++ b/build/resources/deb/DEBIAN/control @@ -0,0 +1,8 @@ +Package: sourcegit +Version: 2025.10 +Priority: optional +Depends: libx11-6, libice6, libsm6, @ICU_DEPS@, 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/postinst b/build/resources/deb/DEBIAN/postinst new file mode 100755 index 000000000..8049afa26 --- /dev/null +++ b/build/resources/deb/DEBIAN/postinst @@ -0,0 +1,33 @@ +#!/bin/sh + +set -e + +# summary of how this script can be called: +# * `configure' +# * `abort-upgrade' +# * `abort-remove' `in-favour' +# +# * `abort-remove' +# * `abort-deconfigure' `in-favour' +# `removing' +# +# for details, see http://www.debian.org/doc/debian-policy/ + +case "$1" in + configure) + # Update desktop database + update-desktop-database /usr/share/applications 2>/dev/null || true + # Update icon cache for hicolor theme only + gtk-update-icon-cache -f /usr/share/icons/hicolor 2>/dev/null || true + ;; + + abort-upgrade|abort-remove|abort-deconfigure) + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/build/resources/deb/DEBIAN/preinst b/build/resources/deb/DEBIAN/preinst new file mode 100755 index 000000000..a93f8090f --- /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 000000000..c2c9e4f08 --- /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/flatpak/sourcegit.desktop b/build/resources/flatpak/sourcegit.desktop new file mode 100644 index 000000000..f1213e6f6 --- /dev/null +++ b/build/resources/flatpak/sourcegit.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=SourceGit +Comment=Open-source & Free Git GUI Client +Exec=sourcegit.sh +Icon=io.github.sourcegit_scm.sourcegit +Terminal=false +Type=Application +Categories=Development +MimeType=inode/directory; diff --git a/build/resources/flatpak/sourcegit.metainfo.xml b/build/resources/flatpak/sourcegit.metainfo.xml new file mode 100644 index 000000000..7601b2e7d --- /dev/null +++ b/build/resources/flatpak/sourcegit.metainfo.xml @@ -0,0 +1,73 @@ + + + io.github.sourcegit_scm.sourcegit + MIT + MIT + SourceGit + Open-source and Free Git GUI Client + +

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 using commitollama
  • +
+
+ https://github.com/sourcegit-scm/sourcegit/issues + https://github.com/sourcegit-scm/sourcegit/issues + https://sourcegit-scm.github.io + https://github.com/sourcegit-scm/sourcegit + io.github.sourcegit_scm.sourcegit.desktop + + sourcegit-scm + + + #f15336 + #f15336 + + + + https://sourcegit-scm.github.io/images/theme_dark.png + Dark Theme + + + https://sourcegit-scm.github.io/images/theme_light.png + Light Theme + + + +
diff --git a/build/resources/rpm/SPECS/build.spec b/build/resources/rpm/SPECS/build.spec new file mode 100644 index 000000000..669fdf846 --- /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 %{_topdir}/../../SourceGit/* %{buildroot}/opt/sourcegit/ +ln -rsf %{buildroot}/opt/sourcegit/sourcegit %{buildroot}/%{_bindir} +cp -r %{_topdir}/../_common/applications %{buildroot}/%{_datadir} +cp -r %{_topdir}/../_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 000000000..8d636b5bb --- /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 000000000..262f141ae --- /dev/null +++ b/build/scripts/package.linux.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -e +set -o +set -u +set pipefail + +# ICU versions to support (Debian has no virtual package, must list all) +# Format: space-separated version numbers +ICU_VERSIONS="78 77 76 74 72 71 70 69 68 67 66 65 63" + +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 +rm -f SourceGit/*.pdb + +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/_common/icons/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) + +# Generate ICU dependencies string for Debian +# Debian lacks libicu virtual package, must list all versions with OR operator +icu_deps="libicu" +for v in $ICU_VERSIONS; do + icu_deps="$icu_deps | libicu$v" +done + +# Update the control file (replace placeholder, not whole Depends line) +sed -i -e "s/^Version:.*/Version: $VERSION/" \ + -e "s/^Architecture:.*/Architecture: $arch/" \ + -e "s/^Installed-Size:.*/Installed-Size: $installed_size/" \ + -e "s/@ICU_DEPS@/$icu_deps/" \ + 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 000000000..8f3ddc77c --- /dev/null +++ b/build/scripts/package.osx-app.sh @@ -0,0 +1,17 @@ +#!/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 +rm -f SourceGit.app/Contents/MacOS/*.pdb + +zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit.app diff --git a/build/scripts/package.win.ps1 b/build/scripts/package.win.ps1 new file mode 100644 index 000000000..04cd07134 --- /dev/null +++ b/build/scripts/package.win.ps1 @@ -0,0 +1,2 @@ +Remove-Item -Path build\SourceGit\*.pdb -Force +Compress-Archive -Path build\SourceGit -DestinationPath "build\sourcegit_${env:VERSION}.${env:RUNTIME}.zip" -Force \ No newline at end of file diff --git a/depends/AvaloniaEdit b/depends/AvaloniaEdit new file mode 160000 index 000000000..11bf2b6a8 --- /dev/null +++ b/depends/AvaloniaEdit @@ -0,0 +1 @@ +Subproject commit 11bf2b6a8265329974606604ecc96ece8bfbc0b9 diff --git a/global.json b/global.json new file mode 100644 index 000000000..32035c656 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false + } +} diff --git a/screenshots/theme_dark.png b/screenshots/theme_dark.png index 4a9646dc1..6ecde949b 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 669b4d18c..205e38d6b 100644 Binary files a/screenshots/theme_light.png and b/screenshots/theme_light.png differ diff --git a/src/AI/Agent.cs b/src/AI/Agent.cs new file mode 100644 index 000000000..a01839063 --- /dev/null +++ b/src/AI/Agent.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using OpenAI.Chat; + +namespace SourceGit.AI +{ + public class Agent + { + public Agent(Service service) + { + _service = service; + } + + public async Task GenerateCommitMessageAsync(string repo, string currentBranch, string changeList, string amendParent, Action onUpdate, CancellationToken cancellation) + { + var chatClient = _service.GetChatClient(); + if (chatClient == null) + throw new Exception("Failed to fetch available models from this service. Please check your configuration and try again."); + + var options = new ChatCompletionOptions() { Tools = { ChatTools.GetDetailChangesInFile } }; + var userMessageBuilder = new StringBuilder(); + userMessageBuilder + .AppendLine("Generate a commit message (follow the rule of conventional commit message) for given git repository.") + .AppendLine("- Read all given changed files before generating. Only binary files (such as images, audios ...) can be skipped.") + .AppendLine("- Output the conventional commit message (with detail changes in list) directly. Do not explain your output nor introduce your answer.") + .AppendLine(_service.AdditionalPrompt) + .Append("Repository path: ").AppendLine(repo.Quoted()) + .Append("Current branch: ").AppendLine(currentBranch.Quoted()) + .AppendLine("Changed files ('A' means added, 'M' means modified, 'D' means deleted, 'T' means type changed, 'R' means renamed, 'C' means copied): ") + .Append(changeList); + + var messages = new List() { new UserChatMessage(userMessageBuilder.ToString()) }; + + do + { + ChatCompletion completion = await chatClient.CompleteChatAsync(messages, options, cancellation); + var inProgress = false; + + switch (completion.FinishReason) + { + case ChatFinishReason.Stop: + if (onUpdate != null) + { + onUpdate.Invoke(string.Empty); + onUpdate.Invoke("# Assistant"); + if (completion.Content.Count > 0) + { + var text = completion.Content[0].Text.ReplaceLineEndings("\n").Trim(); + var start = 0; + var len = text.Length; + if (text.StartsWith("```", StringComparison.Ordinal)) + { + var idx = text.IndexOf('\n') + 1; + start += idx; + len -= idx; + } + + if (text.EndsWith("\n```", StringComparison.Ordinal)) + len -= 4; + + if (len > 0) + onUpdate.Invoke(text.Substring(start, len)); + else + onUpdate.Invoke("[No content was generated.]"); + } + else + { + onUpdate.Invoke("[No content was generated.]"); + } + + onUpdate.Invoke(string.Empty); + onUpdate.Invoke("# Token Usage"); + onUpdate.Invoke($"Total: {completion.Usage.TotalTokenCount}. Input: {completion.Usage.InputTokenCount}. Output: {completion.Usage.OutputTokenCount}"); + } + break; + case ChatFinishReason.Length: + throw new Exception("The response was cut off because it reached the maximum length. Consider increasing the max tokens limit."); + case ChatFinishReason.ToolCalls: + { + var message = new AssistantChatMessage(completion); +#pragma warning disable SCME0001 + var hasReasoningContent = completion.Patch.TryGetValue("$.choices[0].message.reasoning_content"u8, out string reasoning); + if (hasReasoningContent) + { + if (string.IsNullOrEmpty(reasoning)) + message.Patch.Set("$.reasoning_content"u8, BinaryData.FromString("\"\"")); + else + message.Patch.Set("$.reasoning_content"u8, reasoning); + } +#pragma warning restore SCME0001 + messages.Add(message); + + foreach (var call in completion.ToolCalls) + { + var result = await ChatTools.ProcessAsync(call, repo, amendParent, onUpdate); + messages.Add(result); + } + + inProgress = true; + break; + } + case ChatFinishReason.ContentFilter: + throw new Exception("Omitted content due to a content filter flag"); + default: + break; + } + + if (!inProgress) + break; + } while (true); + } + + private readonly Service _service; + } +} diff --git a/src/AI/ChatTools.cs b/src/AI/ChatTools.cs new file mode 100644 index 000000000..57a8e1459 --- /dev/null +++ b/src/AI/ChatTools.cs @@ -0,0 +1,53 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using OpenAI.Chat; + +namespace SourceGit.AI +{ + public static class ChatTools + { + public static readonly ChatTool GetDetailChangesInFile = ChatTool.CreateFunctionTool( + "GetDetailChangesInFile", + "Get the detailed changes in the specified file in the specified repository.", + BinaryData.FromBytes(Encoding.UTF8.GetBytes(""" + { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "The path to the file." + }, + "originalFile": { + "type": "string", + "description": "The path to the original file when it has been renamed or copied." + } + }, + "required": ["file"] + } + """)), false); + + public static async Task ProcessAsync(ChatToolCall call, string repo, string amendParent, Action output) + { + using var doc = JsonDocument.Parse(call.FunctionArguments); + + if (call.FunctionName.Equals(GetDetailChangesInFile.FunctionName)) + { + var hasFile = doc.RootElement.TryGetProperty("file", out var filePath); + var hasOriginalFile = doc.RootElement.TryGetProperty("originalFile", out var originalFilePath); + if (!hasFile) + throw new ArgumentException("file", "The file argument is required"); + + output?.Invoke($"Read changes in file: {filePath.GetString()}"); + + var orgFilePath = hasOriginalFile ? originalFilePath.GetString() : string.Empty; + var rs = await new Commands.GetFileChangeForAI(repo, filePath.GetString(), orgFilePath, amendParent).ReadAsync(); + var message = rs.IsSuccess ? rs.StdOut : string.Empty; + return new ToolChatMessage(call.Id, message); + } + + throw new NotSupportedException($"The tool {call.FunctionName} is not supported"); + } + } +} diff --git a/src/AI/Service.cs b/src/AI/Service.cs new file mode 100644 index 000000000..7d311c6c0 --- /dev/null +++ b/src/AI/Service.cs @@ -0,0 +1,98 @@ +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Azure.AI.OpenAI; +using CommunityToolkit.Mvvm.ComponentModel; +using OpenAI; +using OpenAI.Chat; + +namespace SourceGit.AI +{ + public class Service : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string Server + { + get; + set; + } = string.Empty; + + public string ApiKey + { + get; + set; + } = string.Empty; + + public bool ReadApiKeyFromEnv + { + get; + set; + } = false; + + [JsonIgnore] + public List AvailableModels + { + get; + private set; + } = []; + + public string Model + { + get => _model; + set => SetProperty(ref _model, value); + } + + public bool AutoFetchAvailableModels + { + get => _autoFetchAvailableModels; + set => SetProperty(ref _autoFetchAvailableModels, value); + } + + public string AdditionalPrompt + { + get; + set; + } = string.Empty; + + public void FetchAvailableModels() + { + if (!_autoFetchAvailableModels) + { + if (!string.IsNullOrEmpty(Model)) + AvailableModels = [Model]; + return; + } + + var allModels = GetOpenAIClient().GetOpenAIModelClient().GetModels(); + AvailableModels = new List(); + foreach (var model in allModels.Value) + AvailableModels.Add(model.Id); + + if (AvailableModels.Count > 0 && (string.IsNullOrEmpty(Model) || !AvailableModels.Contains(Model))) + Model = AvailableModels[0]; + } + + public ChatClient GetChatClient() + { + return !string.IsNullOrEmpty(Model) ? GetOpenAIClient().GetChatClient(Model) : null; + } + + private OpenAIClient GetOpenAIClient() + { + var credential = new ApiKeyCredential(ReadApiKeyFromEnv ? Environment.GetEnvironmentVariable(ApiKey) : ApiKey); + return Server.Contains("openai.azure.com/", StringComparison.Ordinal) + ? new AzureOpenAIClient(new Uri(Server), credential) + : new OpenAIClient(credential, new() { Endpoint = new Uri(Server) }); + } + + private string _name = string.Empty; + private string _model = string.Empty; + private bool _autoFetchAvailableModels = true; + } +} diff --git a/src/App.Commands.cs b/src/App.Commands.cs new file mode 100644 index 000000000..06c0afbf1 --- /dev/null +++ b/src/App.Commands.cs @@ -0,0 +1,100 @@ +using System; +using System.Windows.Input; +using Avalonia.Controls.ApplicationLifetimes; + +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(async _ => + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var dialog = new Views.Preferences(); + await dialog.ShowDialog(owner); + } + }); + + public static readonly Command OpenHotkeysCommand = new Command(async _ => + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var dialog = new Views.Hotkeys(); + await dialog.ShowDialog(owner); + } + }); + + public static readonly Command OpenAppDataDirCommand = new Command(_ => + { + Native.OS.OpenInFileManager(Native.OS.DataDir); + }); + + public static readonly Command OpenAboutCommand = new Command(async _ => + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var dialog = new Views.About(); + await dialog.ShowDialog(owner); + } + }); + + 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 HideAppCommand = new Command(_ => + { + if (OperatingSystem.IsMacOS()) + Native.MacOSUtilities.HideSelf(); + }); + + public static readonly Command HideOtherApplicationsCommand = new Command(_ => + { + if (OperatingSystem.IsMacOS()) + Native.MacOSUtilities.HideOtherApplications(); + }); + + public static readonly Command ShowAllApplicationsCommand = new Command(_ => + { + if (OperatingSystem.IsMacOS()) + Native.MacOSUtilities.ShowAllApplications(); + }); + } +} diff --git a/src/App.Extensions.cs b/src/App.Extensions.cs new file mode 100644 index 000000000..08c1b5478 --- /dev/null +++ b/src/App.Extensions.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using Avalonia.Media; + +namespace SourceGit +{ + public static class StringExtensions + { + public static string Quoted(this string value) + { + return $"\"{Escaped(value)}\""; + } + + public static string Escaped(this string value) + { + return value.Replace("\"", "\\\"", StringComparison.Ordinal); + } + + public static string EscapeForBRE(this string value) + { + return value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace(".", "\\.", StringComparison.Ordinal) + .Replace("[", "\\[", StringComparison.Ordinal) + .Replace("*", "\\*", StringComparison.Ordinal) + .Replace("^", "\\^", StringComparison.Ordinal) + .Replace("$", "\\$", StringComparison.Ordinal) + .Replace("{", "\\{", StringComparison.Ordinal); + } + + public static string FormatFontNames(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; + + var sb = new StringBuilder(); + var prevChar = '\0'; + + foreach (var c in t) + { + if (c == ' ' && prevChar == ' ') + continue; + sb.Append(c); + prevChar = c; + } + + var name = sb.ToString(); + try + { + var fontFamily = FontFamily.Parse(name); + if (fontFamily.FamilyTypefaces.Count > 0) + trimmed.Add(name); + } + catch + { + // Ignore exceptions. + } + } + + return trimmed.Count > 0 ? string.Join(',', trimmed) : string.Empty; + } + } + + public static class CommandExtensions + { + public static T Use(this T cmd, Models.ICommandLog log) where T : Commands.Command + { + cmd.Log = log; + return cmd; + } + + public static T WithCancellation(this T cmd, CancellationToken token) where T : Commands.Command + { + cmd.CancellationToken = token; + return cmd; + } + } + + public static class DirectoryInfoExtension + { + public static void WalkFiles(this DirectoryInfo dir, Action onFile, int maxDepth = 4) + { + try + { + var options = new EnumerationOptions() + { + IgnoreInaccessible = true, + RecurseSubdirectories = false, + }; + + foreach (var file in dir.GetFiles("*", options)) + onFile(file.FullName); + + if (maxDepth > 0) + { + foreach (var subDir in dir.GetDirectories("*", options)) + { + if (subDir.Name.StartsWith(".", StringComparison.Ordinal) || + subDir.Name.Equals("node_modules", StringComparison.OrdinalIgnoreCase)) + continue; + + WalkFiles(subDir, onFile, maxDepth - 1); + } + } + } + catch + { + // Ignore exceptions. + } + } + } +} diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs new file mode 100644 index 000000000..8eef67ae7 --- /dev/null +++ b/src/App.JsonCodeGen.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit +{ + public class DateTimeConverter : JsonConverter + { + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateTime.ParseExact(reader.GetString(), FORMAT, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).ToLocalTime(); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToUniversalTime().ToString(FORMAT)); + } + + private const string FORMAT = "yyyy-MM-ddTHH:mm:ssZ"; + } + + 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(DateTimeConverter), + typeof(ColorConverter), + typeof(GridLengthConverter), + ] + )] + [JsonSerializable(typeof(Models.ExternalToolCustomization))] + [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] + [JsonSerializable(typeof(Models.JetBrainsState))] + [JsonSerializable(typeof(Models.ThemeOverrides))] + [JsonSerializable(typeof(Models.Version))] + [JsonSerializable(typeof(Models.RepositorySettings))] + [JsonSerializable(typeof(Models.RepositoryUIStates))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(ViewModels.Preferences))] + [JsonSerializable(typeof(ViewModels.RepositoryNodeMinimalInfo))] + internal partial class JsonCodeGen : JsonSerializerContext { } +} diff --git a/src/App.axaml b/src/App.axaml index b91242945..bfc05cfb4 100644 --- a/src/App.axaml +++ b/src/App.axaml @@ -1,18 +1,32 @@ - + + + + + + + + + + + + + + @@ -22,4 +36,21 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + diff --git a/src/App.axaml.cs b/src/App.axaml.cs index 96276fb9d..a22f764d6 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -1,165 +1,606 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json; +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; using Avalonia.Styling; -using System; -using System.IO; -using System.Reflection; -using System.Text; - -namespace SourceGit { - public partial class App : Application { +using Avalonia.Threading; +namespace SourceGit +{ + public partial class App : Application + { + #region App Entry Point [STAThread] - public static void Main(string[] args) { - try { - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - } catch (Exception ex) { - var builder = new StringBuilder(); - builder.Append("Crash: "); - builder.Append(ex.Message); - builder.Append("\n\n"); - builder.Append("----------------------------\n"); - builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}\n"); - builder.Append($"OS: {Environment.OSVersion.ToString()}\n"); - builder.Append($"Framework: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\n"); - builder.Append($"Source: {ex.Source}\n"); - builder.Append($"---------------------------\n\n"); - builder.Append(ex.StackTrace); - - var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); - var file = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SourceGit", - $"crash_{time}.log"); - File.WriteAllText(file, builder.ToString()); - } - } - - public static AppBuilder BuildAvaloniaApp() { - var builder = AppBuilder.Configure(); - builder.UsePlatformDetect(); + public static void Main(string[] args) + { + Native.OS.SetupDataDir(); - if (OperatingSystem.IsWindows()) { - builder.With(new FontManagerOptions() { - FontFallbacks = [ - new FontFallback { FontFamily = new FontFamily("Microsoft YaHei UI") } - ] - }); - } else if (OperatingSystem.IsMacOS()) { - builder.With(new FontManagerOptions() { - FontFallbacks = [ - new FontFallback { FontFamily = new FontFamily("PingFang SC") } - ] - }); - builder.With(new MacOSPlatformOptions() { - DisableDefaultApplicationMenuItems = true, - DisableNativeMenus = true, - }); + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + Native.OS.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) + { + Native.OS.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; } + #endregion - public static void RaiseException(string context, string message) { - if (Current is App app && app._notificationReceiver != null) { - var notice = new Models.Notification() { IsError = true, Message = message }; - app._notificationReceiver.OnReceiveNotification(context, notice); + #region Utility Functions + public static async Task AskConfirmAsync(string message, Models.ConfirmButtonType buttonType = Models.ConfirmButtonType.OkCancel) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var confirm = new Views.Confirm(); + confirm.SetData(message, buttonType); + return await confirm.ShowDialog(owner); } + + return false; } - public static void SendNotification(string context, string message) { - if (Current is App app && app._notificationReceiver != null) { - var notice = new Models.Notification() { IsError = false, Message = message }; - app._notificationReceiver.OnReceiveNotification(context, notice); + public static async Task AskConfirmEmptyCommitAsync(bool hasLocalChanges, bool hasSelectedUnstaged) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var confirm = new Views.ConfirmEmptyCommit(); + confirm.TxtMessage.Text = Text(hasLocalChanges ? "ConfirmEmptyCommit.WithLocalChanges" : "ConfirmEmptyCommit.NoLocalChanges"); + confirm.BtnStageAllAndCommit.IsVisible = hasLocalChanges; + confirm.BtnStageSelectedAndCommit.IsVisible = hasSelectedUnstaged; + return await confirm.ShowDialog(owner); } + + return Models.ConfirmEmptyCommitResult.Cancel; } - public static void SetLocale(string localeKey) { - var app = Current as App; - var targetLocale = app.Resources[localeKey] as ResourceDictionary; - if (targetLocale == null || targetLocale == app._activeLocale) { + public static void SetLocale(string localeKey) + { + var locale = Models.Locale.Supported.Find(x => x.Key.Equals(localeKey, StringComparison.OrdinalIgnoreCase)); + var finalLocaleKey = locale?.Key ?? "en_US"; + + if (Current is not App app || + app.Resources[finalLocaleKey] is not ResourceDictionary targetLocale || + targetLocale == app._activeLocale) return; - } - if (app._activeLocale != null) { + if (app._activeLocale != null) app.Resources.MergedDictionaries.Remove(app._activeLocale); - } app.Resources.MergedDictionaries.Add(targetLocale); app._activeLocale = targetLocale; } - public static void SetTheme(string theme) { - if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) { - App.Current.RequestedThemeVariant = ThemeVariant.Light; - } else if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) { - App.Current.RequestedThemeVariant = ThemeVariant.Dark; - } else { - App.Current.RequestedThemeVariant = ThemeVariant.Default; + public static void SetTheme(string theme, string themeOverridesFile) + { + if (Current is not App app) + 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(); + using var stream = File.OpenRead(themeOverridesFile); + var overrides = JsonSerializer.Deserialize(stream, 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); + + app.Resources.MergedDictionaries.Add(resDic); + app._themeOverrides = resDic; + } + catch + { + // ignore + } + } + else + { + Models.CommitGraph.SetDefaultPens(); } } - public static async void CopyText(string data) { - if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - if (desktop.MainWindow.Clipboard is { } clipbord) { - await clipbord.SetTextAsync(data); + public static void SetFonts(string defaultFont, string monospaceFont) + { + if (Current is not App app) + return; + + if (app._fontsOverrides != null) + { + app.Resources.MergedDictionaries.Remove(app._fontsOverrides); + app._fontsOverrides = null; + } + + defaultFont = StringExtensions.FormatFontNames(defaultFont); + monospaceFont = StringExtensions.FormatFontNames(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 NL,{defaultFont}"; + resDic.Add("Fonts.Monospace", FontFamily.Parse(monospaceFont)); } } + else + { + if (!string.IsNullOrEmpty(defaultFont) && !monospaceFont.Contains(defaultFont, StringComparison.Ordinal)) + monospaceFont = $"{monospaceFont},{defaultFont}"; + + resDic.Add("Fonts.Monospace", FontFamily.Parse(monospaceFont)); + } + + if (resDic.Count > 0) + { + app.Resources.MergedDictionaries.Add(resDic); + app._fontsOverrides = resDic; + } } - public static string Text(string key, params object[] args) { - var fmt = Current.FindResource($"Text.{key}") as string; - if (string.IsNullOrWhiteSpace(fmt)) return $"Text.{key}"; + 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; - icon.Data = Current.FindResource(key) as StreamGeometry; - return icon; + 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.Shutdown(exitCode); + else + Environment.Exit(exitCode); } + #endregion - public static TopLevel GetTopLevel() { - if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - return desktop.MainWindow; + #region Overrides + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + + var pref = ViewModels.Preferences.Instance; + SetLocale(pref.Locale); + SetTheme(pref.Theme, pref.ThemeOverrides); + SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily); + } + + 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 (TryLaunchAsFileHistoryViewer(desktop)) + return; + + if (TryLaunchAsBlameViewer(desktop)) + return; + + if (TryLaunchAsCoreEditor(desktop)) + return; + + if (TryLaunchAsAskpass(desktop)) + return; + + TryLaunchAsNormal(desktop); } - return null; } + #endregion + + #region Launch Ways + 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].Replace('\\', '/').Trim('\"').Trim(); + 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; - public static void Quit() { - if (Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow.Close(); - desktop.Shutdown(); + var jobsFile = Path.Combine(dirInfo.Parent!.FullName, "sourcegit.interactive_rebase"); + if (!File.Exists(jobsFile)) + return true; + + using var stream = File.OpenRead(jobsFile); + var collection = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.InteractiveRebaseJobCollection); + collection.WriteTodoList(file); + 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].Replace('\\', '/').Trim('\"').Trim(); + 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.interactive_rebase"); + 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(); + using var stream = File.OpenRead(jobsFile); + var collection = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.InteractiveRebaseJobCollection); + if (collection.Onto.StartsWith(onto, StringComparison.OrdinalIgnoreCase) && collection.OrigHead.StartsWith(origHead, StringComparison.OrdinalIgnoreCase)) + collection.WriteCommitMessage(doneFile, file); + + return true; + } + + private bool TryLaunchAsFileHistoryViewer(IClassicDesktopStyleApplicationLifetime desktop) + { + var args = desktop.Args; + if (args is not { Length: > 1 } || !args[0].Equals("--history", StringComparison.Ordinal)) + return false; + + var fullPath = Path.GetFullPath(args[1].Replace('\\', '/').Trim('\"').Trim()); + var dir = Path.GetDirectoryName(fullPath); + + var test = new Commands.QueryRepositoryRootPath(dir).GetResult(); + if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + { + Console.Out.WriteLine($"'{args[1]}' is not in a valid git repository"); + desktop.Shutdown(-1); + return true; + } + + Native.OS.SetupExternalTools(); + Models.AvatarManager.Instance.Start(); + + var repo = test.StdOut.Trim(); + var relativePath = Path.GetRelativePath(repo, fullPath).Replace('\\', '/'); + if (File.Exists(fullPath)) + { + desktop.MainWindow = new Views.FileHistories() + { + DataContext = new ViewModels.FileHistories(repo, relativePath) + }; } + else if (Directory.Exists(fullPath)) + { + desktop.MainWindow = new Views.DirHistories() + { + DataContext = new ViewModels.DirHistories(repo, relativePath.TrimEnd('/')) + }; + } + else + { + Console.Out.WriteLine($"'{args[1]}' does not exist in repository: '{repo}'"); + desktop.Shutdown(-1); + } + + return true; } - public override void Initialize() { - AvaloniaXamlLoader.Load(this); + private bool TryLaunchAsBlameViewer(IClassicDesktopStyleApplicationLifetime desktop) + { + var args = desktop.Args; + if (args is not { Length: > 1 } || !args[0].Equals("--blame", StringComparison.Ordinal)) + return false; + + var file = Path.GetFullPath(args[1].Replace('\\', '/').Trim('\"').Trim()); + var dir = Path.GetDirectoryName(file); + + var test = new Commands.QueryRepositoryRootPath(dir).GetResult(); + if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + { + Console.Out.WriteLine($"'{args[1]}' is not in a valid git repository"); + desktop.Shutdown(-1); + return true; + } + + var repo = test.StdOut.Trim(); + var head = new Commands.QuerySingleCommit(repo, "HEAD").GetResult(); + if (head == null) + { + Console.Out.WriteLine($"{repo} has no commits!"); + desktop.Shutdown(-1); + return true; + } - SetLocale(ViewModels.Preference.Instance.Locale); - SetTheme(ViewModels.Preference.Instance.Theme); + var relFile = Path.GetRelativePath(repo, file); + var viewer = new Views.Blame() + { + DataContext = new ViewModels.Blame(repo, relFile, head) + }; + desktop.MainWindow = viewer; + return true; } - public override void OnFrameworkInitializationCompleted() { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - BindingPlugins.DataValidators.RemoveAt(0); + private bool TryLaunchAsCoreEditor(IClassicDesktopStyleApplicationLifetime desktop) + { + var args = desktop.Args; + if (args is not { Length: > 1 } || !args[0].Equals("--core-editor", StringComparison.Ordinal)) + return false; + + var file = args[1].Replace('\\', '/').Trim('\"').Trim(); + if (!File.Exists(file)) + { + desktop.Shutdown(-1); + return true; + } - var launcher = new Views.Launcher(); - _notificationReceiver = launcher; - desktop.MainWindow = launcher; + var editor = new Views.CommitMessageEditor(); + editor.AsStandalone(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; } - base.OnFrameworkInitializationCompleted(); + return false; + } + + private void TryLaunchAsNormal(IClassicDesktopStyleApplicationLifetime desktop) + { + _ipcChannel = new Models.IpcChannel(); + if (!_ipcChannel.IsFirstInstance) + { + var arg = desktop.Args is { Length: > 0 } ? desktop.Args[0] : string.Empty; + if (!string.IsNullOrEmpty(arg)) + { + arg = arg.Replace('\\', '/').TrimEnd('/').Trim('\"').Trim(); + if (arg.Length > 0 && !Path.IsPathFullyQualified(arg)) + arg = Path.GetFullPath(arg); + } + + _ipcChannel.SendToFirstInstance(arg); + Environment.Exit(0); + return; + } + + Native.OS.SetupExternalTools(); + Models.AvatarManager.Instance.Start(); + + if (this.TryGetFeature() is { } activatable) + { + activatable.Activated += (_, e) => + { + if (e is FileActivatedEventArgs { Files: { Count: > 0 } } fileArgs) + _launcher?.TryOpenRepositoryFromPath(fileArgs.Files[0].Path.LocalPath); + }; + } + + string startupRepo = null; + if (desktop.Args is { Length: 1 }) + { + var arg = desktop.Args[0].Replace('\\', '/').TrimEnd('/').Trim('\"').Trim(); + if (Directory.Exists(arg)) + startupRepo = arg; + } + + var pref = ViewModels.Preferences.Instance; + pref.SetCanModify(); + pref.UpdateAvailableAIModels(); + + _launcher = new ViewModels.Launcher(startupRepo); + desktop.MainWindow = new Views.Launcher() { DataContext = _launcher }; + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + // Fix macOS crash when quiting from Dock + if (OperatingSystem.IsMacOS()) + { + desktop.ShutdownRequested += (_, e) => + { + e.Cancel = true; + Dispatcher.UIThread.Post(() => Quit(0)); + }; + } + + desktop.Exit += (_, _) => + { + _ipcChannel?.Dispose(); + _ipcChannel = null; + }; + + _ipcChannel.MessageReceived += repo => + { + Dispatcher.UIThread.Invoke(() => + { + _launcher.TryOpenRepositoryFromPath(repo); + if (desktop.MainWindow is Views.Launcher main) + main.BringToTop(); + }); + }; + +#if !DISABLE_UPDATE_DETECTION + if (pref.ShouldCheck4UpdateOnStartup()) + Check4Update(); +#endif + } + #endregion + + #region Check for Updates + private void Check4Update(bool manually = false) + { + if (_launcher != null) + _launcher.NewVersion = null; + + Task.Run(async () => + { + try + { + // Fetch latest release information. + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(5); + + var data = await client.GetStringAsync("https://sourcegit-scm.github.io/data/version.json"); + var ver = JsonSerializer.Deserialize(data, JsonCodeGen.Default.Version); + if (ver == null) + return; + + if (manually) + { + if (ver.IsNewVersion) + ShowSelfUpdateResult(ver); + else + ShowSelfUpdateResult(new Models.AlreadyUpToDate()); + } + else if (_launcher != null) + { + if (!ver.IsNewVersion || ver.TagName == ViewModels.Preferences.Instance.IgnoreUpdateTag) + return; + + _launcher.NewVersion = ver; + } + } + catch (Exception e) + { + if (manually) + ShowSelfUpdateResult(new Models.SelfUpdateFailed(e)); + } + }); + } + + private void ShowSelfUpdateResult(object data) + { + try + { + Dispatcher.UIThread.Invoke(async () => + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + var ctx = new ViewModels.SelfUpdate { Data = data }; + var dialog = new Views.SelfUpdate() { DataContext = ctx }; + await dialog.ShowDialog(owner); + } + }); + } + catch + { + // Ignore exceptions. + } } + #endregion + private Models.IpcChannel _ipcChannel = null; + private ViewModels.Launcher _launcher = null; private ResourceDictionary _activeLocale = null; - private Models.INotificationReceiver _notificationReceiver = null; + private ResourceDictionary _themeOverrides = null; + private ResourceDictionary _fontsOverrides = null; } -} \ No newline at end of file +} diff --git a/src/App.manifest b/src/App.manifest index b3bc3bdf4..11a2ff11c 100644 --- a/src/App.manifest +++ b/src/App.manifest @@ -1,7 +1,7 @@  diff --git a/src/App.plist b/src/App.plist deleted file mode 100644 index 0ae0faa59..000000000 --- a/src/App.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleIconFile - App.icns - CFBundleIdentifier - com.sourcegit-scm.sourcegit - CFBundleName - SourceGit - CFBundleVersion - 8.2.0 - LSMinimumSystemVersion - 10.12 - CFBundleExecutable - SourceGit - CFBundleInfoDictionaryVersion - 6.0 - CFBundlePackageType - APPL - CFBundleShortVersionString - 8.2 - NSHighResolutionCapable - - - \ No newline at end of file diff --git a/src/BuildMacOS.command b/src/BuildMacOS.command deleted file mode 100644 index c8a028ea8..000000000 --- a/src/BuildMacOS.command +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh - -dotnet publish -c Release -r osx-x64 -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained -dotnet publish -c Release -r osx-arm64 -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained - -rm -rf build - -mkdir -p build/SourceGit -mkdir -p build/SourceGit/x64/SourceGit.app/Contents/MacOS -mkdir -p build/SourceGit/arm64/SourceGit.app/Contents/MacOS -mkdir -p build/SourceGit/x64/SourceGit.app/Contents/Resources -mkdir -p build/SourceGit/arm64/SourceGit.app/Contents/Resources - -cp App.plist build/SourceGit/x64/SourceGit.app/Contents/Info.plist -cp App.plist build/SourceGit/arm64/SourceGit.app/Contents/Info.plist - -cp App.icns build/SourceGit/x64/SourceGit.app/Contents/Resources/App.icns -cp App.icns build/SourceGit/arm64/SourceGit.app/Contents/Resources/App.icns - -cp -r bin/Release/net8.0/osx-x64/publish/* build/SourceGit/x64/SourceGit.app/Contents/MacOS/ -cp -r bin/Release/net8.0/osx-arm64/publish/* build/SourceGit/arm64/SourceGit.app/Contents/MacOS/ - -rm -rf build/SourceGit/x64/SourceGit.app/Contents/MacOS/SourceGit.dsym -rm -rf build/SourceGit/arm64/SourceGit.app/Contents/MacOS/SourceGit.dsym - -cd build -zip SourceGit.macOS.zip -r SourceGit \ No newline at end of file diff --git a/src/BuildWindows.bat b/src/BuildWindows.bat deleted file mode 100644 index 8ec725022..000000000 --- a/src/BuildWindows.bat +++ /dev/null @@ -1 +0,0 @@ -dotnet publish -c Release -r win-x64 -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained \ No newline at end of file diff --git a/src/Commands/Add.cs b/src/Commands/Add.cs index c83c330de..916063d82 100644 --- a/src/Commands/Add.cs +++ b/src/Commands/Add.cs @@ -1,24 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - public class Add : Command { - public Add(string repo, List changes = null) { +namespace SourceGit.Commands +{ + public class Add : Command + { + public Add(string repo, string pathspecFromFile) + { WorkingDirectory = repo; Context = repo; - - if (changes == null || changes.Count == 0) { - Args = "add ."; - } else { - var builder = new StringBuilder(); - builder.Append("add --"); - foreach (var c in changes) { - builder.Append(" \""); - builder.Append(c.Path); - builder.Append("\""); - } - Args = builder.ToString(); - } + Args = $"add --force --verbose --pathspec-from-file={pathspecFromFile.Quoted()}"; } } } diff --git a/src/Commands/Apply.cs b/src/Commands/Apply.cs index 1f550de4c..ca6ffe8d9 100644 --- a/src/Commands/Apply.cs +++ b/src/Commands/Apply.cs @@ -1,13 +1,26 @@ -namespace SourceGit.Commands { - public class Apply : Command { - public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceMode, string extra) { +using System.Text; + +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 (!string.IsNullOrEmpty(extra)) Args += $"{extra} "; - Args += $"\"{file}\""; + + var builder = new StringBuilder(1024); + builder.Append("apply "); + + if (ignoreWhitespace) + builder.Append("--ignore-whitespace "); + else + builder.Append("--whitespace=").Append(whitespaceMode).Append(' '); + + if (!string.IsNullOrEmpty(extra)) + builder.Append(extra).Append(' '); + + Args = builder.Append(file.Quoted()).ToString(); } } } diff --git a/src/Commands/Archive.cs b/src/Commands/Archive.cs index ac748653f..e2214aac8 100644 --- a/src/Commands/Archive.cs +++ b/src/Commands/Archive.cs @@ -1,19 +1,12 @@ -using System; - -namespace SourceGit.Commands { - public class Archive : Command { - public Archive(string repo, string revision, string saveTo, Action outputHandler) { +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}"; - TraitErrorAsOutput = true; - _outputHandler = outputHandler; + Args = $"archive --format=zip --verbose --output={saveTo.Quoted()} {revision}"; } - - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); - } - - private Action _outputHandler; } } diff --git a/src/Commands/AssumeUnchanged.cs b/src/Commands/AssumeUnchanged.cs index d7dc4c065..2ddaa3c33 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 { - class ViewCommand : Command { - private static readonly Regex REG = new Regex(@"^(\w)\s+(.+)$"); - - public ViewCommand(string repo) { - WorkingDirectory = repo; - Args = "ls-files -v"; - RaiseError = false; - } - - public List Result() { - Exec(); - return _outs; - } - - protected 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); - } - } - - private List _outs = new List(); - } - - class ModCommand : Command { - public ModCommand(string repo, string file, bool bAdd) { - var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; - - WorkingDirectory = repo; - Context = repo; - Args = $"update-index {mode} -- \"{file}\""; - } +namespace SourceGit.Commands +{ + public class AssumeUnchanged : Command + { + public AssumeUnchanged(string repo, string file, bool bAdd) + { + var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; + + WorkingDirectory = repo; + Context = repo; + Args = $"update-index {mode} -- {file.Quoted()}"; } - - public AssumeUnchanged(string repo) { - _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(); - } - - private string _repo; } } diff --git a/src/Commands/Bisect.cs b/src/Commands/Bisect.cs new file mode 100644 index 000000000..a3bf1a976 --- /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 0598fa0fd..0f247d736 100644 --- a/src/Commands/Blame.cs +++ b/src/Commands/Blame.cs @@ -1,32 +1,51 @@ using System; using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; -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+\((.*)\s+(\d+)\s+[\-\+]?\d+\s+\d+\) (.*)")] + private static partial Regex REG_FORMAT(); - public Blame(string repo, string file, string revision) { + public Blame(string repo, string file, string revision, bool ignoreWhitespace) + { WorkingDirectory = repo; Context = repo; - Args = $"blame -t {revision} -- \"{file}\""; RaiseError = false; - _result.File = file; + var builder = new StringBuilder(); + builder.Append("blame -f -t "); + if (ignoreWhitespace) + builder.Append("-w "); + builder.Append(revision).Append(" -- ").Append(file.Quoted()); + + Args = builder.ToString(); } - public Models.BlameData Result() { - var succ = Exec(); - if (!succ) { - return new Models.BlameData(); + public async Task ReadAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return _result; + + 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) { + if (_needUnifyCommitSHA) + { + foreach (var line in _result.LineInfos) + { + if (line.CommitSHA.Length > _minSHALen) line.CommitSHA = line.CommitSHA.Substring(0, _minSHALen); - } } } @@ -34,44 +53,47 @@ public Models.BlameData Result() { return _result; } - protected override void OnReadline(string line) { - if (_result.IsBinary) return; - if (string.IsNullOrEmpty(line)) return; - - if (line.IndexOf('\0') >= 0) { + private void ParseLine(string line) + { + if (line.Contains('\0')) + { _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); + _content.AppendLine(match.Groups[5].Value); - var commit = match.Groups[1].Value; - var author = match.Groups[2].Value; - var timestamp = int.Parse(match.Groups[3].Value); - var when = UTC_START.AddSeconds(timestamp).ToString("yyyy/MM/dd"); + var commit = match.Groups[1].Value; + var file = match.Groups[2].Value.Trim(); + var author = match.Groups[3].Value; + var timestamp = ulong.Parse(match.Groups[4].Value); - var info = new Models.BlameLineInfo() { + var info = new Models.BlameLineInfo() + { IsFirstInGroup = commit != _lastSHA, CommitSHA = commit, + File = file, Author = author, - Time = when, + Timestamp = timestamp, }; _result.LineInfos.Add(info); _lastSHA = commit; - if (line[0] == '^') { + if (line[0] == '^') + { _needUnifyCommitSHA = true; _minSHALen = Math.Min(_minSHALen, commit.Length); } } - private Models.BlameData _result = new Models.BlameData(); - private StringBuilder _content = new StringBuilder(); + private readonly Models.BlameData _result = new Models.BlameData(); + private readonly StringBuilder _content = new StringBuilder(); 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 f109d1e48..6ed017841 100644 --- a/src/Commands/Branch.cs +++ b/src/Commands/Branch.cs @@ -1,39 +1,59 @@ -namespace SourceGit.Commands { - public static class Branch { - public static bool Create(string repo, string name, string basedOn) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"branch {name} {basedOn}"; - return cmd.Exec(); +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class Branch : Command + { + public Branch(string repo, string name) + { + WorkingDirectory = repo; + Context = repo; + _name = name; + } + + public async Task CreateAsync(string basedOn, bool force) + { + var builder = new StringBuilder(); + builder.Append("branch "); + if (force) + builder.Append("-f "); + builder.Append(_name); + builder.Append(" "); + builder.Append(basedOn); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public static bool Rename(string repo, string name, string to) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"branch -M {name} {to}"; - return cmd.Exec(); + public async Task RenameAsync(string to) + { + Args = $"branch -M {_name} {to}"; + return await ExecAsync().ConfigureAwait(false); } - public static bool SetUpstream(string repo, string name, string upstream) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - if (string.IsNullOrEmpty(upstream)) { - cmd.Args = $"branch {name} --unset-upstream"; - } else { - cmd.Args = $"branch {name} -u {upstream}"; - } - return cmd.Exec(); + public async Task SetUpstreamAsync(Models.Branch tracking) + { + if (tracking == null) + Args = $"branch {_name} --unset-upstream"; + else + Args = $"branch {_name} -u {tracking.FriendlyName}"; + + return await ExecAsync().ConfigureAwait(false); + } + + public async Task DeleteLocalAsync(bool force) + { + Args = $"branch {(force ? "-D" : "-d")} {_name}"; + return await ExecAsync().ConfigureAwait(false); } - public static bool Delete(string repo, string name) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"branch -D {name}"; - return cmd.Exec(); + public async Task DeleteRemoteAsync(string remote, bool force) + { + Args = $"branch {(force ? "-D" : "-d")} -r {remote}/{_name}"; + return await ExecAsync().ConfigureAwait(false); } + + private readonly string _name; } } diff --git a/src/Commands/Checkout.cs b/src/Commands/Checkout.cs index 14121fab2..024636bf9 100644 --- a/src/Commands/Checkout.cs +++ b/src/Commands/Checkout.cs @@ -1,59 +1,91 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Checkout : Command { - public Checkout(string 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; - _outputHandler = onProgress; - return Exec(); - } + public async Task BranchAsync(string branch, bool force) + { + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(branch); - public bool Branch(string branch, string basedOn, Action onProgress) { - Args = $"checkout --progress -b {branch} {basedOn}"; - TraitErrorAsOutput = true; - _outputHandler = onProgress; - return Exec(); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public bool File(string file, bool useTheirs) { - if (useTheirs) { - Args = $"checkout --theirs -- \"{file}\""; - } else { - Args = $"checkout --ours -- \"{file}\""; - } + public async Task BranchAsync(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); - return Exec(); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public bool FileWithRevision(string file, string revision) { - Args = $"checkout {revision} -- \"{file}\""; - return Exec(); + public async Task CommitAsync(string commitId, bool force) + { + var option = force ? "--force" : string.Empty; + Args = $"checkout {option} --detach --progress {commitId}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Files(List files) { - StringBuilder builder = new StringBuilder(); - builder.Append("checkout -f -q --"); - foreach (var f in files) { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } + public async Task UseTheirsAsync(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --theirs --"); + foreach (var f in files) + builder.Append(' ').Append(f.Quoted()); Args = builder.ToString(); - return Exec(); + return await ExecAsync().ConfigureAwait(false); } - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); + public async Task UseMineAsync(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --ours --"); + foreach (var f in files) + builder.Append(' ').Append(f.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - private Action _outputHandler; + public async Task FileWithRevisionAsync(string file, string revision) + { + Args = $"checkout --no-overlay {revision} -- {file.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task MultipleFilesWithRevisionAsync(List files, string revision) + { + var builder = new StringBuilder(); + builder + .Append("checkout --no-overlay ") + .Append(revision) + .Append(" --"); + + foreach (var f in files) + builder.Append(' ').Append(f.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } } } diff --git a/src/Commands/CherryPick.cs b/src/Commands/CherryPick.cs index 6160ebaca..d9d4faea2 100644 --- a/src/Commands/CherryPick.cs +++ b/src/Commands/CherryPick.cs @@ -1,10 +1,24 @@ -namespace SourceGit.Commands { - public class CherryPick : Command { - public CherryPick(string repo, string commit, bool noCommit) { - var mode = noCommit ? "-n" : "--ff"; +using System.Text; + +namespace SourceGit.Commands +{ + public class CherryPick : Command + { + public CherryPick(string repo, string commits, bool noCommit, bool appendSourceToMessage, string extraParams) + { WorkingDirectory = repo; Context = repo; - Args = $"cherry-pick {mode} {commit}"; + + var builder = new StringBuilder(1024); + builder.Append("cherry-pick "); + if (noCommit) + builder.Append("-n "); + if (appendSourceToMessage) + builder.Append("-x "); + if (!string.IsNullOrEmpty(extraParams)) + builder.Append(extraParams).Append(' '); + + Args = builder.Append(commits).ToString(); } } } diff --git a/src/Commands/Clean.cs b/src/Commands/Clean.cs index 56a56a6c4..119b73aa6 100644 --- a/src/Commands/Clean.cs +++ b/src/Commands/Clean.cs @@ -1,26 +1,18 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - public class Clean : Command { - public Clean(string repo) { +namespace SourceGit.Commands +{ + public class Clean : Command + { + public Clean(string repo, Models.CleanMode mode) + { WorkingDirectory = repo; Context = 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("\""); - } - WorkingDirectory = repo; - Context = repo; - Args = builder.ToString(); + Args = mode switch + { + Models.CleanMode.OnlyUntrackedFiles => "clean -qfd", + Models.CleanMode.OnlyIgnoredFiles => "clean -qfdX", + _ => "clean -qfdx", + }; } } } diff --git a/src/Commands/Clone.cs b/src/Commands/Clone.cs index a6228e209..ffd11bda9 100644 --- a/src/Commands/Clone.cs +++ b/src/Commands/Clone.cs @@ -1,31 +1,24 @@ -using System; +using System.Text; -namespace SourceGit.Commands { - public class Clone : Command { - private Action _notifyProgress; - - public Clone(string ctx, string path, string url, string localName, string sshKey, string extraArgs, Action ouputHandler) { +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; - TraitErrorAsOutput = true; - - if (string.IsNullOrEmpty(sshKey)) { - Args = "-c credential.helper=manager "; - } else { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } - - Args += "clone --progress --verbose --recurse-submodules "; + SSHKey = sshKey; - if (!string.IsNullOrEmpty(extraArgs)) Args += $"{extraArgs} "; - Args += $"{url} "; - if (!string.IsNullOrEmpty(localName)) Args += localName; - - _notifyProgress = ouputHandler; - } + var builder = new StringBuilder(1024); + builder.Append("clone --progress --verbose "); + if (!string.IsNullOrEmpty(extraArgs)) + builder.Append(extraArgs).Append(' '); + builder.Append(url.Quoted()).Append(' '); + if (!string.IsNullOrEmpty(localName)) + builder.Append(localName.Quoted()); - protected override void OnReadline(string line) { - _notifyProgress?.Invoke(line); + Args = builder.ToString(); } } } diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index cf79841db..977c81012 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -1,147 +1,259 @@ -using Avalonia.Threading; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Text; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - public class Command { - public class CancelToken { - public bool Requested { get; set; } = false; - } - - public class ReadToEndResult { - public bool IsSuccess { get; set; } - public string StdOut { get; set; } - public string StdErr { get; set; } - } - - public string Context { get; set; } = string.Empty; - public CancelToken Cancel { get; set; } = null; - public string WorkingDirectory { get; set; } = null; - public string Args { get; set; } = string.Empty; - public bool RaiseError { get; set; } = true; - public bool TraitErrorAsOutput { get; set; } = false; - - public bool Exec() { - var start = new ProcessStartInfo(); - start.FileName = Native.OS.GitInstallPath; - 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(WorkingDirectory)) start.WorkingDirectory = WorkingDirectory; - - var errs = new List(); - var proc = new Process() { StartInfo = start }; - var isCancelled = false; - - proc.OutputDataReceived += (_, e) => { - if (Cancel != null && Cancel.Requested) { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) proc.Kill(true); - return; - } - - if (e.Data != null) OnReadline(e.Data); - }; - - proc.ErrorDataReceived += (_, e) => { - if (Cancel != null && Cancel.Requested) { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) proc.Kill(true); - return; - } - - if (string.IsNullOrEmpty(e.Data)) return; - if (TraitErrorAsOutput) OnReadline(e.Data); - - // Ignore progress messages - if (e.Data.StartsWith("remote: Enumerating objects:", StringComparison.Ordinal)) return; - if (e.Data.StartsWith("remote: Counting objects:", StringComparison.Ordinal)) return; - if (e.Data.StartsWith("remote: Compressing objects:", StringComparison.Ordinal)) return; - if (e.Data.StartsWith("Filtering content:", StringComparison.Ordinal)) return; - if (_progressRegex.IsMatch(e.Data)) return; - errs.Add(e.Data); - }; - - try { - proc.Start(); - } catch (Exception e) { - if (RaiseError) { - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(Context, e.Message); - }); - } - return false; - } - - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - proc.WaitForExit(); - - int exitCode = proc.ExitCode; - proc.Close(); - - if (!isCancelled && exitCode != 0 && errs.Count > 0) { - if (RaiseError) { - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(Context, string.Join("\n", errs)); - }); - } - return false; - } else { - return true; - } - } - - public ReadToEndResult ReadToEnd() { - var start = new ProcessStartInfo(); - start.FileName = Native.OS.GitInstallPath; - 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(WorkingDirectory)) start.WorkingDirectory = WorkingDirectory; - - 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; - } - - protected virtual void OnReadline(string line) { } - - private static readonly Regex _progressRegex = new Regex(@"\d+%"); - } -} \ No newline at end of file +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public partial class Command + { + public class Result + { + public bool IsSuccess { get; set; } = false; + public string StdOut { get; set; } = string.Empty; + public string StdErr { get; set; } = string.Empty; + + public static Result Failed(string reason) => new Result() { StdErr = reason }; + } + + public enum EditorType + { + None, + CoreEditor, + RebaseEditor, + } + + public string Context { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = null; + public EditorType Editor { get; set; } = EditorType.CoreEditor; + public string SSHKey { get; set; } = string.Empty; + public string Args { get; set; } = string.Empty; + + // Only used in `ExecAsync` mode. + public CancellationToken CancellationToken { get; set; } = CancellationToken.None; + public bool RaiseError { get; set; } = true; + public Models.ICommandLog Log { get; set; } = null; + + public async Task ExecAsync() + { + Log?.AppendLine($"$ git {Args}\n"); + + var errs = new List(); + + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.OutputDataReceived += (_, e) => HandleOutput(e.Data, errs); + proc.ErrorDataReceived += (_, e) => HandleOutput(e.Data, errs); + + var captured = new CapturedProcess() { Process = proc }; + var capturedLock = new object(); + try + { + proc.Start(); + + // Not safe, please only use `CancellationToken` in readonly commands. + if (CancellationToken.CanBeCanceled) + { + CancellationToken.Register(() => + { + lock (capturedLock) + { + if (captured is { Process: { HasExited: false } }) + captured.Process.Kill(true); + } + }); + } + } + catch (Exception e) + { + if (RaiseError) + RaiseException(e.Message); + + Log?.AppendLine(string.Empty); + return false; + } + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + try + { + await proc.WaitForExitAsync(CancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + HandleOutput(e.Message, errs); + } + + lock (capturedLock) + { + captured.Process = null; + } + + Log?.AppendLine(string.Empty); + + if (!CancellationToken.IsCancellationRequested && proc.ExitCode != 0) + { + if (RaiseError) + { + var errMsg = string.Join("\n", errs).Trim(); + if (!string.IsNullOrEmpty(errMsg)) + RaiseException(errMsg); + } + + return false; + } + + return true; + } + + protected Result ReadToEnd() + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + + try + { + proc.Start(); + } + catch (Exception e) + { + return Result.Failed(e.Message); + } + + var rs = new Result() { IsSuccess = true }; + rs.StdOut = proc.StandardOutput.ReadToEnd(); + rs.StdErr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + rs.IsSuccess = proc.ExitCode == 0; + return rs; + } + + protected async Task ReadToEndAsync() + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + + try + { + proc.Start(); + } + catch (Exception e) + { + return Result.Failed(e.Message); + } + + var rs = new Result() { IsSuccess = true }; + rs.StdOut = await proc.StandardOutput.ReadToEndAsync(CancellationToken).ConfigureAwait(false); + rs.StdErr = await proc.StandardError.ReadToEndAsync(CancellationToken).ConfigureAwait(false); + await proc.WaitForExitAsync(CancellationToken).ConfigureAwait(false); + + rs.IsSuccess = proc.ExitCode == 0; + return rs; + } + + protected ProcessStartInfo CreateGitStartInfo(bool redirect) + { + var start = new ProcessStartInfo(); + start.FileName = Native.OS.GitExecutable; + start.UseShellExecute = false; + start.CreateNoWindow = true; + + if (redirect) + { + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + } + + // Force using this app as SSH askpass program + var selfExecFile = Environment.ProcessPath; + 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 (!OperatingSystem.IsLinux()) + start.Environment.Add("DISPLAY", "required"); + + // 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}' -F '/dev/null'"); + + // Force using en_US.UTF-8 locale + if (OperatingSystem.IsLinux()) + { + start.Environment.Add("LANG", "C"); + start.Environment.Add("LC_ALL", "C"); + } + + var builder = new StringBuilder(2048); + builder + .Append("--no-pager -c core.quotepath=off -c credential.helper=") + .Append(Native.OS.CredentialHelper) + .Append(' '); + + switch (Editor) + { + case EditorType.CoreEditor: + builder.Append($"""-c core.editor="\"{selfExecFile}\" --core-editor" """); + break; + case EditorType.RebaseEditor: + builder.Append($"""-c core.editor="\"{selfExecFile}\" --rebase-message-editor" -c sequence.editor="\"{selfExecFile}\" --rebase-todo-editor" -c rebase.abbreviateCommands=true """); + break; + default: + builder.Append("-c core.editor=true "); + break; + } + + builder.Append(Args); + start.Arguments = builder.ToString(); + + // Working directory + if (!string.IsNullOrEmpty(WorkingDirectory)) + start.WorkingDirectory = WorkingDirectory; + + return start; + } + + protected void RaiseException(string error) + { + Models.Notification.Send(Context, error, true); + } + + private void HandleOutput(string line, List errs) + { + if (line == null) + return; + + 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); + } + + private class CapturedProcess + { + public Process Process { get; set; } = null; + } + + [GeneratedRegex(@"\d+%")] + private static partial Regex REG_PROGRESS(); + } +} diff --git a/src/Commands/Commit.cs b/src/Commands/Commit.cs index 2733590c2..b756c4ab4 100644 --- a/src/Commands/Commit.cs +++ b/src/Commands/Commit.cs @@ -1,16 +1,57 @@ using System.IO; +using System.Text; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Commit : Command { - public Commit(string repo, string message, bool amend, bool allowEmpty = false) { - var file = Path.GetTempFileName(); - File.WriteAllText(file, message); +namespace SourceGit.Commands +{ + public class Commit : Command + { + public Commit(string repo, string message, bool signOff, bool noVerify, bool amend, bool resetAuthor) + { + _tmpFile = Path.GetTempFileName(); + _message = message; WorkingDirectory = repo; Context = repo; - Args = $"commit --file=\"{file}\""; - if (amend) Args += " --amend --no-edit"; - if (allowEmpty) Args += " --allow-empty"; + + var builder = new StringBuilder(); + builder.Append("commit --allow-empty --file="); + builder.Append(_tmpFile.Quoted()); + builder.Append(' '); + + if (signOff) + builder.Append("--signoff "); + + if (noVerify) + builder.Append("--no-verify "); + + if (amend) + { + builder.Append("--amend "); + if (resetAuthor) + builder.Append("--reset-author "); + builder.Append("--no-edit"); + } + + Args = builder.ToString(); + } + + public async Task RunAsync() + { + try + { + await File.WriteAllTextAsync(_tmpFile, _message).ConfigureAwait(false); + var succ = await ExecAsync().ConfigureAwait(false); + File.Delete(_tmpFile); + return succ; + } + catch + { + return false; + } } + + private readonly string _tmpFile; + private readonly string _message; } } diff --git a/src/Commands/CompareRevisions.cs b/src/Commands/CompareRevisions.cs index 2b10adab2..c7ee58915 100644 --- a/src/Commands/CompareRevisions.cs +++ b/src/Commands/CompareRevisions.cs @@ -1,38 +1,95 @@ using System.Collections.Generic; +using System.Diagnostics; using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class CompareRevisions : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); +namespace SourceGit.Commands +{ + public partial class CompareRevisions : Command + { + [GeneratedRegex(@"^([MADT])\s+(.+)$")] + private static partial Regex REG_FORMAT(); + [GeneratedRegex(@"^([CR])[0-9]{0,4}\s+(.+)$")] + private static partial Regex REG_RENAME_FORMAT(); - public CompareRevisions(string repo, string start, string end) { + public CompareRevisions(string repo, string start, string end) + { WorkingDirectory = repo; Context = repo; - Args = $"diff --name-status {start} {end}"; + + var based = string.IsNullOrEmpty(start) ? "-R" : start; + Args = $"diff --name-status {based} {end}"; } - public List Result() { - Exec(); - _changes.Sort((l, r) => l.Path.CompareTo(r.Path)); - return _changes; + 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.Quoted()}"; } - protected override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; + public async Task> ReadAsync() + { + var changes = new List(); + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + { + match = REG_RENAME_FORMAT().Match(line); + if (match.Success) + { + var type = match.Groups[1].Value; + var renamed = new Models.Change() { Path = match.Groups[2].Value }; + renamed.Set(type == "R" ? Models.ChangeState.Renamed : Models.ChangeState.Copied); + changes.Add(renamed); + } + + continue; + } - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; + 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 'R': change.Set(Models.ChangeState.Renamed); _changes.Add(change); break; - case 'C': change.Set(Models.ChangeState.Copied); _changes.Add(change); break; + 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 'T': + change.Set(Models.ChangeState.TypeChanged); + changes.Add(change); + break; + } + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + } + catch + { + //ignore changes; } - } - private List _changes = new List(); + return changes; + } } } diff --git a/src/Commands/Config.cs b/src/Commands/Config.cs index 4a0ebb30a..75f4d738a 100644 --- a/src/Commands/Config.cs +++ b/src/Commands/Config.cs @@ -1,31 +1,63 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Config : Command { - public Config(string repository) { - WorkingDirectory = repository; - Context = repository; - RaiseError = false; +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 Dictionary ListAll() { + public Dictionary ReadAll() + { Args = "config -l"; var output = ReadToEnd(); var rs = new Dictionary(); - if (output.IsSuccess) { - var lines = output.StdOut.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) { - var idx = line.IndexOf('='); - if (idx != -1) { - var key = line.Substring(0, idx).Trim(); - var val = line.Substring(idx+1).Trim(); - if (rs.ContainsKey(key)) { - rs[key] = val; - } else { - rs.Add(key, val); - } + if (output.IsSuccess) + { + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var parts = line.Split('=', 2); + if (parts.Length == 2) + rs[parts[0]] = parts[1]; + } + } + + return rs; + } + + public async Task> ReadAllAsync() + { + Args = "config -l"; + + var output = await ReadToEndAsync().ConfigureAwait(false); + var rs = new Dictionary(); + if (output.IsSuccess) + { + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var parts = line.Split('=', 2); + if (parts.Length == 2) + { + var key = parts[0].ToLower(CultureInfo.CurrentCulture); // Always use lower case for key + var value = parts[1]; + rs[key] = value; } } } @@ -33,27 +65,32 @@ public Dictionary ListAll() { return rs; } - public string Get(string key) { + public string Get(string key) + { Args = $"config {key}"; return ReadToEnd().StdOut.Trim(); } - public bool Set(string key, string value, bool allowEmpty = false) { - if (!allowEmpty && string.IsNullOrWhiteSpace(value)) { - if (string.IsNullOrEmpty(WorkingDirectory)) { - Args = $"config --global --unset {key}"; - } else { - Args = $"config --unset {key}"; - } - } else { - if (string.IsNullOrWhiteSpace(WorkingDirectory)) { - Args = $"config --global {key} \"{value}\""; - } else { - Args = $"config {key} \"{value}\""; - } - } + public async Task GetAsync(string key) + { + Args = $"config {key}"; - return Exec(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + return rs.StdOut.Trim(); } + + public async Task SetAsync(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.Quoted()}"; + + return await ExecAsync().ConfigureAwait(false); + } + + private bool _isLocal = false; } } diff --git a/src/Commands/CountLocalChanges.cs b/src/Commands/CountLocalChanges.cs new file mode 100644 index 000000000..17916926d --- /dev/null +++ b/src/Commands/CountLocalChanges.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class CountLocalChanges : Command + { + public CountLocalChanges(string repo, bool includeUntracked) + { + var option = includeUntracked ? "-uall" : "-uno"; + WorkingDirectory = repo; + Context = repo; + Args = $"--no-optional-locks status {option} --ignore-submodules=all --porcelain"; + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + 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 51a592de9..89248ddc1 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -1,129 +1,349 @@ -using System; +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Diff : Command { - private static readonly Regex REG_INDICATOR = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@"); - private static readonly string PREFIX_LFS = " version https://git-lfs.github.com/spec/"; +namespace SourceGit.Commands +{ + public partial class Diff : Command + { + [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] + private static partial Regex REG_INDICATOR(); + + [GeneratedRegex(@"^index\s([0-9a-f]{6,64})\.\.([0-9a-f]{6,64})(\s[1-9]{6})?")] + private static partial Regex REG_HASH_CHANGE(); + + private const char PREFIX_CONTEXT = ' '; + private const char PREFIX_DELETED = '-'; + private const char PREFIX_ADDED = '+'; + private const char PREFIX_COMMAND = '\\'; + + private const string FILE_MODE_OLD = "old mode "; + private const string FILE_MODE_NEW = "new mode "; + private const string FILE_MODE_DELETED = "deleted file mode "; + private const string FILE_MODE_ADDED = "new file mode "; + + private const string LFS_SPECIFIER = "version https://git-lfs.github.com/spec/"; + private const string LFS_OID_PREFIX = "oid sha256:"; + private const string LFS_SIZE_PREFIX = "size "; + + private const string SPECIAL_DIFF_START = "diff "; + private const string SPECIAL_BINARY = "Binary files "; + private const string SPECIAL_NO_NEWLINE = " No newline at end of file"; + private const string SPECIAL_SUBMODULE = "Subproject commit "; + + public Diff(string repo, Models.DiffOption opt, int numContextLines, bool ignoreWhitespace, bool ignoreCRAtEOL) + { + _result.TextDiff = new Models.TextDiff(); - public Diff(string repo, Models.DiffOption opt) { WorkingDirectory = repo; Context = repo; - Args = $"diff --ignore-cr-at-eol --unified=4 {opt}"; + + var builder = new StringBuilder(256); + builder.Append("diff --no-color --no-ext-diff --full-index --patch "); + if (ignoreWhitespace) + builder.Append("--ignore-space-change --ignore-blank-lines "); + if (ignoreCRAtEOL) + builder.Append("--ignore-cr-at-eol "); + builder.Append("--unified=").Append(numContextLines).Append(' '); + builder.Append(opt.ToString()); + + Args = builder.ToString(); } - public Models.DiffResult Result() { - Exec(); + public async Task ReadAsync() + { + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); - if (_result.IsBinary || _result.IsLFS) { - _result.TextDiff = null; - } else { - ProcessInlineHighlights(); + using var ms = new MemoryStream(); + await proc.StandardOutput.BaseStream.CopyToAsync(ms, CancellationToken).ConfigureAwait(false); + + if (ms.TryGetBuffer(out var buffer)) + { + var start = buffer.Offset; + var end = buffer.Offset + buffer.Count; + while (start < end) + { + var lineEnd = Array.IndexOf(buffer.Array, (byte)'\n', start); + if (lineEnd < 0) + { + ParseLine(buffer[start..]); + break; + } + + ParseLine(buffer[start..lineEnd]); + if (_result.IsBinary) + break; + + start = lineEnd + 1; + } + } - if (_result.TextDiff.Lines.Count == 0) { - _result.TextDiff = null; - } else { - _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); - } + await proc.WaitForExitAsync(CancellationToken).ConfigureAwait(false); + } + catch + { + // Ignore exceptions. } - return _result; - } + if (_isLFS || _result.IsBinary || _result.TextDiff.Lines.Count == 0) + { + _result.TextDiff = null; + } + else + { + if (_isInChunk) + { + ProcessInlineHighlights(); + _isInChunk = false; + } + + if (_result.TextDiff.Lines.Count < 4) + { + var isSubmoduleChange = true; - protected override void OnReadline(string line) { - if (_result.IsBinary) return; - - if (_result.IsLFS) { - var ch = line[0]; - if (ch == '-') { - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - _result.LFSDiff.Old.Oid = line.Substring(11); - } else if (line.StartsWith("size ")) { - _result.LFSDiff.Old.Size = long.Parse(line.Substring(5)); + for (int i = 1; i < _result.TextDiff.Lines.Count; i++) + { + var line = _result.TextDiff.Lines[i]; + if (!line.Content.StartsWith(SPECIAL_SUBMODULE, StringComparison.Ordinal)) + { + isSubmoduleChange = false; + break; + } } - } else if (ch == '+') { - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - _result.LFSDiff.New.Oid = line.Substring(11); - } else if (line.StartsWith("size ")) { - _result.LFSDiff.New.Size = long.Parse(line.Substring(5)); + + if (isSubmoduleChange) + { + _result.IsSubmoduleChange = true; + _result.TextDiff = null; + return _result; } - } else if (line.StartsWith(" size ")) { - _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(line.Substring(6)); } - return; + + _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); + _result.TextDiff.OldMode = _result.OldMode; + _result.TextDiff.NewMode = _result.NewMode; + _result.TextDiff.OldHash = _result.OldHash; + _result.TextDiff.NewHash = _result.NewHash; } - if (_result.TextDiff.Lines.Count == 0) { - var match = REG_INDICATOR.Match(line); - if (!match.Success) { - if (line.StartsWith("Binary", StringComparison.Ordinal)) _result.IsBinary = true; + return _result; + } + + private void ParseLine(ArraySegment lineBytes) + { + // Decode line bytes to UTF-8 string + var line = Encoding.UTF8.GetString(lineBytes.Array, lineBytes.Offset, lineBytes.Count); + if (line.Length == 0) + return; + + // If we are reading a chunk-body, try to read the current line as chunk-body first (because + // there are usually more chunk-body lines than chunk-indicator lines). + if (_isInChunk) + { + if (ParseChunkBodyLine(line, lineBytes)) return; - } + ProcessInlineHighlights(); + _isInChunk = false; + } + + // If the current line is not a chunk-body, try to parse it as chunk-indicator + if (ParseChunkStartLine(line)) + { + _isInChunk = true; + return; + } + + // Fallback to diff headers to support type-changed diff (multiple headers). + ParseDiffHeaderLine(line); + } + + private void ParseDiffHeaderLine(string line) + { + if (line.StartsWith(SPECIAL_DIFF_START, StringComparison.Ordinal)) + return; + + if (ParseFileModeChange(line)) + return; + + var match = REG_HASH_CHANGE().Match(line); + if (match.Success) + { + if (string.IsNullOrEmpty(_result.OldHash)) + _result.OldHash = match.Groups[1].Value; + _result.NewHash = match.Groups[2].Value; + return; + } + + if (line.StartsWith(SPECIAL_BINARY, StringComparison.Ordinal)) + _result.IsBinary = true; + } + + private bool ParseChunkStartLine(string line) + { + var match = REG_INDICATOR().Match(line); + if (match.Success) + { _oldLine = int.Parse(match.Groups[1].Value); _newLine = int.Parse(match.Groups[2].Value); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0)); - } else { - if (line.Length == 0) { - ProcessInlineHighlights(); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine)); - _oldLine++; - _newLine++; - return; - } + _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, null, 0, 0); + _result.TextDiff.Lines.Add(_last); + return true; + } - var ch = line[0]; - if (ch == '-') { - _deleted.Add(new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0)); - _oldLine++; - } else if (ch == '+') { - _added.Add(new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine)); - _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); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0)); - } else { - if (line.StartsWith(PREFIX_LFS)) { - _result.IsLFS = true; - _result.LFSDiff = new Models.LFSDiff(); - return; - } + return false; + } - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), _oldLine, _newLine)); - _oldLine++; - _newLine++; - } + private bool ParseChunkBodyLine(string line, ArraySegment lineBytes) + { + var prefix = line[0]; + var content = line.Substring(1); + var rawContent = lineBytes[1..].ToArray(); + if (ParseLFSChange(prefix, content)) + return true; + + if (prefix == PREFIX_DELETED) + { + _result.TextDiff.DeletedLines++; + _last = new Models.TextDiffLine(Models.TextDiffLineType.Deleted, content, rawContent, _oldLine, 0); + _deleted.Add(_last); + _oldLine++; + return true; + } + + if (prefix == PREFIX_ADDED) + { + _result.TextDiff.AddedLines++; + _last = new Models.TextDiffLine(Models.TextDiffLineType.Added, content, rawContent, 0, _newLine); + _added.Add(_last); + _newLine++; + return true; + } + + if (prefix == PREFIX_CONTEXT) + { + ProcessInlineHighlights(); + + _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, content, rawContent, _oldLine, _newLine); + _result.TextDiff.Lines.Add(_last); + _oldLine++; + _newLine++; + return true; + } + + if (prefix == PREFIX_COMMAND) + { + if (content.Equals(SPECIAL_NO_NEWLINE, StringComparison.Ordinal)) + _last.NoNewLineEndOfFile = true; + return true; + } + + return false; + } + + private bool ParseFileModeChange(string line) + { + if (line.StartsWith(FILE_MODE_OLD, StringComparison.Ordinal)) + { + _result.OldMode = int.Parse(line.AsSpan(9)); + return true; + } + + if (line.StartsWith(FILE_MODE_NEW, StringComparison.Ordinal)) + { + _result.NewMode = int.Parse(line.AsSpan(9)); + return true; + } + + if (line.StartsWith(FILE_MODE_DELETED, StringComparison.Ordinal)) + { + _result.OldMode = int.Parse(line.AsSpan(18)); + return true; + } + + if (line.StartsWith(FILE_MODE_ADDED, StringComparison.Ordinal)) + { + _result.NewMode = int.Parse(line.AsSpan(14)); + return true; + } + + return false; + } + + private bool ParseLFSChange(char prefix, string content) + { + if (_isLFS) + { + if (prefix == PREFIX_DELETED) + { + if (content.StartsWith(LFS_OID_PREFIX, StringComparison.Ordinal)) + _result.LFSDiff.Old.Oid = content.Substring(11); + else if (content.StartsWith(LFS_SIZE_PREFIX, StringComparison.Ordinal)) + _result.LFSDiff.Old.Size = long.Parse(content.AsSpan(5)); + } + else if (prefix == PREFIX_ADDED) + { + if (content.StartsWith(LFS_OID_PREFIX, StringComparison.Ordinal)) + _result.LFSDiff.New.Oid = content.Substring(11); + else if (content.StartsWith(LFS_SIZE_PREFIX, StringComparison.Ordinal)) + _result.LFSDiff.New.Size = long.Parse(content.AsSpan(5)); } + else if (prefix == PREFIX_CONTEXT) + { + if (content.StartsWith(LFS_SIZE_PREFIX, StringComparison.Ordinal)) + _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(content.AsSpan(5)); + } + return true; } + + if ((_oldLine == 1 && _newLine == 1 && prefix == PREFIX_CONTEXT) || + (_oldLine == 1 && _newLine == 0 && prefix == PREFIX_DELETED) || + (_oldLine == 0 && _newLine == 1 && prefix == PREFIX_ADDED)) + { + if (content.StartsWith(LFS_SPECIFIER, StringComparison.Ordinal)) + { + _isLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return true; + } + } + + return false; } - private void ProcessInlineHighlights() { - if (_deleted.Count > 0) { - if (_added.Count == _deleted.Count) { - for (int i = _added.Count - 1; i >= 0; 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.TextInlineChange.Compare(left.Content, right.Content); - if (chunks.Count > 4) continue; + if (chunks.Count > 4) + continue; - foreach (var chunk in chunks) { - if (chunk.DeletedCount > 0) { - left.Highlights.Add(new Models.TextInlineRange(chunk.DeletedStart, chunk.DeletedCount)); - } + foreach (var chunk in chunks) + { + if (chunk.DeletedCount > 0) + left.Highlights.Add(new Models.TextRange(chunk.DeletedStart, chunk.DeletedCount)); - if (chunk.AddedCount > 0) { - right.Highlights.Add(new Models.TextInlineRange(chunk.AddedStart, chunk.AddedCount)); - } + if (chunk.AddedCount > 0) + right.Highlights.Add(new Models.TextRange(chunk.AddedStart, chunk.AddedCount)); } } } @@ -132,16 +352,20 @@ private void ProcessInlineHighlights() { _deleted.Clear(); } - if (_added.Count > 0) { + if (_added.Count > 0) + { _result.TextDiff.Lines.AddRange(_added); _added.Clear(); } } - private Models.DiffResult _result = new Models.DiffResult() { TextDiff = new Models.TextDiff() }; - private List _deleted = new List(); - private List _added = new List(); + 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; + private bool _isInChunk = false; + private bool _isLFS = false; } } diff --git a/src/Commands/DiffTool.cs b/src/Commands/DiffTool.cs new file mode 100644 index 000000000..a102bd474 --- /dev/null +++ b/src/Commands/DiffTool.cs @@ -0,0 +1,77 @@ +using System; +using System.Diagnostics; + +namespace SourceGit.Commands +{ + public class DiffTool : Command + { + public DiffTool(string repo, Models.DiffOption option) + { + WorkingDirectory = repo; + Context = repo; + _option = option; + } + + public void Open() + { + var tool = Native.OS.GetDiffMergeTool(true); + if (tool == null) + { + RaiseException("Invalid diff/merge tool in preference setting!"); + return; + } + + if (string.IsNullOrEmpty(tool.Cmd)) + { + if (!CheckGitConfiguration()) + return; + + Args = $"difftool -g --no-prompt {_option}"; + } + else + { + var cmd = $"{tool.Exec.Quoted()} {tool.Cmd}"; + Args = $"-c difftool.sourcegit.cmd={cmd.Quoted()} difftool --tool=sourcegit --no-prompt {_option}"; + } + + try + { + Process.Start(CreateGitStartInfo(false)); + } + catch (Exception ex) + { + RaiseException(ex.Message); + } + } + + private bool CheckGitConfiguration() + { + var config = new Config(WorkingDirectory).ReadAll(); + if (config.TryGetValue("diff.guitool", out var guiTool)) + return CheckCLIBasedTool(guiTool); + if (config.TryGetValue("merge.guitool", out var mergeGuiTool)) + return CheckCLIBasedTool(mergeGuiTool); + if (config.TryGetValue("diff.tool", out var diffTool)) + return CheckCLIBasedTool(diffTool); + if (config.TryGetValue("merge.tool", out var mergeTool)) + return CheckCLIBasedTool(mergeTool); + + RaiseException("Missing git configuration: diff.guitool"); + return false; + } + + private bool CheckCLIBasedTool(string tool) + { + if (tool.StartsWith("vimdiff", StringComparison.Ordinal) || + tool.StartsWith("nvimdiff", StringComparison.Ordinal)) + { + RaiseException($"CLI based diff tool \"{tool}\" is not supported by this app!"); + return false; + } + + return true; + } + + private Models.DiffOption _option; + } +} diff --git a/src/Commands/Discard.cs b/src/Commands/Discard.cs index 95f118c91..8d08b90a7 100644 --- a/src/Commands/Discard.cs +++ b/src/Commands/Discard.cs @@ -1,42 +1,94 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public static class Discard { - public static void All(string repo) { - new Reset(repo, "HEAD", "--hard").Exec(); - new Clean(repo).Exec(); +namespace SourceGit.Commands +{ + public static class Discard + { + /// + /// Discard all local changes (unstaged & staged) + /// + public static async Task AllAsync(string repo, bool includeModified, bool includeUntracked, bool includeIgnored, Models.ICommandLog log) + { + if (includeUntracked) + { + // Untracked paths that contains `.git` file (detached submodule) must be removed manually. + var changes = await new QueryLocalChanges(repo).GetResultAsync().ConfigureAwait(false); + 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); + } + } + } + catch (Exception e) + { + Models.Notification.Send(repo, $"Failed to discard changes. Reason: {e.Message}", true); + } + + if (includeIgnored) + await new Clean(repo, Models.CleanMode.UntrackedAndIgnoredFiles).Use(log).ExecAsync().ConfigureAwait(false); + else + await new Clean(repo, Models.CleanMode.OnlyUntrackedFiles).Use(log).ExecAsync().ConfigureAwait(false); + } + else if (includeIgnored) + { + await new Clean(repo, Models.CleanMode.OnlyIgnoredFiles).Use(log).ExecAsync().ConfigureAwait(false); + } + + if (includeModified) + await new Reset(repo, "", "--hard").Use(log).ExecAsync().ConfigureAwait(false); } - public static void ChangesInWorkTree(string repo, List changes) { - var needClean = new List(); - var needCheckout = new List(); + /// + /// Discard selected changes (only unstaged). + /// + /// + /// + /// + public static async Task ChangesAsync(string repo, List changes, Models.ICommandLog log) + { + var restores = new List(); - foreach (var c in changes) { - if (c.WorkTree == Models.ChangeState.Untracked || c.WorkTree == Models.ChangeState.Added) { - needClean.Add(c.Path); - } else { - needCheckout.Add(c.Path); + 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); + } } } - - 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) + { + Models.Notification.Send(repo, $"Failed to discard changes. Reason: {e.Message}", true); } - 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)); - } - } - - public static void ChangesInStaged(string repo, List changes) { - for (int i = 0; i < changes.Count; i += 10) { - var count = Math.Min(10, changes.Count - i); - var files = new List(); - for (int j = 0; j < count; j++) files.Add(changes[i + j].Path); - new Restore(repo, files, "--staged --worktree").Exec(); + if (restores.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync(pathSpecFile, restores).ConfigureAwait(false); + await new Restore(repo, pathSpecFile).Use(log).ExecAsync().ConfigureAwait(false); + File.Delete(pathSpecFile); } } } diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs index 1c5acebb5..914361257 100644 --- a/src/Commands/Fetch.cs +++ b/src/Commands/Fetch.cs @@ -1,123 +1,53 @@ -using System; -using System.Collections.Generic; -using System.Threading; +using System.Text; using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Fetch : Command { - public Fetch(string repo, string remote, bool prune, Action outputHandler) { - _outputHandler = outputHandler; - WorkingDirectory = repo; - Context = 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; - - AutoFetch.MarkFetched(repo); - } +namespace SourceGit.Commands +{ + public class Fetch : Command + { + public Fetch(string repo, string remote, bool noTags, bool force) + { + _remote = remote; - public Fetch(string repo, string remote, string localBranch, string remoteBranch, Action outputHandler) { - _outputHandler = outputHandler; WorkingDirectory = repo; Context = 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}"; - } - - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); - } - - private Action _outputHandler; - } - public class AutoFetch { - public static bool IsEnabled { - get; - set; - } = false; + var builder = new StringBuilder(512); + builder.Append("fetch --progress --verbose "); + builder.Append(noTags ? "--no-tags " : "--tags "); + if (force) + builder.Append("--force "); + builder.Append(remote); - class Job { - public Fetch Cmd = null; - public DateTime NextRunTimepoint = DateTime.MinValue; + Args = builder.ToString(); } - static AutoFetch() { - Task.Run(() => { - while (true) { - if (!IsEnabled) { - Thread.Sleep(10000); - continue; - } + public Fetch(string repo, string remote) + { + _remote = remote; - var now = DateTime.Now; - var uptodate = new List(); - lock (_lock) { - foreach (var job in _jobs) { - if (job.Value.NextRunTimepoint.Subtract(now).TotalSeconds <= 0) { - uptodate.Add(job.Value); - } - } - } - - foreach (var job in uptodate) { - job.Cmd.Exec(); - job.NextRunTimepoint = DateTime.Now.AddSeconds(_fetchInterval); - } + WorkingDirectory = repo; + Context = repo; + RaiseError = false; - Thread.Sleep(2000); - } - }); + Args = $"fetch --progress --verbose {remote}"; } - public static void AddRepository(string repo) { - var job = new Job { - Cmd = new Fetch(repo, "--all", true, null) { RaiseError = false }, - NextRunTimepoint = DateTime.Now.AddSeconds(_fetchInterval), - }; - - lock (_lock) { - if (_jobs.ContainsKey(repo)) { - _jobs[repo] = job; - } else { - _jobs.Add(repo, job); - } - } - } + public Fetch(string repo, Models.Branch local, Models.Branch remote) + { + _remote = remote.Remote; - public static void RemoveRepository(string repo) { - lock (_lock) { - _jobs.Remove(repo); - } + WorkingDirectory = repo; + Context = repo; + Args = $"fetch --progress --verbose {remote.Remote} {remote.Name}:{local.Name}"; } - public static void MarkFetched(string repo) { - lock (_lock) { - if (_jobs.ContainsKey(repo)) { - _jobs[repo].NextRunTimepoint = DateTime.Now.AddSeconds(_fetchInterval); - } - } + public async Task RunAsync() + { + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{_remote}.sshkey").ConfigureAwait(false); + return await ExecAsync().ConfigureAwait(false); } - private static Dictionary _jobs = new Dictionary(); - private static object _lock = new object(); - private static double _fetchInterval = 10 * 60; + private readonly string _remote; } } diff --git a/src/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs index c139c4776..cf97f8b07 100644 --- a/src/Commands/FormatPatch.cs +++ b/src/Commands/FormatPatch.cs @@ -1,9 +1,13 @@ -namespace SourceGit.Commands { - public class FormatPatch : Command { - public FormatPatch(string repo, string commit, string saveTo) { +namespace SourceGit.Commands +{ + public class FormatPatch : Command + { + public FormatPatch(string repo, string commit, string saveTo) + { WorkingDirectory = repo; Context = repo; - Args = $"format-patch {commit} -1 -o \"{saveTo}\""; + Editor = EditorType.None; + Args = $"format-patch {commit} -1 --output={saveTo.Quoted()}"; } } } diff --git a/src/Commands/GC.cs b/src/Commands/GC.cs index 0c9d5761d..0b27f487f 100644 --- a/src/Commands/GC.cs +++ b/src/Commands/GC.cs @@ -1,19 +1,12 @@ -using System; - -namespace SourceGit.Commands { - public class GC : Command { - public GC(string repo, Action outputHandler) { - _outputHandler = outputHandler; +namespace SourceGit.Commands +{ + public class GC : Command + { + public GC(string repo) + { WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - Args = "gc"; + Args = "gc --prune=now"; } - - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); - } - - private Action _outputHandler; } } diff --git a/src/Commands/GetFileChangeForAI.cs b/src/Commands/GetFileChangeForAI.cs new file mode 100644 index 000000000..128d0aba6 --- /dev/null +++ b/src/Commands/GetFileChangeForAI.cs @@ -0,0 +1,31 @@ +using System; +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class GetFileChangeForAI : Command + { + public GetFileChangeForAI(string repo, string file, string originalFile, string amendParent) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("diff --no-color --no-ext-diff --diff-algorithm=minimal --cached "); + if (!string.IsNullOrEmpty(amendParent)) + builder.Append(amendParent).Append(' '); + builder.Append("-- "); + if (!string.IsNullOrEmpty(originalFile) && !file.Equals(originalFile, StringComparison.Ordinal)) + builder.Append(originalFile.Quoted()).Append(' '); + builder.Append(file.Quoted()); + + Args = builder.ToString(); + } + + public async Task ReadAsync() + { + return await ReadToEndAsync().ConfigureAwait(false); + } + } +} diff --git a/src/Commands/GitFlow.cs b/src/Commands/GitFlow.cs index 0d277f58e..0438d76de 100644 --- a/src/Commands/GitFlow.cs +++ b/src/Commands/GitFlow.cs @@ -1,77 +1,113 @@ -using Avalonia.Threading; -using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class GitFlow : Command { - public GitFlow(string repo) { +namespace SourceGit.Commands +{ + public class GitFlow : Command + { + public GitFlow(string repo) + { WorkingDirectory = repo; Context = repo; } - public bool Init(List branches, string master, string develop, string feature, string release, string hotfix, string version) { - var current = branches.Find(x => x.IsCurrent); + public async Task InitAsync(string production, string develop, string feature, string release, string hotfix, string tag) + { + if (Native.OS.GitFlowVersion == Models.GitFlowVersion.Next) + { + var builder = new StringBuilder(); + builder + .Append("flow init --preset=classic ") + .Append("--main=").Append(production.Quoted()).Append(' ') + .Append("--develop=").Append(develop.Quoted()).Append(' ') + .Append("--feature=").Append(feature).Append(' ') + .Append("--bugfix=bugfix/ ") + .Append("--release=").Append(release).Append(' ') + .Append("--hotfix=").Append(hotfix).Append(' ') + .Append("--support=support/"); - var masterBranch = branches.Find(x => x.Name == master); - if (masterBranch == null && current != null) Branch.Create(WorkingDirectory, master, current.Head); + if (!string.IsNullOrEmpty(tag)) + builder.Append(" --tag=").Append(tag); - var devBranch = branches.Find(x => x.Name == develop); - if (devBranch == null && current != null) Branch.Create(WorkingDirectory, develop, current.Head); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } - var cmd = new Config(WorkingDirectory); - 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); + var config = new Config(WorkingDirectory); + await config.SetAsync("gitflow.branch.master", production).ConfigureAwait(false); + await config.SetAsync("gitflow.branch.develop", develop).ConfigureAwait(false); + await config.SetAsync("gitflow.prefix.feature", feature).ConfigureAwait(false); + await config.SetAsync("gitflow.prefix.bugfix", "bugfix/").ConfigureAwait(false); + await config.SetAsync("gitflow.prefix.release", release).ConfigureAwait(false); + await config.SetAsync("gitflow.prefix.hotfix", hotfix).ConfigureAwait(false); + await config.SetAsync("gitflow.prefix.support", "support/").ConfigureAwait(false); + await config.SetAsync("gitflow.prefix.versiontag", tag, true).ConfigureAwait(false); Args = "flow init -d"; - return Exec(); + return await ExecAsync().ConfigureAwait(false); } - public bool 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: - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(Context, "Bad branch type!!!"); - }); - return false; + public async Task StartAsync(Models.GitFlowBranchType type, string name, Models.Branch based) + { + 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: + RaiseException("Bad git-flow branch type!!!"); + return false; } - return Exec(); + builder.Append(" start ").Append(name); + if (based != null) + builder.Append(' ').Append(based.Name); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public bool 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: - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(Context, "Bad branch type!!!"); - }); - return false; + public async Task FinishAsync(Models.GitFlowBranchType type, string name, bool rebase, bool squash, bool keepBranch) + { + 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: + RaiseException("Bad git-flow branch type!!!"); + return false; } - return Exec(); + builder.Append(" finish "); + if (rebase) + builder.Append("--rebase "); + if (squash) + builder.Append("--squash "); + if (keepBranch) + builder.Append("--keep "); + builder.Append(name); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } } } diff --git a/src/Commands/Init.cs b/src/Commands/Init.cs index f009ebe0e..c44486da9 100644 --- a/src/Commands/Init.cs +++ b/src/Commands/Init.cs @@ -1,6 +1,9 @@ -namespace SourceGit.Commands { - public class Init : Command { - public Init(string ctx, string dir) { +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/InteractiveRebase.cs b/src/Commands/InteractiveRebase.cs new file mode 100644 index 000000000..6b61b9d2a --- /dev/null +++ b/src/Commands/InteractiveRebase.cs @@ -0,0 +1,23 @@ +using System.Text; + +namespace SourceGit.Commands +{ + public class InteractiveRebase : Command + { + public InteractiveRebase(string repo, string basedOn, bool autoStash, bool noVerify) + { + WorkingDirectory = repo; + Context = repo; + Editor = EditorType.RebaseEditor; + + var builder = new StringBuilder(512); + builder.Append("-c core.commentChar=\"^\" -c core.commentString=\"±\" rebase -i --autosquash "); + if (autoStash) + builder.Append("--autostash "); + if (noVerify) + builder.Append("--no-verify "); + + Args = builder.Append(basedOn).ToString(); + } + } +} diff --git a/src/Commands/IsAncestor.cs b/src/Commands/IsAncestor.cs new file mode 100644 index 000000000..e40793e2d --- /dev/null +++ b/src/Commands/IsAncestor.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class IsAncestor : Command + { + public IsAncestor(string repo, string checkPoint, string endPoint) + { + WorkingDirectory = repo; + Context = repo; + Args = $"merge-base --is-ancestor {checkPoint} {endPoint}"; + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return rs.IsSuccess; + } + } +} diff --git a/src/Commands/IsBareRepository.cs b/src/Commands/IsBareRepository.cs new file mode 100644 index 000000000..03520131c --- /dev/null +++ b/src/Commands/IsBareRepository.cs @@ -0,0 +1,36 @@ +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class IsBareRepository : Command + { + public IsBareRepository(string path) + { + WorkingDirectory = path; + Args = "rev-parse --is-bare-repository"; + } + + public bool GetResult() + { + 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"; + } + + public async Task GetResultAsync() + { + if (!Directory.Exists(Path.Combine(WorkingDirectory, "refs")) || + !Directory.Exists(Path.Combine(WorkingDirectory, "objects")) || + !File.Exists(Path.Combine(WorkingDirectory, "HEAD"))) + return false; + + var rs = await ReadToEndAsync().ConfigureAwait(false); + return rs.IsSuccess && rs.StdOut.Trim() == "true"; + } + } +} diff --git a/src/Commands/IsBinary.cs b/src/Commands/IsBinary.cs index d9096aa2d..9dbe05459 100644 --- a/src/Commands/IsBinary.cs +++ b/src/Commands/IsBinary.cs @@ -1,18 +1,25 @@ using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class IsBinary : Command { - private static readonly Regex REG_TEST = new Regex(@"^\-\s+\-\s+.*$"); +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) { + public IsBinary(string repo, string revision, string path) + { WorkingDirectory = repo; Context = repo; - Args = $"diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904 {commit} --numstat -- \"{path}\""; + Args = $"diff --no-color --no-ext-diff --numstat {Models.EmptyTreeHash.Guess(revision)} {revision} -- {path.Quoted()}"; RaiseError = false; } - public bool Result() { - return REG_TEST.IsMatch(ReadToEnd().StdOut); + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return REG_TEST().IsMatch(rs.StdOut.Trim()); } } } diff --git a/src/Commands/IsCommitSHA.cs b/src/Commands/IsCommitSHA.cs new file mode 100644 index 000000000..dcf9b1a9f --- /dev/null +++ b/src/Commands/IsCommitSHA.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class IsCommitSHA : Command + { + public IsCommitSHA(string repo, string hash) + { + WorkingDirectory = repo; + Args = $"cat-file -t {hash}"; + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return rs.IsSuccess && rs.StdOut.Trim().Equals("commit"); + } + } +} diff --git a/src/Commands/IsLFSFiltered.cs b/src/Commands/IsLFSFiltered.cs index 39e24654a..5c45f8bc6 100644 --- a/src/Commands/IsLFSFiltered.cs +++ b/src/Commands/IsLFSFiltered.cs @@ -1,14 +1,38 @@ -namespace SourceGit.Commands { - public class IsLFSFiltered : Command { - public IsLFSFiltered(string repo, string path) { +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class IsLFSFiltered : Command + { + public IsLFSFiltered(string repo, string path) + { + WorkingDirectory = repo; + Context = repo; + Args = $"check-attr -z filter {path.Quoted()}"; + RaiseError = false; + } + + public IsLFSFiltered(string repo, string sha, string path) + { WorkingDirectory = repo; Context = repo; - Args = $"check-attr -a -z \"{path}\""; + Args = $"check-attr --source {sha} -z filter {path.Quoted()}"; RaiseError = false; } - public bool Result() { - var rs = ReadToEnd(); + public bool GetResult() + { + return Parse(ReadToEnd()); + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return Parse(rs); + } + + private bool Parse(Result rs) + { return rs.IsSuccess && rs.StdOut.Contains("filter\0lfs"); } } diff --git a/src/Commands/IssueTracker.cs b/src/Commands/IssueTracker.cs new file mode 100644 index 000000000..e4c156417 --- /dev/null +++ b/src/Commands/IssueTracker.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class IssueTracker : Command + { + public IssueTracker(string repo, bool isShared) + { + WorkingDirectory = repo; + Context = repo; + + if (isShared) + { + var storage = $"{repo}/.issuetracker"; + _isStorageFileExists = File.Exists(storage); + _baseArg = $"config -f {storage.Quoted()}"; + } + else + { + _isStorageFileExists = true; + _baseArg = "config --local"; + } + } + + public async Task ReadAllAsync(List outs, bool isShared) + { + if (!_isStorageFileExists) + return; + + Args = $"{_baseArg} -l"; + + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var parts = line.Split('=', 2); + if (parts.Length < 2) + continue; + + var key = parts[0]; + var value = parts[1]; + + if (!key.StartsWith("issuetracker.", StringComparison.Ordinal)) + continue; + + if (key.EndsWith(".regex", StringComparison.Ordinal)) + { + var prefixLen = "issuetracker.".Length; + var suffixLen = ".regex".Length; + var ruleName = key.Substring(prefixLen, key.Length - prefixLen - suffixLen); + FindOrAdd(outs, ruleName, isShared).RegexString = value; + } + else if (key.EndsWith(".url", StringComparison.Ordinal)) + { + var prefixLen = "issuetracker.".Length; + var suffixLen = ".url".Length; + var ruleName = key.Substring(prefixLen, key.Length - prefixLen - suffixLen); + FindOrAdd(outs, ruleName, isShared).URLTemplate = value; + } + } + } + } + + public async Task AddAsync(Models.IssueTracker rule) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.regex {rule.RegexString.Quoted()}"; + + var succ = await ExecAsync().ConfigureAwait(false); + if (succ) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.url {rule.URLTemplate.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + return false; + } + + public async Task UpdateRegexAsync(Models.IssueTracker rule) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.regex {rule.RegexString.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task UpdateURLTemplateAsync(Models.IssueTracker rule) + { + Args = $"{_baseArg} issuetracker.{rule.Name.Quoted()}.url {rule.URLTemplate.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task RemoveAsync(string name) + { + if (!_isStorageFileExists) + return true; + + Args = $"{_baseArg} --remove-section issuetracker.{name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + private Models.IssueTracker FindOrAdd(List rules, string ruleName, bool isShared) + { + var rule = rules.Find(x => x.Name.Equals(ruleName, StringComparison.Ordinal)); + if (rule != null) + return rule; + + rule = new Models.IssueTracker() { IsShared = isShared, Name = ruleName }; + rules.Add(rule); + return rule; + } + + private readonly bool _isStorageFileExists; + private readonly string _baseArg; + } +} diff --git a/src/Commands/LFS.cs b/src/Commands/LFS.cs index d632a25ba..6e4283359 100644 --- a/src/Commands/LFS.cs +++ b/src/Commands/LFS.cs @@ -1,40 +1,112 @@ -using System; -using System.IO; - -namespace SourceGit.Commands { - public class LFS { - class PruneCmd : Command { - public PruneCmd(string repo, Action onProgress) { - WorkingDirectory = repo; - Context = repo; - Args = "lfs prune"; - TraitErrorAsOutput = true; - _outputHandler = onProgress; - } +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); - } +namespace SourceGit.Commands +{ + public class LFS : Command + { + public LFS(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public async Task InstallAsync() + { + Args = "lfs install --local"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task TrackAsync(string pattern, bool isFilenameMode) + { + var builder = new StringBuilder(); + builder.Append("lfs track "); + builder.Append(isFilenameMode ? "--filename " : string.Empty); + builder.Append(pattern.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } + + public async Task FetchAsync(string remote) + { + Args = $"lfs fetch {remote}"; + await ExecAsync().ConfigureAwait(false); + } - private Action _outputHandler; + public async Task PullAsync(string remote) + { + Args = $"lfs pull {remote}"; + await ExecAsync().ConfigureAwait(false); } - public LFS(string repo) { - _repo = repo; + public async Task PushAsync(string remote) + { + Args = $"lfs push {remote}"; + await ExecAsync().ConfigureAwait(false); } - public bool IsEnabled() { - var path = Path.Combine(_repo, ".git", "hooks", "pre-push"); - if (!File.Exists(path)) return false; + public async Task PruneAsync() + { + Args = "lfs prune"; + await ExecAsync().ConfigureAwait(false); + } + + public async Task> GetLocksAsync(string remote) + { + Args = $"lfs locks --json --remote={remote}"; + + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (rs.IsSuccess) + { + try + { + var locks = JsonSerializer.Deserialize(rs.StdOut, JsonCodeGen.Default.ListLFSLock); + return locks; + } + catch + { + // Ignore exceptions. + } + } + + return []; + } - var content = File.ReadAllText(path); - return content.Contains("git lfs pre-push"); + public async Task LockAsync(string remote, string file) + { + Args = $"lfs lock --remote={remote} {file.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public void Prune(Action outputHandler) { - new PruneCmd(_repo, outputHandler).Exec(); + public async Task UnlockAsync(string remote, string file, bool force) + { + var builder = new StringBuilder(); + builder + .Append("lfs unlock --remote=") + .Append(remote) + .Append(force ? " -f " : " ") + .Append(file.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - private string _repo; + public async Task UnlockMultipleAsync(string remote, List files, bool force) + { + var builder = new StringBuilder(); + builder + .Append("lfs unlock --remote=") + .Append(remote) + .Append(force ? " -f" : " "); + + foreach (string file in files) + builder.Append(' ').Append(file.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } } } diff --git a/src/Commands/Merge.cs b/src/Commands/Merge.cs index 9854e8c53..bcb8811f9 100644 --- a/src/Commands/Merge.cs +++ b/src/Commands/Merge.cs @@ -1,19 +1,44 @@ -using System; +using System.Collections.Generic; +using System.Text; -namespace SourceGit.Commands { - public class Merge : Command { - public Merge(string repo, string source, string mode, Action outputHandler) { - _outputHandler = outputHandler; +namespace SourceGit.Commands +{ + public class Merge : Command + { + public Merge(string repo, string source, string mode, bool edit) + { WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - Args = $"merge --progress {source} {mode}"; - } - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); + var builder = new StringBuilder(); + builder.Append("merge --progress "); + builder.Append(edit ? "--edit " : "--no-edit "); + builder.Append(source); + builder.Append(' '); + builder.Append(mode); + + Args = builder.ToString(); } - private Action _outputHandler = null; + 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=").Append(strategy).Append(' '); + if (!autoCommit) + builder.Append("--no-commit "); + + foreach (var t in targets) + { + builder.Append(t); + builder.Append(' '); + } + + Args = builder.ToString(); + } } } diff --git a/src/Commands/MergeBase.cs b/src/Commands/MergeBase.cs new file mode 100644 index 000000000..2e349a84b --- /dev/null +++ b/src/Commands/MergeBase.cs @@ -0,0 +1,32 @@ +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public partial class MergeBase : Command + { + [GeneratedRegex(@"^[0-9a-f]{8,64}$")] + private static partial Regex REG_HEX(); + + public MergeBase(string repo, string rev1, string rev2) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + Args = $"merge-base {rev1} {rev2}"; + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return string.Empty; + + var trimmed = rs.StdOut.Trim(); + if (REG_HEX().IsMatch(trimmed)) + return trimmed; + + return string.Empty; + } + } +} diff --git a/src/Commands/MergeTool.cs b/src/Commands/MergeTool.cs index d18576365..45e17cc6e 100644 --- a/src/Commands/MergeTool.cs +++ b/src/Commands/MergeTool.cs @@ -1,50 +1,65 @@ -using Avalonia.Threading; -using System.IO; - -namespace SourceGit.Commands { - public static class MergeTool { - public static bool OpenForMerge(string repo, string tool, string mergeCmd, string file) { - if (string.IsNullOrWhiteSpace(tool) || string.IsNullOrWhiteSpace(mergeCmd)) { - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(repo, "Invalid external merge tool settings!"); - }); +using System; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class MergeTool : Command + { + public MergeTool(string repo, string file) + { + WorkingDirectory = repo; + Context = repo; + _file = string.IsNullOrEmpty(file) ? string.Empty : file.Quoted(); + } + + public async Task OpenAsync() + { + var tool = Native.OS.GetDiffMergeTool(false); + if (tool == null) + { + RaiseException("Invalid diff/merge tool in preference setting!"); return false; } - if (!File.Exists(tool)) { - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(repo, $"Can NOT found external merge tool in '{tool}'!"); - }); - return false; + if (string.IsNullOrEmpty(tool.Cmd)) + { + var ok = await CheckGitConfigurationAsync(); + if (!ok) + return false; + + Args = $"mergetool -g --no-prompt {_file}"; + } + else + { + var cmd = $"{tool.Exec.Quoted()} {tool.Cmd}"; + Args = $"-c mergetool.sourcegit.cmd={cmd.Quoted()} -c mergetool.writeToTemp=true -c mergetool.keepBackup=false -c mergetool.trustExitCode=true mergetool --tool=sourcegit {_file}"; } - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.RaiseError = false; - cmd.Args = $"-c mergetool.sourcegit.cmd=\"\\\"{tool}\\\" {mergeCmd}\" -c mergetool.writeToTemp=true -c mergetool.keepBackup=false -c mergetool.trustExitCode=true mergetool --tool=sourcegit \"{file}\""; - return cmd.Exec(); + return await ExecAsync().ConfigureAwait(false); } - public static bool OpenForDiff(string repo, string tool, string diffCmd, Models.DiffOption option) { - if (string.IsNullOrWhiteSpace(tool) || string.IsNullOrWhiteSpace(diffCmd)) { - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(repo, "Invalid external merge tool settings!"); - }); + private async Task CheckGitConfigurationAsync() + { + var tool = await new Config(WorkingDirectory).GetAsync("merge.guitool"); + if (string.IsNullOrEmpty(tool)) + tool = await new Config(WorkingDirectory).GetAsync("merge.tool"); + + if (string.IsNullOrEmpty(tool)) + { + RaiseException("Missing git configuration: merge.guitool"); return false; } - if (!File.Exists(tool)) { - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(repo, $"Can NOT found external merge tool in '{tool}'!"); - }); + if (tool.StartsWith("vimdiff", StringComparison.Ordinal) || + tool.StartsWith("nvimdiff", StringComparison.Ordinal)) + { + RaiseException($"CLI based merge tool \"{tool}\" is not supported by this app!"); return false; } - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.RaiseError = false; - cmd.Args = $"-c difftool.sourcegit.cmd=\"\\\"{tool}\\\" {diffCmd}\" difftool --tool=sourcegit --no-prompt {option}"; - return cmd.Exec(); + return true; } + + private string _file; } } diff --git a/src/Commands/MergeTree.cs b/src/Commands/MergeTree.cs new file mode 100644 index 000000000..41e9ea28a --- /dev/null +++ b/src/Commands/MergeTree.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class MergeTree : Command + { + public MergeTree(string repo, string source, string dest) + { + WorkingDirectory = repo; + Args = $"merge-tree --write-tree {source} {dest}"; + } + + public async Task GetExitCodeAsync() + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(false); + + var exitCode = -1; + try + { + proc.Start(); + await proc.WaitForExitAsync().ConfigureAwait(false); + exitCode = proc.ExitCode; + } + catch + { + // Ignore any exceptions and just return -1 + } + + return exitCode; + } + } +} diff --git a/src/Commands/Move.cs b/src/Commands/Move.cs new file mode 100644 index 000000000..566f984f9 --- /dev/null +++ b/src/Commands/Move.cs @@ -0,0 +1,23 @@ +using System.Text; + +namespace SourceGit.Commands +{ + public class Move : Command + { + public Move(string repo, string oldPath, string newPath, bool force) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("mv -v "); + if (force) + builder.Append("-f "); + builder.Append(oldPath.Quoted()); + builder.Append(' '); + builder.Append(newPath.Quoted()); + + Args = builder.ToString(); + } + } +} diff --git a/src/Commands/Pull.cs b/src/Commands/Pull.cs index f9112206b..75b433e73 100644 --- a/src/Commands/Pull.cs +++ b/src/Commands/Pull.cs @@ -1,29 +1,35 @@ -using System; +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class Pull : Command + { + public Pull(string repo, string remote, string branch, bool useRebase) + { + _remote = remote; -namespace SourceGit.Commands { - public class Pull : Command { - public Pull(string repo, string remote, string branch, bool useRebase, Action outputHandler) { - _outputHandler = outputHandler; WorkingDirectory = repo; Context = 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 "; - } + var builder = new StringBuilder(512); + builder + .Append("pull --verbose --progress --rebase=") + .Append(useRebase ? "true" : "false") + .Append(' ') + .Append(remote) + .Append(' ') + .Append(branch); - Args += "pull --verbose --progress --tags "; - if (useRebase) Args += "--rebase "; - Args += $"{remote} {branch}"; + Args = builder.ToString(); } - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); + public async Task RunAsync() + { + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{_remote}.sshkey").ConfigureAwait(false); + return await ExecAsync().ConfigureAwait(false); } - private Action _outputHandler; + private readonly string _remote; } } diff --git a/src/Commands/Push.cs b/src/Commands/Push.cs index 8839666b0..44394dc41 100644 --- a/src/Commands/Push.cs +++ b/src/Commands/Push.cs @@ -1,70 +1,54 @@ -using System; +using System.Text; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Push : Command { - public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool force, bool track, Action onProgress) { - WorkingDirectory = repo; - Context = repo; - TraitErrorAsOutput = true; - _outputHandler = 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 "; +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) + { + _remote = remote; - Args += $"{remote} {local}:{remoteBranch}"; - } - - /// - /// Only used to delete a remote branch!!!!!! - /// - /// - /// - /// - public Push(string repo, string remote, string branch) { WorkingDirectory = repo; Context = 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 += $"push {remote} --delete {branch}"; + var builder = new StringBuilder(1024); + builder.Append("push --progress --verbose "); + if (withTags) + builder.Append("--tags "); + if (checkSubmodules) + builder.Append("--recurse-submodules=check "); + if (track) + builder.Append("-u "); + if (force) + builder.Append("--force-with-lease "); + + builder.Append(remote).Append(' ').Append(local).Append(':').Append(remoteBranch); + Args = builder.ToString(); } - public Push(string repo, string remote, string tag, bool isDelete) { + public Push(string repo, string remote, string refname, bool isDelete) + { + _remote = remote; + WorkingDirectory = repo; Context = 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 "; - } + var builder = new StringBuilder(512); + builder.Append("push "); + if (isDelete) + builder.Append("--delete "); + builder.Append(remote).Append(' ').Append(refname); - Args += "push "; - if (isDelete) Args += "--delete "; - Args += $"{remote} refs/tags/{tag}"; + Args = builder.ToString(); } - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); + public async Task RunAsync() + { + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{_remote}.sshkey").ConfigureAwait(false); + return await ExecAsync().ConfigureAwait(false); } - private Action _outputHandler = null; + private readonly string _remote; } } diff --git a/src/Commands/QueryAssumeUnchangedFiles.cs b/src/Commands/QueryAssumeUnchangedFiles.cs new file mode 100644 index 000000000..0fa0f8d0f --- /dev/null +++ b/src/Commands/QueryAssumeUnchangedFiles.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +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 async Task> GetResultAsync() + { + var outs = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + 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/QueryAuthors.cs b/src/Commands/QueryAuthors.cs new file mode 100644 index 000000000..d5e443d5e --- /dev/null +++ b/src/Commands/QueryAuthors.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryAuthors : Command + { + public QueryAuthors(string repo) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + Args = "log -100000 --all --format=%aN±%aE"; + } + + public async Task> GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return []; + + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + var added = new HashSet(); + var users = new List(); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + if (!string.IsNullOrEmpty(line) && !added.Contains(line)) + { + var user = Models.User.FindOrAdd(line); + users.Add(user); + added.Add(line); + } + + start = end + 1; + if (start >= rs.StdOut.Length - 1) + break; + + end = rs.StdOut.IndexOf('\n', start); + } + + return users; + } + } +} diff --git a/src/Commands/QueryBranches.cs b/src/Commands/QueryBranches.cs index 8c2db9a2b..b741fb5c0 100644 --- a/src/Commands/QueryBranches.cs +++ b/src/Commands/QueryBranches.cs @@ -1,76 +1,118 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QueryBranches : Command { - private static readonly string PREFIX_LOCAL = "refs/heads/"; - private static readonly string PREFIX_REMOTE = "refs/remotes/"; - private static readonly Regex REG_AHEAD = new Regex(@"ahead (\d+)"); - private static readonly Regex REG_BEHIND = new Regex(@"behind (\d+)"); +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) { + public QueryBranches(string repo) + { WorkingDirectory = repo; Context = repo; - Args = "branch -l --all -v --format=\"%(refname)$%(objectname)$%(HEAD)$%(upstream)$%(upstream:track)\""; + Args = "branch -l --all -v --format=\"%(refname)%00%(committerdate:unix)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)%00%(worktreepath)\""; } - public List Result() { - Exec(); - return _branches; + public async Task> GetResultAsync() + { + var branches = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return branches; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var mismatched = new HashSet(); + var remotes = new Dictionary(); + foreach (var line in lines) + { + var b = ParseLine(line, mismatched); + if (b != null) + { + branches.Add(b); + if (!b.IsLocal) + remotes.Add(b.FullName, b); + } + } + + foreach (var b in branches) + { + if (b.IsLocal && !string.IsNullOrEmpty(b.Upstream)) + { + if (remotes.TryGetValue(b.Upstream, out var upstream)) + { + b.IsUpstreamGone = false; + + if (mismatched.Contains(b.FullName)) + await new QueryTrackStatus(WorkingDirectory).GetResultAsync(b, upstream).ConfigureAwait(false); + } + else + { + b.IsUpstreamGone = true; + } + } + } + + return branches; } - protected override void OnReadline(string line) { - var parts = line.Split('$'); - if (parts.Length != 5) return; + private Models.Branch ParseLine(string line, HashSet mismatched) + { + var parts = line.Split('\0'); + if (parts.Length != 7) + return null; var branch = new Models.Branch(); var refName = parts[0]; - if (refName.EndsWith("/HEAD")) return; + 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)) { + 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)) { + } + else if (refName.StartsWith(PREFIX_REMOTE, StringComparison.Ordinal)) + { var name = refName.Substring(PREFIX_REMOTE.Length); - var shortNameIdx = name.IndexOf('/'); - if (shortNameIdx < 0) return; + var nameParts = name.Split('/', 2); + if (nameParts.Length != 2) + return null; - branch.Remote = name.Substring(0, shortNameIdx); - branch.Name = name.Substring(branch.Remote.Length + 1); + branch.Remote = nameParts[0]; + branch.Name = nameParts[1]; branch.IsLocal = false; - } else { + } + else + { branch.Name = refName; branch.IsLocal = true; } - branch.FullName = refName; - branch.Head = parts[1]; - branch.IsCurrent = parts[2] == "*"; - branch.Upstream = parts[3]; - branch.UpstreamTrackStatus = ParseTrackStatus(parts[4]); - - _branches.Add(branch); - } - - private string ParseTrackStatus(string data) { - if (string.IsNullOrEmpty(data)) return string.Empty; - - string track = string.Empty; + ulong.TryParse(parts[1], out var committerDate); - var ahead = REG_AHEAD.Match(data); - if (ahead.Success) { - track += ahead.Groups[1].Value + "↑ "; - } + branch.FullName = refName; + branch.CommitterDate = committerDate; + branch.Head = parts[2]; + branch.IsCurrent = parts[3] == "*"; + branch.Upstream = parts[4]; + branch.IsUpstreamGone = false; - var behind = REG_BEHIND.Match(data); - if (behind.Success) { - track += behind.Groups[1].Value + "↓"; - } + if (branch.IsLocal && + !string.IsNullOrEmpty(branch.Upstream) && + !string.IsNullOrEmpty(parts[5]) && + !parts[5].Equals("=", StringComparison.Ordinal)) + mismatched.Add(branch.FullName); - return track.Trim(); + branch.WorktreePath = parts[6]; + return branch; } - - private List _branches = new List(); } } diff --git a/src/Commands/QueryCommitChanges.cs b/src/Commands/QueryCommitChanges.cs deleted file mode 100644 index 7b252efd5..000000000 --- a/src/Commands/QueryCommitChanges.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - public class QueryCommitChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - - public QueryCommitChanges(string repo, string commitSHA) { - WorkingDirectory = repo; - Context = repo; - Args = $"show --name-status {commitSHA}"; - } - - public List Result() { - Exec(); - _changes.Sort((l, r) => l.Path.CompareTo(r.Path)); - return _changes; - } - - protected 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.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 'R': change.Set(Models.ChangeState.Renamed); _changes.Add(change); break; - case 'C': change.Set(Models.ChangeState.Copied); _changes.Add(change); break; - } - } - - private List _changes = new List(); - } -} diff --git a/src/Commands/QueryCommitFullMessage.cs b/src/Commands/QueryCommitFullMessage.cs new file mode 100644 index 000000000..2e9db9298 --- /dev/null +++ b/src/Commands/QueryCommitFullMessage.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; + +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 GetResult() + { + var rs = ReadToEnd(); + return rs.IsSuccess ? rs.StdOut.TrimEnd() : string.Empty; + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return rs.IsSuccess ? rs.StdOut.TrimEnd() : string.Empty; + } + } +} diff --git a/src/Commands/QueryCommitSignInfo.cs b/src/Commands/QueryCommitSignInfo.cs new file mode 100644 index 000000000..179e4cddd --- /dev/null +++ b/src/Commands/QueryCommitSignInfo.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; + +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 async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + 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 index 0d6c31e29..1a5073988 100644 --- a/src/Commands/QueryCommits.cs +++ b/src/Commands/QueryCommits.cs @@ -1,152 +1,111 @@ using System; using System.Collections.Generic; - -namespace SourceGit.Commands { - public class QueryCommits : 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 QueryCommits(string repo, string limits, bool needFindHead = true) { +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryCommits : Command + { + public QueryCommits(string repo, string limits, bool markMerged = true) + { WorkingDirectory = repo; - Args = "log --date-order --decorate=full --pretty=raw " + limits; - findFirstMerged = needFindHead; + Context = repo; + Args = $"log --no-show-signature --decorate=full --format=%H%x00%P%x00%D%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s {limits}"; + _markMerged = markMerged; } - public List Result() { - Exec(); + public QueryCommits(string repo, string filter, Models.CommitSearchMethod method, bool onlyCurrentBranch) + { + var builder = new StringBuilder(); + builder.Append("log -1000 --date-order --no-show-signature --decorate=full --format=%H%x00%P%x00%D%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s "); - if (current != null) { - current.Message = current.Message.Trim(); - commits.Add(current); - } + if (!onlyCurrentBranch) + builder.Append("--branches --remotes "); - if (findFirstMerged && !isHeadFounded && commits.Count > 0) { - MarkFirstMerged(); + if (method == Models.CommitSearchMethod.ByAuthor) + { + builder.Append("-i --author=").Append(filter.Quoted()); } - - return commits; - } - - protected 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; + else if (method == Models.CommitSearchMethod.ByMessage) + { + var words = filter.Split([' ', '\t', '\r'], StringSplitOptions.RemoveEmptyEntries); + foreach (var word in words) + builder.Append("--grep=").Append(word.Trim().Quoted()).Append(' '); + builder.Append("--all-match -i"); } - - 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; + else if (method == Models.CommitSearchMethod.ByPath) + { + builder.Append("-- ").Append(filter.Quoted()); } - - 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.Substring(7), 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.Substring(10), 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"); + else + { + builder.Append("-G").Append(filter.Quoted()); } - } - private bool ParseDecorators(List decorators, string data) { - bool isHeadOfCurrent = false; + WorkingDirectory = repo; + Context = repo; + Args = builder.ToString(); + _markMerged = 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(), - }); + public async Task> GetResultAsync() + { + var commits = new List(); + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + var findHead = false; + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + var parts = line.Split('\0'); + if (parts.Length != 8) + continue; + + var commit = new Models.Commit() { SHA = parts[0] }; + commit.ParseParents(parts[1]); + commit.ParseDecorators(parts[2]); + commit.Author = Models.User.FindOrAdd(parts[3]); + commit.AuthorTime = ulong.Parse(parts[4]); + commit.Committer = Models.User.FindOrAdd(parts[5]); + commit.CommitterTime = ulong.Parse(parts[6]); + commit.Subject = parts[7]; + commits.Add(commit); + + if (!findHead && commit.IsMerged) + findHead = true; } - } - decorators.Sort((l, r) => { - if (l.Type != r.Type) { - return (int)l.Type - (int)r.Type; - } else { - return l.Name.CompareTo(r.Name); + await proc.WaitForExitAsync().ConfigureAwait(false); + + if (_markMerged && !findHead && commits.Count > 0) + { + var set = await new QueryCurrentBranchCommitHashes(WorkingDirectory, commits[^1].CommitterTime) + .GetResultAsync() + .ConfigureAwait(false); + + foreach (var c in commits) + { + if (set.Contains(c.SHA)) + { + c.IsMerged = true; + break; + } + } } - }); + } + catch (Exception e) + { + RaiseException($"Failed to query commits. Reason: {e.Message}"); + } - return isHeadOfCurrent; + return commits; } - private void MarkFirstMerged() { - Args = $"log --since=\"{commits[commits.Count - 1].CommitterTimeStr}\" --format=\"%H\""; - - var rs = ReadToEnd(); - var shas = rs.StdOut.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; - } - } - } + private bool _markMerged = false; } } diff --git a/src/Commands/QueryCommitsForInteractiveRebase.cs b/src/Commands/QueryCommitsForInteractiveRebase.cs new file mode 100644 index 000000000..7f0d1ba1a --- /dev/null +++ b/src/Commands/QueryCommitsForInteractiveRebase.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +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 --topo-order --cherry-pick --right-only --no-merges --no-show-signature --decorate=full --format=\"%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s%n%B%n{_boundary}\" {on}...HEAD"; + } + + public async Task> GetResultAsync() + { + var commits = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + { + RaiseException($"Failed to query commits for interactive-rebase. Reason: {rs.StdErr}"); + return commits; + } + + Models.InteractiveCommit current = null; + + 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: + current.Commit.ParseParents(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; + case 7: + current.Commit.Subject = 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 readonly string _boundary; + } +} diff --git a/src/Commands/QueryConflictFileState.cs b/src/Commands/QueryConflictFileState.cs new file mode 100644 index 000000000..f9a52ee12 --- /dev/null +++ b/src/Commands/QueryConflictFileState.cs @@ -0,0 +1,55 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryConflictFileState : Command + { + public QueryConflictFileState(string repo, Models.Change change) + { + var opt = new Models.DiffOption(change, true); + + WorkingDirectory = repo; + Context = repo; + Args = $"diff --no-color --no-ext-diff --no-textconv --full-index --patch {opt}"; + } + + public async Task GetResultAsync() + { + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + var isBinary = false; + var tokenCount = 0; + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + if (isBinary) + continue; + + if (line.StartsWith("Binary files ", StringComparison.Ordinal)) + isBinary = true; + else if (line.StartsWith("++<<<<<<<", StringComparison.Ordinal) || + line.StartsWith("++=======", StringComparison.Ordinal) || + line.StartsWith("++>>>>>>>", StringComparison.Ordinal)) + tokenCount++; + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + + if (isBinary) + return Models.ConflictFileState.UnmergedBinary; + + return tokenCount == 0 ? Models.ConflictFileState.Resolved : Models.ConflictFileState.UnmergedText; + } + catch + { + return Models.ConflictFileState.Unknown; + } + } + } +} diff --git a/src/Commands/QueryCurrentBranch.cs b/src/Commands/QueryCurrentBranch.cs new file mode 100644 index 000000000..8b698d1e5 --- /dev/null +++ b/src/Commands/QueryCurrentBranch.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryCurrentBranch : Command + { + public QueryCurrentBranch(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "branch --show-current"; + } + + public string GetResult() + { + return ReadToEnd().StdOut.Trim(); + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return rs.StdOut.Trim(); + } + } +} diff --git a/src/Commands/QueryCurrentBranchCommitHashes.cs b/src/Commands/QueryCurrentBranchCommitHashes.cs new file mode 100644 index 000000000..393160602 --- /dev/null +++ b/src/Commands/QueryCurrentBranchCommitHashes.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryCurrentBranchCommitHashes : Command + { + public QueryCurrentBranchCommitHashes(string repo, ulong sinceTimestamp) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --since=@{sinceTimestamp} --format=%H"; + } + + public async Task> GetResultAsync() + { + var outs = new HashSet(); + + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { Length: > 8 } line) + outs.Add(line); + + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch + { + // Ignore exceptions; + } + + return outs; + } + } +} diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs index 172134c55..d111b5f21 100644 --- a/src/Commands/QueryFileContent.cs +++ b/src/Commands/QueryFileContent.cs @@ -1,23 +1,68 @@ -using System.Text; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QueryFileContent : Command { - public QueryFileContent(string repo, string revision, string file) { - WorkingDirectory = repo; - Context = repo; - Args = $"show {revision}:\"{file}\""; - } +namespace SourceGit.Commands +{ + public static class QueryFileContent + { + public static async Task RunAsync(string repo, string revision, string file) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"show {revision}:{file.Quoted()}"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardOutput = true; - public string Result() { - Exec(); - return _builder.ToString(); - } + var stream = new MemoryStream(); + try + { + using var proc = Process.Start(starter)!; + await proc.StandardOutput.BaseStream.CopyToAsync(stream).ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch (Exception e) + { + Models.Notification.Send(repo, $"Failed to query file content: {e}", true); + } - protected override void OnReadline(string line) { - _builder.Append(line); - _builder.Append('\n'); + stream.Position = 0; + return stream; } - private StringBuilder _builder = new StringBuilder(); + public static async Task FromLFSAsync(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; + + var stream = new MemoryStream(); + try + { + using var proc = Process.Start(starter)!; + await proc.StandardInput.WriteLineAsync("version https://git-lfs.github.com/spec/v1").ConfigureAwait(false); + await proc.StandardInput.WriteLineAsync($"oid sha256:{oid}").ConfigureAwait(false); + await proc.StandardInput.WriteLineAsync($"size {size}").ConfigureAwait(false); + await proc.StandardOutput.BaseStream.CopyToAsync(stream).ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch (Exception e) + { + Models.Notification.Send(repo, $"Failed to query file content: {e}", true); + } + + stream.Position = 0; + return stream; + } } } diff --git a/src/Commands/QueryFileHistory.cs b/src/Commands/QueryFileHistory.cs new file mode 100644 index 000000000..5dca109a2 --- /dev/null +++ b/src/Commands/QueryFileHistory.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public partial class QueryFileHistory : Command + { + [GeneratedRegex(@"^([MADT])\s+(.+)$")] + private static partial Regex REG_FORMAT(); + [GeneratedRegex(@"^([CR])[0-9]{0,4}\s+(.+)$")] + private static partial Regex REG_RENAME_FORMAT(); + + public QueryFileHistory(string repo, string path, string head) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + + var builder = new StringBuilder(); + builder.Append("log --no-show-signature --date-order -n 10000 --decorate=no --format=\"@%H%x00%P%x00%aN±%aE%x00%at%x00%s\" --follow --name-status "); + if (!string.IsNullOrEmpty(head)) + builder.Append(head).Append(" "); + builder.Append("-- ").Append(path.Quoted()); + + Args = builder.ToString(); + } + + public async Task> GetResultAsync() + { + var versions = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return versions; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (lines.Length == 0) + return versions; + + Models.FileVersion last = null; + foreach (var line in lines) + { + if (line.StartsWith('@')) + { + var parts = line.Split('\0'); + if (parts.Length != 5) + continue; + + last = new Models.FileVersion(); + last.SHA = parts[0].Substring(1); + last.HasParent = !string.IsNullOrEmpty(parts[1]); + last.Author = Models.User.FindOrAdd(parts[2]); + last.AuthorTime = ulong.Parse(parts[3]); + last.Subject = parts[4]; + versions.Add(last); + } + else if (last != null) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + { + match = REG_RENAME_FORMAT().Match(line); + if (match.Success) + { + var type = match.Groups[1].Value; + last.Change.Path = match.Groups[2].Value; + last.Change.Set(type == "R" ? Models.ChangeState.Renamed : Models.ChangeState.Copied); + } + + continue; + } + + last.Change.Path = match.Groups[2].Value; + + var status = match.Groups[1].Value; + switch (status[0]) + { + case 'M': + last.Change.Set(Models.ChangeState.Modified); + break; + case 'A': + last.Change.Set(Models.ChangeState.Added); + break; + case 'D': + last.Change.Set(Models.ChangeState.Deleted); + break; + case 'T': + last.Change.Set(Models.ChangeState.TypeChanged); + break; + } + } + } + + return versions; + } + } +} diff --git a/src/Commands/QueryFileSize.cs b/src/Commands/QueryFileSize.cs index e16bf9e61..c1ad51dfe 100644 --- a/src/Commands/QueryFileSize.cs +++ b/src/Commands/QueryFileSize.cs @@ -1,29 +1,31 @@ using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QueryFileSize : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^\d+\s+\w+\s+[0-9a-f]+\s+(\d+)\s+.*$"); +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) { + public QueryFileSize(string repo, string file, string revision) + { WorkingDirectory = repo; Context = repo; - Args = $"ls-tree {revision} -l -- {file}"; + Args = $"ls-tree {revision} -l -- {file.Quoted()}"; } - public long Result() { - if (_result != 0) return _result; - - var rs = ReadToEnd(); - if (rs.IsSuccess) { - var match = REG_FORMAT.Match(rs.StdOut); - if (match.Success) { + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (rs.IsSuccess) + { + var match = REG_FORMAT().Match(rs.StdOut); + if (match.Success) return long.Parse(match.Groups[1].Value); - } } return 0; } - - private long _result = 0; } } diff --git a/src/Commands/QueryGitCommonDir.cs b/src/Commands/QueryGitCommonDir.cs new file mode 100644 index 000000000..e71cf2b07 --- /dev/null +++ b/src/Commands/QueryGitCommonDir.cs @@ -0,0 +1,27 @@ +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryGitCommonDir : Command + { + public QueryGitCommonDir(string workDir) + { + WorkingDirectory = workDir; + Args = "rev-parse --git-common-dir"; + RaiseError = false; + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess || string.IsNullOrEmpty(rs.StdOut)) + return string.Empty; + + var dir = rs.StdOut.Trim(); + if (Path.IsPathRooted(dir)) + return dir; + return Path.GetFullPath(Path.Combine(WorkingDirectory, dir)); + } + } +} diff --git a/src/Commands/QueryGitDir.cs b/src/Commands/QueryGitDir.cs index dcdc216b1..5a91b2173 100644 --- a/src/Commands/QueryGitDir.cs +++ b/src/Commands/QueryGitDir.cs @@ -1,20 +1,30 @@ using System.IO; -namespace SourceGit.Commands { - public class QueryGitDir : Command { - public QueryGitDir(string 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().StdOut; - if (string.IsNullOrEmpty(rs)) return null; + public string GetResult() + { + return Parse(ReadToEnd()); + } + + private string Parse(Result rs) + { + if (!rs.IsSuccess) + return null; + + var stdout = rs.StdOut.Trim(); + if (string.IsNullOrEmpty(stdout)) + return null; - rs = rs.Trim(); - if (Path.IsPathRooted(rs)) return rs; - return Path.GetFullPath(Path.Combine(WorkingDirectory, rs)); + return Path.IsPathRooted(stdout) ? stdout : Path.GetFullPath(Path.Combine(WorkingDirectory, stdout)); } } } diff --git a/src/Commands/QueryLocalChanges.cs b/src/Commands/QueryLocalChanges.cs index 260885d54..d1bd30a2c 100644 --- a/src/Commands/QueryLocalChanges.cs +++ b/src/Commands/QueryLocalChanges.cs @@ -1,66 +1,177 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QueryLocalChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private static readonly string[] UNTRACKED = [ "no", "all" ]; +namespace SourceGit.Commands +{ + public partial class QueryLocalChanges : Command + { + [GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] + private static partial Regex REG_FORMAT(); - public QueryLocalChanges(string repo, bool includeUntracked = true) { + public QueryLocalChanges(string repo, bool includeUntracked = true, bool noOptionalLocks = true) + { WorkingDirectory = repo; Context = repo; - Args = $"status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain"; - } - public List Result() { - Exec(); - return _changes; + var builder = new StringBuilder(); + if (noOptionalLocks) + builder.Append("--no-optional-locks "); + if (includeUntracked) + builder.Append("-c core.untrackedCache=true -c status.showUntrackedFiles=all status -uall --ignore-submodules=dirty --porcelain"); + else + builder.Append("status -uno --ignore-submodules=dirty --porcelain"); + + Args = builder.ToString(); } - protected 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 + public async Task> GetResultAsync() + { + var outs = new List(); + + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + 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; + 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 " 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, Models.ChangeState.None); break; - case "MM": change.Set(Models.ChangeState.Modified, Models.ChangeState.Modified); break; - case "MD": change.Set(Models.ChangeState.Modified, Models.ChangeState.Deleted); break; - case "A": change.Set(Models.ChangeState.Added, Models.ChangeState.None); break; - case "AM": change.Set(Models.ChangeState.Added, Models.ChangeState.Modified); break; - case "AD": change.Set(Models.ChangeState.Added, Models.ChangeState.Deleted); break; - case "D": change.Set(Models.ChangeState.Deleted, Models.ChangeState.None); break; - case "R": change.Set(Models.ChangeState.Renamed, Models.ChangeState.None); break; - case "RM": change.Set(Models.ChangeState.Renamed, Models.ChangeState.Modified); break; - case "RD": change.Set(Models.ChangeState.Renamed, Models.ChangeState.Deleted); break; - case "C": change.Set(Models.ChangeState.Copied, Models.ChangeState.None); break; - case "CM": change.Set(Models.ChangeState.Copied, Models.ChangeState.Modified); break; - case "CD": change.Set(Models.ChangeState.Copied, Models.ChangeState.Deleted); break; - case "DR": change.Set(Models.ChangeState.Deleted, Models.ChangeState.Renamed); break; - case "DC": change.Set(Models.ChangeState.Deleted, Models.ChangeState.Copied); break; - case "DD": change.Set(Models.ChangeState.Deleted, Models.ChangeState.Deleted); break; - case "AU": change.Set(Models.ChangeState.Added, Models.ChangeState.Unmerged); break; - case "UD": change.Set(Models.ChangeState.Unmerged, Models.ChangeState.Deleted); break; - case "UA": change.Set(Models.ChangeState.Unmerged, Models.ChangeState.Added); break; - case "DU": change.Set(Models.ChangeState.Deleted, Models.ChangeState.Unmerged); break; - case "AA": change.Set(Models.ChangeState.Added, Models.ChangeState.Added); break; - case "UU": change.Set(Models.ChangeState.Unmerged, Models.ChangeState.Unmerged); break; - case "??": change.Set(Models.ChangeState.Untracked, Models.ChangeState.Untracked); break; - default: return; + 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); + } + } + catch + { + // Ignore exceptions. } - _changes.Add(change); + return outs; } - - private List _changes = new List(); } } diff --git a/src/Commands/QueryPickableCommits.cs b/src/Commands/QueryPickableCommits.cs new file mode 100644 index 000000000..7ea26e5b2 --- /dev/null +++ b/src/Commands/QueryPickableCommits.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryPickableCommits : Command + { + public QueryPickableCommits(string repo, string based, string target) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --topo-order --cherry-pick --right-only --no-merges --no-show-signature --decorate=full --format=%H%x00%P%x00%D%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s {based}...{target}"; + } + + public async Task> GetResultAsync() + { + var commits = new List(); + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + var parts = line.Split('\0'); + if (parts.Length != 8) + continue; + + var commit = new Models.Commit() { SHA = parts[0] }; + commit.ParseParents(parts[1]); + commit.ParseDecorators(parts[2]); + commit.Author = Models.User.FindOrAdd(parts[3]); + commit.AuthorTime = ulong.Parse(parts[4]); + commit.Committer = Models.User.FindOrAdd(parts[5]); + commit.CommitterTime = ulong.Parse(parts[6]); + commit.Subject = parts[7]; + commits.Add(commit); + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch (Exception e) + { + RaiseException($"Failed to query commits. Reason: {e.Message}"); + } + + return commits; + } + } +} diff --git a/src/Commands/QueryRefsContainsCommit.cs b/src/Commands/QueryRefsContainsCommit.cs new file mode 100644 index 000000000..186b6a80b --- /dev/null +++ b/src/Commands/QueryRefsContainsCommit.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +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 async Task> GetResultAsync() + { + var outs = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return outs; + + var lines = rs.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)) + outs.Add(new() { Name = line.Substring("refs/heads/".Length), Type = Models.DecoratorType.LocalBranchHead }); + else if (line.StartsWith("refs/remotes/", StringComparison.Ordinal)) + outs.Add(new() { Name = line.Substring("refs/remotes/".Length), Type = Models.DecoratorType.RemoteBranchHead }); + else if (line.StartsWith("refs/tags/", StringComparison.Ordinal)) + outs.Add(new() { Name = line.Substring("refs/tags/".Length), Type = Models.DecoratorType.Tag }); + } + + return outs; + } + } +} diff --git a/src/Commands/QueryRemotes.cs b/src/Commands/QueryRemotes.cs index e8e21b4a7..f8a23b7aa 100644 --- a/src/Commands/QueryRemotes.cs +++ b/src/Commands/QueryRemotes.cs @@ -1,34 +1,73 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QueryRemotes : Command { - private static readonly Regex REG_REMOTE = new Regex(@"^([\w\.\-]+)\s*(\S+).*$"); +namespace SourceGit.Commands +{ + public partial class QueryRemotes : Command + { + [GeneratedRegex(@"^([\w\.\-]+)\s*(\S+).*$")] + private static partial Regex REG_REMOTE(); - public QueryRemotes(string repo) { + public QueryRemotes(string repo) + { WorkingDirectory = repo; Context = repo; Args = "remote -v"; } - public List Result() { - Exec(); - return _loaded; - } + public async Task> GetResultAsync() + { + var outs = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return outs; - protected override void OnReadline(string line) { - var match = REG_REMOTE.Match(line); - if (!match.Success) return; + var config = await new Config(WorkingDirectory).ReadAllAsync().ConfigureAwait(false); + var disableAutoFetchRemotes = new HashSet(); + foreach (var (k, v) in config) + { + if (k.StartsWith("remote.", StringComparison.Ordinal) && + k.EndsWith(".disableautofetch", StringComparison.Ordinal) && + v.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + var remoteName = k.Substring(7, k.Length - 24).Trim('"'); + disableAutoFetchRemotes.Add(remoteName); + } + } - var remote = new Models.Remote() { - Name = match.Groups[1].Value, - URL = match.Groups[2].Value, - }; + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_REMOTE().Match(line); + if (!match.Success) + continue; - if (_loaded.Find(x => x.Name == remote.Name) != null) return; - _loaded.Add(remote); - } + var remote = new Models.Remote() + { + Name = match.Groups[1].Value, + URL = match.Groups[2].Value, + DisableAutoFetch = disableAutoFetchRemotes.Contains(match.Groups[1].Value) + }; + + if (outs.Find(x => x.Name == remote.Name) != null) + continue; - private List _loaded = new List(); + if (remote.URL.StartsWith("git@", StringComparison.Ordinal)) + { + var hostEnd = remote.URL.IndexOf(':', 4); + if (hostEnd > 4) + { + var host = remote.URL.Substring(4, hostEnd - 4); + Models.HTTPSValidator.Add(host); + } + } + + outs.Add(remote); + } + + return outs; + } } } diff --git a/src/Commands/QueryRepositoryRootPath.cs b/src/Commands/QueryRepositoryRootPath.cs index 51a1a15ab..89d259296 100644 --- a/src/Commands/QueryRepositoryRootPath.cs +++ b/src/Commands/QueryRepositoryRootPath.cs @@ -1,15 +1,23 @@ -namespace SourceGit.Commands { - public class QueryRepositoryRootPath : Command { - public QueryRepositoryRootPath(string path) { +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryRepositoryRootPath : Command + { + public QueryRepositoryRootPath(string path) + { WorkingDirectory = path; Args = "rev-parse --show-toplevel"; - RaiseError = false; } - public string Result() { - var rs = ReadToEnd().StdOut; - if (string.IsNullOrEmpty(rs)) return null; - return rs.Trim(); + public Result GetResult() + { + return ReadToEnd(); + } + + public async Task GetResultAsync() + { + return await ReadToEndAsync().ConfigureAwait(false); } } } diff --git a/src/Commands/QueryRepositoryStatus.cs b/src/Commands/QueryRepositoryStatus.cs new file mode 100644 index 000000000..3d6adac8e --- /dev/null +++ b/src/Commands/QueryRepositoryStatus.cs @@ -0,0 +1,59 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public partial class QueryRepositoryStatus : Command + { + [GeneratedRegex(@"\+(\d+) \-(\d+)")] + private static partial Regex REG_BRANCH_AB(); + + public QueryRepositoryStatus(string repo) + { + WorkingDirectory = repo; + RaiseError = false; + } + + public async Task GetResultAsync() + { + Args = "status --porcelain=v2 -b"; + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return null; + + var status = new Models.RepositoryStatus(); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var count = lines.Length; + if (count < 2) + return null; + + var sha1 = lines[0].Substring(13).Trim(); // Remove "# branch.oid " prefix + var head = lines[1].Substring(14).Trim(); // Remove "# branch.head " prefix + + if (head.Equals("(detached)", StringComparison.Ordinal)) + status.CurrentBranch = sha1.Length > 10 ? $"({sha1.Substring(0, 10)})" : "-"; + else + status.CurrentBranch = head; + + if (count == 4 && lines[3].StartsWith("# branch.ab ", StringComparison.Ordinal)) + ParseTrackStatus(status, lines[3].Substring(12).Trim()); + + status.LocalChanges = await new CountLocalChanges(WorkingDirectory, true) { RaiseError = false } + .GetResultAsync() + .ConfigureAwait(false); + + return status; + } + + private void ParseTrackStatus(Models.RepositoryStatus status, string input) + { + var match = REG_BRANCH_AB().Match(input); + if (match.Success) + { + status.Ahead = int.Parse(match.Groups[1].Value); + status.Behind = int.Parse(match.Groups[2].Value); + } + } + } +} diff --git a/src/Commands/QueryRevisionByRefName.cs b/src/Commands/QueryRevisionByRefName.cs new file mode 100644 index 000000000..78104523a --- /dev/null +++ b/src/Commands/QueryRevisionByRefName.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryRevisionByRefName : Command + { + public QueryRevisionByRefName(string repo, string refname) + { + WorkingDirectory = repo; + Context = repo; + Args = $"rev-parse {refname}"; + } + + public string GetResult() + { + return Parse(ReadToEnd()); + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return Parse(rs); + } + + private string Parse(Result rs) + { + 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 000000000..e4dcc25c5 --- /dev/null +++ b/src/Commands/QueryRevisionFileNames.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryRevisionFileNames : Command + { + public QueryRevisionFileNames(string repo, string revision) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -r --name-only {revision}"; + } + + public async Task> GetResultAsync() + { + var outs = new List(); + + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { Length: > 0 } line) + outs.Add(line); + + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch + { + // Ignore exceptions. + } + + return outs; + } + } +} diff --git a/src/Commands/QueryRevisionObjects.cs b/src/Commands/QueryRevisionObjects.cs index b0c48f2ac..a7eaaa9e7 100644 --- a/src/Commands/QueryRevisionObjects.cs +++ b/src/Commands/QueryRevisionObjects.cs @@ -1,39 +1,70 @@ using System.Collections.Generic; +using System.Diagnostics; +using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QueryRevisionObjects : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^\d+\s+(\w+)\s+([0-9a-f]+)\s+(.*)$"); - private List objects = new List(); +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) { + public QueryRevisionObjects(string repo, string sha, string parentFolder) + { WorkingDirectory = repo; Context = repo; - Args = $"ls-tree -r {sha}"; - } - public List Result() { - Exec(); - return objects; + var builder = new StringBuilder(1024); + builder.Append("ls-tree ").Append(sha); + if (!string.IsNullOrEmpty(parentFolder)) + builder.Append(" -- ").Append(parentFolder.Quoted()); + + Args = builder.ToString(); } - protected override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; + public async Task> GetResultAsync() + { + var outs = new List(); + + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + continue; - var obj = new Models.Object(); - obj.SHA = match.Groups[2].Value; - obj.Type = Models.ObjectType.Blob; - obj.Path = match.Groups[3].Value; + 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; + obj.Type = match.Groups[1].Value switch + { + "blob" => Models.ObjectType.Blob, + "tree" => Models.ObjectType.Tree, + "tag" => Models.ObjectType.Tag, + "commit" => Models.ObjectType.Commit, + _ => obj.Type, + }; + + outs.Add(obj); + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch + { + // Ignore exceptions. } - objects.Add(obj); + return outs; } } } diff --git a/src/Commands/QuerySingleCommit.cs b/src/Commands/QuerySingleCommit.cs new file mode 100644 index 000000000..822f5c4a5 --- /dev/null +++ b/src/Commands/QuerySingleCommit.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; + +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 GetResult() + { + var rs = ReadToEnd(); + return Parse(rs); + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return Parse(rs); + } + + private Models.Commit Parse(Result rs) + { + if (!rs.IsSuccess || string.IsNullOrEmpty(rs.StdOut)) + return null; + + 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; + } + } +} diff --git a/src/Commands/QueryStagedChangesWithAmend.cs b/src/Commands/QueryStagedChangesWithAmend.cs new file mode 100644 index 000000000..0cd420592 --- /dev/null +++ b/src/Commands/QueryStagedChangesWithAmend.cs @@ -0,0 +1,97 @@ +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]{4,64}) [0-9a-f]{4,64} ([ADMT])\d{0,6}\t(.*)$")] + private static partial Regex REG_FORMAT1(); + [GeneratedRegex(@"^:[\d]{6} ([\d]{6}) ([0-9a-f]{4,64}) [0-9a-f]{4,64} ([RC])\d{0,6}\t(.*\t.*)$")] + private static partial Regex REG_FORMAT2(); + + public QueryStagedChangesWithAmend(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public List GetResult() + { + Args = "show --no-show-signature --format=\"%H %P\" -s HEAD"; + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return []; + + var shas = rs.StdOut.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (shas.Length == 0) + return []; + + var parent = shas.Length > 1 ? shas[1] : Models.EmptyTreeHash.Guess(shas[0]); + Args = $"diff-index --cached -M {parent}"; + 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[4].Value, + DataForAmend = new Models.ChangeDataForAmend() + { + FileMode = match.Groups[1].Value, + ObjectHash = match.Groups[2].Value, + ParentSHA = parent, + }, + }; + var type = match.Groups[3].Value; + change.Set(type == "R" ? Models.ChangeState.Renamed : Models.ChangeState.Copied); + 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 "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; + } + } +} diff --git a/src/Commands/QueryStagedFileBlobGuid.cs b/src/Commands/QueryStagedFileBlobGuid.cs deleted file mode 100644 index 4a1c30e2f..000000000 --- a/src/Commands/QueryStagedFileBlobGuid.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - public class QueryStagedFileBlobGuid : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^\d+\s+([0-9a-f]+)\s+.*$"); - - 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/QueryStashChanges.cs b/src/Commands/QueryStashChanges.cs deleted file mode 100644 index c2d50d45f..000000000 --- a/src/Commands/QueryStashChanges.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - public class QueryStashChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - - public QueryStashChanges(string repo, string sha) { - WorkingDirectory = repo; - Context = repo; - Args = $"diff --name-status --pretty=format: {sha}^ {sha}"; - } - - public List Result() { - Exec(); - return _changes; - } - - protected 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.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 'R': change.Set(Models.ChangeState.Renamed); _changes.Add(change); break; - case 'C': change.Set(Models.ChangeState.Copied); _changes.Add(change); break; - } - } - - private List _changes = new List(); - } -} diff --git a/src/Commands/QueryStashes.cs b/src/Commands/QueryStashes.cs index 7e1595004..2b292f711 100644 --- a/src/Commands/QueryStashes.cs +++ b/src/Commands/QueryStashes.cs @@ -1,47 +1,69 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QueryStashes : Command { - private static readonly Regex REG_STASH = new Regex(@"^Reflog: refs/(stash@\{\d+\}).*$"); - - public QueryStashes(string repo) { +namespace SourceGit.Commands +{ + public class QueryStashes : Command + { + public QueryStashes(string repo) + { WorkingDirectory = repo; Context = repo; - Args = "stash list --pretty=raw"; + Args = "stash list -z --no-show-signature --format=\"%H%n%P%n%ct%n%gd%n%B\""; } - public List Result() { - Exec(); - if (_current != null) _stashes.Add(_current); - return _stashes; - } + public async Task> GetResultAsync() + { + var outs = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return outs; - protected override void OnReadline(string line) { - if (line.StartsWith("commit ", StringComparison.Ordinal)) { - if (_current != null && !string.IsNullOrEmpty(_current.Name)) _stashes.Add(_current); - _current = new Models.Stash() { SHA = line.Substring(7, 8) }; - return; - } + var items = rs.StdOut.Split('\0', StringSplitOptions.RemoveEmptyEntries); + foreach (var item in items) + { + var current = new Models.Stash(); + + var nextPartIdx = 0; + var start = 0; + var end = item.IndexOf('\n', start); + while (end > 0 && nextPartIdx < 4) + { + var line = item.Substring(start, end - start); + + switch (nextPartIdx) + { + case 0: + current.SHA = line; + break; + case 1: + if (line.Length > 6) + current.Parents.AddRange(line.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + break; + case 2: + current.Time = ulong.Parse(line); + break; + case 3: + current.Name = line; + break; + } + + nextPartIdx++; - 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.Substring(7), ref user, ref time); - _current.Author = user; - _current.Time = time; + start = end + 1; + if (start >= item.Length - 1) + break; + + end = item.IndexOf('\n', start); + } + + if (start < item.Length) + current.Message = item.Substring(start); + + outs.Add(current); } + return outs; } - - private List _stashes = new List(); - private Models.Stash _current = null; } } diff --git a/src/Commands/QuerySubmoduleRevision.cs b/src/Commands/QuerySubmoduleRevision.cs new file mode 100644 index 000000000..a70f1efb6 --- /dev/null +++ b/src/Commands/QuerySubmoduleRevision.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QuerySubmoduleRevision : Command + { + public QuerySubmoduleRevision(string repo, string revision) + { + WorkingDirectory = repo; + Context = repo; + + if (revision.EndsWith("-dirty", StringComparison.Ordinal)) + { + _hasUncommittedChange = true; + _revision = revision.Substring(0, revision.Length - 6); + } + else + { + _hasUncommittedChange = false; + _revision = revision; + } + } + + public async Task GetResultAsync() + { + Args = $"show --no-show-signature --decorate=full --format=%H%x00%P%x00%D%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s%x00%B -s {_revision}"; + + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess || string.IsNullOrEmpty(rs.StdOut)) + return null; + + var commit = new Models.Commit(); + var lines = rs.StdOut.Split('\0'); + if (lines.Length < 9) + 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]; + + var message = new Models.CommitFullMessage() { Message = lines[8].TrimEnd() }; + var uncommittedChangesCount = 0; + if (_hasUncommittedChange) + uncommittedChangesCount = await new CountLocalChanges(WorkingDirectory, true) + .GetResultAsync() + .ConfigureAwait(false); + + return new Models.RevisionSubmodule() + { + Commit = commit, + FullMessage = message, + UncommittedChanges = uncommittedChangesCount + }; + } + + private bool _hasUncommittedChange = false; + private string _revision = string.Empty; + } +} diff --git a/src/Commands/QuerySubmodules.cs b/src/Commands/QuerySubmodules.cs index 8a581f93c..6063b3cf8 100644 --- a/src/Commands/QuerySubmodules.cs +++ b/src/Commands/QuerySubmodules.cs @@ -1,35 +1,159 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class QuerySubmodules : Command { - private readonly Regex REG_FORMAT1 = new Regex(@"^[\-\+ ][0-9a-f]+\s(.*)\s\(.*\)$"); - private readonly Regex REG_FORMAT2 = new Regex(@"^[\-\+ ][0-9a-f]+\s(.*)$"); +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) { + public QuerySubmodules(string repo) + { WorkingDirectory = repo; Context = repo; Args = "submodule status"; } - public List Result() { - Exec(); - return _submodules; - } + public async Task> GetResultAsync() + { + var submodules = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + + 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; - protected override void OnReadline(string line) { - var match = REG_FORMAT1.Match(line); - if (match.Success) { - _submodules.Add(match.Groups[1].Value); - return; + 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); + } } - - match = REG_FORMAT2.Match(line); - if (match.Success) { - _submodules.Add(match.Groups[1].Value); + + if (submodules.Count > 0) + { + Args = "config --file .gitmodules --list"; + rs = await ReadToEndAsync().ConfigureAwait(false); + 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)) + { + // Find name alias. + foreach (var kv in modules) + { + if (kv.Value.Path.Equals(name, StringComparison.Ordinal)) + { + m = kv.Value; + break; + } + } + + if (m == null) + { + 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; + else if (key.Equals("branch", StringComparison.Ordinal)) + m.Branch = val; + } + } + + foreach (var kv in modules) + { + if (map.TryGetValue(kv.Value.Path, out var m)) + { + m.URL = kv.Value.URL; + m.Branch = kv.Value.Branch; + } + } + } } + + if (needCheckLocalChanges) + { + var builder = new StringBuilder(); + foreach (var kv in map) + { + if (kv.Value.Status == Models.SubmoduleStatus.Normal) + builder.Append(kv.Key.Quoted()).Append(' '); + } + + Args = $"--no-optional-locks status --porcelain -- {builder}"; + rs = await ReadToEndAsync().ConfigureAwait(false); + 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 List _submodules = new List(); + private class ModuleInfo + { + public string Path { get; set; } = string.Empty; + public string URL { get; set; } = string.Empty; + public string Branch { get; set; } = "HEAD"; + } } } diff --git a/src/Commands/QueryTags.cs b/src/Commands/QueryTags.cs index 470301a37..9033a8d04 100644 --- a/src/Commands/QueryTags.cs +++ b/src/Commands/QueryTags.cs @@ -1,34 +1,55 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryTags : Command + { + public QueryTags(string repo) + { + _boundary = $"----- BOUNDARY OF TAGS {Guid.NewGuid()} -----"; -namespace SourceGit.Commands { - public class QueryTags : Command { - public QueryTags(string repo) { Context = repo; WorkingDirectory = repo; - Args = "for-each-ref --sort=-creatordate --format=\"$%(refname:short)$%(objectname)$%(*objectname)\" refs/tags"; + Args = $"tag -l --format=\"{_boundary}%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(taggername)±%(taggeremail:trim)%00%(creatordate:unix)%00%(contents:subject)%0a%0a%(contents:body)\""; } - public List Result() { - Exec(); - return _loaded; - } + public async Task> GetResultAsync() + { + var tags = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return tags; - protected override void OnReadline(string line) { - var subs = line.Split(new char[] { '$' }, StringSplitOptions.RemoveEmptyEntries); - if (subs.Length == 2) { - _loaded.Add(new Models.Tag() { - Name = subs[0], - SHA = subs[1], - }); - } else if (subs.Length == 3) { - _loaded.Add(new Models.Tag() { - Name = subs[0], - SHA = subs[2], + var records = rs.StdOut.Split(_boundary, StringSplitOptions.RemoveEmptyEntries); + foreach (var record in records) + { + var subs = record.Split('\0'); + if (subs.Length != 7) + continue; + + var name = subs[0].Substring(10); + var message = subs[6].Trim(); + if (!string.IsNullOrEmpty(message) && message.Equals(name, StringComparison.Ordinal)) + message = null; + + ulong.TryParse(subs[5], out var creatorDate); + + tags.Add(new Models.Tag() + { + Name = name, + IsAnnotated = subs[1].Equals("tag", StringComparison.Ordinal), + SHA = string.IsNullOrEmpty(subs[3]) ? subs[2] : subs[3], + Creator = Models.User.FindOrAdd(subs[4]), + CreatorDate = creatorDate, + Message = message, }); } + + return tags; } - private List _loaded = new List(); + private readonly string _boundary; } } diff --git a/src/Commands/QueryTrackStatus.cs b/src/Commands/QueryTrackStatus.cs new file mode 100644 index 000000000..f00074f82 --- /dev/null +++ b/src/Commands/QueryTrackStatus.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryTrackStatus : Command + { + public QueryTrackStatus(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public async Task GetResultAsync(Models.Branch local, Models.Branch remote) + { + Args = $"rev-list --left-right {local.Head}...{remote.Head}"; + + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line[0] == '>') + local.Behind.Add(line.Substring(1)); + else + local.Ahead.Add(line.Substring(1)); + } + } + } +} diff --git a/src/Commands/QueryUpdatableSubmodules.cs b/src/Commands/QueryUpdatableSubmodules.cs new file mode 100644 index 000000000..55f429905 --- /dev/null +++ b/src/Commands/QueryUpdatableSubmodules.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public partial class QueryUpdatableSubmodules : Command + { + [GeneratedRegex(@"^([\-\+])([0-9a-f]+)\s(.*?)(\s\(.*\))?$")] + private static partial Regex REG_FORMAT_STATUS(); + + public QueryUpdatableSubmodules(string repo, bool includeUninited) + { + WorkingDirectory = repo; + Context = repo; + Args = "submodule status"; + + _includeUninited = includeUninited; + } + + public async Task> GetResultAsync() + { + var submodules = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + + 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 (!_includeUninited && stat.StartsWith('-')) + continue; + + submodules.Add(path); + } + } + + return submodules; + } + + private bool _includeUninited = false; + } +} diff --git a/src/Commands/Rebase.cs b/src/Commands/Rebase.cs index 88d304aa4..fdc585c01 100644 --- a/src/Commands/Rebase.cs +++ b/src/Commands/Rebase.cs @@ -1,11 +1,22 @@ -namespace SourceGit.Commands { - public class Rebase : Command { - public Rebase(string repo, string basedOn, bool autoStash) { +using System.Text; + +namespace SourceGit.Commands +{ + public class Rebase : Command + { + public Rebase(string repo, string basedOn, bool autoStash, bool noVerify) + { WorkingDirectory = repo; Context = repo; - Args = "rebase "; - if (autoStash) Args += "--autostash "; - Args += basedOn; + + var builder = new StringBuilder(512); + builder.Append("-c core.commentChar=\"^\" -c core.commentString=\"±\" rebase "); + if (autoStash) + builder.Append("--autostash "); + if (noVerify) + builder.Append("--no-verify "); + + Args = builder.Append(basedOn).ToString(); } } } diff --git a/src/Commands/Remote.cs b/src/Commands/Remote.cs index 1a5260452..6a1503774 100644 --- a/src/Commands/Remote.cs +++ b/src/Commands/Remote.cs @@ -1,33 +1,60 @@ -namespace SourceGit.Commands { - public class Remote : Command { - public Remote(string repo) { +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class Remote : Command + { + public Remote(string repo) + { WorkingDirectory = repo; Context = repo; } - public bool Add(string name, string url) { + public async Task AddAsync(string name, string url) + { Args = $"remote add {name} {url}"; - return Exec(); + return await ExecAsync(); } - public bool Delete(string name) { + public async Task DeleteAsync(string name) + { Args = $"remote remove {name}"; - return Exec(); + return await ExecAsync(); } - public bool Rename(string name, string to) { + public async Task RenameAsync(string name, string to) + { Args = $"remote rename {name} {to}"; - return Exec(); + return await ExecAsync(); } - public bool Prune(string name) { + public async Task PruneAsync(string name) + { Args = $"remote prune {name}"; - return Exec(); + return await ExecAsync(); + } + + public async Task GetURLAsync(string name, bool isPush) + { + Args = "remote get-url" + (isPush ? " --push " : " ") + name; + + var rs = await ReadToEndAsync(); + return rs.IsSuccess ? rs.StdOut.Trim() : string.Empty; } - public bool SetURL(string name, string url) { - Args = $"remote set-url {name} {url}"; - return Exec(); + public async Task SetURLAsync(string name, string url, bool isPush) + { + Args = "remote set-url" + (isPush ? " --push " : " ") + $"{name} {url}"; + return await ExecAsync(); + } + + public async Task HasBranchAsync(string remote, string branch) + { + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{remote}.sshkey"); + Args = $"ls-remote {remote} {branch}"; + + var rs = await ReadToEndAsync(); + return rs.IsSuccess && rs.StdOut.Trim().Length > 0; } } } diff --git a/src/Commands/Remove.cs b/src/Commands/Remove.cs new file mode 100644 index 000000000..eb715e2ca --- /dev/null +++ b/src/Commands/Remove.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Remove : Command + { + public Remove(string repo, List files) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("rm -f --"); + foreach (var file in files) + builder.Append(' ').Append(file.Quoted()); + + Args = builder.ToString(); + } + } +} diff --git a/src/Commands/Replay.cs b/src/Commands/Replay.cs new file mode 100644 index 000000000..3f2546224 --- /dev/null +++ b/src/Commands/Replay.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class Replay : Command + { + public Replay(string repo, string onto, string range) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + Args = $"replay --onto {onto} {range}"; + } + + public async Task GetExitCodeAsync() + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(false); + + var exitCode = -1; + try + { + proc.Start(); + await proc.WaitForExitAsync().ConfigureAwait(false); + exitCode = proc.ExitCode; + } + catch + { + // Ignore any exceptions and just return -1 + } + + return exitCode; + } + } +} diff --git a/src/Commands/Reset.cs b/src/Commands/Reset.cs index 445890bda..f640d0de4 100644 --- a/src/Commands/Reset.cs +++ b/src/Commands/Reset.cs @@ -1,32 +1,19 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - public class Reset : Command { - public Reset(string repo) { +namespace SourceGit.Commands +{ + public class Reset : Command + { + public Reset(string repo, string revision, string mode) + { WorkingDirectory = repo; Context = repo; - Args = "reset"; + Args = $"reset {mode} {revision}"; } - public Reset(string repo, List changes) { + public Reset(string repo, string pathspec) + { WorkingDirectory = repo; Context = repo; - - var builder = new StringBuilder(); - builder.Append("reset --"); - foreach (var c in changes) { - builder.Append(" \""); - builder.Append(c.Path); - builder.Append("\""); - } - Args = builder.ToString(); + Args = $"reset --pathspec-from-file={pathspec.Quoted()}"; } - - public Reset(string repo, string revision, string mode) { - WorkingDirectory = repo; - Context = repo; - Args = $"reset {mode} {revision}"; - } } } diff --git a/src/Commands/Restore.cs b/src/Commands/Restore.cs index c0aeafd51..bf3bd0a55 100644 --- a/src/Commands/Restore.cs +++ b/src/Commands/Restore.cs @@ -1,18 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - public class Restore : Command { - public Restore(string repo, List files, string extra) { +namespace SourceGit.Commands +{ + public class Restore : Command + { + public Restore(string repo, string pathspecFile) + { WorkingDirectory = repo; Context = repo; - - StringBuilder builder = new StringBuilder(); - builder.Append("restore "); - if (!string.IsNullOrEmpty(extra)) builder.Append(extra).Append(" "); - builder.Append("--"); - foreach (var f in files) builder.Append(' ').Append('"').Append(f).Append('"'); - Args = builder.ToString(); - } + Args = $"restore --progress --worktree --recurse-submodules --pathspec-from-file={pathspecFile.Quoted()}"; + } } } diff --git a/src/Commands/Revert.cs b/src/Commands/Revert.cs index 6b9549f55..f42a62d05 100644 --- a/src/Commands/Revert.cs +++ b/src/Commands/Revert.cs @@ -1,10 +1,23 @@ -namespace SourceGit.Commands { - public class Revert : Command { - public Revert(string repo, string commit, bool autoCommit) { +using System.Text; + +namespace SourceGit.Commands +{ + public class Revert : Command + { + public Revert(string repo, string commit, bool autoCommit) + { WorkingDirectory = repo; Context = repo; - Args = $"revert {commit} --no-edit"; - if (!autoCommit) Args += " --no-commit"; + + var builder = new StringBuilder(512); + builder + .Append("revert -m 1 ") + .Append(commit) + .Append(" --no-edit"); + if (!autoCommit) + builder.Append(" --no-commit"); + + Args = builder.ToString(); } } } diff --git a/src/Commands/SaveChangesAsPatch.cs b/src/Commands/SaveChangesAsPatch.cs index 8a2a7bc6a..4ec83a6a6 100644 --- a/src/Commands/SaveChangesAsPatch.cs +++ b/src/Commands/SaveChangesAsPatch.cs @@ -1,44 +1,75 @@ -using Avalonia.Threading; -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public static class SaveChangesAsPatch { - public static bool Exec(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; +namespace SourceGit.Commands +{ + public static class SaveChangesAsPatch + { + public static async Task ProcessLocalChangesAsync(string repo, List changes, bool isUnstaged, string saveTo) + { + await using (var sw = File.Create(saveTo)) + { + foreach (var change in changes) + { + if (!await ProcessSingleChangeAsync(repo, new Models.DiffOption(change, isUnstaged), sw)) + return false; } } return true; } - private static bool ProcessSingleChange(string repo, Models.DiffOption opt, FileStream writer) { + public static async Task ProcessRevisionCompareChangesAsync(string repo, List changes, string baseRevision, string targetRevision, string saveTo) + { + await using (var sw = File.Create(saveTo)) + { + foreach (var change in changes) + { + if (!await ProcessSingleChangeAsync(repo, new Models.DiffOption(baseRevision, targetRevision, change), sw)) + return false; + } + } + + return true; + } + + public static async Task ProcessStashChangesAsync(string repo, List opts, string saveTo) + { + await using (var sw = File.Create(saveTo)) + { + foreach (var opt in opts) + { + if (!await ProcessSingleChangeAsync(repo, opt, sw)) + return false; + } + } + return true; + } + + private static async Task ProcessSingleChangeAsync(string repo, Models.DiffOption opt, FileStream writer) + { var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; - starter.FileName = Native.OS.GitInstallPath; - starter.Arguments = $"diff --ignore-cr-at-eol --unified=4 {opt}"; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"diff --no-color --no-ext-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); - }); + try + { + using var proc = Process.Start(starter)!; + await proc.StandardOutput.BaseStream.CopyToAsync(writer).ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + return proc.ExitCode == 0; + } + catch (Exception e) + { + Models.Notification.Send(repo, "Save change to patch failed: " + e.Message, true); return false; } } diff --git a/src/Commands/SaveRevisionFile.cs b/src/Commands/SaveRevisionFile.cs index bd38b6174..410d15b07 100644 --- a/src/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -1,27 +1,35 @@ -using Avalonia.Threading; -using System; +using System; using System.Diagnostics; using System.IO; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public static class SaveRevisionFile { - public static void Run(string repo, string revision, string file, string saveTo) { - var isLFSFiltered = new IsLFSFiltered(repo, file).Result(); - if (isLFSFiltered) { - var tmpFile = saveTo + ".tmp"; - if (ExecCmd(repo, $"show {revision}:\"{file}\"", tmpFile)) { - ExecCmd(repo, $"lfs smudge", saveTo, tmpFile); - } - File.Delete(tmpFile); - } else { - ExecCmd(repo, $"show {revision}:\"{file}\"", saveTo); +namespace SourceGit.Commands +{ + public static class SaveRevisionFile + { + public static async Task RunAsync(string repo, string revision, string file, string saveTo) + { + var dir = Path.GetDirectoryName(saveTo) ?? string.Empty; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var isLFSFiltered = await new IsLFSFiltered(repo, revision, file).GetResultAsync().ConfigureAwait(false); + if (isLFSFiltered) + { + var pointerStream = await QueryFileContent.RunAsync(repo, revision, file).ConfigureAwait(false); + await ExecCmdAsync(repo, "lfs smudge", saveTo, pointerStream).ConfigureAwait(false); + } + else + { + await ExecCmdAsync(repo, $"show {revision}:{file.Quoted()}", saveTo).ConfigureAwait(false); } } - - private static bool ExecCmd(string repo, string args, string outputFile, string inputFile = null) { + + private static async Task ExecCmdAsync(string repo, string args, string outputFile, Stream input = null) + { var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; - starter.FileName = Native.OS.GitInstallPath; + starter.FileName = Native.OS.GitExecutable; starter.Arguments = args; starter.UseShellExecute = false; starter.CreateNoWindow = true; @@ -30,32 +38,24 @@ private static bool ExecCmd(string repo, string args, string outputFile, string starter.RedirectStandardOutput = true; starter.RedirectStandardError = true; - using (var sw = File.OpenWrite(outputFile)) { - try { - var proc = new Process() { StartInfo = starter }; - proc.Start(); + await using (var sw = File.Create(outputFile)) + { + try + { + using var proc = Process.Start(starter)!; - if (inputFile != null) { - using (StreamReader sr = new StreamReader(inputFile)) { - while (true) { - var line = sr.ReadLine(); - if (line == null) break; - proc.StandardInput.WriteLine(line); - } - } + if (input != null) + { + var inputString = await new StreamReader(input).ReadToEndAsync().ConfigureAwait(false); + await proc.StandardInput.WriteAsync(inputString).ConfigureAwait(false); } - proc.StandardOutput.BaseStream.CopyTo(sw); - proc.WaitForExit(); - var rs = proc.ExitCode == 0; - proc.Close(); - - return rs; - } catch (Exception e) { - Dispatcher.UIThread.Invoke(() => { - App.RaiseException(repo, "Save file failed: " + e.Message); - }); - return false; + await proc.StandardOutput.BaseStream.CopyToAsync(sw).ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + } + catch (Exception e) + { + Models.Notification.Send(repo, "Save file failed: " + e.Message, true); } } } diff --git a/src/Commands/Stash.cs b/src/Commands/Stash.cs index 720129a71..31b5b71cc 100644 --- a/src/Commands/Stash.cs +++ b/src/Commands/Stash.cs @@ -1,69 +1,103 @@ using System.Collections.Generic; -using System.IO; +using System.Text; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Stash : Command { - public Stash(string repo) { +namespace SourceGit.Commands +{ + public class Stash : Command + { + public Stash(string repo) + { WorkingDirectory = repo; Context = repo; } - public bool Push(string message) { - Args = $"stash push -m \"{message}\""; - return Exec(); + public async Task PushAsync(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 "); + if (!string.IsNullOrEmpty(message)) + builder.Append("-m ").Append(message.Quoted()); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public bool Push(List changes, string message) { - var temp = Path.GetTempFileName(); - var stream = new FileStream(temp, FileMode.Create); - var writer = new StreamWriter(stream); + public async Task PushAsync(string message, List changes, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --include-untracked "); + if (keepIndex) + builder.Append("--keep-index "); + if (!string.IsNullOrEmpty(message)) + builder.Append("-m ").Append(message.Quoted()).Append(' '); + + builder.Append("-- "); + foreach (var c in changes) + builder.Append(c.Path.Quoted()).Append(' '); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } - var needAdd = new List(); - foreach (var c in changes) { - writer.WriteLine(c.Path); + public async Task PushAsync(string message, string pathspecFromFile, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --include-untracked --pathspec-from-file=").Append(pathspecFromFile.Quoted()).Append(" "); + if (keepIndex) + builder.Append("--keep-index "); + if (!string.IsNullOrEmpty(message)) + builder.Append("-m ").Append(message.Quoted()); - if (c.WorkTree == Models.ChangeState.Added || c.WorkTree == Models.ChangeState.Untracked) { - needAdd.Add(c); - if (needAdd.Count > 10) { - new Add(WorkingDirectory, needAdd).Exec(); - needAdd.Clear(); - } - } - } - if (needAdd.Count > 0) { - new Add(WorkingDirectory, needAdd).Exec(); - needAdd.Clear(); - } + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } - writer.Flush(); - stream.Flush(); - writer.Close(); - stream.Close(); + public async Task PushOnlyStagedAsync(string message, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --staged "); + if (keepIndex) + builder.Append("--keep-index "); + if (!string.IsNullOrEmpty(message)) + builder.Append("-m ").Append(message.Quoted()); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } - Args = $"stash push -m \"{message}\" --pathspec-from-file=\"{temp}\""; - var succ = Exec(); - File.Delete(temp); - return succ; + public async Task ApplyAsync(string name, bool restoreIndex) + { + var opts = restoreIndex ? "--index" : string.Empty; + Args = $"stash apply -q {opts} {name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Apply(string name) { - Args = $"stash apply -q {name}"; - return Exec(); + public async Task CheckoutBranchAsync(string name, string branch) + { + Args = $"stash branch {branch.Quoted()} {name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Pop(string name) { - Args = $"stash pop -q {name}"; - return Exec(); + public async Task PopAsync(string name) + { + Args = $"stash pop -q --index {name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Drop(string name) { - Args = $"stash drop -q {name}"; - return Exec(); + public async Task DropAsync(string name) + { + Args = $"stash drop -q {name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Clear() { + public async Task ClearAsync() + { Args = "stash clear"; - return Exec(); + return await ExecAsync().ConfigureAwait(false); } } } diff --git a/src/Commands/Statistics.cs b/src/Commands/Statistics.cs index fb0e7ee0d..3c6ed017b 100644 --- a/src/Commands/Statistics.cs +++ b/src/Commands/Statistics.cs @@ -1,32 +1,50 @@ -using System; - -namespace SourceGit.Commands { - public class Statistics : Command { - public Statistics(string repo) { - _statistics = new Models.Statistics(); +using System.IO; +using System.Text; +using System.Threading.Tasks; +namespace SourceGit.Commands +{ + public class Statistics : Command + { + public Statistics(string repo, int max, Models.Branch specBranch) + { WorkingDirectory = repo; Context = repo; - Args = $"log --date-order --branches --remotes --since=\"{_statistics.Since()}\" --pretty=format:\"%ct$%cn\""; - } - public Models.Statistics Result() { - Exec(); - _statistics.Complete(); - return _statistics; + var builder = new StringBuilder(); + builder + .Append("log --date-order -") + .Append(max) + .Append(" --format=%ct$%aN±%aE "); + + if (specBranch == null || string.IsNullOrEmpty(specBranch.FullName)) + builder.Append("--branches --remotes"); + else + builder.Append(specBranch.FullName); + + Args = builder.ToString(); } - protected override void OnReadline(string line) { - var dateEndIdx = line.IndexOf('$', StringComparison.Ordinal); - if (dateEndIdx == -1) return; + public async Task ReadAsync() + { + var statistics = new Models.Statistics(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return statistics; - var dateStr = line.Substring(0, dateEndIdx); - var date = 0.0; - if (!double.TryParse(dateStr, out date)) return; + var sr = new StringReader(rs.StdOut); + while (sr.ReadLine() is { } line) + ParseLine(statistics, line); - _statistics.AddCommit(line.Substring(dateEndIdx + 1), date); + statistics.Complete(); + return statistics; } - private Models.Statistics _statistics = null; + private void ParseLine(Models.Statistics statistics, string line) + { + var parts = line.Split('$', 2); + if (parts.Length == 2 && double.TryParse(parts[0], out var date)) + statistics.AddCommit(parts[1], date); + } } } diff --git a/src/Commands/Submodule.cs b/src/Commands/Submodule.cs index a6aeee834..d8b1dbcb7 100644 --- a/src/Commands/Submodule.cs +++ b/src/Commands/Submodule.cs @@ -1,43 +1,80 @@ -using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; -namespace SourceGit.Commands { - public class Submodule : Command { - public Submodule(string repo) { +namespace SourceGit.Commands +{ + public class Submodule : Command + { + public Submodule(string repo) + { WorkingDirectory = repo; Context = repo; } - public bool Add(string url, string relativePath, bool recursive, Action outputHandler) { - _outputHandler = outputHandler; - Args = $"submodule add {url} {relativePath}"; - if (!Exec()) return false; - - if (recursive) { - Args = $"submodule update --init --recursive -- {relativePath}"; - return Exec(); - } else { - Args = $"submodule update --init -- {relativePath}"; - return true; - } + public async Task AddAsync(string url, string relativePath, bool recursive) + { + Args = $"-c protocol.file.allow=always submodule add {url.Quoted()} {relativePath.Quoted()}"; + + var succ = await ExecAsync().ConfigureAwait(false); + if (!succ) + return false; + + if (recursive) + Args = $"submodule update --init --recursive -- {relativePath.Quoted()}"; + else + Args = $"submodule update --init -- {relativePath.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Update() { - Args = $"submodule update --rebase --remote"; - return Exec(); + public async Task SetURLAsync(string path, string url) + { + Args = $"submodule set-url -- {path.Quoted()} {url.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Delete(string relativePath) { - Args = $"submodule deinit -f {relativePath}"; - if (!Exec()) return false; + public async Task SetBranchAsync(string path, string branch) + { + if (string.IsNullOrEmpty(branch)) + Args = $"submodule set-branch -d -- {path.Quoted()}"; + else + Args = $"submodule set-branch -b {branch.Quoted()} -- {path.Quoted()}"; - Args = $"rm -rf {relativePath}"; - return Exec(); + return await ExecAsync().ConfigureAwait(false); } - protected override void OnReadline(string line) { - _outputHandler?.Invoke(line); + public async Task UpdateAsync(List modules, bool init, bool recursive, bool useRemote) + { + 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(' ').Append(module.Quoted()); + } + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } + + public async Task DeinitAsync(string module, bool force) + { + Args = force ? $"submodule deinit -f -- {module.Quoted()}" : $"submodule deinit -- {module.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - private Action _outputHandler; + public async Task DeleteAsync(string module) + { + Args = $"rm -rf {module.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } } } diff --git a/src/Commands/Tag.cs b/src/Commands/Tag.cs index d0ab3bcb3..7af76a9e4 100644 --- a/src/Commands/Tag.cs +++ b/src/Commands/Tag.cs @@ -1,39 +1,59 @@ -using System.Collections.Generic; -using System.IO; - -namespace SourceGit.Commands { - public static class Tag { - public static bool Add(string repo, string name, string basedOn, string message) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"tag -a {name} {basedOn} "; - - if (!string.IsNullOrEmpty(message)) { - string tmp = Path.GetTempFileName(); - File.WriteAllText(tmp, message); - cmd.Args += $"-F \"{tmp}\""; - } else { - cmd.Args += $"-m {name}"; - } +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class Tag : Command + { + public Tag(string repo, string name) + { + WorkingDirectory = repo; + Context = repo; + _name = name; + } - return cmd.Exec(); + public async Task AddAsync(string basedOn) + { + Args = $"tag --no-sign {_name} {basedOn}"; + return await ExecAsync().ConfigureAwait(false); } - public static bool Delete(string repo, string name, List remotes) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"tag --delete {name}"; - if (!cmd.Exec()) return false; - - if (remotes != null) { - foreach (var r in remotes) { - new Push(repo, r.Name, name, true).Exec(); - } + public async Task AddAsync(string basedOn, string message, bool sign) + { + var builder = new StringBuilder(); + builder + .Append("tag ") + .Append(sign ? "--sign -a " : "--no-sign -a ") + .Append(_name) + .Append(' ') + .Append(basedOn); + + if (!string.IsNullOrEmpty(message)) + { + string tmp = Path.GetTempFileName(); + await File.WriteAllTextAsync(tmp, message); + builder.Append(" -F ").Append(tmp.Quoted()); + + Args = builder.ToString(); + var succ = await ExecAsync().ConfigureAwait(false); + File.Delete(tmp); + return succ; } - return true; + builder.Append(" -m "); + builder.Append(_name); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } + + public async Task DeleteAsync() + { + Args = $"tag --delete {_name}"; + return await ExecAsync().ConfigureAwait(false); + } + + private readonly string _name; } } diff --git a/src/Commands/UpdateIndexInfo.cs b/src/Commands/UpdateIndexInfo.cs new file mode 100644 index 000000000..d8c47ca91 --- /dev/null +++ b/src/Commands/UpdateIndexInfo.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class UpdateIndexInfo + { + public UpdateIndexInfo(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("\n100644 "); + _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 async Task ExecAsync() + { + 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; + starter.StandardInputEncoding = new UTF8Encoding(false); + starter.StandardErrorEncoding = Encoding.UTF8; + + try + { + using var proc = Process.Start(starter)!; + await proc.StandardInput.WriteAsync(_patchBuilder.ToString()); + proc.StandardInput.Close(); + + var err = await proc.StandardError.ReadToEndAsync().ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + var rs = proc.ExitCode == 0; + + if (!rs) + Models.Notification.Send(_repo, err, true); + + return rs; + } + catch (Exception e) + { + Models.Notification.Send(_repo, "Failed to update index: " + e.Message, true); + return false; + } + } + + private readonly string _repo; + private readonly StringBuilder _patchBuilder = new(); + } +} diff --git a/src/Commands/Version.cs b/src/Commands/Version.cs deleted file mode 100644 index f4da17677..000000000 --- a/src/Commands/Version.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SourceGit.Commands { - public class Version : Command { - public Version() { - Args = "-v"; - RaiseError = false; - } - - public string Query() { - var rs = ReadToEnd(); - if (!rs.IsSuccess || string.IsNullOrWhiteSpace(rs.StdOut)) return string.Empty; - return rs.StdOut.Trim().Substring("git version ".Length); - } - } -} diff --git a/src/Commands/Worktree.cs b/src/Commands/Worktree.cs new file mode 100644 index 000000000..7b70e2ab4 --- /dev/null +++ b/src/Commands/Worktree.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class Worktree : Command + { + public Worktree(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public async Task> ReadAllAsync() + { + Args = "worktree list --porcelain"; + + var rs = await ReadToEndAsync().ConfigureAwait(false); + var worktrees = new List(); + Models.Worktree last = null; + 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() }; + worktrees.Add(last); + continue; + } + + if (last == null) + continue; + + 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 async Task AddAsync(string fullpath, string name, bool createNew, string tracking) + { + var builder = new StringBuilder(1024); + builder.Append("worktree add "); + if (!string.IsNullOrEmpty(tracking)) + builder.Append("--track "); + if (!string.IsNullOrEmpty(name)) + builder.Append(createNew ? "-b " : "-B ").Append(name).Append(' '); + builder.Append(fullpath.Quoted()).Append(' '); + + if (!string.IsNullOrEmpty(tracking)) + builder.Append(tracking); + else if (!string.IsNullOrEmpty(name) && !createNew) + builder.Append(name); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } + + public async Task PruneAsync() + { + Args = "worktree prune -v"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task LockAsync(string fullpath) + { + Args = $"worktree lock {fullpath.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task UnlockAsync(string fullpath) + { + Args = $"worktree unlock {fullpath.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task RemoveAsync(string fullpath, bool force) + { + if (force) + Args = $"worktree remove -f {fullpath.Quoted()}"; + else + Args = $"worktree remove {fullpath.Quoted()}"; + + return await ExecAsync().ConfigureAwait(false); + } + } +} diff --git a/src/Converters/BookmarkConverters.cs b/src/Converters/BookmarkConverters.cs deleted file mode 100644 index 1eca567bb..000000000 --- a/src/Converters/BookmarkConverters.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Avalonia.Data.Converters; -using Avalonia.Media; - -namespace SourceGit.Converters { - public static class BookmarkConverters { - public static FuncValueConverter ToBrush = - new FuncValueConverter(bookmark => Models.Bookmarks.Brushes[bookmark]); - - public static FuncValueConverter ToStrokeThickness = - new FuncValueConverter(bookmark => bookmark == 0 ? 1.0 : 0); - } -} diff --git a/src/Converters/BoolConverters.cs b/src/Converters/BoolConverters.cs index 65593f8fa..5c5dd9047 100644 --- a/src/Converters/BoolConverters.cs +++ b/src/Converters/BoolConverters.cs @@ -1,8 +1,19 @@ -using Avalonia.Data.Converters; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using Avalonia.Media; -namespace SourceGit.Converters { - public static class BoolConverters { - public static FuncValueConverter ToCommitOpacity = - new FuncValueConverter(x => x ? 1 : 0.5); +namespace SourceGit.Converters +{ + public static class BoolConverters + { + public static readonly FuncValueConverter IsBoldToFontWeight = + new(x => x ? FontWeight.Bold : FontWeight.Regular); + + public static readonly FuncValueConverter IsMergedToOpacity = + new(x => x ? 1 : 0.65); + + public static readonly FuncValueConverter IsWarningToBrush = + new(x => x ? Brushes.DarkGoldenrod : Application.Current?.FindResource("Brush.FG1") as IBrush); } } diff --git a/src/Converters/BranchConverters.cs b/src/Converters/BranchConverters.cs deleted file mode 100644 index c067a4818..000000000 --- a/src/Converters/BranchConverters.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Avalonia.Data.Converters; - -namespace SourceGit.Converters { - public static class BranchConverters { - public static FuncValueConverter ToName = - new FuncValueConverter(v => v.IsLocal ? v.Name : $"{v.Remote}/{v.Name}"); - } -} diff --git a/src/Converters/ChangeViewModeConverters.cs b/src/Converters/ChangeViewModeConverters.cs deleted file mode 100644 index 8bfb08986..000000000 --- a/src/Converters/ChangeViewModeConverters.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Data.Converters; -using Avalonia.Media; - -namespace SourceGit.Converters { - public static class ChangeViewModeConverters { - public static FuncValueConverter ToIcon = - new FuncValueConverter(v => { - switch (v) { - case Models.ChangeViewMode.List: - return App.Current?.FindResource("Icons.List") as StreamGeometry; - case Models.ChangeViewMode.Grid: - return App.Current?.FindResource("Icons.Grid") as StreamGeometry; - default: - return App.Current?.FindResource("Icons.Tree") as StreamGeometry; - } - }); - - public static FuncValueConverter IsList = - new FuncValueConverter(v => v == Models.ChangeViewMode.List); - - public static FuncValueConverter IsGrid = - new FuncValueConverter(v => v == Models.ChangeViewMode.Grid); - - public static FuncValueConverter IsTree = - new FuncValueConverter(v => v == Models.ChangeViewMode.Tree); - } -} diff --git a/src/Converters/DecoratorTypeConverters.cs b/src/Converters/DecoratorTypeConverters.cs deleted file mode 100644 index 2e9460c99..000000000 --- a/src/Converters/DecoratorTypeConverters.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Data.Converters; -using Avalonia.Media; - -namespace SourceGit.Converters { - public static class DecoratorTypeConverters { - public static FuncValueConverter ToBackground = - new FuncValueConverter(v => { - if (v == Models.DecoratorType.Tag) return Models.DecoratorResources.Backgrounds[0]; - return Models.DecoratorResources.Backgrounds[1]; - }); - - public static FuncValueConverter ToIcon = - new FuncValueConverter(v => { - var key = "Icons.Tag"; - switch (v) { - case Models.DecoratorType.CurrentBranchHead: - key = "Icons.Check"; - break; - case Models.DecoratorType.RemoteBranchHead: - key = "Icons.Remote"; - break; - case Models.DecoratorType.LocalBranchHead: - key = "Icons.Branch"; - break; - default: - break; - } - - return Application.Current?.FindResource(key) as StreamGeometry; - }); - } -} diff --git a/src/Converters/DirtyStateConverters.cs b/src/Converters/DirtyStateConverters.cs new file mode 100644 index 000000000..f140f7d3c --- /dev/null +++ b/src/Converters/DirtyStateConverters.cs @@ -0,0 +1,28 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class DirtyStateConverters + { + public static readonly FuncValueConverter ToBrush = + new FuncValueConverter(v => + { + if (v.HasFlag(Models.DirtyState.HasLocalChanges)) + return Brushes.Gray; + if (v.HasFlag(Models.DirtyState.HasPendingPullOrPush)) + return Brushes.RoyalBlue; + return Brushes.Transparent; + }); + + public static readonly FuncValueConverter ToDesc = + new FuncValueConverter(v => + { + if (v.HasFlag(Models.DirtyState.HasLocalChanges)) + return " • " + App.Text("DirtyState.HasLocalChanges"); + if (v.HasFlag(Models.DirtyState.HasPendingPullOrPush)) + return " • " + App.Text("DirtyState.HasPendingPullOrPush"); + return " • " + App.Text("DirtyState.UpToDate"); + }); + } +} diff --git a/src/Converters/DoubleConverters.cs b/src/Converters/DoubleConverters.cs new file mode 100644 index 000000000..871a80b3e --- /dev/null +++ b/src/Converters/DoubleConverters.cs @@ -0,0 +1,23 @@ +using Avalonia; +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("F0") + "%"); + + public static readonly FuncValueConverter OneMinusToPercentage = + new FuncValueConverter(v => ((1.0 - v) * 100).ToString("F0") + "%"); + + public static readonly FuncValueConverter ToLeftMargin = + new FuncValueConverter(v => new Thickness(v, 0, 0, 0)); + } +} diff --git a/src/Converters/FilterModeConverters.cs b/src/Converters/FilterModeConverters.cs new file mode 100644 index 000000000..016613e83 --- /dev/null +++ b/src/Converters/FilterModeConverters.cs @@ -0,0 +1,19 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class FilterModeConverters + { + public static readonly FuncValueConverter ToBorderBrush = + new FuncValueConverter(v => + { + return v switch + { + Models.FilterMode.Included => Brushes.Green, + Models.FilterMode.Excluded => Brushes.Red, + _ => Brushes.Transparent, + }; + }); + } +} diff --git a/src/Converters/IntConverters.cs b/src/Converters/IntConverters.cs index 4a97a90d3..f44fa96d5 100644 --- a/src/Converters/IntConverters.cs +++ b/src/Converters/IntConverters.cs @@ -1,14 +1,26 @@ -using Avalonia.Data.Converters; +using Avalonia; +using Avalonia.Data.Converters; -namespace SourceGit.Converters { - public static class IntConverters { - public static FuncValueConverter IsGreaterThanZero = - new FuncValueConverter(v => v > 0); +namespace SourceGit.Converters +{ + public static class IntConverters + { + public static readonly FuncValueConverter IsGreaterThanZero = + new(v => v > 0); - public static FuncValueConverter IsZero = - new FuncValueConverter(v => v == 0); + public static readonly FuncValueConverter IsGreaterThanFour = + new(v => v > 4); - public static FuncValueConverter IsOne = - new FuncValueConverter(v => v == 1); + public static readonly FuncValueConverter IsZero = + new(v => v == 0); + + public static readonly FuncValueConverter IsNotOne = + new(v => v != 1); + + public static readonly FuncValueConverter ToTreeMargin = + new(v => new Thickness(v * 16, 0, 0, 0)); + + public static readonly FuncValueConverter ToUnsolvedDesc = + new(v => v == 0 ? App.Text("MergeConflictEditor.AllResolved") : App.Text("MergeConflictEditor.ConflictsRemaining", v)); } } diff --git a/src/Converters/InteractiveRebaseActionConverters.cs b/src/Converters/InteractiveRebaseActionConverters.cs new file mode 100644 index 000000000..81f5564a7 --- /dev/null +++ b/src/Converters/InteractiveRebaseActionConverters.cs @@ -0,0 +1,31 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class InteractiveRebaseActionConverters + { + public static readonly FuncValueConverter ToIconBrush = + new(v => + { + return v switch + { + Models.InteractiveRebaseAction.Pick => Brushes.Green, + Models.InteractiveRebaseAction.Edit => Brushes.Orange, + Models.InteractiveRebaseAction.Reword => Brushes.Orange, + Models.InteractiveRebaseAction.Squash => Brushes.LightGray, + Models.InteractiveRebaseAction.Fixup => Brushes.LightGray, + _ => Brushes.Red, + }; + }); + + public static readonly FuncValueConverter ToName = + new(v => v.ToString()); + + public static readonly FuncValueConverter IsDrop = + new(v => v == Models.InteractiveRebaseAction.Drop); + + public static readonly FuncValueConverter ToOpacity = + new(v => v > Models.InteractiveRebaseAction.Reword ? 0.65 : 1.0); + } +} diff --git a/src/Converters/LauncherPageConverters.cs b/src/Converters/LauncherPageConverters.cs deleted file mode 100644 index 6ba2a0a4c..000000000 --- a/src/Converters/LauncherPageConverters.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Avalonia.Collections; -using Avalonia.Data.Converters; -using System.Collections.Generic; - -namespace SourceGit.Converters { - public static class LauncherPageConverters { - public static FuncMultiValueConverter ToTabSeperatorVisible = - new FuncMultiValueConverter(v => { - if (v == null) return false; - - var array = new List(); - array.AddRange(v); - if (array.Count != 3) return false; - - var self = array[0] as ViewModels.LauncherPage; - if (self == null) return false; - - var selected = array[1] as ViewModels.LauncherPage; - var collections = array[2] as AvaloniaList; - - if (selected != null && collections != null && (self == selected || collections.IndexOf(self) + 1 == collections.IndexOf(selected))) { - return false; - } else { - return true; - } - }); - } -} diff --git a/src/Converters/ListConverters.cs b/src/Converters/ListConverters.cs index bf52a281d..e0c5967e8 100644 --- a/src/Converters/ListConverters.cs +++ b/src/Converters/ListConverters.cs @@ -1,12 +1,25 @@ -using Avalonia.Data.Converters; -using System.Collections; +using System.Collections; +using System.Collections.Generic; -namespace SourceGit.Converters { - public static class ListConverters { - public static FuncValueConverter ToCount = - new FuncValueConverter(v => $" ({v.Count})"); +using Avalonia.Data.Converters; - public static FuncValueConverter IsNotNullOrEmpty = +namespace SourceGit.Converters +{ + public static class ListConverters + { + 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/LongConverters.cs b/src/Converters/LongConverters.cs new file mode 100644 index 000000000..05a55e95b --- /dev/null +++ b/src/Converters/LongConverters.cs @@ -0,0 +1,25 @@ +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class LongConverters + { + public static readonly FuncValueConverter ToFileSize = new(bytes => + { + if (bytes < KB) + return $"{bytes:N0} B"; + + if (bytes < MB) + return $"{(bytes / KB):G3} KB ({bytes:N0})"; + + if (bytes < GB) + return $"{(bytes / MB):G3} MB ({bytes:N0})"; + + return $"{(bytes / GB):G3} GB ({bytes:N0})"; + }); + + private const double KB = 1024; + private const double MB = 1024 * 1024; + private const double GB = 1024 * 1024 * 1024; + } +} diff --git a/src/Converters/ObjectConverters.cs b/src/Converters/ObjectConverters.cs new file mode 100644 index 000000000..f7c57764d --- /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 index e1de3795c..23dae2ab3 100644 --- a/src/Converters/PathConverters.cs +++ b/src/Converters/PathConverters.cs @@ -1,18 +1,17 @@ -using Avalonia.Data.Converters; using System.IO; +using Avalonia.Data.Converters; -namespace SourceGit.Converters { - public static class PathConverters { - public static FuncValueConverter PureFileName = - new FuncValueConverter(fullpath => Path.GetFileName(fullpath) ?? ""); +namespace SourceGit.Converters +{ + public static class PathConverters + { + public static readonly FuncValueConverter PureFileName = + new(v => Path.GetFileName(v) ?? ""); - public static FuncValueConverter PureDirectoryName = - new FuncValueConverter(fullpath => Path.GetDirectoryName(fullpath) ?? ""); + public static readonly FuncValueConverter PureDirectoryName = + new(v => Path.GetDirectoryName(v) ?? ""); - public static FuncValueConverter TruncateIfTooLong = - new FuncValueConverter(fullpath => { - if (fullpath.Length <= 50) return fullpath; - return fullpath.Substring(0, 20) + ".../" + Path.GetFileName(fullpath); - }); + public static readonly FuncValueConverter RelativeToHome = + new(Native.OS.GetRelativePathToHome); } } diff --git a/src/Converters/StringConverters.cs b/src/Converters/StringConverters.cs index 0f305700b..4a6fd33d3 100644 --- a/src/Converters/StringConverters.cs +++ b/src/Converters/StringConverters.cs @@ -1,56 +1,95 @@ -using Avalonia.Data.Converters; -using Avalonia.Styling; -using System; +using System; using System.Globalization; -namespace SourceGit.Converters { - public static class StringConverters { - public class ToLocaleConverter : IValueConverter { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { +using Avalonia.Data.Converters; +using Avalonia.Input; +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 object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value as Models.Locale)?.Key; } } - public static ToLocaleConverter ToLocale = new ToLocaleConverter(); + public static readonly ToLocaleConverter ToLocale = new ToLocaleConverter(); - public class ToThemeConverter : IValueConverter { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { + public class ToThemeConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { var theme = (string)value; - if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) { + if (string.IsNullOrEmpty(theme)) + return ThemeVariant.Default; + + if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) return ThemeVariant.Light; - } else if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) { + + if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) return ThemeVariant.Dark; - } else { - return ThemeVariant.Default; - } + + return ThemeVariant.Default; } - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - var theme = (ThemeVariant)value; - return theme.Key; + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value as ThemeVariant)?.Key; } } - public static ToThemeConverter ToTheme = new ToThemeConverter(); + public static readonly ToThemeConverter ToTheme = new ToThemeConverter(); - public class FormatByResourceKeyConverter : IValueConverter { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { + 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) { + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { throw new NotImplementedException(); } } - public static FormatByResourceKeyConverter FormatByResourceKey = new FormatByResourceKeyConverter(); + 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); + + public static readonly FuncValueConverter ToFriendlyUpstream = + new FuncValueConverter(v => v is { Length: > 13 } ? v.Substring(13) : string.Empty); - public static FuncValueConverter ToShortSHA = - new FuncValueConverter(v => v.Length > 10 ? v.Substring(0, 10) : v); + public static readonly FuncValueConverter FromKeyGesture = + new FuncValueConverter(v => v?.ToString("p", null) ?? string.Empty); } } diff --git a/src/Converters/WindowStateConverters.cs b/src/Converters/WindowStateConverters.cs deleted file mode 100644 index 67e5a3be1..000000000 --- a/src/Converters/WindowStateConverters.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Data.Converters; -using Avalonia.Media; -using Avalonia; -using System.Runtime.InteropServices; - -namespace SourceGit.Converters { - public static class WindowStateConverters { - public static FuncValueConverter ToContentMargin = - new FuncValueConverter(state => { - if (state == WindowState.Maximized && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return new Thickness(6); - } else { - return new Thickness(0); - } - }); - - public static FuncValueConverter ToTitleBarHeight = - new FuncValueConverter(state => { - if (state == WindowState.Maximized && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return new GridLength(30); - } else { - return new GridLength(38); - } - }); - - public static FuncValueConverter ToMaxOrRestoreIcon = - new FuncValueConverter(state => { - if (state == WindowState.Maximized) { - return Application.Current?.FindResource("Icons.Window.Restore") as StreamGeometry; - } else { - return Application.Current?.FindResource("Icons.Window.Maximize") as StreamGeometry; - } - }); - } -} diff --git a/src/Models/ApplyWhiteSpaceMode.cs b/src/Models/ApplyWhiteSpaceMode.cs index b35fa5f2d..9bcd04b3d 100644 --- a/src/Models/ApplyWhiteSpaceMode.cs +++ b/src/Models/ApplyWhiteSpaceMode.cs @@ -1,13 +1,17 @@ -namespace SourceGit.Models { - public class ApplyWhiteSpaceMode { - public string Name { get; set; } - public string Desc { get; set; } - public string Arg { get; set; } +namespace SourceGit.Models +{ + public class ApplyWhiteSpaceMode(string n, string d, string a) + { + 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 ApplyWhiteSpaceMode(string n, string d, string a) { - Name = App.Text(n); - Desc = App.Text(d); - Arg = a; - } + public string Name { get; set; } = n; + public string Desc { get; set; } = d; + public string Arg { get; set; } = a; } } diff --git a/src/Models/AvatarManager.cs b/src/Models/AvatarManager.cs index 4d81e1123..9616ac64d 100644 --- a/src/Models/AvatarManager.cs +++ b/src/Models/AvatarManager.cs @@ -1,125 +1,237 @@ -using Avalonia.Media.Imaging; -using Avalonia.Threading; 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; -namespace SourceGit.Models { - public interface IAvatarHost { - void OnAvatarResourceChanged(string md5); - } - - public static class AvatarManager { - public static string SelectedServer { - get; - set; - } = "https://www.gravatar.com/avatar/"; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; - static AvatarManager() { - _storePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SourceGit", "avatars"); - if (!Directory.Exists(_storePath)) Directory.CreateDirectory(_storePath); +namespace SourceGit.Models +{ + public interface IAvatarHost + { + void OnAvatarResourceChanged(string email, Bitmap image); + } - Task.Run(() => { - while (true) { - var md5 = null as string; + public partial class AvatarManager + { + public static AvatarManager Instance + { + get + { + return _instance ??= new AvatarManager(); + } + } - lock (_synclock) { - foreach (var one in _requesting) { - md5 = one; + 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(async () => + { + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(2); + + while (true) + { + string email = null; + + lock (_synclock) + { + foreach (var one in _requesting) + { + email = one; break; } } - if (md5 == null) { + if (email == null) + { Thread.Sleep(100); continue; } + var md5 = GetEmailHash(email); + var matchGitHubUser = REG_GITHUB_USER_EMAIL().Match(email); + var url = $"https://www.gravatar.com/avatar/{md5}?d=404"; + if (matchGitHubUser.Success) + { + var githubUser = matchGitHubUser.Groups[2].Value; + if (githubUser.EndsWith("[bot]", StringComparison.OrdinalIgnoreCase)) + githubUser = githubUser.Substring(0, githubUser.Length - 5); + + url = $"https://avatars.githubusercontent.com/{githubUser}"; + } + var localFile = Path.Combine(_storePath, md5); - var img = null as Bitmap; - try { - var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(2) }; - var task = client.GetAsync($"{SelectedServer}{md5}?d=404"); - task.Wait(); - - var rsp = task.Result; - if (rsp.IsSuccessStatusCode) { - using (var stream = rsp.Content.ReadAsStream()) { - using (var writer = File.OpenWrite(localFile)) { + Bitmap img = null; + try + { + var rsp = await client.GetAsync(url); + if (rsp.IsSuccessStatusCode) + { + using (var stream = rsp.Content.ReadAsStream()) + { + using (var writer = File.Create(localFile)) + { stream.CopyTo(writer); } } - using (var reader = File.OpenRead(localFile)) { + using (var reader = File.OpenRead(localFile)) + { img = Bitmap.DecodeToWidth(reader, 128); } } - } catch { } + } + catch + { + // ignored + } - lock (_synclock) { - _requesting.Remove(md5); + lock (_synclock) + { + _requesting.Remove(email); } - Dispatcher.UIThread.InvokeAsync(() => { - if (_resources.ContainsKey(md5)) _resources[md5] = img; - else _resources.Add(md5, img); - NotifyResourceChanged(md5); + Dispatcher.UIThread.Post(() => + { + _resources[email] = img; + NotifyResourceChanged(email, img); }); } + + // ReSharper disable once FunctionNeverReturns }); } - public static void Subscribe(IAvatarHost host) { + public void Subscribe(IAvatarHost host) + { _avatars.Add(host); } - public static void Unsubscribe(IAvatarHost host) { + public void Unsubscribe(IAvatarHost host) + { _avatars.Remove(host); } - public static Bitmap Request(string md5, bool forceRefetch = false) { - if (forceRefetch) { - if (_resources.ContainsKey(md5)) _resources.Remove(md5); + public Bitmap Request(string email, bool forceRefetch) + { + if (forceRefetch) + { + if (_defaultAvatars.Contains(email)) + return null; - var localFile = Path.Combine(_storePath, md5); - if (File.Exists(localFile)) File.Delete(localFile); + _resources.Remove(email); - NotifyResourceChanged(md5); - } else { - if (_resources.ContainsKey(md5)) return _resources[md5]; + var localFile = Path.Combine(_storePath, GetEmailHash(email)); + if (File.Exists(localFile)) + File.Delete(localFile); - var localFile = Path.Combine(_storePath, md5); - if (File.Exists(localFile)) { - try { - using (var stream = File.OpenRead(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(md5, img); + _resources.Add(email, img); return img; } - } catch { } + } + catch + { + // ignore + } } } - lock (_synclock) { - if (!_requesting.Contains(md5)) _requesting.Add(md5); + lock (_synclock) + { + _requesting.Add(email); } return null; } - private static void NotifyResourceChanged(string md5) { - foreach (var avatar in _avatars) { - avatar.OnAvatarResourceChanged(md5); + public void SetFromLocal(string email, string file) + { + try + { + Bitmap image; + + using (var stream = File.OpenRead(file)) + { + image = Bitmap.DecodeToWidth(stream, 128); + } + + _resources[email] = image; + + lock (_synclock) + { + _requesting.Remove(email); + } + + var store = Path.Combine(_storePath, GetEmailHash(email)); + File.Copy(file, store, true); + NotifyResourceChanged(email, image); + } + catch + { + // ignore } } - private static object _synclock = new object(); - private static string _storePath = string.Empty; - private static List _avatars = new List(); - private static Dictionary _resources = new Dictionary(); - private static HashSet _requesting = new HashSet(); + 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)); + return Convert.ToHexStringLower(hash); + } + + 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 000000000..286a02c4f --- /dev/null +++ b/src/Models/Bisect.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public enum BisectState + { + None = 0, + WaitingForFirstBad, + WaitingForCheckoutAnother, + WaitingForFirstGood, + WaitingForMark, + } + + [Flags] + public enum BisectCommitFlag + { + None = 0, + Good, + Bad, + Skipped, + } + + public class Bisect + { + public HashSet Bads + { + get; + set; + } = []; + + public HashSet Goods + { + get; + set; + } = []; + + public HashSet Skipped + { + get; + set; + } = []; + } +} diff --git a/src/Models/Blame.cs b/src/Models/Blame.cs index d9d3c9a4b..a8fac34c8 100644 --- a/src/Models/Blame.cs +++ b/src/Models/Blame.cs @@ -1,17 +1,20 @@ using System.Collections.Generic; -namespace SourceGit.Models { - public class BlameLineInfo { +namespace SourceGit.Models +{ + public class BlameLineInfo + { public bool IsFirstInGroup { get; set; } = false; public string CommitSHA { get; set; } = string.Empty; + public string File { get; set; } = string.Empty; public string Author { get; set; } = string.Empty; - public string Time { get; set; } = string.Empty; + public ulong Timestamp { get; set; } = 0; } - 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 class BlameData + { public bool IsBinary { get; set; } = false; + public string Content { get; set; } = string.Empty; + public List LineInfos { get; set; } = []; } } diff --git a/src/Models/Bookmarks.cs b/src/Models/Bookmarks.cs index 5c5e522cf..ae3b2abd3 100644 --- a/src/Models/Bookmarks.cs +++ b/src/Models/Bookmarks.cs @@ -1,9 +1,9 @@ -using System.Collections.Generic; - -namespace SourceGit.Models { - public static class Bookmarks { +namespace SourceGit.Models +{ + public static class Bookmarks + { public static readonly Avalonia.Media.IBrush[] Brushes = [ - Avalonia.Media.Brushes.Transparent, + null, Avalonia.Media.Brushes.Red, Avalonia.Media.Brushes.Orange, Avalonia.Media.Brushes.Gold, @@ -13,10 +13,9 @@ public static class Bookmarks { Avalonia.Media.Brushes.Purple, ]; - public static readonly List Supported = new List(); - - static Bookmarks() { - for (int i = 0; i < Brushes.Length; i++) Supported.Add(i); + public static Avalonia.Media.IBrush Get(int i) + { + return (i >= 0 && i < Brushes.Length) ? Brushes[i] : null; } } } diff --git a/src/Models/Branch.cs b/src/Models/Branch.cs index f1041839d..47aa2153a 100644 --- a/src/Models/Branch.cs +++ b/src/Models/Branch.cs @@ -1,12 +1,44 @@ -namespace SourceGit.Models { - public class Branch { +using System.Collections.Generic; + +namespace SourceGit.Models +{ + 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 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 List Ahead { get; set; } = []; + public List Behind { get; set; } = []; public string Remote { get; set; } + public bool IsUpstreamGone { get; set; } + public string WorktreePath { get; set; } + + public bool HasWorktree => !IsCurrent && !string.IsNullOrEmpty(WorktreePath); + public string FriendlyName => IsLocal ? Name : $"{Remote}/{Name}"; + public bool IsTrackStatusVisible => Ahead.Count > 0 || Behind.Count > 0; + + public string TrackStatusDescription + { + get + { + var ahead = Ahead.Count; + var behind = Behind.Count; + if (ahead > 0) + return behind > 0 ? $"{ahead}↑ {behind}↓" : $"{ahead}↑"; + + return behind > 0 ? $"{behind}↓" : string.Empty; + } + } } } diff --git a/src/Models/BranchTreeNode.cs b/src/Models/BranchTreeNode.cs deleted file mode 100644 index f2e1da634..000000000 --- a/src/Models/BranchTreeNode.cs +++ /dev/null @@ -1,162 +0,0 @@ -using Avalonia.Collections; -using System; -using System.Collections.Generic; - -namespace SourceGit.Models { - public enum BranchTreeNodeType { - Remote, - Folder, - Branch, - } - - public class BranchTreeNode { - public string Name { get; set; } - public BranchTreeNodeType Type { get; set; } - public object Backend { get; set; } - public bool IsExpanded { get; set; } - public bool IsFiltered { get; set; } - public List Children { get; set; } = new List(); - - public bool IsUpstreamTrackStatusVisible { - get => IsBranch && !string.IsNullOrEmpty((Backend as Branch).UpstreamTrackStatus); - } - - public string UpstreamTrackStatus { - get => Type == BranchTreeNodeType.Branch ? (Backend as Branch).UpstreamTrackStatus : ""; - } - - public bool IsRemote { - get => Type == BranchTreeNodeType.Remote; - } - - public bool IsFolder { - get => Type == BranchTreeNodeType.Folder; - } - - public bool IsBranch { - get => Type == BranchTreeNodeType.Branch; - } - - public bool IsCurrent { - get => IsBranch && (Backend as Branch).IsCurrent; - } - - public class Builder { - public List Locals => _locals; - public List Remotes => _remotes; - - public void Run(List branches, List remotes) { - foreach (var remote in remotes) { - var path = $"remote/{remote.Name}"; - var node = new BranchTreeNode() { - Name = remote.Name, - Type = BranchTreeNodeType.Remote, - Backend = remote, - IsExpanded = _expanded.Contains(path), - }; - - _maps.Add(path, node); - _remotes.Add(node); - } - - foreach (var branch in branches) { - var isFiltered = _filters.Contains(branch.FullName); - if (branch.IsLocal) { - MakeBranchNode(branch, _locals, "local", isFiltered); - } else { - var remote = _remotes.Find(x => x.Name == branch.Remote); - if (remote != null) MakeBranchNode(branch, remote.Children, $"remote/{remote.Name}", isFiltered); - } - } - - SortNodes(_locals); - SortNodes(_remotes); - } - - public void SetFilters(AvaloniaList filters) { - _filters.AddRange(filters); - } - - public void CollectExpandedNodes(List nodes, bool isLocal) { - CollectExpandedNodes(nodes, isLocal ? "local" : "remote"); - } - - private void CollectExpandedNodes(List nodes, string prefix) { - foreach (var node in nodes) { - var path = prefix + "/" + node.Name; - if (node.Type != BranchTreeNodeType.Branch && node.IsExpanded) _expanded.Add(path); - CollectExpandedNodes(node.Children, path); - } - } - - private void MakeBranchNode(Branch branch, List roots, string prefix, bool isFiltered) { - var subs = branch.Name.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - - if (subs.Length == 1) { - var node = new BranchTreeNode() { - Name = subs[0], - Type = BranchTreeNodeType.Branch, - Backend = branch, - IsExpanded = false, - IsFiltered = isFiltered, - }; - roots.Add(node); - return; - } - - BranchTreeNode lastFolder = null; - string path = prefix; - for (int i = 0; i < subs.Length - 1; i++) { - path = string.Concat(path, "/", subs[i]); - if (_maps.ContainsKey(path)) { - lastFolder = _maps[path]; - } else if (lastFolder == null) { - lastFolder = new BranchTreeNode() { - Name = subs[i], - Type = BranchTreeNodeType.Folder, - IsExpanded = _expanded.Contains(path), - }; - roots.Add(lastFolder); - _maps.Add(path, lastFolder); - } else { - var folder = new BranchTreeNode() { - Name = subs[i], - Type = BranchTreeNodeType.Folder, - IsExpanded = _expanded.Contains(path), - }; - _maps.Add(path, folder); - lastFolder.Children.Add(folder); - lastFolder = folder; - } - } - - var last = new BranchTreeNode() { - Name = subs[subs.Length - 1], - Type = BranchTreeNodeType.Branch, - Backend = branch, - IsExpanded = false, - IsFiltered = isFiltered, - }; - lastFolder.Children.Add(last); - } - - private void SortNodes(List nodes) { - nodes.Sort((l, r) => { - if (l.Type == r.Type) { - return l.Name.CompareTo(r.Name); - } else { - return (int)(l.Type) - (int)(r.Type); - } - }); - - foreach (var node in nodes) SortNodes(node.Children); - } - - private List _locals = new List(); - private List _remotes = new List(); - private HashSet _expanded = new HashSet(); - private List _filters = new List(); - private Dictionary _maps = new Dictionary(); - } - } -} diff --git a/src/Models/CRLFMode.cs b/src/Models/CRLFMode.cs index a75c247ca..46f5442f8 100644 --- a/src/Models/CRLFMode.cs +++ b/src/Models/CRLFMode.cs @@ -1,21 +1,17 @@ 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; } +namespace SourceGit.Models +{ + public class CRLFMode(string name, string value, string desc) + { + public string Name { get; set; } = name; + public string Value { get; set; } = value; + public string Desc { get; set; } = desc; - public static List Supported = new List() { + 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/Change.cs b/src/Models/Change.cs index 15596a13a..baf6e8500 100644 --- a/src/Models/Change.cs +++ b/src/Models/Change.cs @@ -1,56 +1,117 @@ -namespace SourceGit.Models { - public enum ChangeViewMode { +namespace SourceGit.Models +{ + public enum ChangeViewMode + { List, Grid, Tree, } - public enum ChangeState { + public enum ChangeState + { None, Modified, + TypeChanged, Added, Deleted, Renamed, Copied, - Unmerged, - Untracked + Untracked, + Conflicted, } - public class Change { - public ChangeState Index { get; set; } + public enum ConflictReason + { + None, + BothDeleted, + AddedByUs, + DeletedByThem, + AddedByThem, + DeletedByUs, + BothAdded, + BothModified, + } + + 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 == ChangeState.Unmerged || WorkTree == ChangeState.Unmerged) return true; - if (Index == ChangeState.Added && WorkTree == ChangeState.Added) return true; - if (Index == ChangeState.Deleted && WorkTree == ChangeState.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 string WorkTreeDesc => TYPE_DESCS[(int)WorkTree]; + public string IndexDesc => TYPE_DESCS[(int)Index]; - public void Set(ChangeState index, ChangeState workTree = ChangeState.None) { + public void Set(ChangeState index, ChangeState workTree = ChangeState.None) + { Index = index; WorkTree = workTree; - if (index == ChangeState.Renamed || workTree == ChangeState.Renamed) { - var idx = Path.IndexOf('\t'); - if (idx >= 0) { - OriginalPath = Path.Substring(0, idx); - Path = Path.Substring(idx + 1); - } else { - idx = Path.IndexOf(" -> "); - if (idx > 0) { - OriginalPath = Path.Substring(0, idx); - Path = Path.Substring(idx + 4); - } + if (index == ChangeState.Renamed || index == ChangeState.Copied || workTree == ChangeState.Renamed) + { + var parts = Path.Split('\t', 2); + if (parts.Length < 2) + parts = Path.Split(" -> ", 2); + if (parts.Length == 2) + { + OriginalPath = parts[0]; + Path = parts[1]; } } - 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/CleanMode.cs b/src/Models/CleanMode.cs new file mode 100644 index 000000000..0c7d7858d --- /dev/null +++ b/src/Models/CleanMode.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public enum CleanMode + { + OnlyUntrackedFiles = 0, + OnlyIgnoredFiles, + UntrackedAndIgnoredFiles, + } +} diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index 36261cf41..de299ed6b 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -1,44 +1,136 @@ -using Avalonia; -using System; +using System; using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; -namespace SourceGit.Models { - public class Commit { +namespace SourceGit.Models +{ + public enum CommitSearchMethod + { + BySHA = 0, + ByAuthor, + ByMessage, + ByPath, + ByContent, + } + + public class Commit : ObservableObject + { public string SHA { get; set; } = string.Empty; 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 bool HasDecorators => Decorators.Count > 0; + public List Parents { get; set; } = new(); + public List Decorators { get; set; } = new(); + public bool IsMerged { get; set; } = false; - public Thickness Margin { get; set; } = new Thickness(0); + public int Color { get; set; } = 0; + public double LeftMargin { get; set; } = 0; + + public bool IsHighlightedInGraph + { + get => _isHighlightedInGraph; + set => SetProperty(ref _isHighlightedInGraph, value); + } + + public bool IsCommitterVisible => !Author.Equals(Committer) || AuthorTime != CommitterTime; + public bool IsCurrentHead => Decorators.Find(x => x.Type is DecoratorType.CurrentBranchHead or DecoratorType.CurrentCommitHead) != null; + public bool HasDecorators => Decorators.Count > 0; + public string FirstParentToCompare => Parents.Count > 0 ? $"{SHA}^" : EmptyTreeHash.Guess(SHA); + + public string GetFriendlyName() + { + var branchDecorator = Decorators.Find(x => x.Type is DecoratorType.LocalBranchHead or DecoratorType.RemoteBranchHead); + if (branchDecorator != null) + return branchDecorator.Name; - public string AuthorTimeStr => _utcStart.AddSeconds(AuthorTime).ToString("yyyy/MM/dd HH:mm:ss"); - public string CommitterTimeStr => _utcStart.AddSeconds(CommitterTime).ToString("yyyy/MM/dd HH:mm:ss"); - public string AuthorTimeShortStr => _utcStart.AddSeconds(AuthorTime).ToString("yyyy/MM/dd"); - public string CommitterTimeShortStr => _utcStart.AddSeconds(CommitterTime).ToString("yyyy/MM/dd"); + var tagDecorator = Decorators.Find(x => x.Type is DecoratorType.Tag); + if (tagDecorator != null) + return tagDecorator.Name; - public bool IsCommitterVisible { - get => Author != Committer || AuthorTime != CommitterTime; + return SHA[..10]; } - public string FullMessage { - get => string.IsNullOrWhiteSpace(Message) ? Subject : $"{Subject}\n\n{Message}"; + public void ParseParents(string data) + { + if (data.Length < 8) + return; + + Parents.AddRange(data.Split(' ', StringSplitOptions.RemoveEmptyEntries)); } - public static void ParseUserAndTime(string data, ref User user, ref ulong time) { - var userEndIdx = data.IndexOf('>'); - if (userEndIdx < 0) return; + public void ParseDecorators(string data) + { + if (data.Length < 3) + return; + + var subs = data.Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var sub in subs) + { + var d = sub.Trim(); + if (d.EndsWith("/HEAD", StringComparison.Ordinal)) + continue; - var timeEndIdx = data.IndexOf(' ', userEndIdx + 2); - user = User.FindOrAdd(data.Substring(0, userEndIdx)); - time = timeEndIdx < 0 ? 0 : ulong.Parse(data.Substring(userEndIdx + 2, timeEndIdx - userEndIdx - 2)); + 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) => + { + var delta = (int)l.Type - (int)r.Type; + if (delta != 0) + return delta; + return NumericSort.Compare(l.Name, r.Name); + }); } - private static readonly DateTime _utcStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); + private bool _isHighlightedInGraph = false; + } + + 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 index cabaff144..8557f271d 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -1,205 +1,450 @@ -using Avalonia; -using System; +using System; using System.Collections.Generic; -namespace SourceGit.Models { - public class CommitGraph { - public class Path { - public List Points = new List(); - public int Color = 0; - } +using Avalonia; +using Avalonia.Media; - public class PathHelper { - public string Next; - public bool IsMerged; - public double LastX; - public double LastY; - public double EndY; - public Path Path; +namespace SourceGit.Models +{ + public record CommitGraphLayout(double StartY, double ClipWidth, double RowHeight); - public PathHelper(string next, bool isMerged, int color, Point start) { - Next = next; - IsMerged = isMerged; - LastX = start.X; - LastY = start.Y; - EndY = LastY; + public enum CommitGraphHighlighting + { + All = 0, + CurrentBranchOnly, + SelectedCommitsOnly, + CurrentBranchAndSelectedCommits, + } - Path = new Path(); - Path.Color = color; - Path.Points.Add(start); - } + public class CommitGraph + { + public static List Pens { get; } = []; - public PathHelper(string next, bool isMerged, int color, Point start, Point to) { - Next = next; - IsMerged = isMerged; - LastX = to.X; - LastY = to.Y; - EndY = LastY; + public static void SetDefaultPens(double thickness = 2) + { + SetPens(s_defaultPenColors, thickness); + } - Path = new Path(); - Path.Color = color; - Path.Points.Add(start); - Path.Points.Add(to); - } + public static void SetPens(List colors, double thickness) + { + Pens.Clear(); - public void Add(double x, double y, double halfHeight, bool isEnd = false) { - if (x > LastX) { - Add(new Point(LastX, LastY)); - Add(new Point(x, y - halfHeight)); - if (isEnd) Add(new Point(x, y)); - } else if (x < LastX) { - if (y > LastY + halfHeight) Add(new Point(LastX, LastY + halfHeight)); - Add(new Point(x, y)); - } else if (isEnd) { - Add(new Point(x, y)); - } + foreach (var c in colors) + Pens.Add(new Pen(c.ToUInt32(), thickness)); - LastX = x; - LastY = y; - } + s_penCount = colors.Count; + } - private void Add(Point p) { - if (EndY < p.Y) { - Path.Points.Add(p); - EndY = p.Y; - } - } + public class Path(int color, bool isHighlighted) + { + public List Points { get; } = []; + public int Color { get; } = color; + public bool IsHighlighted { get; } = isHighlighted; } - public class Link { + public class Link + { public Point Start; public Point Control; public Point End; public int Color; + public bool IsHighlighted; } - public class Dot { + public enum DotType + { + Default, + Head, + Merge, + } + + public class Dot + { + public DotType Type; public Point Center; public int Color; + public bool IsHighlighted; } - public List Paths { get; set; } = new List(); - public List Links { get; set; } = new List(); - public List Dots { get; set; } = new List(); + public List Paths { get; } = []; + public List Links { get; } = []; + public List Dots { get; } = []; - public static CommitGraph Parse(List commits, double rowHeight, int colorCount) { - double UNIT_WIDTH = 12; - double HALF_WIDTH = 6; - double UNIT_HEIGHT = rowHeight; - double HALF_HEIGHT = rowHeight / 2; + public static CommitGraph Generate(List commits, bool recalculateMergeState, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits) + { + 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 mapUnsolved = new Dictionary(); var ended = new List(); - var offsetY = -HALF_HEIGHT; - var colorIdx = 0; + var offsetY = -halfHeight; + var colorPicker = new ColorPicker(); + var merged = new HashSet(); + + foreach (var commit in commits) + { + PathHelper major = null; - foreach (var commit in commits) { - var major = null as PathHelper; - var isMerged = commit.IsMerged; - var oldCount = unsolved.Count; + // Update merge state of this commit. + if (recalculateMergeState) + { + if (commit.IsMerged) + { + merged.Remove(commit.SHA); + foreach (var p in commit.Parents) + merged.Add(p); + } + else if (merged.Remove(commit.SHA)) + { + commit.IsMerged = true; + foreach (var p in commit.Parents) + merged.Add(p); + } + } // Update current y offset - offsetY += UNIT_HEIGHT; + offsetY += unitHeight; // Find first curves that links to this commit and marks others that links to this commit ended. - double offsetX = -HALF_WIDTH; - foreach (var l in unsolved) { - if (l.Next == commit.SHA) { - if (major == null) { - offsetX += UNIT_WIDTH; + var offsetX = 4 - halfWidth; + var maxOffsetOld = unsolved.Count > 0 ? unsolved[^1].LastX : offsetX + unitWidth; + var isHighlighted = false; + foreach (var l in unsolved) + { + if (l.Next.Equals(commit.SHA, StringComparison.Ordinal)) + { + if (major == null) + { + offsetX += unitWidth; major = l; + isHighlighted = major.IsHighlighted; - if (commit.Parents.Count > 0) { + if (commit.Parents.Count > 0) + { major.Next = commit.Parents[0]; - if (!mapUnsolved.ContainsKey(major.Next)) mapUnsolved.Add(major.Next, major); - } else { - major.Next = "ENDED"; + major.Goto(offsetX, offsetY, halfHeight); + } + else + { + major.End(offsetX, offsetY, halfHeight); ended.Add(l); } - - major.Add(offsetX, offsetY, HALF_HEIGHT); - } else { - ended.Add(l); } + else + { + l.End(major.LastX, offsetY, halfHeight); + 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, HALF_HEIGHT); + if (!isHighlighted && l.IsHighlighted) + isHighlighted = true; + } + } + else + { + offsetX += unitWidth; + l.Pass(offsetX, offsetY, halfHeight); } } - // Create new curve for branch 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 = (colorIdx + 1) % colorCount; + // Remove ended curves from unsolved + foreach (var l in ended) + { + colorPicker.Recycle(l.Path.Color); + unsolved.Remove(l); } + ended.Clear(); - // Calculate link position of this commit. - Point position = new Point(offsetX, offsetY); - if (major != null) { - major.IsMerged = isMerged; - position = new Point(major.LastX, offsetY); - temp.Dots.Add(new Dot() { Center = position, Color = major.Path.Color }); - } else { - temp.Dots.Add(new Dot() { Center = position, Color = 0 }); + // Calculate highlighted state + if (!isHighlighted) + { + if (highlighting == CommitGraphHighlighting.All) + { + isHighlighted = true; + } + else if (highlighting == CommitGraphHighlighting.CurrentBranchOnly) + { + isHighlighted = commit.IsMerged; + } + else if (highlighting == CommitGraphHighlighting.SelectedCommitsOnly) + { + isHighlighted = highlightExtraCommits.Remove(commit.SHA); + if (isHighlighted) + { + foreach (var p in commit.Parents) + highlightExtraCommits.Add(p); + } + } + else + { + if (commit.IsMerged) + { + isHighlighted = true; + } + else if (highlightExtraCommits.Remove(commit.SHA)) + { + isHighlighted = true; + foreach (var p in commit.Parents) + highlightExtraCommits.Add(p); + } + } } + commit.IsHighlightedInGraph = isHighlighted; - // Deal with parents - 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; - - // Create new curve for parent commit that not includes before - 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 = (colorIdx + 1) % colorCount; + // 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], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY)); + unsolved.Add(major); + temp.Paths.Add(major.Path); } } - - // Remove ended curves from unsolved - foreach (var l in ended) { - l.Add(position.X, position.Y, HALF_HEIGHT, true); - unsolved.Remove(l); + else if (isHighlighted && !major.IsHighlighted && commit.Parents.Count > 0) + { + major.Highlight(); + temp.Paths.Add(major.Path); } - // Margins & merge state (used by datagrid). - commit.IsMerged = isMerged; - commit.Margin = new Thickness(Math.Max(offsetX + HALF_WIDTH, oldCount * UNIT_WIDTH), 0, 0, 0); + // 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, IsHighlighted = isHighlighted }; + 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); - // Clean up - ended.Clear(); - mapUnsolved.Clear(); + // 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 (isHighlighted && !parent.IsHighlighted) + { + parent.Goto(parent.LastX, offsetY + halfHeight, halfHeight); + parent.Highlight(); + 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, + IsHighlighted = isHighlighted, + }); + } + else + { + offsetX += unitWidth; + + // Create new curve for parent commit that not includes before + var l = new PathHelper(parentHash, isHighlighted, colorPicker.Next(), position, new Point(offsetX, position.Y + halfHeight)); + unsolved.Add(l); + temp.Paths.Add(l.Path); + } + } + } + + // Margins & colors (used by Views.Histories). + commit.Color = dotColor; + commit.LeftMargin = Math.Max(offsetX, maxOffsetOld) + halfWidth + 2; } // Deal with curves haven't ended yet. - for (int i = 0; i < unsolved.Count; i++) { + for (var i = 0; i < unsolved.Count; i++) + { var path = unsolved[i]; - var endY = (commits.Count - 0.5) * UNIT_HEIGHT; + var endY = (commits.Count - 0.5) * unitHeight; - if (path.Path.Points.Count == 1 && path.Path.Points[0].Y == endY) continue; - path.Add((i + 0.5) * UNIT_WIDTH, endY + HALF_HEIGHT, HALF_HEIGHT, true); + 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 IsHighlighted { get => Path.IsHighlighted; } + + public PathHelper(string next, bool IsHighlighted, int color, Point start) + { + Next = next; + LastX = start.X; + _lastY = start.Y; + + Path = new Path(color, IsHighlighted); + Path.Points.Add(start); + } + + public PathHelper(string next, bool IsHighlighted, int color, Point start, Point to) + { + Next = next; + LastX = to.X; + _lastY = to.Y; + + Path = new Path(color, IsHighlighted); + 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 highlighted from the end. + /// + public void Highlight() + { + 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 000000000..fa78f206c --- /dev/null +++ b/src/Models/CommitLink.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class CommitLink + { + public string Name { get; } = null; + public string URLPrefix { get; } = 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 link)) + { + var uri = new Uri(link, UriKind.Absolute); + var host = uri.Host; + var route = uri.AbsolutePath.TrimStart('/'); + + if (host.Equals("github.com", StringComparison.Ordinal)) + outs.Add(new($"GitHub ({route})", $"{link}/commit/")); + else if (host.Contains("gitlab", StringComparison.Ordinal)) + outs.Add(new($"GitLab ({route})", $"{link}/-/commit/")); + else if (host.Equals("gitee.com", StringComparison.Ordinal)) + outs.Add(new($"Gitee ({route})", $"{link}/commit/")); + else if (host.Equals("bitbucket.org", StringComparison.Ordinal)) + outs.Add(new($"BitBucket ({route})", $"{link}/commits/")); + else if (host.Equals("codeberg.org", StringComparison.Ordinal)) + outs.Add(new($"Codeberg ({route})", $"{link}/commit/")); + else if (host.Equals("gitea.org", StringComparison.Ordinal)) + outs.Add(new($"Gitea ({route})", $"{link}/commit/")); + else if (host.Equals("git.sr.ht", StringComparison.Ordinal)) + outs.Add(new($"sourcehut ({route})", $"{link}/commit/")); + else if (host.Equals("gitcode.com", StringComparison.Ordinal)) + outs.Add(new($"GitCode ({route})", $"{link}/commit/")); + } + } + + return outs; + } + } +} diff --git a/src/Models/CommitSignInfo.cs b/src/Models/CommitSignInfo.cs new file mode 100644 index 000000000..99317e94e --- /dev/null +++ b/src/Models/CommitSignInfo.cs @@ -0,0 +1,44 @@ +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 + { + return VerifyResult switch + { + 'G' or 'U' => Brushes.Green, + 'X' or 'Y' or 'R' => Brushes.DarkOrange, + 'B' or 'E' => Brushes.Red, + _ => Brushes.Transparent, + }; + } + } + + public string ToolTip + { + get + { + return VerifyResult switch + { + 'G' => "Good signature.", + 'U' => "Good signature with unknown validity.", + 'X' => "Good signature but has expired.", + 'Y' => "Good signature made by expired key.", + 'R' => "Good signature made by a revoked key.", + 'B' => "Bad signature.", + 'E' => "Signature cannot be checked.", + _ => "No signature.", + }; + } + } + } +} diff --git a/src/Models/CommitTemplate.cs b/src/Models/CommitTemplate.cs new file mode 100644 index 000000000..3f331543b --- /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/ConfirmButtonType.cs b/src/Models/ConfirmButtonType.cs new file mode 100644 index 000000000..e0d50b8f1 --- /dev/null +++ b/src/Models/ConfirmButtonType.cs @@ -0,0 +1,8 @@ +namespace SourceGit.Models +{ + public enum ConfirmButtonType + { + OkCancel = 0, + YesNo, + } +} diff --git a/src/Models/ConfirmEmptyCommitResult.cs b/src/Models/ConfirmEmptyCommitResult.cs new file mode 100644 index 000000000..9c36493e3 --- /dev/null +++ b/src/Models/ConfirmEmptyCommitResult.cs @@ -0,0 +1,10 @@ +namespace SourceGit.Models +{ + public enum ConfirmEmptyCommitResult + { + Cancel = 0, + StageSelectedAndCommit, + StageAllAndCommit, + CreateEmptyCommit, + } +} diff --git a/src/Models/Conflict.cs b/src/Models/Conflict.cs new file mode 100644 index 000000000..fd9c69030 --- /dev/null +++ b/src/Models/Conflict.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public enum ConflictFileState + { + Unknown, + UnmergedText, + UnmergedBinary, + Resolved + } + + public enum ConflictPanelType + { + Ours, + Theirs, + Result + } + + public enum ConflictResolution + { + None, + UseOurs, + UseTheirs, + UseBothMineFirst, + UseBothTheirsFirst, + } + + public enum ConflictLineType + { + None, + Common, + Marker, + Ours, + Theirs, + } + + public enum ConflictLineState + { + Normal, + ConflictBlockStart, + ConflictBlock, + ConflictBlockEnd, + ResolvedBlockStart, + ResolvedBlock, + ResolvedBlockEnd, + } + + public class ConflictLine + { + public ConflictLineType Type { get; set; } = ConflictLineType.None; + public string Content { get; set; } = string.Empty; + public string LineNumber { get; set; } = string.Empty; + + public ConflictLine() + { + } + public ConflictLine(ConflictLineType type, string content) + { + Type = type; + Content = content; + } + public ConflictLine(ConflictLineType type, string content, int lineNumber) + { + Type = type; + Content = content; + LineNumber = lineNumber.ToString(); + } + } + + public record ConflictSelectedChunk( + double Y, + double Height, + int ConflictIndex, + ConflictPanelType Panel, + bool IsResolved + ); + + public class ConflictRegion + { + public int StartLineInOriginal { get; set; } + public int EndLineInOriginal { get; set; } + + public string StartMarker { get; set; } = "<<<<<<<"; + public string SeparatorMarker { get; set; } = "======="; + public string EndMarker { get; set; } = ">>>>>>>"; + + public List OursContent { get; set; } = new(); + public List TheirsContent { get; set; } = new(); + + public bool IsResolved { get; set; } = false; + public ConflictResolution ResolutionType { get; set; } = ConflictResolution.None; + } +} diff --git a/src/Models/ConventionalCommitType.cs b/src/Models/ConventionalCommitType.cs new file mode 100644 index 000000000..bf2763a41 --- /dev/null +++ b/src/Models/ConventionalCommitType.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace SourceGit.Models +{ + public class ConventionalCommitType + { + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string PrefillShortDesc { get; set; } = string.Empty; + + public ConventionalCommitType(string name, string type, string description) + { + Name = name; + Type = type; + Description = description; + } + + public static List Load(string storageFile) + { + try + { + if (!string.IsNullOrEmpty(storageFile) && File.Exists(storageFile)) + return JsonSerializer.Deserialize(File.ReadAllText(storageFile), JsonCodeGen.Default.ListConventionalCommitType) ?? []; + } + catch + { + // Ignore errors. + } + + return new List { + 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"), + }; + } + } +} diff --git a/src/Models/Count.cs b/src/Models/Count.cs new file mode 100644 index 000000000..2be46c8f9 --- /dev/null +++ b/src/Models/Count.cs @@ -0,0 +1,4 @@ +namespace SourceGit.Models +{ + public record Count(int Value); +} diff --git a/src/Models/CustomAction.cs b/src/Models/CustomAction.cs new file mode 100644 index 000000000..59652d32f --- /dev/null +++ b/src/Models/CustomAction.cs @@ -0,0 +1,118 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public enum CustomActionScope + { + Repository, + Commit, + Branch, + Tag, + Remote, + File, + } + + public enum CustomActionControlType + { + TextBox = 0, + PathSelector, + CheckBox, + ComboBox, + LocalBranchSelector, + RemoteBranchSelector, + } + + public record CustomActionTargetFile(string File, Commit Revision); + + public class CustomActionControl : ObservableObject + { + public CustomActionControlType Type + { + get => _type; + set => SetProperty(ref _type, value); + } + + public string Label + { + get => _label; + set => SetProperty(ref _label, value); + } + + public string Description + { + get => _description; + set => SetProperty(ref _description, value); + } + + public string StringValue + { + get => _stringValue; + set => SetProperty(ref _stringValue, value); + } + + public string StringFormatter + { + get => _stringFormatter; + set => SetProperty(ref _stringFormatter, value); + } + + public bool BoolValue + { + get => _boolValue; + set => SetProperty(ref _boolValue, value); + } + + private CustomActionControlType _type = CustomActionControlType.TextBox; + private string _label = string.Empty; + private string _description = string.Empty; + private string _stringValue = string.Empty; + private string _stringFormatter = string.Empty; + private bool _boolValue = false; + } + + 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 AvaloniaList Controls + { + get; + set; + } = []; + + 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 000000000..d313d56ae --- /dev/null +++ b/src/Models/DateTimeFormat.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace SourceGit.Models +{ + public class DateTimeFormat + { + public static readonly List Supported = new List + { + new("yyyy/MM/dd"), + new("yyyy.MM.dd"), + new("yyyy-MM-dd"), + new("MM/dd/yyyy"), + new("MM.dd.yyyy"), + new("MM-dd-yyyy"), + new("dd/MM/yyyy"), + new("dd.MM.yyyy"), + new("dd-MM-yyyy"), + new("MMM d yyyy"), + new("d MMM yyyy"), + }; + + public static int ActiveIndex + { + get; + set; + } = 0; + + public static bool Use24Hours + { + get; + set; + } = true; + + public string DateFormat + { + get; + } + + public string Example + { + get => DateTime.Now.ToString(DateFormat, _culture); + } + + private static readonly CultureInfo _culture = CreateCulture(); + + private static CultureInfo CreateCulture() + { + var culture = (CultureInfo)CultureInfo.CurrentCulture.Clone(); + culture.DateTimeFormat.DateSeparator = "/"; + culture.DateTimeFormat.TimeSeparator = ":"; + return culture; + } + + public DateTimeFormat(string date) + { + DateFormat = date; + } + + public static string Format(ulong timestamp, bool dateOnly = false) + { + var localTime = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + return Format(localTime, dateOnly); + } + + public static string Format(DateTime localTime, bool dateOnly = false) + { + var actived = Supported[ActiveIndex]; + if (dateOnly) + return localTime.ToString(actived.DateFormat, _culture); + + var format = Use24Hours ? $"{actived.DateFormat} HH:mm:ss" : $"{actived.DateFormat} hh:mm:ss tt"; + return localTime.ToString(format, _culture); + } + } +} diff --git a/src/Models/DealWithChangesAfterStashing.cs b/src/Models/DealWithChangesAfterStashing.cs new file mode 100644 index 000000000..9393ce749 --- /dev/null +++ b/src/Models/DealWithChangesAfterStashing.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class DealWithChangesAfterStashing(string label, string desc) + { + public string Label { get; set; } = label; + public string Desc { get; set; } = desc; + + public static readonly List Supported = [ + new ("Discard", "All (or selected) changes will be discarded"), + new ("Keep Index", "Staged changes are left intact"), + new ("Keep All", "All (or selected) changes are left intact"), + ]; + } +} diff --git a/src/Models/DealWithLocalChanges.cs b/src/Models/DealWithLocalChanges.cs new file mode 100644 index 000000000..9775c61a4 --- /dev/null +++ b/src/Models/DealWithLocalChanges.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public enum DealWithLocalChanges + { + DoNothing = 0, + StashAndReapply, + Discard, + } +} diff --git a/src/Models/Decorator.cs b/src/Models/Decorator.cs index 5c306b7f2..7d985e316 100644 --- a/src/Models/Decorator.cs +++ b/src/Models/Decorator.cs @@ -1,23 +1,19 @@ -using Avalonia.Media; - -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 static class DecoratorResources { - public static readonly IBrush[] Backgrounds = [ - new SolidColorBrush(0xFF02C302), - new SolidColorBrush(0xFFFFB835), - ]; + public bool IsTag => Type == DecoratorType.Tag; } } diff --git a/src/Models/DiffOption.cs b/src/Models/DiffOption.cs index caf25b5f7..af64b39a4 100644 --- a/src/Models/DiffOption.cs +++ b/src/Models/DiffOption.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Text; -namespace SourceGit.Models { - public class DiffOption { - public Change WorkingCopyChange => _workingCopyChange; +namespace SourceGit.Models +{ + public class DiffOption + { + public bool IsLocalChange => _revisions.Count == 0; public bool IsUnstaged => _isUnstaged; public List Revisions => _revisions; public string Path => _path; @@ -14,27 +17,29 @@ public class DiffOption { /// /// /// - public DiffOption(Change change, bool isUnstaged) { - _workingCopyChange = change; + public DiffOption(Change change, bool isUnstaged) + { _isUnstaged = isUnstaged; + _path = change.Path; + _orgPath = change.OriginalPath; - 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; + if (isUnstaged) + { + switch (change.WorkTree) + { + case ChangeState.Added: + case ChangeState.Untracked: + _extra = "--no-index"; + _orgPath = "/dev/null"; + break; } - } else { - _extra = "--cached"; - _path = change.Path; - _orgPath = change.OriginalPath; + } + else + { + if (change.DataForAmend != null) + _extra = $"--cached {change.DataForAmend.ParentSHA}"; + else + _extra = "--cached"; } } @@ -43,24 +48,69 @@ public DiffOption(Change change, bool isUnstaged) { /// /// /// - public DiffOption(Commit commit, Change change) { - var baseRevision = commit.Parents.Count == 0 ? "4b825dc642cb6eb9a060e54bf8d69288fbee4904" : $"{commit.SHA}^"; - _revisions.Add(baseRevision); + public DiffOption(Commit commit, Change change) + { + _revisions.Add(commit.FirstParentToCompare); _revisions.Add(commit.SHA); _path = change.Path; _orgPath = change.OriginalPath; } /// - /// Diff with filepath. Used by FileHistories + /// Used to diff in `FileHistory` /// - /// - /// - public DiffOption(Commit commit, string file) { - var baseRevision = commit.Parents.Count == 0 ? "4b825dc642cb6eb9a060e54bf8d69288fbee4904" : $"{commit.SHA}^"; - _revisions.Add(baseRevision); - _revisions.Add(commit.SHA); - _path = file; + /// + public DiffOption(FileVersion ver) + { + if (string.IsNullOrEmpty(ver.OriginalPath)) + { + _revisions.Add(ver.HasParent ? $"{ver.SHA}^" : EmptyTreeHash.Guess(ver.SHA)); + _revisions.Add(ver.SHA); + _path = ver.Path; + } + else + { + _revisions.Add($"{ver.SHA}^:{ver.OriginalPath.Quoted()}"); + _revisions.Add($"{ver.SHA}:{ver.Path.Quoted()}"); + _path = ver.Path; + _orgPath = ver.Change.OriginalPath; + _ignorePaths = true; + } + } + + /// + /// Used to diff two revisions in `FileHistory` + /// + /// + /// + public DiffOption(FileVersion start, FileVersion end) + { + if (start.Change.Index == ChangeState.Deleted) + { + _revisions.Add(EmptyTreeHash.Guess(end.SHA)); + _revisions.Add(end.SHA); + _path = end.Path; + } + else if (end.Change.Index == ChangeState.Deleted) + { + _revisions.Add(start.SHA); + _revisions.Add(EmptyTreeHash.Guess(start.SHA)); + _path = start.Path; + } + else if (!end.Path.Equals(start.Path, StringComparison.Ordinal)) + { + _revisions.Add($"{start.SHA}:{start.Path.Quoted()}"); + _revisions.Add($"{end.SHA}:{end.Path.Quoted()}"); + _path = end.Path; + _orgPath = start.Path; + _ignorePaths = true; + } + else + { + _revisions.Add(start.SHA); + _revisions.Add(end.SHA); + _path = start.Path; + } } /// @@ -69,8 +119,9 @@ public DiffOption(Commit commit, string file) { /// /// /// - public DiffOption(string baseRevision, string targetRevision, Change change) { - _revisions.Add(baseRevision); + public DiffOption(string baseRevision, string targetRevision, Change change) + { + _revisions.Add(string.IsNullOrEmpty(baseRevision) ? "-R" : baseRevision); _revisions.Add(targetRevision); _path = change.Path; _orgPath = change.OriginalPath; @@ -79,24 +130,30 @@ public DiffOption(string baseRevision, string targetRevision, Change change) { /// /// Converts to diff command arguments. /// - /// - public override string ToString() { + public override string ToString() + { var builder = new StringBuilder(); - if (!string.IsNullOrEmpty(_extra)) builder.Append($"{_extra} "); - foreach (var r in _revisions) builder.Append($"{r} "); + if (!string.IsNullOrEmpty(_extra)) + builder.Append($"{_extra} "); + foreach (var r in _revisions) + builder.Append($"{r} "); + + if (_ignorePaths) + return builder.ToString(); builder.Append("-- "); - if (!string.IsNullOrEmpty(_orgPath)) builder.Append($"\"{_orgPath}\" "); - builder.Append($"\"{_path}\""); + if (!string.IsNullOrEmpty(_orgPath)) + builder.Append($"{_orgPath.Quoted()} "); + builder.Append(_path.Quoted()); return builder.ToString(); } - private Change _workingCopyChange = null; - private bool _isUnstaged = false; - private string _orgPath = string.Empty; - private string _path = string.Empty; - private string _extra = string.Empty; - private List _revisions = new List(); + 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 = []; + private readonly bool _ignorePaths = false; } } diff --git a/src/Models/DiffResult.cs b/src/Models/DiffResult.cs index a8266b875..ddb3d7e07 100644 --- a/src/Models/DiffResult.cs +++ b/src/Models/DiffResult.cs @@ -1,9 +1,11 @@ -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; - -namespace SourceGit.Models { - public enum TextDiffLineType { +using System.Collections.Generic; +using System.IO; +using Avalonia.Media.Imaging; + +namespace SourceGit.Models +{ + public enum TextDiffLineType + { None, Normal, Indicator, @@ -11,410 +13,99 @@ public enum TextDiffLineType { Deleted, } - public class TextInlineRange { - public int Start { get; set; } - public int Count { get; set; } - public TextInlineRange(int p, int n) { Start = p; Count = n; } + public class TextRange(int p, int n) + { + public int Start { get; set; } = p; + public int End { get; set; } = p + n - 1; } - public class TextDiffLine { + public class TextDiffLine + { public TextDiffLineType Type { get; set; } = TextDiffLineType.None; + public byte[] RawContent { get; set; } = []; 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 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) { + public TextDiffLine(TextDiffLineType type, string content, byte[] rawContent, int oldLine, int newLine) + { Type = type; Content = content; + RawContent = rawContent; 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 class TextDiff { - public string File { get; set; } = string.Empty; + public partial class TextDiff + { public List Lines { get; set; } = new List(); public int MaxLineNumber = 0; + public int AddedLines { get; set; } = 0; + public int DeletedLines { get; set; } = 0; + public int OldMode { get; set; } = 0; + public int NewMode { get; set; } = 0; + public string OldHash { get; set; } = string.Empty; + public string NewHash { get; set; } = string.Empty; + } - public void GenerateNewPatchFromSelection(Change change, string fileBlobGuid, TextDiffSelection selection, bool revert, string output) { - var isTracked = !string.IsNullOrEmpty(fileBlobGuid); - var fileGuid = isTracked ? fileBlobGuid.Substring(0, 8) : "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 indicatorRegex = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@"); - - var match = indicatorRegex.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 indicatorRegex = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@"); - - var match = indicatorRegex.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++; - } - } + public class LFSDiff + { + public LFSObject Old { get; set; } = new LFSObject(); + public LFSObject New { get; set; } = new LFSObject(); + } - 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++; - } - } - } + public class BinaryDiff + { + public long OldSize { get; set; } = 0; + public long NewSize { get; set; } = 0; + } - if (i == end - 1 && tailed) { - newCount++; - oldCount++; - } - } - } + public class ImageDiff + { + public Bitmap Old { get; set; } = null; + public Bitmap New { get; set; } = null; - if (oldCount == 0 && newCount == 0) return false; + public long OldFileSize { get; set; } = 0; + public long NewFileSize { get; set; } = 0; - builder.Append($"\n@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); - return true; - } + 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 LFSDiff { - public LFSObject Old { get; set; } = new LFSObject(); - public LFSObject New { get; set; } = new LFSObject(); + public class EmptyFile + { + public const string SHA1 = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"; + public const string SHA256 = "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"; } - public class BinaryDiff { - public long OldSize { get; set; } = 0; - public long NewSize { get; set; } = 0; + public class NoOrEOLChange; + + public class SubmoduleDiff + { + public string FullPath { get; set; } = string.Empty; + public RevisionSubmodule Old { get; set; } = null; + public RevisionSubmodule New { get; set; } = null; + + public bool CanOpenDetails => File.Exists(Path.Combine(FullPath, ".git")) && + Old != null && Old.Commit.Author != User.Invalid && + New != null && New.Commit.Author != User.Invalid; } - public class DiffResult { + public class DiffResult + { public bool IsBinary { get; set; } = false; - public bool IsLFS { get; set; } = false; + public bool IsSubmoduleChange { get; set; } = false; + public string OldHash { get; set; } = string.Empty; + public string NewHash { get; set; } = string.Empty; + public int OldMode { get; set; } = 0; + public int NewMode { get; set; } = 0; public TextDiff TextDiff { get; set; } = null; public LFSDiff LFSDiff { get; set; } = null; } diff --git a/src/Models/DirtyState.cs b/src/Models/DirtyState.cs new file mode 100644 index 000000000..2b9d898de --- /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/EmptyTreeHash.cs b/src/Models/EmptyTreeHash.cs new file mode 100644 index 000000000..bf1445a0f --- /dev/null +++ b/src/Models/EmptyTreeHash.cs @@ -0,0 +1,13 @@ +namespace SourceGit.Models +{ + public static class EmptyTreeHash + { + public static string Guess(string revision) + { + return revision.Length == 40 ? SHA1 : SHA256; + } + + private const string SHA1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + private const string SHA256 = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; + } +} diff --git a/src/Models/ExternalMergeTools.cs b/src/Models/ExternalMergeTools.cs deleted file mode 100644 index 6efd64719..000000000 --- a/src/Models/ExternalMergeTools.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace SourceGit.Models { - public class ExternalMergeTools { - 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 static List Supported; - - static ExternalMergeTools() { - if (OperatingSystem.IsWindows()) { - Supported = new List() { - new ExternalMergeTools(0, "Custom", "", "", ""), - new ExternalMergeTools(1, "Visual Studio Code", "Code.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(2, "Visual Studio 2017/2019", "vsDiffMerge.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\" /m", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(3, "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\""), - new ExternalMergeTools(4, "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(5, "Beyond Compare 4", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(6, "WinMerge", "WinMergeU.exe", "-u -e \"$REMOTE\" \"$LOCAL\" \"$MERGED\"", "-u -e \"$LOCAL\" \"$REMOTE\""), - }; - } else if (OperatingSystem.IsMacOS()) { - Supported = new List() { - new ExternalMergeTools(0, "Custom", "", "", ""), - new ExternalMergeTools(1, "FileMerge", "/usr/bin/opendiff", "\"$BASE\" \"$LOCAL\" \"$REMOTE\" -ancestor \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(2, "Visual Studio Code", "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(3, "KDiff3", "/Applications/kdiff3.app/Contents/MacOS/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(4, "Beyond Compare 4", "/Applications/Beyond Compare.app/Contents/MacOS/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - }; - } else if (OperatingSystem.IsLinux()) { - Supported = new List() { - new ExternalMergeTools(0, "Custom", "", "", ""), - new ExternalMergeTools(1, "Visual Studio Code", "/usr/share/code/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(2, "KDiff3", "/usr/bin/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMergeTools(3, "Beyond Compare 4", "/usr/bin/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - }; - } else { - Supported = new List() { - new ExternalMergeTools(0, "Custom", "", "", ""), - }; - } - } - - public ExternalMergeTools(int type, string name, string exec, string cmd, string diffCmd) { - Type = type; - Name = name; - Exec = exec; - Cmd = cmd; - DiffCmd = diffCmd; - } - } -} diff --git a/src/Models/ExternalMerger.cs b/src/Models/ExternalMerger.cs new file mode 100644 index 000000000..655a1d58a --- /dev/null +++ b/src/Models/ExternalMerger.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace SourceGit.Models +{ + public class ExternalMerger(string icon, string name, string finder, string mergeCmd, string diffCmd) + { + public string Icon { get; } = icon; + public string Name { get; } = name; + public string Finder { get; } = finder; + public string MergeCmd { get; } = mergeCmd; + public string DiffCmd { get; } = diffCmd; + + 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("git", "Use Git Settings", "", "", ""), + new ExternalMerger("vscode", "Visual Studio Code", "Code.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("vscode_insiders", "Visual Studio Code - Insiders", "Code - Insiders.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("vs", "Visual Studio", "vsDiffMerge.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\" /m", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("tortoise_merge", "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\""), + new ExternalMerger("kdiff3", "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("beyond_compare", "Beyond Compare", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("win_merge", "WinMerge", "WinMergeU.exe", "\"$MERGED\"", "-u -e -sw \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("codium", "VSCodium", "VSCodium.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("plastic_merge", "Plastic SCM", "mergetool.exe", "-s=\"$REMOTE\" -b=\"$BASE\" -d=\"$LOCAL\" -r=\"$MERGED\" --automatic", "-s=\"$LOCAL\" -d=\"$REMOTE\""), + new ExternalMerger("meld", "Meld", "Meld.exe", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("cursor", "Cursor", "Cursor.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + }; + } + else if (OperatingSystem.IsMacOS()) + { + Supported = new List() { + new ExternalMerger("git", "Use Git Settings", "", "", ""), + new ExternalMerger("xcode", "FileMerge", "/usr/bin/opendiff", "\"$LOCAL\" \"$REMOTE\" -ancestor \"$BASE\" -merge \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("vscode", "Visual Studio Code", "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("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("kdiff3", "KDiff3", "/Applications/kdiff3.app/Contents/MacOS/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("beyond_compare", "Beyond Compare", "/Applications/Beyond Compare.app/Contents/MacOS/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("codium", "VSCodium", "/Applications/VSCodium.app/Contents/Resources/app/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("p4merge", "P4Merge", "/Applications/p4merge.app/Contents/Resources/launchp4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("cursor", "Cursor", "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + }; + } + else if (OperatingSystem.IsLinux()) + { + Supported = new List() { + new ExternalMerger("git", "Use Git Settings", "", "", ""), + new ExternalMerger("vscode", "Visual Studio Code", "/usr/share/code/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("vscode_insiders", "Visual Studio Code - Insiders", "/usr/share/code-insiders/code-insiders", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("kdiff3", "KDiff3", "/usr/bin/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("beyond_compare", "Beyond Compare", "/usr/bin/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("meld", "Meld", "/usr/bin/meld", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("codium", "VSCodium", "/usr/share/codium/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("p4merge", "P4Merge", "/usr/local/bin/p4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("cursor", "Cursor", "cursor", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + }; + } + else + { + Supported = new List() { + new ExternalMerger("git", "Use Git Settings", "", "", ""), + }; + } + } + + public string[] GetPatternsToFindExecFile() + { + if (OperatingSystem.IsWindows()) + return Finder.Split(';', StringSplitOptions.RemoveEmptyEntries); + + return [Path.GetFileName(Finder)]; + } + } + + public class DiffMergeTool(string exec, string cmd) + { + public string Exec { get; } = exec; + public string Cmd { get; } = cmd; + } +} diff --git a/src/Models/ExternalTool.cs b/src/Models/ExternalTool.cs new file mode 100644 index 000000000..7f93cf8bd --- /dev/null +++ b/src/Models/ExternalTool.cs @@ -0,0 +1,238 @@ +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 class LaunchOption + { + public string Title { get; set; } + public string Args { get; set; } + + public LaunchOption(string title, string args) + { + Title = title; + Args = args; + } + } + + public string Name { get; } + public string ExecFile { get; } + public Bitmap IconImage { get; } + public bool SupportOpenFolder { get; } + + public ExternalTool(string name, string icon, string execFile, Func> optionsGenerator, bool supportOpenFolder) + { + Name = name; + ExecFile = execFile; + SupportOpenFolder = supportOpenFolder; + + _optionsGenerator = optionsGenerator; + + try + { + var asset = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/ExternalToolIcons/{icon}.png", + UriKind.RelativeOrAbsolute)); + IconImage = new Bitmap(asset); + } + catch + { + // ignore + } + } + + public List MakeLaunchOptions(string repo) + { + return _optionsGenerator?.Invoke(repo); + } + + public void Launch(string args) + { + if (File.Exists(ExecFile)) + { + Process.Start(new ProcessStartInfo() + { + FileName = ExecFile, + Arguments = args, + UseShellExecute = false, + }); + } + } + + private Func> _optionsGenerator = null; + } + + public class VisualStudioInstance + { + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("productPath")] + public string ProductPath { get; set; } = string.Empty; + + [JsonPropertyName("isPrerelease")] + public bool IsPrerelease { get; set; } = false; + } + + public class JetBrainsState + { + [JsonPropertyName("tools")] + public List Tools { get; set; } = new List(); + } + + public class JetBrainsTool + { + [JsonPropertyName("productCode")] + public string ProductCode { get; set; } + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } + [JsonPropertyName("displayVersion")] + public string DisplayVersion { get; set; } + [JsonPropertyName("installLocation")] + public string InstallLocation { get; set; } + [JsonPropertyName("launchCommand")] + public string LaunchCommand { get; set; } + } + + public class ExternalToolCustomization + { + [JsonPropertyName("tools")] + public Dictionary Tools { get; set; } = new Dictionary(); + [JsonPropertyName("excludes")] + public List Excludes { get; set; } = new List(); + } + + public class ExternalToolsFinder + { + public List Tools + { + get; + private set; + } = new List(); + + public ExternalToolsFinder() + { + var customPathsConfig = Path.Combine(Native.OS.DataDir, "external_editors.json"); + try + { + if (File.Exists(customPathsConfig)) + { + using var stream = File.OpenRead(customPathsConfig); + _customization = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.ExternalToolCustomization); + } + } + catch + { + // Ignore + } + + _customization ??= new ExternalToolCustomization(); + } + + public void TryAdd(string name, string icon, Func finder, Func> optionsGenerator = null, bool supportOpenFolder = true) + { + if (_customization.Excludes.Contains(name)) + return; + + if (_customization.Tools.TryGetValue(name, out var customPath) && File.Exists(customPath)) + { + Tools.Add(new ExternalTool(name, icon, customPath, optionsGenerator, supportOpenFolder)); + } + else + { + var path = finder(); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + Tools.Add(new ExternalTool(name, icon, path, optionsGenerator, supportOpenFolder)); + } + } + + public void VSCode(Func platformFinder) + { + TryAdd("Visual Studio Code", "vscode", platformFinder, GenerateVSCodeLaunchOptions); + } + + public void VSCodeInsiders(Func platformFinder) + { + TryAdd("Visual Studio Code - Insiders", "vscode_insiders", platformFinder, GenerateVSCodeLaunchOptions); + } + + public void VSCodium(Func platformFinder) + { + TryAdd("VSCodium", "codium", platformFinder, GenerateVSCodeLaunchOptions); + } + + public void SublimeText(Func platformFinder) + { + TryAdd("Sublime Text", "sublime_text", platformFinder); + } + + public void Zed(Func platformFinder) + { + TryAdd("Zed", "zed", platformFinder); + } + + public void Cursor(Func platformFinder) + { + TryAdd("Cursor", "cursor", platformFinder); + } + + public void FindJetBrainsFromToolbox(Func platformFinder) + { + var supported = 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)) + { + try + { + using var stream = File.OpenRead(state); + var stateData = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.JetBrainsState); + foreach (var tool in stateData.Tools) + { + if (!supported.Contains(tool.ProductCode)) + continue; + + Tools.Add(new ExternalTool( + $"{tool.DisplayName} {tool.DisplayVersion}", + $"JetBrains/{tool.ProductCode}", + Path.Combine(tool.InstallLocation, tool.LaunchCommand), + null, + true)); + } + } + catch + { + // Ignore exceptions. + } + } + } + + private List GenerateVSCodeLaunchOptions(string path) + { + var root = new DirectoryInfo(path); + if (!root.Exists) + return null; + + var options = new List(); + var prefixLen = root.FullName.Length; + root.WalkFiles(f => + { + if (f.EndsWith(".code-workspace", StringComparison.OrdinalIgnoreCase)) + { + var display = f.Substring(prefixLen).TrimStart(Path.DirectorySeparatorChar); + options.Add(new(display, f.Quoted())); + } + }, 2); + return options; + } + + private ExternalToolCustomization _customization = null; + } +} diff --git a/src/Models/FileVersion.cs b/src/Models/FileVersion.cs new file mode 100644 index 000000000..d822b9605 --- /dev/null +++ b/src/Models/FileVersion.cs @@ -0,0 +1,14 @@ +namespace SourceGit.Models +{ + public class FileVersion + { + public string SHA { get; set; } = string.Empty; + public bool HasParent { get; set; } = false; + public User Author { get; set; } = User.Invalid; + public ulong AuthorTime { get; set; } = 0; + public string Subject { get; set; } = string.Empty; + public Change Change { get; set; } = new(); + public string Path => Change.Path; + public string OriginalPath => Change.OriginalPath; + } +} diff --git a/src/Models/GPGFormat.cs b/src/Models/GPGFormat.cs new file mode 100644 index 000000000..0ba4e9e2d --- /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 dca2aa913..a648d4db2 100644 --- a/src/Models/GitFlow.cs +++ b/src/Models/GitFlow.cs @@ -1,30 +1,108 @@ -namespace SourceGit.Models { - public enum GitFlowBranchType { - None, +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public enum GitFlowVersion + { + None = 0, + Legacy, + Next, + } + + public enum GitFlowBranchType + { + None = 0, Feature, Release, Hotfix, } - public class GitFlow { - public string Feature { get; set; } - public string Release { get; set; } - public string Hotfix { get; set; } + public class GitFlow + { + public string ProductionBranch { get; set; } = string.Empty; + public string DevelopmentBranch { 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 void Parse(Dictionary config) + { + // Reset to default values + ProductionBranch = string.Empty; + DevelopmentBranch = string.Empty; + FeaturePrefix = string.Empty; + ReleasePrefix = string.Empty; + HotfixPrefix = string.Empty; + + // Try to parse `git-flow-next` style configuration first if the `git-flow-next` is installed. + if (Native.OS.GitFlowVersion == GitFlowVersion.Next && + config.TryGetValue("gitflow.initialized", out var initialized) && + initialized.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + foreach (var kv in config) + { + if (!kv.Key.StartsWith("gitflow.branch.", StringComparison.Ordinal)) + continue; + + if (kv.Key.EndsWith(".type", StringComparison.Ordinal) && kv.Value.Equals("base", StringComparison.Ordinal)) + { + var b = kv.Key.Substring("gitflow.branch.".Length, kv.Key.Length - "gitflow.branch.".Length - ".type".Length); + if (config.ContainsKey($"gitflow.branch.{b}.parent")) + DevelopmentBranch = b; + else + ProductionBranch = b; + } + else if (kv.Key.EndsWith(".prefix", StringComparison.Ordinal)) + { + var t = kv.Key.Substring("gitflow.branch.".Length, kv.Key.Length - "gitflow.branch.".Length - ".prefix".Length); + if (t.Equals("feature", StringComparison.Ordinal)) + FeaturePrefix = kv.Value; + else if (t.Equals("release", StringComparison.Ordinal)) + ReleasePrefix = kv.Value; + else if (t.Equals("hotfix", StringComparison.Ordinal)) + HotfixPrefix = kv.Value; + } + } + } + + // Fall back to `git-flow` style configuration if `git-flow-next` style is not valid. + if (!IsValid) + { + if (config.TryGetValue("gitflow.branch.master", out var masterName)) + ProductionBranch = masterName; + if (config.TryGetValue("gitflow.branch.develop", out var developName)) + DevelopmentBranch = developName; + if (config.TryGetValue("gitflow.prefix.feature", out var featurePrefix)) + FeaturePrefix = featurePrefix; + if (config.TryGetValue("gitflow.prefix.release", out var releasePrefix)) + ReleasePrefix = releasePrefix; + if (config.TryGetValue("gitflow.prefix.hotfix", out var hotfixPrefix)) + HotfixPrefix = hotfixPrefix; + } + } - public bool IsEnabled { - get { - return !string.IsNullOrEmpty(Feature) - && !string.IsNullOrEmpty(Release) - && !string.IsNullOrEmpty(Hotfix); + public bool IsValid + { + get + { + return !string.IsNullOrEmpty(ProductionBranch) && + !string.IsNullOrEmpty(DevelopmentBranch) && + !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) + { + return type switch + { + GitFlowBranchType.Feature => FeaturePrefix, + GitFlowBranchType.Release => ReleasePrefix, + GitFlowBranchType.Hotfix => HotfixPrefix, + _ => string.Empty, + }; } } } diff --git a/src/Models/GitIgnoreFile.cs b/src/Models/GitIgnoreFile.cs new file mode 100644 index 000000000..2f3540e8b --- /dev/null +++ b/src/Models/GitIgnoreFile.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public record GitIgnoreFile(string DisplayName, string FullPath, string Pattern, bool IsLocalOnly) + { + public static List GetSupported(string repo, string gitDir, string pattern) + { + var supported = new List(); + + // .gitignore in repository root. + supported.Add(new(".gitignore", $"{repo}/.gitignore", pattern, false)); + + // If pattern points to a file/directory that in sub-directory of repository, we should also support .gitignore in that sub-directory. + var normalizedPattern = pattern.Replace('\\', '/').TrimEnd('/'); + var lastDirIdx = normalizedPattern.LastIndexOf('/'); + if (lastDirIdx > 0) + { + var parentDir = normalizedPattern.Substring(0, lastDirIdx); + var overridedPattern = normalizedPattern.Substring(lastDirIdx + 1); + supported.Add(new($"{parentDir}/.gitignore", $"{repo}/{parentDir}/.gitignore", overridedPattern, false)); + } + + // .git/info/exclude in git directory. + var normalizedGitDir = gitDir.Replace('\\', '/'); + var testGitDir = $"{repo}/.git".Replace('\\', '/'); + if (normalizedGitDir.Equals(testGitDir, StringComparison.Ordinal)) + supported.Add(new(".git/info/exclude", $"{normalizedGitDir}/info/exclude", pattern, true)); + else + supported.Add(new(".git/info/exclude", $"{gitDir}/info/exclude", pattern, true)); + + return supported; + } + } +} diff --git a/src/Models/GitVersions.cs b/src/Models/GitVersions.cs new file mode 100644 index 000000000..71fb4657e --- /dev/null +++ b/src/Models/GitVersions.cs @@ -0,0 +1,30 @@ +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); + + /// + /// The minimal version of Git that supports the `git merge-tree --write-tree` command, which is used for testing merge results without actually performing a merge. + /// + public static readonly System.Version TESTING_MERGE = new(2, 38, 0); + + /// + /// The minimal version of Git that supports the `git replay` command. + /// + public static readonly System.Version REPLAY = new(2, 44, 0); + } +} diff --git a/src/Models/HTTPSValidator.cs b/src/Models/HTTPSValidator.cs new file mode 100644 index 000000000..014207c21 --- /dev/null +++ b/src/Models/HTTPSValidator.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Net.Security; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Models +{ + public static class HTTPSValidator + { + public static void Add(string host) + { + lock (_syncLock) + { + // Already checked + if (_hosts.ContainsKey(host)) + return; + + // Temporarily mark as supported to avoid duplicate checks + _hosts.Add(host, true); + + // Well-known hosts always support HTTPS + if (host.Contains("github.com", StringComparison.Ordinal) || + host.Contains("gitlab", StringComparison.Ordinal) || + host.Contains("azure.com", StringComparison.Ordinal) || + host.Equals("gitee.com", StringComparison.Ordinal) || + host.Equals("bitbucket.org", StringComparison.Ordinal) || + host.Equals("gitea.org", StringComparison.Ordinal) || + host.Equals("gitcode.com", StringComparison.Ordinal)) + return; + } + + Task.Run(() => + { + var supported = false; + + try + { + using (var client = new TcpClient()) + { + client.ConnectAsync(host, 443).Wait(3000); + if (!client.Connected) + { + client.ConnectAsync(host, 80).Wait(3000); + supported = !client.Connected; // If the network is not available, assume HTTPS is supported + } + else + { + using (var ssl = new SslStream(client.GetStream(), false, (s, cert, chain, errs) => true)) + { + ssl.AuthenticateAsClient(host); + supported = ssl.IsAuthenticated; // Hand-shake succeeded + } + } + } + } + catch + { + // Ignore exceptions + } + + lock (_syncLock) + { + _hosts[host] = supported; + } + }); + } + + public static bool IsSupported(string host) + { + lock (_syncLock) + { + if (_hosts.TryGetValue(host, out var supported)) + return supported; + + return false; + } + } + + private static Lock _syncLock = new(); + private static Dictionary _hosts = new(); + } +} diff --git a/src/Models/HistoryFilter.cs b/src/Models/HistoryFilter.cs new file mode 100644 index 000000000..b09f074cb --- /dev/null +++ b/src/Models/HistoryFilter.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 HistoryFilter : 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 HistoryFilter() + { + } + + public HistoryFilter(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/HistoryShowFlags.cs b/src/Models/HistoryShowFlags.cs new file mode 100644 index 000000000..4acb964d5 --- /dev/null +++ b/src/Models/HistoryShowFlags.cs @@ -0,0 +1,13 @@ +using System; + +namespace SourceGit.Models +{ + [Flags] + public enum HistoryShowFlags + { + None = 0, + Reflog = 1 << 0, + FirstParentOnly = 1 << 1, + SimplifyByDecoration = 1 << 2, + } +} diff --git a/src/Models/ICommandLog.cs b/src/Models/ICommandLog.cs new file mode 100644 index 000000000..28e1fcb39 --- /dev/null +++ b/src/Models/ICommandLog.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Models +{ + public interface ICommandLogReceiver + { + void OnReceiveCommandLog(string line); + } + + public interface ICommandLog + { + void AppendLine(string line); + } +} diff --git a/src/Models/IRepository.cs b/src/Models/IRepository.cs new file mode 100644 index 000000000..2fc7c6125 --- /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 000000000..5ff862eb3 --- /dev/null +++ b/src/Models/ImageDecoder.cs @@ -0,0 +1,11 @@ +namespace SourceGit.Models +{ + public enum ImageDecoder + { + None = 0, + Builtin, + Pfim, + Tiff, + StbImage, + } +} diff --git a/src/Models/InlineElement.cs b/src/Models/InlineElement.cs new file mode 100644 index 000000000..ea7bcee83 --- /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 000000000..d81aaf8d9 --- /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/InteractiveRebase.cs b/src/Models/InteractiveRebase.cs new file mode 100644 index 000000000..ac7e29d4f --- /dev/null +++ b/src/Models/InteractiveRebase.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + public enum InteractiveRebaseAction + { + Pick, + Edit, + Reword, + Squash, + Fixup, + Drop, + } + + public enum InteractiveRebasePendingType + { + None = 0, + Target, + Pending, + Ignore, + Last, + } + + 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 partial class InteractiveRebaseJobCollection + { + public string OrigHead { get; set; } = string.Empty; + public string Onto { get; set; } = string.Empty; + public List Jobs { get; set; } = new List(); + + public void WriteTodoList(string todoFile) + { + using var writer = new StreamWriter(todoFile); + foreach (var job in Jobs) + { + var code = job.Action switch + { + InteractiveRebaseAction.Pick => 'p', + InteractiveRebaseAction.Edit => 'e', + InteractiveRebaseAction.Reword => 'r', + InteractiveRebaseAction.Squash => 's', + InteractiveRebaseAction.Fixup => 'f', + _ => 'd' + }; + writer.WriteLine($"{code} {job.SHA}"); + } + + writer.Flush(); + } + + public void WriteCommitMessage(string doneFile, string msgFile) + { + var done = File.ReadAllText(doneFile).Trim().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (done.Length == 0) + return; + + var current = done[^1].Trim(); + var match = REG_REBASE_TODO().Match(current); + if (!match.Success) + return; + + var sha = match.Groups[1].Value; + foreach (var job in Jobs) + { + if (job.SHA.StartsWith(sha)) + { + File.WriteAllText(msgFile, job.Message); + return; + } + } + } + + [GeneratedRegex(@"^[a-z]+\s+([a-fA-F0-9]{4,64})(\s+.*)?$")] + private static partial Regex REG_REBASE_TODO(); + } +} diff --git a/src/Models/IpcChannel.cs b/src/Models/IpcChannel.cs new file mode 100644 index 000000000..86c8167ae --- /dev/null +++ b/src/Models/IpcChannel.cs @@ -0,0 +1,115 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Models +{ + public class IpcChannel : IDisposable + { + public bool IsFirstInstance { get; } + + 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( + GetPipeName(), + 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(".", GetPipeName(), 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 static string GetPipeName() + { + // SourceGit does not support multiple instances on macOS, so we can use a fixed pipe name for macOS. + if (OperatingSystem.IsMacOS()) + return "SourceGit"; + + // Windows and Linux can have multiple instances of SourceGit running (portable-mode), so we need to generate a unique pipe name based on the data directory. + var dataDir = Native.OS.DataDir.Replace('\\', '/').TrimEnd('/'); + var hashStr = $"{Environment.UserName}_{dataDir}"; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hashStr))).Substring(0, 10); + return $"SG_{hash}"; + } + + 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 NamedPipeServerStream _server = null; + private CancellationTokenSource _cancellationTokenSource = null; + } +} diff --git a/src/Models/IssueTracker.cs b/src/Models/IssueTracker.cs new file mode 100644 index 000000000..b424707f6 --- /dev/null +++ b/src/Models/IssueTracker.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public class IssueTracker : ObservableObject + { + public bool IsShared + { + get => _isShared; + set => SetProperty(ref _isShared, value); + } + + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string RegexString + { + get => _regexString; + set + { + if (SetProperty(ref _regexString, value)) + { + try + { + _regex = new Regex(_regexString, RegexOptions.Multiline); + } + catch + { + _regex = null; + } + } + + 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); + foreach (Match match in matches) + { + 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 bool _isShared; + private string _name; + private string _regexString; + private string _urlTemplate; + private Regex _regex = null; + } +} diff --git a/src/Models/LFSLock.cs b/src/Models/LFSLock.cs new file mode 100644 index 000000000..8d9a4acff --- /dev/null +++ b/src/Models/LFSLock.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace SourceGit.Models +{ + public class LFSLockOwner + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } + + public class LFSLock + { + [JsonPropertyName("id")] + public string ID { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("owner")] + public LFSLockOwner Owner { get; set; } = null; + } +} diff --git a/src/Models/LFSObject.cs b/src/Models/LFSObject.cs index 13bf0b320..8bc2dda28 100644 --- a/src/Models/LFSObject.cs +++ b/src/Models/LFSObject.cs @@ -1,6 +1,22 @@ -namespace SourceGit.Models { - public class LFSObject { +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/Locales.cs b/src/Models/Locales.cs index 35bb7a296..5bdb2431b 100644 --- a/src/Models/Locales.cs +++ b/src/Models/Locales.cs @@ -1,16 +1,33 @@ -using System.Collections.Generic; +using System.Collections.Generic; -namespace SourceGit.Models { - public class Locale { +namespace SourceGit.Models +{ + public class Locale + { public string Name { get; set; } public string Key { get; set; } - public static List Supported = new List() { + public static readonly List Supported = new List() { + new Locale("Deutsch", "de_DE"), + new Locale("Ελληνικά", "el_GR"), new Locale("English", "en_US"), + new Locale("Español", "es_ES"), + new Locale("Français", "fr_FR"), + new Locale("עברית", "he_IL"), + new Locale("Bahasa Indonesia", "id_ID"), + 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"), + new Locale("한국어", "ko_KR"), }; - public Locale(string name, string key) { + 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 000000000..0fcf76c8f --- /dev/null +++ b/src/Models/MergeMode.cs @@ -0,0 +1,33 @@ +namespace SourceGit.Models +{ + public class MergeMode(string n, string d, string a) + { + public static readonly MergeMode Default = + new MergeMode("Default", "Use git configuration", ""); + + public static readonly MergeMode FastForward = + new MergeMode("Fast-forward", "Refuse to merge when fast-forward is not possible", "--ff-only"); + + public static readonly MergeMode NoFastForward = + new MergeMode("No Fast-forward", "Always create a merge commit", "--no-ff"); + + public static readonly MergeMode Squash = + new MergeMode("Squash", "Squash merge", "--squash"); + + public static readonly MergeMode DontCommit + = new MergeMode("Don't commit", "Merge without commit", "--no-ff --no-commit"); + + public static readonly MergeMode[] Supported = + [ + Default, + FastForward, + NoFastForward, + Squash, + DontCommit, + ]; + + public string Name { get; set; } = n; + public string Desc { get; set; } = d; + public string Arg { get; set; } = a; + } +} diff --git a/src/Models/MergeStrategy.cs b/src/Models/MergeStrategy.cs new file mode 100644 index 000000000..ab1d446b7 --- /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/Notification.cs b/src/Models/Notification.cs index 08c888b90..f033213fb 100644 --- a/src/Models/Notification.cs +++ b/src/Models/Notification.cs @@ -1,10 +1,23 @@ -namespace SourceGit.Models { - public class Notification { - public bool IsError { get; set; } = false; - public string Message { get; set; } = string.Empty; - } +using System; + +namespace SourceGit.Models +{ + public class Notification + { + public static event Action Raised; + + public string Group { get; set; } + public string Message { get; set; } + public bool IsError { get; set; } - public interface INotificationReceiver { - void OnReceiveNotification(string ctx, Notification notice); + public static void Send(string group, string message, bool isError = false) + { + Raised?.Invoke(new Notification + { + Group = group, + Message = message, + IsError = isError + }); + } } } diff --git a/src/Models/Null.cs b/src/Models/Null.cs new file mode 100644 index 000000000..1820d4d05 --- /dev/null +++ b/src/Models/Null.cs @@ -0,0 +1,4 @@ +namespace SourceGit.Models +{ + public class Null; +} diff --git a/src/Models/NumericSort.cs b/src/Models/NumericSort.cs new file mode 100644 index 000000000..433a921bd --- /dev/null +++ b/src/Models/NumericSort.cs @@ -0,0 +1,54 @@ +using System; + +namespace SourceGit.Models +{ + public static class NumericSort + { + public static int Compare(string s1, string s2) + { + var comparer = StringComparer.InvariantCultureIgnoreCase; + + 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 comparer.Compare(c1.ToString(), c2.ToString()); + + 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 = comparer.Compare(sub1, sub2); + + if (result != 0) + return result; + } + + return len1 - len2; + } + } +} diff --git a/src/Models/Object.cs b/src/Models/Object.cs index c2f27a31a..119177eef 100644 --- a/src/Models/Object.cs +++ b/src/Models/Object.cs @@ -1,5 +1,7 @@ -namespace SourceGit.Models { - public enum ObjectType { +namespace SourceGit.Models +{ + public enum ObjectType + { None, Blob, Tree, @@ -7,7 +9,8 @@ public enum ObjectType { Commit, } - 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/PatchGenerator.cs b/src/Models/PatchGenerator.cs new file mode 100644 index 000000000..d73b78e1a --- /dev/null +++ b/src/Models/PatchGenerator.cs @@ -0,0 +1,511 @@ +using System.IO; +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + public partial class PatchGenerator + { + [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] + private static partial Regex REG_INDICATOR(); + + public bool IsValid => _selection != null; + + public PatchGenerator(DiffOption option, TextDiff diff, int startLine, int endLine, bool isCombined, bool isOldSide) + { + _targetFile = option.Path; + _diff = diff; + _isCombined = isCombined; + _isOldSide = isOldSide; + + var lines = _diff.Lines; + var hasChanges = false; + + var selection = new Selection(); + selection.StartLine = startLine; + selection.EndLine = endLine; + + for (int i = 0; i < startLine; i++) + { + var line = lines[i]; + if (line.Type == TextDiffLineType.Added) + selection.IgnoredAdds++; + else if (line.Type == TextDiffLineType.Deleted) + selection.IgnoredDeletes++; + } + + for (int i = startLine; i <= endLine; i++) + { + var line = lines[i]; + if (line.Type == TextDiffLineType.Added) + { + if (isCombined || !isOldSide) + { + hasChanges = true; + break; + } + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (isCombined || isOldSide) + { + hasChanges = true; + break; + } + } + } + + if (hasChanges) + _selection = selection; + } + + public void Generate(string saveTo, bool revert) + { + if (_selection == null) + return; + + using var writer = CreateWriter(saveTo, revert); + + if (_isCombined) + GenerateCombined(writer, revert); + else + GenerateForSingleSide(writer, revert); + } + + private void GenerateCombined(StreamWriter writer, bool revert) + { + var lines = _diff.Lines; + var tail = FindTail(revert); + + // If the first line is not indicator. + if (lines[_selection.StartLine].Type != TextDiffLineType.Indicator) + { + var indicator = _selection.StartLine; + for (int i = _selection.StartLine - 1; 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; i++) + { + var line = lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + ProcessIndicator(writer, line, i, ignoreRemoves, ignoreAdds, revert, tail != null); + } + else if (line.Type == TextDiffLineType.Added) + { + if (revert) + Append(writer, ' ', line); + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (!revert) + Append(writer, ' ', line); + } + else if (line.Type == TextDiffLineType.Normal) + { + Append(writer, ' ', line); + } + } + } + + // Outputs the selected lines. + for (int i = _selection.StartLine; i <= _selection.EndLine; i++) + { + var line = lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + if (!ProcessIndicator(writer, line, i, _selection.IgnoredDeletes, _selection.IgnoredAdds, revert, tail != null)) + break; + } + else if (line.Type == TextDiffLineType.Normal) + { + Append(writer, ' ', line); + } + else if (line.Type == TextDiffLineType.Added) + { + Append(writer, '+', line); + } + else if (line.Type == TextDiffLineType.Deleted) + { + Append(writer, '-', line); + } + } + + if (tail != null) + Append(writer, ' ', tail); + } + + private void GenerateForSingleSide(StreamWriter writer, bool revert) + { + var lines = _diff.Lines; + var tail = FindTail(revert); + + // If the first line is not indicator. + if (lines[_selection.StartLine].Type != TextDiffLineType.Indicator) + { + var indicator = _selection.StartLine; + for (int i = _selection.StartLine - 1; 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; i++) + { + var line = lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + ProcessIndicatorForSingleSide(writer, line, i, ignoreRemoves, ignoreAdds, revert, tail != null); + } + else if (line.Type == TextDiffLineType.Added) + { + if (revert) + Append(writer, ' ', line); + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (!revert) + Append(writer, ' ', line); + } + else if (line.Type == TextDiffLineType.Normal) + { + Append(writer, ' ', line); + } + } + } + + // Outputs the selected lines. + for (int i = _selection.StartLine; i <= _selection.EndLine; i++) + { + var line = lines[i]; + if (line.Type == TextDiffLineType.Indicator) + { + if (!ProcessIndicatorForSingleSide(writer, line, i, _selection.IgnoredDeletes, _selection.IgnoredAdds, revert, tail != null)) + break; + } + else if (line.Type == TextDiffLineType.Normal) + { + Append(writer, ' ', line); + } + else if (line.Type == TextDiffLineType.Added) + { + if (_isOldSide) + { + if (revert) + Append(writer, ' ', line); + else + _selection.IgnoredAdds++; + } + else + { + Append(writer, '+', line); + } + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (_isOldSide) + { + Append(writer, '-', line); + } + else + { + if (!revert) + Append(writer, ' ', line); + else + _selection.IgnoredDeletes++; + } + } + } + + if (tail != null) + Append(writer, ' ', tail); + } + + private StreamWriter CreateWriter(string saveTo, bool revert) + { + var writer = new StreamWriter(saveTo) { NewLine = "\n" }; + writer.WriteLine($"diff --git \"a/{_targetFile}\" \"b/{_targetFile}\""); + + if (_diff.OldMode == 0) + { + if (_diff.NewMode == 0 || revert) + { + writer.WriteLine($"index {_diff.OldHash}..{_diff.NewHash}"); + writer.WriteLine($"--- a/{_targetFile}"); + } + else + { + writer.WriteLine($"new file mode {_diff.NewMode}"); + writer.WriteLine($"--- /dev/null"); + } + } + else if (_diff.NewMode == 0) + { + writer.WriteLine($"index {_diff.OldHash}..{_diff.NewHash} {_diff.OldMode}"); + writer.WriteLine($"--- a/{_targetFile}"); + } + else + { + writer.WriteLine($"--- a/{_targetFile}"); + } + + writer.WriteLine($"+++ b/{_targetFile}"); + return writer; + } + + private TextDiffLine FindTail(bool revert) + { + var lines = _diff.Lines; + + if (_selection.EndLine < lines.Count - 1) + { + var lastLine = lines[_selection.EndLine]; + if (lastLine.Type == TextDiffLineType.Added || lastLine.Type == TextDiffLineType.Deleted) + { + for (int i = _selection.EndLine + 1; 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) + return line; + } + else + { + if (line.Type == TextDiffLineType.Normal || line.Type == TextDiffLineType.Deleted) + return line; + } + } + } + } + + return null; + } + + private void Append(StreamWriter writer, char prefix, TextDiffLine line) + { + writer.Flush(); + + writer.BaseStream.WriteByte((byte)prefix); + writer.BaseStream.Write(line.RawContent); + writer.BaseStream.WriteByte((byte)'\n'); + + if (line.NoNewLineEndOfFile) + writer.WriteLine("\\ No newline at end of file"); + } + + private bool ProcessIndicator(StreamWriter writer, TextDiffLine indicator, int idx, int ignoreRemoves, int ignoreAdds, bool revert, bool tailed) + { + var lines = _diff.Lines; + var start = _selection.StartLine; + var end = _selection.EndLine; + 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) + { + if (revert) + { + newCount++; + oldCount++; + } + } + else + { + newCount++; + } + + if (i == end && tailed) + { + newCount++; + oldCount++; + } + } + else if (test.Type == TextDiffLineType.Deleted) + { + if (i < start) + { + if (!revert) + { + newCount++; + oldCount++; + } + } + else + { + oldCount++; + } + + if (i == end && tailed) + { + newCount++; + oldCount++; + } + } + } + + if (oldCount == 0 && newCount == 0) + return false; + + writer.WriteLine($"@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); + return true; + } + + private bool ProcessIndicatorForSingleSide(StreamWriter writer, TextDiffLine indicator, int idx, int ignoreRemoves, int ignoreAdds, bool revert, bool tailed) + { + var lines = _diff.Lines; + var start = _selection.StartLine; + var end = _selection.EndLine; + 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 || _isOldSide) + { + if (revert) + { + newCount++; + oldCount++; + } + } + else + { + newCount++; + } + + if (i == end && tailed) + { + newCount++; + oldCount++; + } + } + else if (test.Type == TextDiffLineType.Deleted) + { + if (i < start) + { + if (!revert) + { + newCount++; + oldCount++; + } + } + else + { + if (_isOldSide) + { + oldCount++; + } + else + { + if (!revert) + { + newCount++; + oldCount++; + } + } + } + + if (i == end && tailed) + { + newCount++; + oldCount++; + } + } + } + + if (oldCount == 0 && newCount == 0) + return false; + + writer.WriteLine($"@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); + return true; + } + + private class Selection + { + public int StartLine { get; set; } = 0; + public int EndLine { get; set; } = 0; + public int IgnoredAdds { get; set; } = 0; + public int IgnoredDeletes { get; set; } = 0; + } + + private string _targetFile = null; + private TextDiff _diff = null; + private bool _isCombined = false; + private bool _isOldSide = false; + private Selection _selection = null; + } +} diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs index 021ccc679..12681b4b4 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -1,30 +1,142 @@ -using System.Text.RegularExpressions; +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Web; + +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(); -namespace SourceGit.Models { - public class Remote { private static readonly Regex[] URL_FORMATS = [ - 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$"), + REG_HTTPS(), + REG_GIT(), + REG_SSH1(), + REG_SSH2(), ]; public string Name { get; set; } public string URL { get; set; } + public bool DisableAutoFetch { 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; - public static bool IsSSH(string url) { - if (string.IsNullOrWhiteSpace(url)) return false; + if (URL.StartsWith("http://", StringComparison.Ordinal) || URL.StartsWith("https://", StringComparison.Ordinal)) + { + var trimmed = URL.EndsWith(".git", StringComparison.Ordinal) ? URL.Substring(0, URL.Length - 4) : URL; + var uri = new Uri(trimmed); + if (uri.Port != 80 && uri.Port != 443) + url = $"{uri.Scheme}://{uri.Host}:{uri.Port}{uri.AbsolutePath}"; + else + url = $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}"; + return true; + } - for (int i = 1; i < URL_FORMATS.Length; i++) { - if (URL_FORMATS[i].IsMatch(url)) return true; + var match = REG_TO_VISIT_URL_CAPTURE().Match(URL); + if (match.Success) + { + var host = match.Groups[1].Value; + var supportHTTPS = HTTPSValidator.IsSupported(host); + var scheme = supportHTTPS ? "https" : "http"; + url = $"{scheme}://{host}/{match.Groups[2].Value}"; + return true; } return false; } - public static bool IsValidURL(string url) { - foreach (var fmt in URL_FORMATS) { - if (fmt.IsMatch(url)) return true; + public bool TryGetCreatePullRequestURL(out string url, string mergeBranch) + { + url = null; + + if (!TryGetVisitURL(out var baseURL)) + return false; + + var uri = new Uri(baseURL); + var host = uri.Host; + var encodedBranch = HttpUtility.UrlEncode(mergeBranch); + + if (host.Contains("github.com", StringComparison.Ordinal)) + { + url = $"{baseURL}/compare/{encodedBranch}?expand=1"; + return true; + } + + if (host.Contains("gitlab", StringComparison.Ordinal)) + { + url = $"{baseURL}/-/merge_requests/new?merge_request%5Bsource_branch%5D={encodedBranch}"; + return true; + } + + if (host.Equals("gitee.com", StringComparison.Ordinal)) + { + url = $"{baseURL}/pulls/new?source={encodedBranch}"; + return true; } + + if (host.Equals("bitbucket.org", StringComparison.Ordinal)) + { + url = $"{baseURL}/pull-requests/new?source={encodedBranch}"; + return true; + } + + if (host.Equals("gitea.org", StringComparison.Ordinal)) + { + url = $"{baseURL}/compare/{encodedBranch}"; + return true; + } + + if (host.Contains("azure.com", StringComparison.Ordinal) || + host.Contains("visualstudio.com", StringComparison.Ordinal)) + { + url = $"{baseURL}/pullrequestcreate?sourceRef={encodedBranch}"; + return true; + } + return false; } } diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs new file mode 100644 index 000000000..506e6ded7 --- /dev/null +++ b/src/Models/RepositorySettings.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using Avalonia.Collections; + +namespace SourceGit.Models +{ + public class RepositorySettings + { + public string DefaultRemote + { + get; + set; + } = string.Empty; + + public int PreferredMergeMode + { + get; + set; + } = 0; + + public string ConventionalTypesOverride + { + get; + set; + } = string.Empty; + + public bool EnableRecursiveWhenAutoUpdatingSubmodules + { + get; + set; + } = true; + + public bool AskBeforeAutoUpdatingSubmodules + { + get; + set; + } = false; + + public string PreferredOpenAIService + { + get; + set; + } = "---"; + + public AvaloniaList CommitTemplates + { + get; + set; + } = []; + + public AvaloniaList CustomActions + { + get; + set; + } = []; + + public static RepositorySettings Get(string gitCommonDir) + { + var fileInfo = new FileInfo(Path.Combine(gitCommonDir, "sourcegit.settings")); + var fullpath = fileInfo.FullName; + if (_cache.TryGetValue(fullpath, out var setting)) + return setting; + + if (!File.Exists(fullpath)) + { + setting = new(); + } + else + { + try + { + using var stream = File.OpenRead(fullpath); + setting = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.RepositorySettings); + } + catch + { + setting = new(); + } + } + + // Serialize setting again to make sure there are no unnecessary whitespaces. + Task.Run(() => + { + var formatted = JsonSerializer.Serialize(setting, JsonCodeGen.Default.RepositorySettings); + setting._orgHash = HashContent(formatted); + }); + + setting._file = fullpath; + _cache.Add(fullpath, setting); + return setting; + } + + public void Save() + { + try + { + var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.RepositorySettings); + var hash = HashContent(content); + if (!hash.Equals(_orgHash, StringComparison.Ordinal)) + { + var tmpfile = $"{_file}.tmp"; + File.WriteAllText(tmpfile, content); + File.Move(tmpfile, _file, true); + _orgHash = hash; + } + } + catch + { + // Ignore save errors + } + } + + 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); + } + + private static string HashContent(string source) + { + var hash = MD5.HashData(Encoding.Default.GetBytes(source)); + return Convert.ToHexStringLower(hash); + } + + private static Dictionary _cache = new(); + private string _file = string.Empty; + private string _orgHash = string.Empty; + } +} diff --git a/src/Models/RepositoryStatus.cs b/src/Models/RepositoryStatus.cs new file mode 100644 index 000000000..c7c498ae5 --- /dev/null +++ b/src/Models/RepositoryStatus.cs @@ -0,0 +1,29 @@ +namespace SourceGit.Models +{ + public class RepositoryStatus + { + public string CurrentBranch { get; set; } = string.Empty; + public int Ahead { get; set; } = 0; + public int Behind { get; set; } = 0; + public int LocalChanges { get; set; } = 0; + + public bool IsTrackingStatusVisible + { + get + { + return Ahead > 0 || Behind > 0; + } + } + + public string TrackingDescription + { + get + { + if (Ahead > 0) + return Behind > 0 ? $"{Ahead}↑ {Behind}↓" : $"{Ahead}↑"; + + return Behind > 0 ? $"{Behind}↓" : string.Empty; + } + } + } +} diff --git a/src/Models/RepositoryUIStates.cs b/src/Models/RepositoryUIStates.cs new file mode 100644 index 000000000..723fe900b --- /dev/null +++ b/src/Models/RepositoryUIStates.cs @@ -0,0 +1,514 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using Avalonia.Collections; + +namespace SourceGit.Models +{ + public class RepositoryUIStates + { + public HistoryShowFlags HistoryShowFlags + { + get; + set; + } = HistoryShowFlags.None; + + public bool IsAuthorColumnVisibleInHistory + { + get; + set; + } = true; + + public bool IsSHAColumnVisibleInHistory + { + get; + set; + } = true; + + public bool IsAuthorTimeColumnVisibleInHistory + { + get; + set; + } = false; + + public bool IsCommitTimeColumnVisibleInHistory + { + get; + set; + } = true; + + public double AuthorColumnWidth + { + get; + set; + } = 120; + + public bool EnableTopoOrderInHistory + { + get; + set; + } = false; + + public CommitGraphHighlighting GraphHighlighting + { + get; + set; + } = CommitGraphHighlighting.All; + + public BranchSortMode LocalBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public BranchSortMode RemoteBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public bool ShowTagsAsTree + { + get; + set; + } = false; + + public TagSortMode TagSortMode + { + get; + set; + } = TagSortMode.CreatorDate; + + public bool ShowSubmodulesAsTree + { + get; + set; + } = false; + + public bool IncludeUntrackedInLocalChanges + { + get; + set; + } = true; + + public bool EnableForceOnFetch + { + get; + set; + } = false; + + public bool FetchAllRemotes + { + 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 CreateAnnotatedTag + { + get; + set; + } = true; + + public bool PushToRemoteWhenCreateTag + { + get; + set; + } = true; + + public bool PushToRemoteWhenDeleteTag + { + get; + set; + } = false; + + public bool CheckoutBranchOnCreateBranch + { + get; + set; + } = true; + + public bool EnableSignOffForCommit + { + get; + set; + } = false; + + public bool NoVerifyOnCommit + { + get; + set; + } = false; + + public bool IncludeUntrackedWhenStash + { + get; + set; + } = true; + + public bool OnlyStagedWhenStash + { + get; + set; + } = false; + + public int ChangesAfterStashing + { + get; + set; + } = 0; + + 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 string LastCommitMessage + { + get; + set; + } = string.Empty; + + public AvaloniaList HistoryFilters + { + get; + set; + } = []; + + public bool IsHistoryFiltersCollapsed + { + get; + set; + } = false; + + public List RecentCommitMessages + { + get; + set; + } = []; + + public static RepositoryUIStates Load(string gitDir) + { + var fileInfo = new FileInfo(Path.Combine(gitDir, "sourcegit.uistates")); + var fullpath = fileInfo.FullName; + + RepositoryUIStates states; + if (!File.Exists(fullpath)) + { + states = new RepositoryUIStates(); + } + else + { + try + { + using var stream = File.OpenRead(fullpath); + states = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.RepositoryUIStates); + } + catch + { + states = new RepositoryUIStates(); + } + } + + states._file = fullpath; + return states; + } + + public void Save() + { + try + { + var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.RepositoryUIStates); + var tmpfile = $"{_file}.tmp"; + File.WriteAllText(tmpfile, content); + File.Move(tmpfile, _file, true); + } + catch + { + // Ignore save errors + } + } + + public FilterMode GetHistoryFilterMode(string pattern = null) + { + if (string.IsNullOrEmpty(pattern)) + return HistoryFilters.Count == 0 ? FilterMode.None : HistoryFilters[0].Mode; + + foreach (var filter in HistoryFilters) + { + if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + return filter.Mode; + } + + return FilterMode.None; + } + + public Dictionary GetHistoryFiltersMap() + { + var map = new Dictionary(); + foreach (var filter in HistoryFilters) + map.Add(filter.Pattern, filter.Mode); + return map; + } + + public bool UpdateHistoryFilters(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 HistoryFilters) + { + if (filter.Mode != mode) + { + clear = true; + break; + } + } + + if (clear) + { + HistoryFilters.Clear(); + HistoryFilters.Add(new HistoryFilter(pattern, type, mode)); + return true; + } + } + else + { + for (int i = 0; i < HistoryFilters.Count; i++) + { + var filter = HistoryFilters[i]; + if (filter.Type == type && filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + { + HistoryFilters.RemoveAt(i); + return true; + } + } + + return false; + } + + foreach (var filter in HistoryFilters) + { + if (filter.Type != type) + continue; + + if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + return false; + } + + HistoryFilters.Add(new HistoryFilter(pattern, type, mode)); + return true; + } + + public void RemoveHistoryFilter(string pattern, FilterType type) + { + foreach (var filter in HistoryFilters) + { + if (filter.Type == type && filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + { + HistoryFilters.Remove(filter); + break; + } + } + } + + public void RenameBranchFilter(string oldName, string newName) + { + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.LocalBranch && + filter.Pattern.Equals(oldName, StringComparison.Ordinal)) + { + filter.Pattern = $"refs/heads/{newName}"; + break; + } + } + } + + public void RemoveBranchFiltersByPrefix(string pattern) + { + var dirty = new List(); + var prefix = $"{pattern}/"; + + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.Tag) + continue; + + if (filter.Pattern.StartsWith(prefix, StringComparison.Ordinal)) + dirty.Add(filter); + } + + foreach (var filter in dirty) + HistoryFilters.Remove(filter); + } + + public string BuildHistoryParams(string gitDir) + { + var builder = new StringBuilder(); + + if (EnableTopoOrderInHistory) + builder.Append("--topo-order "); + else + builder.Append("--date-order "); + + if (HistoryShowFlags.HasFlag(HistoryShowFlags.Reflog)) + builder.Append("--reflog "); + + if (HistoryShowFlags.HasFlag(HistoryShowFlags.FirstParentOnly)) + builder.Append("--first-parent "); + + if (HistoryShowFlags.HasFlag(HistoryShowFlags.SimplifyByDecoration)) + builder.Append("--simplify-by-decoration "); + + var mode = GetHistoryFilterMode(); + if (mode == FilterMode.None) + builder.Append("--branches --remotes --tags HEAD"); + else if (mode == FilterMode.Included) + BuildHistoryParamsForIncluded(builder); + else + BuildHistoryParamsForExcluded(builder, gitDir); + + return builder.ToString(); + } + + private void BuildHistoryParamsForIncluded(StringBuilder builder) + { + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.LocalBranch) + builder.Append(filter.Pattern).Append(' '); + else if (filter.Type == FilterType.LocalBranchFolder) + builder.Append($"--branches={filter.Pattern.AsSpan(11)}/* "); + else if (filter.Type == FilterType.RemoteBranch) + builder.Append(filter.Pattern).Append(' '); + else if (filter.Type == FilterType.RemoteBranchFolder) + builder.Append($"--remotes={filter.Pattern.AsSpan(13)}/* "); + else if (filter.Type == FilterType.Tag) + builder.Append($"refs/tags/{filter.Pattern} "); + } + } + + private void BuildHistoryParamsForExcluded(StringBuilder builder, string gitDir) + { + var excludedBranches = new List(); + var excludedRemotes = new List(); + var excludedTags = new List(); + foreach (var filter in HistoryFilters) + { + if (filter.Type == FilterType.LocalBranch) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}\" --decorate-refs-exclude=\"{filter.Pattern}\" "); + else if (filter.Type == FilterType.LocalBranchFolder) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\" "); + else if (filter.Type == FilterType.RemoteBranch) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}\" --decorate-refs-exclude=\"{filter.Pattern}\" "); + else if (filter.Type == FilterType.RemoteBranchFolder) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\" "); + else if (filter.Type == FilterType.Tag) + excludedTags.Add($"--exclude=\"{filter.Pattern}\" --decorate-refs-exclude=\"refs/tags/{filter.Pattern}\" "); + } + + foreach (var b in excludedBranches) + builder.Append(b); + + builder.Append("--branches "); + + var isInProgress = File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD")) || + Directory.Exists(Path.Combine(gitDir, "rebase-merge")) || + Directory.Exists(Path.Combine(gitDir, "rebase-apply")) || + File.Exists(Path.Combine(gitDir, "REVERT_HEAD")) || + File.Exists(Path.Combine(gitDir, "MERGE_HEAD")); + if (isInProgress) + builder.Append("HEAD "); + + foreach (var r in excludedRemotes) + builder.Append(r); + + builder.Append("--exclude=origin/HEAD --remotes "); + + foreach (var t in excludedTags) + builder.Append(t); + + builder.Append("--tags "); + } + + public void AddRecentCommitMessage(string message) + { + message = message.Trim().ReplaceLineEndings("\n"); + var existIdx = RecentCommitMessages.IndexOf(message); + if (existIdx == 0) + return; + + if (existIdx > 0) + { + RecentCommitMessages.RemoveAt(existIdx); + RecentCommitMessages.Insert(0, message); + return; + } + + if (RecentCommitMessages.Count > 9) + RecentCommitMessages.RemoveRange(9, RecentCommitMessages.Count - 9); + + RecentCommitMessages.Insert(0, message); + } + + private string _file = string.Empty; + } +} diff --git a/src/Models/ResetMode.cs b/src/Models/ResetMode.cs new file mode 100644 index 000000000..1c84dc8e0 --- /dev/null +++ b/src/Models/ResetMode.cs @@ -0,0 +1,22 @@ +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class ResetMode(string n, string d, string a, string k, IBrush b) + { + 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), + ]; + + public string Name { get; set; } = n; + public string Desc { get; set; } = d; + public string Arg { get; set; } = a; + public string Key { get; set; } = k; + public IBrush Color { get; set; } = b; + } +} diff --git a/src/Models/RevisionFile.cs b/src/Models/RevisionFile.cs index 9dd495456..9a9fe7d20 100644 --- a/src/Models/RevisionFile.cs +++ b/src/Models/RevisionFile.cs @@ -1,17 +1,44 @@ -namespace SourceGit.Models { - public class RevisionBinaryFile { +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 class RevisionTextFile + { public string FileName { get; set; } public string Content { get; set; } } - public class RevisionLFSObject { + public class RevisionLFSObject + { public LFSObject Object { get; set; } } - public class RevisionSubmodule { - public string SHA { get; set; } + public class RevisionSubmodule + { + public Commit Commit { get; set; } = null; + public CommitFullMessage FullMessage { get; set; } = null; + public int UncommittedChanges { get; set; } = 0; } } diff --git a/src/Models/ScanDir.cs b/src/Models/ScanDir.cs new file mode 100644 index 000000000..eb78a79c3 --- /dev/null +++ b/src/Models/ScanDir.cs @@ -0,0 +1,8 @@ +namespace SourceGit.Models +{ + public record ScanDir(string path, string desc) + { + public string Path { get; set; } = path; + public string Desc { get; set; } = desc; + } +} diff --git a/src/Models/SelfUpdate.cs b/src/Models/SelfUpdate.cs new file mode 100644 index 000000000..9cf95a148 --- /dev/null +++ b/src/Models/SelfUpdate.cs @@ -0,0 +1,58 @@ +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("published_at")] + public DateTime PublishedAt { get; set; } + + [JsonPropertyName("body")] + public string Body { get; set; } + + [JsonIgnore] + public System.Version CurrentVersion { get; } + + [JsonIgnore] + public string CurrentVersionStr => $"v{CurrentVersion.Major}.{CurrentVersion.Minor:D2}"; + + [JsonIgnore] + public bool IsNewVersion => CurrentVersion.CompareTo(new System.Version(TagName.Substring(1))) < 0; + + [JsonIgnore] + public string ReleaseDateStr => DateTimeFormat.Format(PublishedAt, true); + + public Version() + { + var assembly = Assembly.GetExecutingAssembly().GetName(); + CurrentVersion = assembly.Version ?? new System.Version(); + } + } + + 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 000000000..e8e585475 --- /dev/null +++ b/src/Models/ShellOrTerminal.cs @@ -0,0 +1,81 @@ +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 string Args { 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", "-d .") + }; + } + else if (OperatingSystem.IsMacOS()) + { + Supported = new List() + { + new ShellOrTerminal("mac-terminal", "Terminal", "Terminal"), + new ShellOrTerminal("iterm2", "iTerm", "iTerm"), + new ShellOrTerminal("warp", "Warp", "Warp"), + new ShellOrTerminal("ghostty", "Ghostty", "Ghostty"), + new ShellOrTerminal("kitty", "kitty", "kitty"), + new ShellOrTerminal("otty", "Otty", "Otty"), + new ShellOrTerminal("cmux", "cmux", "cmux"), + new ShellOrTerminal("mux0", "mux0", "mux0"), + }; + } + 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", "start --cwd ."), + new ShellOrTerminal("ptyxis", "Ptyxis", "ptyxis", "--new-window --working-directory=."), + new ShellOrTerminal("ghostty", "Ghostty", "ghostty"), + new ShellOrTerminal("kitty", "kitty", "kitty"), + new ShellOrTerminal("custom", "Custom", ""), + }; + } + } + + public ShellOrTerminal(string type, string name, string exec, string args = null) + { + Type = type; + Name = name; + Exec = exec; + Args = args; + } + } +} diff --git a/src/Models/Stash.cs b/src/Models/Stash.cs index 8b2973b9e..93439a40a 100644 --- a/src/Models/Stash.cs +++ b/src/Models/Stash.cs @@ -1,15 +1,15 @@ -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.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 Subject => Message.Split('\n', 2)[0].Trim(); + public string UntrackedParent => EmptyTreeHash.Guess(SHA); } } diff --git a/src/Models/Statistics.cs b/src/Models/Statistics.cs index 7f4e48099..4ef9927fc 100644 --- a/src/Models/Statistics.cs +++ b/src/Models/Statistics.cs @@ -1,123 +1,246 @@ using System; using System.Collections.Generic; +using System.Globalization; + +namespace SourceGit.Models +{ + public enum StatisticsMode + { + All = 0, + ThisMonth, + ThisWeek, + } -namespace SourceGit.Models { - public class StatisticsSample { - public string Name { get; set; } - public int Count { get; set; } + 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 Samples { get; set; } = new List(); - public List ByCommitter { get; set; } = new List(); + public class StatisticsSamples + { + public DateTime StartTime { get; } + public DateTime EndTime { get; } + public int Count { get; } + public int MaxValue { get; } + public bool HasSpecialUser => _user != null; + + public StatisticsSamples(StatisticsMode mode, DateTime start, DateTime end, Dictionary all, int maxValue) + { + _mode = mode; + _all = all; + + StartTime = start; + EndTime = end; + + if (maxValue < 8) + MaxValue = 8; + else if (maxValue < 16) + MaxValue = 16; + else if (maxValue < 24) + MaxValue = 24; + else + MaxValue = (int)(Math.Floor(maxValue / 6.0) * 8.0); + + if (maxValue == 0) + Count = 0; + else + Count = mode switch + { + StatisticsMode.All => (end.Year - start.Year) * 12 + (end.Month - start.Month) + 1, + _ => all.Count, + }; + } - public void AddCommit(int index, string committer) { - Total++; - Samples[index].Count++; - - if (_mapByCommitter.ContainsKey(committer)) { - _mapByCommitter[committer].Count++; - } else { - var sample = new StatisticsSample() { Name = committer, Count = 1 }; - _mapByCommitter.Add(committer, sample); - ByCommitter.Add(sample); - } + public void WithUser(Dictionary user) + { + _user = user; + } + + public (string, int, int) GetSample(DateTime time) + { + var label = _mode switch + { + StatisticsMode.All => time.ToString("yyyy/MM"), + StatisticsMode.ThisMonth => time.ToString("MM/dd"), + _ => s_weekdays[(int)time.DayOfWeek] + }; + + var total = _all.GetValueOrDefault(time, 0); + var user = _user?.GetValueOrDefault(time, 0) ?? 0; + return (label, total, user); } - public void Complete() { - ByCommitter.Sort((l, r) => r.Count - l.Count); - _mapByCommitter.Clear(); + public DateTime NextSampleTime(DateTime time) + { + if (_mode != StatisticsMode.All) + return time.AddDays(-1); + + if (time.Month == 1) + return new DateTime(time.Year - 1, 12, 1).ToLocalTime().Date; + + return new DateTime(time.Year, time.Month - 1, 1).ToLocalTime().Date; } - private Dictionary _mapByCommitter = new Dictionary(); + private static readonly string[] s_weekdays = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; + private StatisticsMode _mode; + private Dictionary _all; + private Dictionary _user; } - public class Statistics { - public StatisticsReport Year { get; set; } = new StatisticsReport(); - public StatisticsReport Month { get; set; } = new StatisticsReport(); - public StatisticsReport Week { get; set; } = new StatisticsReport(); - - public Statistics() { - _utcStart = DateTime.UnixEpoch; - _today = DateTime.Today; - _thisWeekStart = _today.AddSeconds(-(int)_today.DayOfWeek * 3600 * 24 - _today.Hour * 3600 - _today.Minute * 60 - _today.Second); - _thisWeekEnd = _thisWeekStart.AddDays(7); - - string[] monthNames = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - - for (int i = 0; i < monthNames.Length; i++) { - Year.Samples.Add(new StatisticsSample { - Name = monthNames[i], - Count = 0, - }); + public class StatisticsReport + { + public StatisticsMode Mode { get; } + public int Total { get; set; } = 0; + public List Authors { get; set; } = new(); + + public StatisticsReport(StatisticsMode mode, DateTime start) + { + Mode = mode; + + if (mode == StatisticsMode.ThisWeek) + { + _minSampleTime = start; + _maxSampleTime = start.AddDays(6); + + for (int i = 0; i < 7; i++) + _all.Add(start.AddDays(i), 0); + } + else if (mode == StatisticsMode.ThisMonth) + { + var now = DateTime.Now; + var maxDays = DateTime.DaysInMonth(now.Year, now.Month); + _minSampleTime = start; + _maxSampleTime = start.AddDays(maxDays - 1); + for (int i = 0; i < maxDays; i++) + _all.Add(start.AddDays(i), 0); } + } - var monthDays = DateTime.DaysInMonth(_today.Year, _today.Month); - for (int i = 0; i < monthDays; i++) { - Month.Samples.Add(new StatisticsSample { - Name = $"{i + 1}", - Count = 0, - }); + 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().Date; + + if (normalized < _minSampleTime) + _minSampleTime = normalized; + if (normalized > _maxSampleTime) + _maxSampleTime = normalized; } - string[] weekDayNames = [ - "SUN", - "MON", - "TUE", - "WED", - "THU", - "FRI", - "SAT", - ]; - - for (int i = 0; i < weekDayNames.Length; i++) { - Week.Samples.Add(new StatisticsSample { - Name = weekDayNames[i], - Count = 0, - }); + if (_all.TryGetValue(normalized, out var vs)) + _all[normalized] = vs + 1; + else + _all.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 + { + var added = new Dictionary(); + added.Add(normalized, 1); + _mapUserSamples.Add(author, added); } } - public string Since() { - return _today.ToString("yyyy-01-01 00:00:00"); - } + public void Complete() + { + foreach (var kv in _mapUsers) + Authors.Add(new StatisticsAuthor(kv.Key, kv.Value)); - public void AddCommit(string committer, double timestamp) { - var time = _utcStart.AddSeconds(timestamp).ToLocalTime(); - if (time.CompareTo(_thisWeekStart) >= 0 && time.CompareTo(_thisWeekEnd) < 0) { - Week.AddCommit((int)time.DayOfWeek, committer); + _mapUsers.Clear(); + Authors.Sort((l, r) => r.Count - l.Count); + + foreach (var kv in _all) + { + if (kv.Value > _maxSampleValue) + _maxSampleValue = kv.Value; } + } + + public StatisticsSamples GetSamples(StatisticsAuthor withUser) + { + var samples = new StatisticsSamples(Mode, _minSampleTime, _maxSampleTime, _all, _maxSampleValue); + if (withUser != null && _mapUserSamples.TryGetValue(withUser.User, out var userSamples)) + samples.WithUser(userSamples); + return samples; + } - if (time.Month == _today.Month) { - Month.AddCommit(time.Day - 1, committer); + private DateTime _minSampleTime = DateTime.MaxValue; + private DateTime _maxSampleTime = DateTime.MinValue; + private int _maxSampleValue = 0; + private Dictionary _all = new(); + private Dictionary _mapUsers = new(); + private Dictionary> _mapUserSamples = new(); + } + + 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('±'); + var email = author.Substring(emailIdx + 1).ToLower(CultureInfo.CurrentCulture); + if (!_users.TryGetValue(email, out var user)) + { + user = User.FindOrAdd(author); + _users.Add(email, user); } - Year.AddCommit(time.Month - 1, committer); + var time = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().Date; + if (time >= _thisWeekStart) + Week.AddCommit(time, user); + + if (time >= _thisMonthStart) + Month.AddCommit(time, user); + + All.AddCommit(time, user); } - public void Complete() { - Year.Complete(); + public void Complete() + { + _users.Clear(); + + All.Complete(); Month.Complete(); Week.Complete(); } - private DateTime _utcStart; - private DateTime _today; - private DateTime _thisWeekStart; - private DateTime _thisWeekEnd; + private readonly DateTime _thisMonthStart; + private readonly DateTime _thisWeekStart; + private readonly Dictionary _users = new(); } } diff --git a/src/Models/Submodule.cs b/src/Models/Submodule.cs new file mode 100644 index 000000000..17080713f --- /dev/null +++ b/src/Models/Submodule.cs @@ -0,0 +1,21 @@ +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 string Branch { 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 6f51a06e4..5e3ed7b41 100644 --- a/src/Models/Tag.cs +++ b/src/Models/Tag.cs @@ -1,7 +1,18 @@ -namespace SourceGit.Models { - public class Tag { - public string Name { get; set; } - public string SHA { get; set; } - public bool IsFiltered { get; set; } +namespace SourceGit.Models +{ + public enum TagSortMode + { + CreatorDate = 0, + Name, + } + + public class Tag + { + public string Name { get; set; } = string.Empty; + public bool IsAnnotated { get; set; } = false; + public string SHA { get; set; } = string.Empty; + public User Creator { get; set; } = null; + public ulong CreatorDate { get; set; } = 0; + public string Message { get; set; } = string.Empty; } } diff --git a/src/Models/TemplateEngine.cs b/src/Models/TemplateEngine.cs new file mode 100644 index 000000000..12280006e --- /dev/null +++ b/src/Models/TemplateEngine.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.IO; +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 >= '0' and <= '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 { } c) + { + if (esc) + { + esc = false; + continue; + } + switch (c) + { + case ESCAPE: + // allow to escape only \ and $ + if (Peek() is ESCAPE or 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; + var nameStart = _pos; + while (Next() is { } c) + { + // name character, continue advancing + if (IsNameChar(c)) + continue; + + var nameEnd = _pos - 1; + // not a name character but name is empty, cancel + if (nameStart >= nameEnd) + return null; + var name = new string(_chars, nameStart, nameEnd - nameStart); + + 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 { } 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 { } c) + { + if (esc) + { + esc = false; + continue; + } + switch (c) + { + case ESCAPE: + // allow to escape only right-brace + 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() { + {"branch_name", GetBranchName}, + {"files_num", GetFilesCount}, + {"files", GetFiles}, + {"pure_files", GetPureFiles}, + }; + + 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 static string GetPureFiles(Context context) + { + var names = new List(); + foreach (var c in context.changes) + names.Add(Path.GetFileName(c.Path)); + return string.Join(", ", names); + } + + private delegate string VariableSliceGetter(Context context, int count); + + private static readonly IReadOnlyDictionary s_slicedVariables = new Dictionary() { + {"files", GetFilesSliced}, + {"pure_files", GetPureFilesSliced} + }; + + 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(); + } + + private static string GetPureFilesSliced(Context context, int count) + { + var sb = new StringBuilder(); + var names = new List(); + var max = Math.Min(count, context.changes.Count); + for (int i = 0; i < max; i++) + names.Add(Path.GetFileName(context.changes[i].Path)); + + sb.AppendJoin(", ", names); + if (max < context.changes.Count) + sb.Append($" and {context.changes.Count - max} other files"); + + return sb.ToString(); + } + } +} diff --git a/src/Models/TextInlineChange.cs b/src/Models/TextInlineChange.cs index 6ce4c3740..e49a6d032 100644 --- a/src/Models/TextInlineChange.cs +++ b/src/Models/TextInlineChange.cs @@ -1,27 +1,25 @@ using System.Collections.Generic; - -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; } - - class Chunk { - public int Hash; +using System.Globalization; + +namespace SourceGit.Models +{ + public class TextInlineChange(int dp, int dc, int ap, int ac) + { + public int DeletedStart { get; set; } = dp; + public int DeletedCount { get; set; } = dc; + public int AddedStart { get; set; } = ap; + public int AddedCount { get; set; } = ac; + + private class Chunk(int hash, int start, int size) + { + public readonly int Hash = hash; + public readonly int Start = start; + public readonly int Size = size; public bool Modified; - public int Start; - public int Size; - - public Chunk(int hash, int start, int size) { - Hash = hash; - Modified = false; - Start = start; - Size = size; - } } - enum Edit { + private enum Edit + { None, DeletedRight, DeletedLeft, @@ -29,7 +27,15 @@ enum Edit { AddedLeft, } - class EditResult { + private enum CharCategory : byte + { + Other, // default: whitespace, control, punctuation, symbols, etc. + Letter, // Ll/Lu/Lt/Lm + digit: ASCII and euro letters (latin, greek, cyrillic, etc.) + OtherLetter, // Lo: CJK, hiragana, katakana, hangul, Thai, Arabic, etc. + } + + private class EditResult + { public Edit State; public int DeleteStart; public int DeleteEnd; @@ -37,14 +43,8 @@ class EditResult { public int AddEnd; } - 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) { + public static List Compare(string oldValue, string newValue) + { var hashes = new Dictionary(); var chunksOld = MakeChunks(hashes, oldValue); var chunksNew = MakeChunks(hashes, newValue); @@ -58,9 +58,11 @@ public static List Compare(string oldValue, string newValue) { var ret = new List(); var posOld = 0; var posNew = 0; - var last = null as TextInlineChange; - do { - while (posOld < sizeOld && posNew < sizeNew && !chunksOld[posOld].Modified && !chunksNew[posNew].Modified) { + TextInlineChange last = null; + do + { + while (posOld < sizeOld && posNew < sizeNew && !chunksOld[posOld].Modified && !chunksNew[posNew].Modified) + { posOld++; posNew++; } @@ -69,20 +71,25 @@ public static List Compare(string oldValue, string newValue) { 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 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; @@ -96,62 +103,89 @@ public static List Compare(string oldValue, string newValue) { 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()); + if (size == 0) + return chunks; + + var prev = GetCategory(text[0]); - for (int i = 0; i < size; i++) { + for (var i = 1; i < size; i++) + { var ch = text[i]; - 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; + var category = GetCategory(ch); + if (prev != category || category == CharCategory.Other) + { + AddChunk(chunks, hashes, text[start..i], start); + start = i; } + prev = category; } - if (start < size) AddChunk(chunks, hashes, text.Substring(start), start); + if (start < size) + AddChunk(chunks, hashes, text[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; @@ -163,38 +197,48 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, 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; @@ -205,36 +249,46 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, } } - 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; @@ -250,15 +304,42 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, return rs; } - private static void AddChunk(List chunks, Dictionary hashes, string data, int start) { - int hash; - if (hashes.TryGetValue(data, out hash)) { - chunks.Add(new Chunk(hash, start, data.Length)); - } else { + private static void AddChunk(List chunks, Dictionary hashes, string data, int start) + { + if (!hashes.TryGetValue(data, out var hash)) + { hash = hashes.Count; hashes.Add(data, hash); - chunks.Add(new Chunk(hash, start, data.Length)); } + chunks.Add(new Chunk(hash, start, data.Length)); } + + private static CharCategory[] BuildCategoryCache() + { + // Pre-compute category for all char values. + // All entries default to Other (0). + var cache = new CharCategory[65536]; + for (int i = 0; i < 65536; i++) + { + var ch = (char)i; + // Unicode Lo: CJK, hiragana, katakana, hangul, Thai, Arabic, Hebrew, etc. + // → group consecutive chars into one chunk (no space delimiter in these languages) + if (char.GetUnicodeCategory(ch) == UnicodeCategory.OtherLetter) + cache[i] = CharCategory.OtherLetter; + + // Unicode Ll/Lu/Lt/Lm + digit: latin, greek, cyrillic and their diacritic variants + // → group consecutive chars into one chunk (words in space-delimited languages) + else if (char.IsLetterOrDigit(ch)) + cache[i] = CharCategory.Letter; + + // everything else (whitespace, control, punctuation, symbols) → Other (default) + } + + return cache; + } + + private static CharCategory GetCategory(char ch) => s_charCategoryCache[ch]; + + private static readonly CharCategory[] s_charCategoryCache = BuildCategoryCache(); } } diff --git a/src/Models/TextMateHelper.cs b/src/Models/TextMateHelper.cs new file mode 100644 index 000000000..5bb50da69 --- /dev/null +++ b/src/Models/TextMateHelper.cs @@ -0,0 +1,153 @@ +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"), + new ExtraGrammar("source.vue", [".vue"], "vue.json"), + ]; + + private static readonly Dictionary s_cachedRawGrammars = new(); + + public static string GetScope(string file, RegistryOptions reg) + { + var extension = Path.GetExtension(file); + if (extension == ".h") + extension = ".cpp"; + else if (extension is ".resx" or ".plist" or ".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) + { + if (string.IsNullOrEmpty(scopeName)) + return null; + + if (s_cachedRawGrammars.TryGetValue(scopeName, out var cached)) + return cached; + + 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 + { + var raw = GrammarReader.ReadGrammarSync(new StreamReader(asset)); + s_cachedRawGrammars.Add(scopeName, raw); + return raw; + } + catch + { + break; + } + } + } + + var fallback = reg.GetGrammar(scopeName); + s_cachedRawGrammars.Add(scopeName, fallback); + return fallback; + } + + 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 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); + + public IRawTheme LoadTheme(ThemeName name) + { + if (s_cachedTheme.TryGetValue(name, out var cached)) + return cached; + + var loaded = _backend.LoadTheme(name); + s_cachedTheme.Add(name, loaded); + return loaded; + } + + private static readonly Dictionary s_cachedTheme = new(); + 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(scope); + GC.Collect(); + } + } + } + } +} diff --git a/src/Models/ThemeOverrides.cs b/src/Models/ThemeOverrides.cs new file mode 100644 index 000000000..c80576dc8 --- /dev/null +++ b/src/Models/ThemeOverrides.cs @@ -0,0 +1,13 @@ +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 e5ed39ef9..a1b984a04 100644 --- a/src/Models/User.cs +++ b/src/Models/User.cs @@ -1,36 +1,38 @@ -using System.Collections.Generic; +using System.Collections.Concurrent; -namespace SourceGit.Models { - 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(string.Empty); 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(string data) + { + var parts = data.Split('±', 2); + if (parts.Length < 2) + { + Email = data; + } + else + { + Name = parts[0]; + Email = parts[1]; + } } - public override int GetHashCode() { - return base.GetHashCode(); + public static User FindOrAdd(string data) + { + return _caches.GetOrAdd(data, key => new User(key)); } - public static User FindOrAdd(string data) { - if (Caches.ContainsKey(data)) { - return Caches[data]; - } else { - var nameEndIdx = data.IndexOf('<'); - var name = nameEndIdx >= 2 ? data.Substring(0, nameEndIdx - 1) : string.Empty; - var email = data.Substring(nameEndIdx + 1); - - User user = new User() { Name = name, Email = email }; - Caches.Add(data, user); - return user; - } + public override string ToString() + { + return $"{Name} <{Email}>"; } + + private static ConcurrentDictionary _caches = new ConcurrentDictionary(); } } diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index 74441dc70..ce18fe4be 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -1,167 +1,364 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; -namespace SourceGit.Models { - public interface IRepository { - string FullPath { get; set; } - string GitDir { get; set; } - - void RefreshBranches(); - void RefreshTags(); - void RefreshCommits(); - void RefreshSubmodules(); - void RefreshWorkingCopyChanges(); - void RefreshStashes(); - } +namespace SourceGit.Models +{ + public class Watcher : IDisposable + { + public class LockContext : IDisposable + { + public LockContext(Watcher target) + { + _target = target; + Interlocked.Increment(ref _target._lockCount); + } + + public void Dispose() + { + Interlocked.Decrement(ref _target._lockCount); + } - public class Watcher : IDisposable { - public Watcher(IRepository repo) { + private Watcher _target; + } + + public Watcher(IRepository repo, string fullpath, string gitDir) + { _repo = repo; + _root = new DirectoryInfo(fullpath).FullName; + _watchers = new List(); + + var testGitDir = new DirectoryInfo(Path.Combine(fullpath, ".git")).FullName; + var desiredDir = new DirectoryInfo(gitDir).FullName; + if (testGitDir.Equals(desiredDir, StringComparison.Ordinal)) + { + var combined = new FileSystemWatcher(); + combined.Path = fullpath; + combined.Filter = "*"; + combined.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; + combined.IncludeSubdirectories = true; + combined.Created += OnRepositoryChanged; + combined.Renamed += OnRepositoryChanged; + combined.Changed += OnRepositoryChanged; + combined.Deleted += OnRepositoryChanged; + combined.EnableRaisingEvents = false; + + _watchers.Add(combined); + } + else + { + var wc = new FileSystemWatcher(); + wc.Path = fullpath; + wc.Filter = "*"; + wc.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; + wc.IncludeSubdirectories = true; + wc.Created += OnWorkingCopyChanged; + wc.Renamed += OnWorkingCopyChanged; + wc.Changed += OnWorkingCopyChanged; + wc.Deleted += OnWorkingCopyChanged; + wc.EnableRaisingEvents = false; + + var git = new FileSystemWatcher(); + git.Path = gitDir; + git.Filter = "*"; + git.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; + git.IncludeSubdirectories = true; + git.Created += OnGitDirChanged; + git.Renamed += OnGitDirChanged; + git.Changed += OnGitDirChanged; + git.Deleted += OnGitDirChanged; + git.EnableRaisingEvents = false; - _wcWatcher = new FileSystemWatcher(); - _wcWatcher.Path = _repo.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; - - _repoWatcher = new FileSystemWatcher(); - _repoWatcher.Path = _repo.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; + _watchers.Add(wc); + _watchers.Add(git); + } _timer = new Timer(Tick, null, 100, 100); + + // Starts filesystem watchers in another thread to avoid UI blocking + Task.Run(() => + { + try + { + foreach (var watcher in _watchers) + watcher.EnableRaisingEvents = true; + } + catch + { + // Ignore exceptions. This may occur while `Dispose` is called. + } + }); } - public void SetEnabled(bool enabled) { - if (enabled) { - if (_lockCount > 0) _lockCount--; - } else { - _lockCount++; - } + public IDisposable Lock() + { + return new LockContext(this); + } + + public void MarkBranchUpdated() + { + Interlocked.Exchange(ref _updateBranch, 0); + Interlocked.Exchange(ref _updateWC, 0); + } + + public void MarkTagUpdated() + { + Interlocked.Exchange(ref _updateTags, 0); + } + + public void MarkWorkingCopyUpdated() + { + Interlocked.Exchange(ref _updateWC, 0); } - public void MarkWorkingCopyRefreshed() { - _updateWC = 0; + public void MarkStashUpdated() + { + Interlocked.Exchange(ref _updateStashes, 0); } - public void Dispose() { - _repoWatcher.EnableRaisingEvents = false; - _repoWatcher.Created -= OnRepositoryChanged; - _repoWatcher.Renamed -= OnRepositoryChanged; - _repoWatcher.Changed -= OnRepositoryChanged; - _repoWatcher.Deleted -= OnRepositoryChanged; - _repoWatcher.Dispose(); - _repoWatcher = null; + public void MarkSubmodulesUpdated() + { + Interlocked.Exchange(ref _updateSubmodules, 0); + } - _wcWatcher.EnableRaisingEvents = false; - _wcWatcher.Created -= OnWorkingCopyChanged; - _wcWatcher.Renamed -= OnWorkingCopyChanged; - _wcWatcher.Changed -= OnWorkingCopyChanged; - _wcWatcher.Deleted -= OnWorkingCopyChanged; - _wcWatcher.Dispose(); - _wcWatcher = null; + public void Dispose() + { + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + _watchers.Clear(); _timer.Dispose(); _timer = null; } - private void Tick(object sender) { - if (_lockCount > 0) return; + private void Tick(object sender) + { + if (Interlocked.Read(ref _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(); - _repo.RefreshBranches(); - _repo.RefreshCommits(); - }); - } else { - Task.Run(() => { - _repo.RefreshBranches(); - _repo.RefreshCommits(); - }); - } + var refreshCommits = false; + var refreshSubmodules = false; + var refreshWC = false; - Task.Run(_repo.RefreshWorkingCopyChanges); + var oldUpdateBranch = Interlocked.Exchange(ref _updateBranch, -1); + if (oldUpdateBranch > 0) + { + if (now > oldUpdateBranch) + { + refreshCommits = true; + refreshSubmodules = _repo.MayHaveSubmodules(); + refreshWC = true; + + _repo.RefreshBranches(); + _repo.RefreshWorktrees(); + } + else + { + Interlocked.CompareExchange(ref _updateBranch, oldUpdateBranch, -1); + } } - if (_updateWC > 0 && now > _updateWC) { - _updateWC = 0; - Task.Run(_repo.RefreshWorkingCopyChanges); + if (refreshWC) + { + Interlocked.Exchange(ref _updateWC, -1); + _repo.RefreshWorkingCopyChanges(); + } + else + { + var oldUpdateWC = Interlocked.Exchange(ref _updateWC, -1); + if (oldUpdateWC > 0) + { + if (now > oldUpdateWC) + _repo.RefreshWorkingCopyChanges(); + else + Interlocked.CompareExchange(ref _updateWC, oldUpdateWC, -1); + } } - if (_updateSubmodules > 0 && now > _updateSubmodules) { - _updateSubmodules = 0; + if (refreshSubmodules) + { + Interlocked.Exchange(ref _updateSubmodules, -1); _repo.RefreshSubmodules(); } + else + { + var oldUpdateSubmodule = Interlocked.Exchange(ref _updateSubmodules, -1); + if (oldUpdateSubmodule > 0) + { + if (now > oldUpdateSubmodule) + _repo.RefreshSubmodules(); + else + Interlocked.CompareExchange(ref _updateSubmodules, oldUpdateSubmodule, -1); + } + } - if (_updateStashes > 0 && now > _updateStashes) { - _updateStashes = 0; - _repo.RefreshStashes(); + var oldUpdateStashes = Interlocked.Exchange(ref _updateStashes, -1); + if (oldUpdateStashes > 0) + { + if (now > oldUpdateStashes) + _repo.RefreshStashes(); + else + Interlocked.CompareExchange(ref _updateStashes, oldUpdateStashes, -1); } - if (_updateTags > 0 && now > _updateTags) { - _updateTags = 0; - _repo.RefreshTags(); - _repo.RefreshCommits(); + var oldUpdateTags = Interlocked.Exchange(ref _updateTags, -1); + if (oldUpdateTags > 0) + { + if (now > oldUpdateTags) + { + refreshCommits = true; + _repo.RefreshTags(); + } + else + { + Interlocked.CompareExchange(ref _updateTags, oldUpdateTags, -1); + } } + + if (refreshCommits) + _repo.RefreshCommits(); + } + + private void OnRepositoryChanged(object o, FileSystemEventArgs e) + { + if (string.IsNullOrEmpty(e.Name) || e.Name.Equals(".git", StringComparison.Ordinal)) + return; + + var name = e.Name.Replace('\\', '/').TrimEnd('/'); + if (name.EndsWith("/.git", StringComparison.Ordinal)) + return; + + if (name.StartsWith(".git/", StringComparison.Ordinal)) + HandleGitDirFileChanged(name.Substring(5)); + else + HandleWorkingCopyFileChanged(name, e.FullPath); } - private void OnRepositoryChanged(object o, FileSystemEventArgs e) { - if (string.IsNullOrEmpty(e.Name)) return; + private void OnGitDirChanged(object o, FileSystemEventArgs e) + { + if (string.IsNullOrEmpty(e.Name)) + return; - var name = e.Name.Replace("\\", "/"); - if (name.StartsWith("modules", StringComparison.Ordinal)) { - _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) || + var name = e.Name.Replace('\\', '/').TrimEnd('/'); + HandleGitDirFileChanged(name); + } + + 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; + + HandleWorkingCopyFileChanged(name, e.FullPath); + } + + private void HandleGitDirFileChanged(string name) + { + 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)) + { + var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + Interlocked.Exchange(ref _updateSubmodules, desired); + Interlocked.Exchange(ref _updateWC, desired); + } + } + else if (name.Equals("MERGE_HEAD", StringComparison.Ordinal) || + name.Equals("AUTO_MERGE", StringComparison.Ordinal)) + { + if (_repo.MayHaveSubmodules()) + Interlocked.Exchange(ref _updateSubmodules, DateTime.Now.AddSeconds(1).ToFileTime()); + } + else if (name.StartsWith("refs/tags", StringComparison.Ordinal)) + { + Interlocked.Exchange(ref _updateTags, DateTime.Now.AddSeconds(.5).ToFileTime()); + } + else if (name.StartsWith("refs/stash", StringComparison.Ordinal)) + { + Interlocked.Exchange(ref _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/")) { - _updateBranch = DateTime.Now.AddSeconds(.5).ToFileTime(); - } else if (name.StartsWith("objects/", StringComparison.Ordinal) || name.Equals("index", StringComparison.Ordinal)) { - _updateWC = DateTime.Now.AddSeconds(.5).ToFileTime(); + (name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal))) + { + Interlocked.Exchange(ref _updateBranch, DateTime.Now.AddSeconds(.5).ToFileTime()); + } + else if (name.StartsWith("reftable/", StringComparison.Ordinal)) + { + var desired = DateTime.Now.AddSeconds(.5).ToFileTime(); + Interlocked.Exchange(ref _updateBranch, desired); + Interlocked.Exchange(ref _updateTags, desired); + Interlocked.Exchange(ref _updateStashes, desired); + } + else if (name.StartsWith("objects/", StringComparison.Ordinal) || name.Equals("index", StringComparison.Ordinal)) + { + Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime()); } } - private void OnWorkingCopyChanged(object o, FileSystemEventArgs e) { - if (string.IsNullOrEmpty(e.Name)) return; + private void HandleWorkingCopyFileChanged(string name, string fullpath) + { + if (name.StartsWith(".vs/", StringComparison.Ordinal)) + return; + + if (name.Equals(".gitmodules", StringComparison.Ordinal)) + { + var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + Interlocked.Exchange(ref _updateSubmodules, desired); + Interlocked.Exchange(ref _updateWC, desired); + return; + } - var name = e.Name.Replace("\\", "/"); - if (name == ".git" || name.StartsWith(".git/", StringComparison.Ordinal)) return; - if (_updateWC == 0) _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + var dir = Directory.Exists(fullpath) ? fullpath : Path.GetDirectoryName(fullpath); + if (IsInSubmodule(dir)) + { + Interlocked.Exchange(ref _updateSubmodules, DateTime.Now.AddSeconds(1).ToFileTime()); + return; + } + + Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime()); } - private 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; + private bool IsInSubmodule(string folder) + { + if (string.IsNullOrEmpty(folder) || folder.Equals(_root, StringComparison.Ordinal)) + return false; + + if (File.Exists($"{folder}/.git")) + return true; + + return IsInSubmodule(Path.GetDirectoryName(folder)); + } + + private readonly IRepository _repo; + private readonly string _root; + private List _watchers; + private Timer _timer; + + private long _lockCount; + private long _updateWC; + private long _updateBranch; + private long _updateSubmodules; + private long _updateStashes; + private long _updateTags; } } diff --git a/src/Models/Worktree.cs b/src/Models/Worktree.cs new file mode 100644 index 000000000..27a9415d5 --- /dev/null +++ b/src/Models/Worktree.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Models +{ + public class Worktree + { + public string Branch { get; set; } = string.Empty; + public string FullPath { 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; set; } = false; + } +} diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs index 009bed422..a28d88ebe 100644 --- a/src/Native/Linux.cs +++ b/src/Native/Linux.cs @@ -1,73 +1,156 @@ -using System.Diagnostics; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Runtime.Versioning; -namespace SourceGit.Native { +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; + +namespace SourceGit.Native +{ [SupportedOSPlatform("linux")] - internal class Linux : OS.IBackend { - public string FindGitExecutable() { - if (File.Exists("/usr/bin/git")) return "/usr/bin/git"; - return string.Empty; + internal class Linux : OS.IBackend + { + public void SetupApp(AppBuilder builder) + { + builder.With(new X11PlatformOptions() { EnableIme = true }); } - public string FindVSCode() { - if (File.Exists("/usr/share/code/code")) return "/usr/share/code/code"; - return string.Empty; + public void SetupWindow(Window window) + { + window.BorderThickness = new Thickness(0); + + 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 void OpenBrowser(string url) { - if (!File.Exists("/usr/bin/xdg-open")) { - App.RaiseException("", $"You should install xdg-open first!"); - return; + public string GetDataDir() + { + // AppImage supports portable mode + var appImage = Environment.GetEnvironmentVariable("APPIMAGE"); + if (!string.IsNullOrEmpty(appImage) && File.Exists(appImage)) + { + var portableDir = Path.Combine(Path.GetDirectoryName(appImage)!, "data"); + if (Directory.Exists(portableDir)) + return portableDir; } - Process.Start("xdg-open", $"\"{url}\""); + // Runtime data dir: ~/.sourcegit + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".sourcegit"); } - public void OpenInFileManager(string path, bool select) { - if (!File.Exists("/usr/bin/xdg-open")) { - App.RaiseException("", $"You should install xdg-open first!"); - return; - } + 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 localAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var finder = new Models.ExternalToolsFinder(); + finder.VSCode(() => FindExecutable("code")); + finder.VSCodeInsiders(() => FindExecutable("code-insiders")); + finder.VSCodium(() => FindExecutable("codium")); + finder.Cursor(() => FindExecutable("cursor")); + finder.FindJetBrainsFromToolbox(() => Path.Combine(localAppDataDir, "JetBrains/Toolbox")); + finder.SublimeText(() => FindExecutable("subl")); + finder.Zed(() => + { + var exec = FindExecutable("zeditor"); + return string.IsNullOrEmpty(exec) ? FindExecutable("zed") : exec; + }); + return finder.Tools; + } - if (Directory.Exists(path)) { - Process.Start("xdg-open", $"\"{path}\""); - } else { + public void OpenBrowser(string url) + { + var browser = Environment.GetEnvironmentVariable("BROWSER"); + if (string.IsNullOrEmpty(browser)) + browser = "xdg-open"; + Process.Start(browser, url.Quoted()); + } + + public void OpenInFileManager(string path) + { + if (Directory.Exists(path)) + { + Process.Start("xdg-open", path.Quoted()); + } + else + { var dir = Path.GetDirectoryName(path); - if (Directory.Exists(dir)) { - Process.Start("xdg-open", $"\"{dir}\""); - } + if (Directory.Exists(dir)) + Process.Start("xdg-open", dir.Quoted()); } } - public void OpenTerminal(string workdir) { - var dir = string.IsNullOrEmpty(workdir) ? "~" : workdir; - if (File.Exists("/usr/bin/gnome-ternimal")) { - Process.Start("/usr/bin/gnome-ternimal", $"--working-directory=\"{dir}\""); - } else if (File.Exists("/usr/bin/konsole")) { - Process.Start("/usr/bin/konsole", $"--workdir \"{dir}\""); - } else if (File.Exists("/usr/bin/xfce4-terminal")) { - Process.Start("/usr/bin/xfce4-terminal", $"--working-directory=\"{dir}\""); - } else { - App.RaiseException("", $"Only supports gnome-ternimal/konsole/xfce4-terminal!"); - return; + public void OpenTerminal(string workdir, string args) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var cwd = string.IsNullOrEmpty(workdir) ? home : workdir; + + var startInfo = new ProcessStartInfo(); + startInfo.WorkingDirectory = cwd; + startInfo.FileName = OS.ShellOrTerminal; + startInfo.Arguments = args; + + try + { + Process.Start(startInfo); + } + catch (Exception e) + { + Models.Notification.Send(workdir, $"Failed to start '{OS.ShellOrTerminal}'. Reason: {e.Message}", true); } } - public void OpenWithDefaultEditor(string file) { - if (!File.Exists("/usr/bin/xdg-open")) { - App.RaiseException("", $"You should install xdg-open first!"); - return; - } + public void OpenWithDefaultEditor(string file) + { + var proc = Process.Start("xdg-open", file.Quoted()); + if (proc != null) + { + proc.WaitForExit(); + + if (proc.ExitCode != 0) + Models.Notification.Send("", $"Failed to open: {file}", true); - var proc = Process.Start("xdg-open", $"\"{file}\""); - proc.WaitForExit(); + proc.Close(); + } + } - if (proc.ExitCode != 0) { - App.RaiseException("", $"Failed to open \"{file}\""); + 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; } - proc.Close(); + var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", filename); + return File.Exists(local) ? local : string.Empty; } } } diff --git a/src/Native/MacOS.cs b/src/Native/MacOS.cs index 4d64614d0..54a2a8a72 100644 --- a/src/Native/MacOS.cs +++ b/src/Native/MacOS.cs @@ -1,55 +1,288 @@ -using System.Diagnostics; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Runtime.InteropServices; using System.Runtime.Versioning; -using System.Text; -namespace SourceGit.Native { +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; + +namespace SourceGit.Native +{ [SupportedOSPlatform("macOS")] - internal class MacOS : OS.IBackend { - public string FindGitExecutable() { - if (File.Exists("/usr/bin/git")) return "/usr/bin/git"; - return string.Empty; - } + 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; - public string FindVSCode() { - if (File.Exists("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code")) { - return "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"; + 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; + window.BorderThickness = new Thickness(0); + } + + public string GetDataDir() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SourceGit"); + } + + 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 void OpenBrowser(string url) { + public string FindTerminal(Models.ShellOrTerminal shell) + { + return shell.Exec; + } + + public List FindExternalTools() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + 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.Cursor(() => "/Applications/Cursor.app/Contents/Resources/app/bin/cursor"); + finder.FindJetBrainsFromToolbox(() => Path.Combine(home, "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.Tools; + } + + 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 OpenInFileManager(string path) + { + if (Directory.Exists(path)) + Process.Start("open", path.Quoted()); + else if (File.Exists(path)) + Process.Start("open", $"{path.Quoted()} -R"); + } + + public void OpenTerminal(string workdir, string _) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var dir = string.IsNullOrEmpty(workdir) ? home : workdir; + Process.Start("open", $"-a {OS.ShellOrTerminal} {dir.Quoted()}"); + } + + public void OpenWithDefaultEditor(string file) + { + Process.Start("open", file.Quoted()); + } + } + + [SupportedOSPlatform("macOS")] + public static class MacOSUtilities + { + [StructLayout(LayoutKind.Sequential)] + public struct CGPoint + { + public double X; + public double Y; + + public CGPoint(double x, double y) + { + X = x; + Y = y; + } + + public override int GetHashCode() + { + return HashCode.Combine(X, Y); + } + + public override bool Equals([NotNullWhen(true)] object obj) + { + if (obj is not CGPoint point) + return false; + + return X == point.X && Y == point.Y; } + + public static bool operator ==(CGPoint left, CGPoint right) => left.Equals(right); + public static bool operator !=(CGPoint left, CGPoint right) => !left.Equals(right); } - public void OpenTerminal(string workdir) { - var dir = string.IsNullOrEmpty(workdir) ? "~" : workdir; - var builder = new StringBuilder(); - builder.AppendLine("on run argv"); - builder.AppendLine(" tell application \"Terminal\""); - builder.AppendLine($" do script \"cd '{dir}'\""); - builder.AppendLine(" activate"); - builder.AppendLine(" end tell"); - builder.AppendLine("end run"); + [StructLayout(LayoutKind.Sequential)] + public struct CGSize + { + public double Width; + public double Height; - var tmp = Path.GetTempFileName(); - File.WriteAllText(tmp, builder.ToString()); + public override int GetHashCode() + { + return HashCode.Combine(Width, Height); + } + + public override bool Equals([NotNullWhen(true)] object obj) + { + if (obj is not CGSize size) + return false; + + return Width == size.Width && Height == size.Height; + } + + public static bool operator ==(CGSize left, CGSize right) => left.Equals(right); + public static bool operator !=(CGSize left, CGSize right) => !left.Equals(right); + } + + [StructLayout(LayoutKind.Sequential)] + public struct CGRect + { + public CGPoint Origin; + public CGSize Size; + + public override int GetHashCode() + { + return HashCode.Combine(Origin, Size); + } + + public override bool Equals([NotNullWhen(true)] object obj) + { + if (obj is not CGRect rect) + return false; + + return Origin == rect.Origin && Size == rect.Size; + } + + public static bool operator ==(CGRect left, CGRect right) => left.Equals(right); + public static bool operator !=(CGRect left, CGRect right) => !left.Equals(right); + } + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_getClass")] + public static extern IntPtr objc_getClass(string name); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "sel_registerName")] + public static extern IntPtr sel_registerName(string name); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + public static extern IntPtr objc_msgSend_IntPtr(IntPtr receiver, IntPtr selector); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + public static extern IntPtr objc_msgSend_IntPtr_Int(IntPtr receiver, IntPtr selector, int arg); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + public static extern void objc_msgSend_Void_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + public static extern void objc_msgSend_Void_Point(IntPtr receiver, IntPtr selector, CGPoint arg); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend_stret")] + public static extern void objc_msgSendStrect_Rect(out CGRect rect, IntPtr receiver, IntPtr selector); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + public static extern CGRect objc_msgSend_Rect(IntPtr receiver, IntPtr selector); + + private static readonly IntPtr s_selStandardWindowButton = sel_registerName("standardWindowButton:"); + private static readonly IntPtr s_selFrame = sel_registerName("frame"); + private static readonly IntPtr s_selSetFrameOrigin = sel_registerName("setFrameOrigin:"); + + public static void AdjustTrafficLightsForThickTitleBar(Window window) + { + IntPtr nsWindow = window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + if (nsWindow == IntPtr.Zero) + return; + + IntPtr nsCloseBtn = objc_msgSend_IntPtr_Int(nsWindow, s_selStandardWindowButton, 0); + IntPtr nsMinBtn = objc_msgSend_IntPtr_Int(nsWindow, s_selStandardWindowButton, 1); + IntPtr nsZoomBtn = objc_msgSend_IntPtr_Int(nsWindow, s_selStandardWindowButton, 2); + if (nsCloseBtn == IntPtr.Zero || nsMinBtn == IntPtr.Zero || nsZoomBtn == IntPtr.Zero) + return; + + // For Intel CPU, we need to use `objc_msgSend_stret` to get the `CGRect` struct, while for Apple Silicon, we can directly use `objc_msgSend`. + if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + objc_msgSendStrect_Rect(out var frame, nsCloseBtn, s_selFrame); + if (Math.Abs(frame.Origin.X - 14) <= 0.5 || Math.Abs(frame.Origin.Y - 2) <= 0.5) + return; + } + else + { + CGRect frame = objc_msgSend_Rect(nsCloseBtn, s_selFrame); + if (Math.Abs(frame.Origin.X - 14) <= 0.5 || Math.Abs(frame.Origin.Y - 2) <= 0.5) + return; + } + + objc_msgSend_Void_Point(nsCloseBtn, s_selSetFrameOrigin, new(14, 2)); + objc_msgSend_Void_Point(nsMinBtn, s_selSetFrameOrigin, new(14 + 20, 2)); + objc_msgSend_Void_Point(nsZoomBtn, s_selSetFrameOrigin, new(14 + 40, 2)); + } + + public static void HideSelf() + { + IntPtr nsApplicationClass = objc_getClass("NSApplication"); + IntPtr nsSharedApplicationSelector = sel_registerName("sharedApplication"); + IntPtr nsApp = objc_msgSend_IntPtr(nsApplicationClass, nsSharedApplicationSelector); + IntPtr nsMethodSelector = sel_registerName("hide:"); + IntPtr nsDelegateSelector = sel_registerName("delegate"); + IntPtr nsDelegate = objc_msgSend_IntPtr(nsApp, nsDelegateSelector); + objc_msgSend_Void_IntPtr(nsApp, nsMethodSelector, nsDelegate); + } - var proc = Process.Start("/usr/bin/osascript", $"\"{tmp}\""); - proc.Exited += (o, e) => File.Delete(tmp); + public static void HideOtherApplications() + { + IntPtr nsApplicationClass = objc_getClass("NSApplication"); + IntPtr nsSharedApplicationSelector = sel_registerName("sharedApplication"); + IntPtr nsApp = objc_msgSend_IntPtr(nsApplicationClass, nsSharedApplicationSelector); + IntPtr nsMethodSelector = sel_registerName("hideOtherApplications:"); + IntPtr nsDelegateSelector = sel_registerName("delegate"); + IntPtr nsDelegate = objc_msgSend_IntPtr(nsApp, nsDelegateSelector); + objc_msgSend_Void_IntPtr(nsApp, nsMethodSelector, nsDelegate); } - public void OpenWithDefaultEditor(string file) { - Process.Start("open", file); + public static void ShowAllApplications() + { + IntPtr nsApplicationClass = objc_getClass("NSApplication"); + IntPtr nsSharedApplicationSelector = sel_registerName("sharedApplication"); + IntPtr nsApp = objc_msgSend_IntPtr(nsApplicationClass, nsSharedApplicationSelector); + IntPtr nsMethodSelector = sel_registerName("unhideAllApplications:"); + IntPtr nsDelegateSelector = sel_registerName("delegate"); + IntPtr nsDelegate = objc_msgSend_IntPtr(nsApp, nsDelegateSelector); + objc_msgSend_Void_IntPtr(nsApp, nsMethodSelector, nsDelegate); } } } diff --git a/src/Native/OS.cs b/src/Native/OS.cs index c73c7eb31..ceaa69c84 100644 --- a/src/Native/OS.cs +++ b/src/Native/OS.cs @@ -1,77 +1,389 @@ using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; -namespace SourceGit.Native { - public static class OS { - public interface IBackend { +namespace SourceGit.Native +{ + public static partial class OS + { + public interface IBackend + { + void SetupApp(AppBuilder builder); + void SetupWindow(Window window); + + string GetDataDir(); string FindGitExecutable(); - string FindVSCode(); + string FindTerminal(Models.ShellOrTerminal shell); + List FindExternalTools(); - void OpenTerminal(string workdir); - void OpenInFileManager(string path, bool select); + void OpenTerminal(string workdir, string args); + void OpenInFileManager(string path); void OpenBrowser(string url); void OpenWithDefaultEditor(string file); } - public static string GitInstallPath { + public static string DataDir + { get; - set; + private set; + } = string.Empty; + + public static string GitExecutable + { + get => _gitExecutable; + set + { + if (_gitExecutable != value) + { + _gitExecutable = value; + UpdateGitVersion(); + } + } } - public static string VSCodeExecutableFile { + public static string GitVersionString + { + get; + private set; + } = string.Empty; + + public static Version GitVersion + { + get; + private set; + } = new Version(0, 0, 0); + + public static Models.GitFlowVersion GitFlowVersion + { + get; + private set; + } = Models.GitFlowVersion.None; + + public static string CredentialHelper + { + get; + set; + } = "manager"; + + public static string ShellOrTerminal + { + get; + set; + } = string.Empty; + + public static string ShellOrTerminalArgs + { + get; + set; + } = string.Empty; + + public static List ExternalTools + { + get; + set; + } = []; + + public static int ExternalMergerType + { + get; + set; + } = 0; + + public static string ExternalMergerExecFile + { get; set; + } = string.Empty; + + public static string ExternalMergeArgs + { + get; + set; + } = string.Empty; + + public static string ExternalDiffArgs + { + get; + set; + } = string.Empty; + + public static bool UseSystemWindowFrame + { + get => OperatingSystem.IsLinux() && _enableSystemWindowFrame; + set => _enableSystemWindowFrame = value; } - static OS() { - if (OperatingSystem.IsMacOS()) { - _backend = new MacOS(); - VSCodeExecutableFile = _backend.FindVSCode(); - } else if (OperatingSystem.IsWindows()) { + static OS() + { + if (OperatingSystem.IsWindows()) _backend = new Windows(); - VSCodeExecutableFile = _backend.FindVSCode(); - } else if (OperatingSystem.IsLinux()) { + else if (OperatingSystem.IsMacOS()) + _backend = new MacOS(); + else if (OperatingSystem.IsLinux()) _backend = new Linux(); - VSCodeExecutableFile = _backend.FindVSCode(); - } else { - throw new Exception("Platform unsupported!!!"); + else + throw new PlatformNotSupportedException(); + } + + public static void SetupDataDir() + { + DataDir = _backend.GetDataDir(); + if (!Directory.Exists(DataDir)) + Directory.CreateDirectory(DataDir); + } + + public static void SetupApp(AppBuilder builder) + { + _backend.SetupApp(builder); + } + + public static void SetupExternalTools() + { + ExternalTools = _backend.FindExternalTools(); + } + + public static void SetupForWindow(Window window) + { + _backend.SetupWindow(window); + } + + public static void LogException(Exception ex) + { + if (ex == null) + return; + + var crashDir = Path.Combine(DataDir, "crashes"); + if (!Directory.Exists(crashDir)) + Directory.CreateDirectory(crashDir); + + var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + var file = Path.Combine(crashDir, $"{time}.log"); + using var writer = new StreamWriter(file); + writer.WriteLine($"Crash::: {ex.GetType().FullName}: {ex.Message}"); + writer.WriteLine(); + writer.WriteLine("----------------------------"); + writer.WriteLine($"Version: {Assembly.GetExecutingAssembly().GetName().Version}"); + writer.WriteLine($"OS: {Environment.OSVersion}"); + writer.WriteLine($"Framework: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}"); + writer.WriteLine($"Source: {ex.Source}"); + writer.WriteLine($"Thread Name: {Thread.CurrentThread.Name ?? "Unnamed"}"); + writer.WriteLine($"App Start Time: {Process.GetCurrentProcess().StartTime}"); + writer.WriteLine($"Exception Time: {DateTime.Now}"); + writer.WriteLine($"Memory Usage: {Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024} MB"); + writer.WriteLine("----------------------------"); + writer.WriteLine(); + writer.WriteLine(ex); + writer.Flush(); + } + + 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) + { + ShellOrTerminal = shell != null ? _backend.FindTerminal(shell) : string.Empty; + ShellOrTerminalArgs = shell.Args; + } + + public static Models.DiffMergeTool GetDiffMergeTool(bool onlyDiff) + { + if (ExternalMergerType < 0 || ExternalMergerType >= Models.ExternalMerger.Supported.Count) + return null; + + if (ExternalMergerType != 0 && (string.IsNullOrEmpty(ExternalMergerExecFile) || !File.Exists(ExternalMergerExecFile))) + return null; + + return new Models.DiffMergeTool(ExternalMergerExecFile, onlyDiff ? ExternalDiffArgs : ExternalMergeArgs); + } + + public static void AutoSelectExternalMergeToolExecFile() + { + if (ExternalMergerType >= 0 && ExternalMergerType < Models.ExternalMerger.Supported.Count) + { + var merger = Models.ExternalMerger.Supported[ExternalMergerType]; + var externalTool = ExternalTools.Find(x => x.Name.Equals(merger.Name, StringComparison.Ordinal)); + if (externalTool != null) + ExternalMergerExecFile = externalTool.ExecFile; + else if (!OperatingSystem.IsWindows() && File.Exists(merger.Finder)) + ExternalMergerExecFile = merger.Finder; + else + ExternalMergerExecFile = string.Empty; + + ExternalDiffArgs = merger.DiffCmd; + ExternalMergeArgs = merger.MergeCmd; + } + else + { + ExternalMergerExecFile = string.Empty; + ExternalDiffArgs = string.Empty; + ExternalMergeArgs = string.Empty; } } - public static string FindGitExecutable() { - return _backend?.FindGitExecutable(); + public static void OpenInFileManager(string path) + { + _backend.OpenInFileManager(path); } - public static void OpenInFileManager(string path, bool select = false) { - _backend?.OpenInFileManager(path, select); + public static void OpenBrowser(string url) + { + if (!IsSafeBrowserTarget(url)) + { + Models.Notification.Send(null, $"Blocked unsafe URL: {url}", true); + return; + } + + _backend.OpenBrowser(url); + } + + private static bool IsSafeBrowserTarget(string url) + { + return Uri.IsWellFormedUriString(url, UriKind.Absolute) && + Uri.TryCreate(url, UriKind.Absolute, out var uri) && + ( + uri.Scheme == Uri.UriSchemeHttp || + uri.Scheme == Uri.UriSchemeHttps || + uri.Scheme == Uri.UriSchemeFtp + ); + } + + public static void OpenTerminal(string workdir) + { + if (string.IsNullOrEmpty(ShellOrTerminal)) + Models.Notification.Send(workdir, "Terminal is not specified! Please confirm that the correct shell/terminal has been configured.", true); + else + _backend.OpenTerminal(workdir, ShellOrTerminalArgs); } - public static void OpenBrowser(string url) { - _backend?.OpenBrowser(url); + public static void OpenWithDefaultEditor(string file) + { + _backend.OpenWithDefaultEditor(file); } - public static void OpenTerminal(string workdir) { - _backend?.OpenTerminal(workdir); + public static string GetAbsPath(string root, string sub) + { + var fullpath = Path.Combine(root, sub); + if (OperatingSystem.IsWindows()) + return fullpath.Replace('/', '\\'); + + return fullpath; } - public static void OpenWithDefaultEditor(string file) { - _backend?.OpenWithDefaultEditor(file); + public static string GetRelativePathToHome(string path) + { + if (OperatingSystem.IsWindows()) + return path; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; + if (path.StartsWith(home, StringComparison.Ordinal)) + return $"~{path.AsSpan(prefixLen)}"; + + return path; } - public static void OpenInVSCode(string repo) { - if (string.IsNullOrEmpty(VSCodeExecutableFile)) { - App.RaiseException(repo, "Visual Studio Code can NOT be found in your system!!!"); + private static void UpdateGitVersion() + { + if (string.IsNullOrEmpty(_gitExecutable) || !File.Exists(_gitExecutable)) + { + GitVersionString = string.Empty; + GitVersion = new Version(0, 0, 0); + GitFlowVersion = Models.GitFlowVersion.None; return; } - Process.Start(new ProcessStartInfo() { - WorkingDirectory = repo, - FileName = VSCodeExecutableFile, - Arguments = $"\"{repo}\"", - UseShellExecute = false, - }); + 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; + + try + { + using var proc = Process.Start(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(); + } + + // Update git flow version in background to avoid blocking the UI + Task.Run(UpdateGitFlowVersion); + } + } + catch + { + // Ignore errors + } + } + + private static void UpdateGitFlowVersion() + { + var start = new ProcessStartInfo(); + start.FileName = _gitExecutable; + start.Arguments = "flow version"; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + + GitFlowVersion = Models.GitFlowVersion.None; + + try + { + using var proc = Process.Start(start)!; + var rs = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + if (proc.ExitCode == 0 && !string.IsNullOrWhiteSpace(rs)) + { + if (rs.Contains("git-flow-next", StringComparison.Ordinal)) + GitFlowVersion = Models.GitFlowVersion.Next; + else + GitFlowVersion = Models.GitFlowVersion.Legacy; + } + } + catch + { + // Ignore errors + } } + [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 index 9cf7cef78..2ec98ca40 100644 --- a/src/Native/Windows.cs +++ b/src/Native/Windows.cs @@ -1,105 +1,416 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; +using System.Text.Json; -namespace SourceGit.Native { +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace SourceGit.Native +{ [SupportedOSPlatform("windows")] - internal class Windows : OS.IBackend { + internal class Windows : OS.IBackend + { [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, SetLastError = false)] private static extern bool PathFindOnPath([In, Out] StringBuilder pszFile, [In] string[] ppszOtherDirs); - public string FindGitExecutable() { + [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); + + public void SetupApp(AppBuilder builder) + { + // Do nothing for now. + } + + public void SetupWindow(Window window) + { + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; + window.ExtendClientAreaToDecorationsHint = true; + window.BorderThickness = new Thickness(1); + window.Padding = new Thickness(0); + } + + public string GetDataDir() + { + var execFile = Environment.ProcessPath; + var portableDir = Path.Combine(Path.GetDirectoryName(execFile)!, "data"); + if (Directory.Exists(portableDir)) + return portableDir; + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SourceGit"); + } + + 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 != null) { - return Path.Combine(git.GetValue("InstallPath") as string, "bin", "git.exe"); - } + 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)) { + if (!PathFindOnPath(builder, null)) return null; - } var exePath = builder.ToString(); - if (string.IsNullOrEmpty(exePath)) return null; + if (!string.IsNullOrEmpty(exePath)) + return exePath; - return exePath; + return null; } - public string FindVSCode() { - var root = Microsoft.Win32.RegistryKey.OpenBaseKey( - Microsoft.Win32.RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? Microsoft.Win32.RegistryView.Registry64 : Microsoft.Win32.RegistryView.Registry32); + public string FindTerminal(Models.ShellOrTerminal shell) + { + switch (shell.Type) + { + case "git-bash": + if (string.IsNullOrEmpty(OS.GitExecutable)) + break; - var vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{C26E74D1-022E-4238-8B9D-1E7564A36CC9}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } + var binDir = Path.GetDirectoryName(OS.GitExecutable)!; + var bash = Path.GetFullPath(Path.Combine(binDir, "..", "git-bash.exe")); + if (!File.Exists(bash)) + break; - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{1287CAD5-7C8D-410D-88B9-0D1EE4A83FF2}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } + return bash; + case "pwsh": + var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Registry64); - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{F8A2A208-72B3-4D61-95FC-8A65D340689B}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } + 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; + } - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; + 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 void OpenBrowser(string url) { - var info = new ProcessStartInfo("cmd", $"/c start {url}"); + public List FindExternalTools() + { + var localAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var finder = new Models.ExternalToolsFinder(); + finder.VSCode(FindVSCode); + finder.VSCodeInsiders(FindVSCodeInsiders); + finder.VSCodium(FindVSCodium); + FindVisualStudio(finder); + finder.Cursor(() => Path.Combine(localAppDataDir, @"Programs\Cursor\Cursor.exe")); + finder.FindJetBrainsFromToolbox(() => Path.Combine(localAppDataDir, @"JetBrains\Toolbox")); + finder.SublimeText(FindSublimeText); + finder.Zed(FindZed); + return finder.Tools; + } + + public void OpenBrowser(string url) + { + var info = new ProcessStartInfo(url); + info.UseShellExecute = true; info.CreateNoWindow = true; Process.Start(info); } - public void OpenTerminal(string workdir) { - var bash = Path.Combine(Path.GetDirectoryName(OS.GitInstallPath), "bash.exe"); - if (!File.Exists(bash)) { - App.RaiseException(string.IsNullOrEmpty(workdir) ? "" : workdir, $"Can NOT found bash.exe under '{Path.GetDirectoryName(OS.GitInstallPath)}'"); + public void OpenTerminal(string workdir, string args) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var cwd = string.IsNullOrEmpty(workdir) ? home : workdir; + var terminal = OS.ShellOrTerminal; + + if (!File.Exists(terminal)) + { + Models.Notification.Send(workdir, "Terminal is not specified! Please confirm that the correct shell/terminal has been configured.", true); return; } var startInfo = new ProcessStartInfo(); - startInfo.UseShellExecute = true; - startInfo.FileName = bash; - if (!string.IsNullOrEmpty(workdir) && Path.Exists(workdir)) startInfo.WorkingDirectory = workdir; + startInfo.WorkingDirectory = cwd; + startInfo.FileName = terminal; + startInfo.Arguments = args; Process.Start(startInfo); } - public void OpenInFileManager(string path, bool select) { - var fullpath = string.Empty; - if (File.Exists(path)) { - fullpath = new FileInfo(path).FullName; - } else { - fullpath = new DirectoryInfo(path).FullName; - } + public void OpenInFileManager(string path) + { + if (File.Exists(path)) + { + var pidl = ILCreateFromPathW(new FileInfo(path).FullName); - if (select) { - Process.Start("explorer", $"/select,\"{fullpath}\""); - } else { - Process.Start("explorer", fullpath); + try + { + SHOpenFolderAndSelectItems(pidl, 0, 0, 0); + } + finally + { + ILFree(pidl); + } + + return; } + + var dir = new DirectoryInfo(path).FullName + Path.DirectorySeparatorChar; + Process.Start(new ProcessStartInfo(dir) + { + UseShellExecute = true, + CreateNoWindow = true, + }); } - public void OpenWithDefaultEditor(string file) { + public void OpenWithDefaultEditor(string file) + { var info = new FileInfo(file); - var start = new ProcessStartInfo("cmd", $"/c start {info.FullName}"); + var start = new ProcessStartInfo("cmd", $"""/c start "" {info.FullName.Quoted()}"""); start.CreateNoWindow = true; Process.Start(start); } + + #region HELPER_METHODS + private List GenerateVSProjectLaunchOptions(string path) + { + var root = new DirectoryInfo(path); + if (!root.Exists) + return null; + + var options = new List(); + var prefixLen = root.FullName.Length; + root.WalkFiles(f => + { + if (f.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || + f.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase)) + { + var display = f.Substring(prefixLen).TrimStart(Path.DirectorySeparatorChar); + options.Add(new(display, f.Quoted())); + } + }); + return options; + } + #endregion + + #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 void FindVisualStudio(Models.ExternalToolsFinder finder) + { + var vswhere = Environment.ExpandEnvironmentVariables(@"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"); + if (!File.Exists(vswhere)) + return; + + var startInfo = new ProcessStartInfo(); + startInfo.FileName = vswhere; + startInfo.Arguments = "-format json -prerelease -utf8"; + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + startInfo.WindowStyle = ProcessWindowStyle.Hidden; + startInfo.RedirectStandardOutput = true; + startInfo.StandardOutputEncoding = Encoding.UTF8; + + try + { + using var proc = Process.Start(startInfo)!; + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode == 0) + { + var instances = JsonSerializer.Deserialize(output, JsonCodeGen.Default.ListVisualStudioInstance); + foreach (var instance in instances) + { + var exec = instance.ProductPath; + var icon = instance.IsPrerelease ? "vs-preview" : "vs"; + finder.TryAdd(instance.DisplayName, icon, () => exec, GenerateVSProjectLaunchOptions, false); + } + } + } + catch + { + // Just ignore. + } + } + + private string FindZed() + { + var currentUser = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.CurrentUser, + Microsoft.Win32.RegistryView.Registry64); + + // NOTE: this is the official Zed Preview reg data. + var preview = currentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{F70E4811-D0E2-4D88-AC99-D63752799F95}_is1"); + if (preview != null) + return preview.GetValue("DisplayIcon") as string; + + var findInPath = new StringBuilder("zed.exe", 512); + if (PathFindOnPath(findInPath, null)) + return findInPath.ToString(); + + return string.Empty; + } + #endregion + } + + [SupportedOSPlatform("windows")] + public static class Win64Utilities + { + [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); + + public static void FixWindowFrame(Window w) + { + if (w.WindowState == WindowState.Maximized) + { + w.BorderThickness = new Thickness(0); + w.Padding = new Thickness(8, 6, 8, 8); + } + else + { + w.BorderThickness = new Thickness(1); + w.Padding = new Thickness(0); + } + + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) + return; + + Dispatcher.UIThread.Post(() => + { + 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); + } } } diff --git a/src/Resources/Fonts.axaml b/src/Resources/Fonts.axaml deleted file mode 100644 index 29681db51..000000000 --- a/src/Resources/Fonts.axaml +++ /dev/null @@ -1,6 +0,0 @@ - - avares://SourceGit/Resources/Fonts/JetBrainsMono-Regular.ttf#JetBrains Mono - avares://SourceGit/Resources/Fonts/JetBrainsMono-Bold.ttf#JetBrains Mono - avares://SourceGit/Resources/Fonts/JetBrainsMono-Italic.ttf#JetBrains Mono - diff --git a/src/Resources/Fonts/JetBrainsMono-Bold.ttf b/src/Resources/Fonts/JetBrainsMono-Bold.ttf deleted file mode 100644 index 8c93043de..000000000 Binary files a/src/Resources/Fonts/JetBrainsMono-Bold.ttf and /dev/null differ diff --git a/src/Resources/Fonts/JetBrainsMono-Italic.ttf b/src/Resources/Fonts/JetBrainsMono-Italic.ttf deleted file mode 100644 index ccc9d6a5b..000000000 Binary files a/src/Resources/Fonts/JetBrainsMono-Italic.ttf and /dev/null differ diff --git a/src/Resources/Fonts/JetBrainsMono-Regular.ttf b/src/Resources/Fonts/JetBrainsMono-Regular.ttf deleted file mode 100644 index dff66cc50..000000000 Binary files a/src/Resources/Fonts/JetBrainsMono-Regular.ttf and /dev/null differ diff --git a/src/Resources/Fonts/JetBrainsMonoNL-Bold.ttf b/src/Resources/Fonts/JetBrainsMonoNL-Bold.ttf new file mode 100644 index 000000000..f78f84fb4 Binary files /dev/null and b/src/Resources/Fonts/JetBrainsMonoNL-Bold.ttf differ diff --git a/src/Resources/Fonts/JetBrainsMonoNL-Italic.ttf b/src/Resources/Fonts/JetBrainsMonoNL-Italic.ttf new file mode 100644 index 000000000..4e9c3802d Binary files /dev/null and b/src/Resources/Fonts/JetBrainsMonoNL-Italic.ttf differ diff --git a/src/Resources/Fonts/JetBrainsMonoNL-Regular.ttf b/src/Resources/Fonts/JetBrainsMonoNL-Regular.ttf new file mode 100644 index 000000000..70d2ec9e2 Binary files /dev/null and b/src/Resources/Fonts/JetBrainsMonoNL-Regular.ttf differ diff --git a/src/Resources/Grammars/haxe.json b/src/Resources/Grammars/haxe.json new file mode 100644 index 000000000..3f78154d0 --- /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 000000000..3be425772 --- /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 000000000..2857f7171 --- /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 000000000..6be4678fc --- /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": "(?)", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.end.html.vue" + } + }, + "patterns": [ + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)md\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "text.html.markdown", + "patterns": [ + { + "include": "text.html.markdown" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)html\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "text.html.derivative", + "patterns": [ + { + "include": "#html-stuff" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)pug\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "text.pug", + "patterns": [ + { + "include": "text.pug" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)stylus\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.stylus", + "patterns": [ + { + "include": "source.stylus" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)postcss\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.postcss", + "patterns": [ + { + "include": "source.postcss" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)sass\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.sass", + "patterns": [ + { + "include": "source.sass" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)css\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.css", + "patterns": [ + { + "include": "source.css" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)scss\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.css.scss", + "patterns": [ + { + "include": "source.css.scss" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)less\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.css.less", + "patterns": [ + { + "include": "source.css.less" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)js\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.js", + "patterns": [ + { + "include": "source.js" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)ts\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.ts", + "patterns": [ + { + "include": "source.ts" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)jsx\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.js.jsx", + "patterns": [ + { + "include": "source.js.jsx" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)tsx\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.tsx", + "patterns": [ + { + "include": "source.tsx" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)coffee\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.coffee", + "patterns": [ + { + "include": "source.coffee" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)json\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.json", + "patterns": [ + { + "include": "source.json" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)jsonc\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.json.comments", + "patterns": [ + { + "include": "source.json.comments" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)json5\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.json5", + "patterns": [ + { + "include": "source.json5" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)yaml\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.yaml", + "patterns": [ + { + "include": "source.yaml" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)toml\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.toml", + "patterns": [ + { + "include": "source.toml" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)(gql|graphql)\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.graphql", + "patterns": [ + { + "include": "source.graphql" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)\\b(?=[^>]*\\blang\\s*=\\s*(['\"]?)vue\\b\\2)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "source.vue", + "patterns": [ + { + "include": "source.vue" + } + ] + } + ] + }, + { + "begin": "(template)\\b", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/template\\b)", + "name": "text.html.derivative", + "patterns": [ + { + "include": "#html-stuff" + } + ] + } + ] + }, + { + "begin": "(script)\\b", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/script\\b)", + "name": "source.js", + "patterns": [ + { + "include": "source.js" + } + ] + } + ] + }, + { + "begin": "(style)\\b", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/style\\b)", + "name": "source.css", + "patterns": [ + { + "include": "source.css" + } + ] + } + ] + }, + { + "begin": "([a-zA-Z0-9:-]+)", + "beginCaptures": { + "1": { + "name": "entity.name.tag.$1.html.vue" + } + }, + "end": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "patterns": [ + { + "include": "#tag-stuff" + }, + { + "begin": "(?<=>)", + "end": "(?=<\\/)", + "name": "text" + } + ] + } + ] + } + ], + "repository": { + "self-closing-tag": { + "begin": "(<)([a-zA-Z0-9:-]+)(?=([^>]+/>))", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + } + }, + "end": "(/>)", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.end.html.vue" + } + }, + "name": "self-closing-tag", + "patterns": [ + { + "include": "#tag-stuff" + } + ] + }, + "template-tag": { + "patterns": [ + { + "include": "#template-tag-1" + }, + { + "include": "#template-tag-2" + } + ] + }, + "template-tag-1": { + "begin": "(<)(template)\\b(>)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + }, + "2": { + "name": "entity.name.tag.$2.html.vue" + }, + "3": { + "name": "punctuation.definition.tag.end.html.vue" + } + }, + "end": "(/?>)", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.end.html.vue" + } + }, + "name": "meta.template-tag.start", + "patterns": [ + { + "begin": "\\G", + "end": "(?=/>)|(()", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.end.html.vue" + } + }, + "name": "meta.template-tag.start", + "patterns": [ + { + "begin": "\\G", + "end": "(?=/>)|(()|(>)", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.end.html.vue" + } + }, + "name": "meta.tag-stuff", + "patterns": [ + { + "include": "#vue-directives" + }, + { + "include": "text.html.basic#attribute" + } + ] + }, + "vue-directives": { + "patterns": [ + { + "include": "#vue-directives-control" + }, + { + "include": "#vue-directives-generic-attr" + }, + { + "include": "#vue-directives-style-attr" + }, + { + "include": "#vue-directives-original" + } + ] + }, + "vue-directives-original": { + "begin": "(?:(?:(v-[\\w-]+)(:)?)|([:\\.])|(@)|(#))(?:(?:(\\[)([^\\]]*)(\\]))|([\\w-]+))?", + "beginCaptures": { + "1": { + "name": "entity.other.attribute-name.html.vue" + }, + "2": { + "name": "punctuation.separator.key-value.html.vue" + }, + "3": { + "name": "punctuation.attribute-shorthand.bind.html.vue" + }, + "4": { + "name": "punctuation.attribute-shorthand.event.html.vue" + }, + "5": { + "name": "punctuation.attribute-shorthand.slot.html.vue" + }, + "6": { + "name": "punctuation.separator.key-value.html.vue" + }, + "7": { + "name": "source.ts.embedded.html.vue", + "patterns": [ + { + "include": "source.ts#expression" + } + ] + }, + "8": { + "name": "punctuation.separator.key-value.html.vue" + }, + "9": { + "name": "entity.other.attribute-name.html.vue" + } + }, + "end": "(?=\\s*[^=\\s])", + "name": "meta.attribute.directive.vue", + "patterns": [ + { + "match": "(\\.)([\\w-]*)", + "1": { + "name": "punctuation.separator.key-value.html.vue" + }, + "2": { + "name": "entity.other.attribute-name.html.vue" + } + }, + { + "include": "#vue-directives-expression" + } + ] + }, + "vue-directives-control": { + "begin": "(?:(v-for)|(v-if|v-else-if|v-else))(?=[=/>)\\s])", + "beginCaptures": { + "1": { + "name": "keyword.control.loop.vue" + }, + "2": { + "name": "keyword.control.conditional.vue" + } + }, + "end": "(?=\\s*[^=\\s])", + "name": "meta.attribute.directive.control.vue", + "patterns": [ + { + "include": "#vue-directives-expression" + } + ] + }, + "vue-directives-expression": { + "patterns": [ + { + "begin": "(=)\\s*('|\"|`)", + "beginCaptures": { + "1": { + "name": "punctuation.separator.key-value.html.vue" + }, + "2": { + "name": "punctuation.definition.string.begin.html.vue" + } + }, + "end": "(\\2)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.html.vue" + } + }, + "patterns": [ + { + "begin": "(?<=('|\"|`))", + "end": "(?=\\1)", + "name": "source.ts.embedded.html.vue", + "patterns": [ + { + "include": "source.ts#expression" + } + ] + } + ] + }, + { + "begin": "(=)\\s*(?=[^'\"`])", + "beginCaptures": { + "1": { + "name": "punctuation.separator.key-value.html.vue" + } + }, + "end": "(?=(\\s|>|\\/>))", + "patterns": [ + { + "begin": "(?=[^'\"`])", + "end": "(?=(\\s|>|\\/>))", + "name": "source.ts.embedded.html.vue", + "patterns": [ + { + "include": "source.ts#expression" + } + ] + } + ] + } + ] + }, + "vue-directives-style-attr": { + "begin": "\\b(style)\\s*(=)", + "beginCaptures": { + "1": { + "name": "entity.other.attribute-name.html.vue" + }, + "2": { + "name": "punctuation.separator.key-value.html.vue" + } + }, + "end": "(?<='|\")", + "name": "meta.attribute.style.vue", + "patterns": [ + { + "comment": "Copy from source.css#rule-list-innards", + "begin": "('|\")", + "beginCaptures": { + "1": { + "name": "punctuation.definition.string.begin.html.vue" + } + }, + "end": "(\\1)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.html.vue" + } + }, + "name": "source.css.embedded.html.vue", + "patterns": [ + { + "include": "source.css#comment-block" + }, + { + "include": "source.css#escapes" + }, + { + "include": "source.css#font-features" + }, + { + "match": "(?x) (?)" + } + ] + } + ] + }, + "vue-interpolations": { + "patterns": [ + { + "begin": "(\\{\\{)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.interpolation.begin.html.vue" + } + }, + "end": "(\\}\\})", + "endCaptures": { + "1": { + "name": "punctuation.definition.interpolation.end.html.vue" + } + }, + "name": "expression.embedded.vue", + "patterns": [ + { + "begin": "\\G", + "end": "(?=\\}\\})", + "name": "source.ts.embedded.html.vue", + "patterns": [ + { + "include": "source.ts#expression" + } + ] + } + ] + } + ] + }, + "vue-comments": { + "patterns": [ + { + "include": "#vue-comments-key-value" + }, + { + "begin": "", + "name": "comment.block.vue" + } + ] + }, + "vue-comments-key-value": { + "begin": "()", + "endCaptures": { + "1": { + "name": "punctuation.definition.comment.vue" + } + }, + "name": "comment.block.vue", + "patterns": [ + { + "include": "source.json#value" + } + ] + } + } +} diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index 5b652d8a4..6852b6a51 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -1,86 +1,166 @@ - M0 0M1024 1024 M960 544H64a32 32 0 1 1 0-64h896a32 32 0 1 1 0 64 - 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 - M1024 750v110c0 50-41 91-91 91h-841A92 92 0 010 859v-110C0 699 41 658 91 658h841c50 0 91 41 91 91z - M0 4 0 20 16 20 0 4M4 0 20 0 20 16 4 0z - 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 - M30 0 30 30 0 15z - M0 0 0 30 30 15z - 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 - M512 945c-238 0-433-195-433-433S274 79 512 79c238 0 433 195 433 433S750 945 512 945M512 0C228 0 0 228 0 512s228 512 512 512 512-228 512-512-228-512-512-512zM752 477H364l128-128a38 38 0 000-55 38 38 0 00-55 0l-185 183a55 55 0 00-16 39c0 16 6 30 16 39l185 185a39 39 0 0028 12 34 34 0 0028-14 38 38 0 000-55l-128-128h386c22 0 41-18 41-39a39 39 0 00-39-39 - M1231 0v372H120V0zM1352 484v56H0v-56zM1147 939H205V737h942v203M1231 1024V652H120V1024z - 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 - 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 - 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 - 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 - 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 - M702 677 590 565a148 148 0 10-25 27L676 703zm-346-200a115 115 0 11115 115A115 115 0 01355 478z + 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 + M233 163h560c11 0 20-9 20-20V63h-226c-6-36-37-63-74-63s-68 27-74 63h-226v80c0 11 9 20 20 20zM883 63h-10v112c0 33-27 60-60 60h-600c-33 0-60-27-60-60V63h-10c-44 0-80 36-80 80v801c0 44 36 80 80 80h740c44 0 80-36 80-80V143c0-44-36-80-80-80zm-720 469c0-24 20-44 44-44h62v-72c0-24 20-44 44-44s44 20 44 44v72h62c24 0 44 20 44 44s-20 44-44 44h-62v72c0 24-20 44-44 44s-44-20-44-44v-72h-62c-24 0-44-20-44-44zm700 307c0 24-20 44-44 44h-612c-24 0-44-20-44-44s20-44 44-44h612c24 0 44 20 44 44zm0-191c0 24-20 44-44 44h-232c-24 0-44-20-44-44s20-44 44-44h232c24 0 44 20 44 44zm0-191c0 24-20 44-44 44h-162c-24 0-44-20-44-44s20-44 44-44h162c24 0 44 20 44 44z + M366 146l293 0 0-73-293 0 0 73zm658 366 0 274q0 38-27 65t-65 27l-841 0q-38 0-65-27t-27-65l0-274 384 0 0 91q0 15 11 26t26 11l183 0q15 0 26-11t11-26l0-91 384 0zm-439 0 0 73-146 0 0-73 146 0zm439-274 0 219-1024 0 0-219q0-38 27-65t65-27l201 0 0-91q0-23 16-39t39-16l329 0q23 0 39 16t16 39l0 91 201 0q38 0 65 27t27 65z + M567 192C385 192 238 335 238 512H128l142 138 3 5L421 512H311c0-138 114-249 256-249s256 111 256 249c0 138-114 249-256 249a258 258 0 01-181-73l-52 51A332 332 0 00567 832C749 832 896 689 896 512S749 192 567 192zm-37 178v178l155 90 28-46-129-74v-148H530z + 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 + M171 85h683a43 43 0 0143 43v822a21 21 0 01-30 19L512 812l-354 158A21 21 0 01128 950V128a43 43 0 0143-43zm341 491 125 66-24-140 101-99-140-20L512 256l-63 127-140 20 101 99-24 140L512 576z + 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 + M325 171a100 100 0 00-96 71l-93 301-66 213a43 43 0 1082 25L209 597h232l56 183a43 43 0 0082-25l-66-213-92-301A100 100 0 00325 171zm14 96L415 512H235l76-245a15 15 0 0129 0zm382 144C751 392 792 384 843 384v0c59 0 103 15 131 44 25 26 37 63 37 111v225a41 41 0 11-82 0v-22a156 156 0 01-57 46c-26 12-56 18-91 18-42 0-74-11-98-32-25-22-37-49-37-82 0-45 17-80 53-104 33-23 78-36 137-37l87-2v-16c0-53-29-78-86-78-25 0-44 4-59 13-6 4-11 8-16 13-15 16-34 32-56 31-26-2-46-27-33-50a127 127 0 0149-50zm200 221v-20l-81 2c-70 2-105 26-105 74a41 41 0 0018 35c14 10 30 15 47 15 34 0 63-10 86-29 23-20 36-46 36-77z + 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 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 - 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 - 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 - 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 - 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 - 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 - M448 64l128 128h448v768H0V64z - M832 960l192-512H192L0 960zM128 384L0 960V128h288l128 128h416v128z + 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 + M523 398 918 3l113 113-396 396 397 397-113 113-397-397-397 397-113-113 397-397L14 116l113-113 396 396z + 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 + M277 708h469a43 43 0 110 85h-469a43 43 0 110-85zM331 85h362c34 0 63 0 86 2 24 2 46 6 67 17a171 171 0 0175 75c11 21 15 43 17 67C939 268 939 297 939 331v362c0 34 0 63-2 86-2 24-6 46-17 67a171 171 0 01-75 75c-21 11-43 15-67 17-23 2-51 2-86 2H331c-34 0-63 0-86-2-24-2-46-6-67-17a171 171 0 01-75-75c-11-21-15-43-17-67C85 756 85 727 85 693V331c0-34 0-63 2-86 2-24 6-46 17-67a171 171 0 0175-75c21-11 43-15 67-17C268 85 297 85 331 85zM252 172c-19 1-28 4-35 8a85 85 0 00-37 37c-3 7-6 16-8 35C171 271 171 296 171 333v358c0 37 0 61 2 81 1 19 4 28 8 35a85 85 0 0037 37c7 3 16 6 35 8C271 853 296 853 333 853h358c37 0 61 0 81-2 19-1 28-4 35-8a85 85 0 0037-37c3-7 6-16 8-35 2-19 2-44 2-81V333c0-37 0-61-2-81-1-19-4-28-8-35a85 85 0 00-37-37c-7-3-16-6-35-8C753 171 728 171 691 171H333c-37 0-61 0-81 2z + 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 + M325 312l-60 60L404 513 265 652l60 60 200-200L325 312zm194 345h236v97h-236v-97zM29 77v870h968V77H29zm870 774H125V173h774v678z + 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 + M64 128h896v64H64V128zm832 128h-64v512h64V256zm-175 281a151 151 0 00-23-50 112 112 0 00-39-34 117 117 0 00-55-12c-13 0-24 2-35 5a113 113 0 00-29 13 110 110 0 00-23 20l-11 17V293H448V768h57v-37l8 11c6 7 12 12 19 17 7 5 16 9 25 12 10 3 20 4 32 4 23 0 43-5 60-14 17-9 32-22 44-38 12-16 21-35 26-56 6-22 9-45 9-70a240 240 0 00-8-62zM627 498c10 5 18 11 25 20 7 9 12 20 16 33 3 11 5 24 6 38l-0 8c0 21-2 40-6 56a121 121 0 01-18 39c-7 10-17 18-27 23-21 10-49 11-68 1a81 81 0 01-25-19 81 81 0 01-14-23s-11-29-11-61c0-32 11-64 11-64 4-11 9-20 14-28 7-9 16-17 27-22 11-6 23-8 38-8 12 0 23 2 33 7zM960 832H64v64h896v-64zM180 640l-47 130H64l2-5 156-454h60l159 458H372L322 640H180zm72-229h-1l-58 176h118l-58-176z + 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 + M339 297c17-23 25-51 25-83 2-42-12-79-43-108S255 62 215 65c-32 0-60 8-84 25s-42 39-54 67-15 56-10 86 19 55 40 76c21 21 47 35 76 41v303c-30 6-55 20-76 41-21 21-35 47-40 76-5 30-2 58 10 86s30 50 54 67 52 25 83 25 59-8 84-25c25-17 45-39 57-67 6-19 10-39 10-61 0-30-8-57-25-83-21-34-52-55-92-64v-299l25-6c28-13 50-32 67-57zm-45 471c8 15 12 30 11 46-1 16-6 31-16 46-10 15-23 25-40 32s-34 8-51 5-32-11-46-24-22-28-24-45c-6-28-1-53 18-75s41-33 68-33c17 0 32 4 46 13 14 8 25 20 33 35zM167 288c-15-11-26-24-33-41-7-17-10-34-6-51 3-17 11-32 24-45s28-21 46-25 36-3 53 5c17 7 30 18 40 32 10 14 15 29 16 46 1 17-3 33-11 48-8 15-20 27-33 35-14 8-29 13-46 13s-33-5-48-16zm615 45c2-19-1-38-10-57-11-28-29-50-54-67s-53-25-83-25h-111l76-76-45-41-127 127v41l127 127 45-41-76-76h111c23 0 44 8 62 25 18 17 27 38 27 64v89h57V332zm0 449H960v-61H782V542h-61v178H543v61h178v178h61V781z + 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 + M853 137h-102V102h-68v34H341V102h-68v34h-102C133 137 102 167 102 205v68h819V205c0-38-31-68-68-68zM102 341v512C102 891 133 922 171 922h683C891 922 922 891 922 853v-512H102zM444 819h-68V512H307v-68h137V819zm273-273a102 102 0 01-30 72l-97 97 0 0a34 34 0 00-10 24v12H717V819H512v-80a102 102 0 0130-72l97-97a34 34 0 0010-24h0C649 527 633 512 614 512s-34 15-34 34H512c0-57 46-102 102-102s102 46 102 102h-0 0z + 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 + M926 356V780a73 73 0 01-73 73H171a73 73 0 01-73-73V356l304 258a171 171 0 00221 0L926 356zM853 171a74 74 0 0126 5 73 73 0 0131 22 74 74 0 0111 18c3 8 5 16 6 24L926 244v24L559 581a73 73 0 01-91 3l-4-3L98 268v-24a73 73 0 0140-65A73 73 0 01171 171h683z + 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 - 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 + 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 + M888 314l-198-198c-8-8-21-8-29 0s-8 21 0 29l163 163-216 0L609 43c0-11-9-20-20-20l-408 0c-11 0-20 9-20 20l0 939c0 11 9 20 20 20l694 0c11 0 20-9 20-20l0-653C894 323 892 318 888 314zM854 961l-653 0L201 63l367 0 0 265c0 11 9 20 20 20l265 0L854 961z 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 - 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 + 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 + M320 239 213 299l60-107L213 85l107 60L427 85 367 192 427 299 320 239m512 418L939 597l-60 107L939 811l-107-60L725 811l60-107L725 597l107 60M939 85l-60 107L939 299l-107-60L725 299l60-107L725 85l107 60L939 85m-369 460 104-104-90-90-104 104 90 90m44-234 100 100c17 16 17 44 0 60L215 969c-17 17-44 17-60 0l-100-100c-17-16-17-44 0-60L553 311c17-17 44-17 60 0z + 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 + M0 0 M1024 1024M51 130H254C264 144 407 384 407 384L357 486 204 231H51C25 231 0 205 0 180 0 155 26 130 51 130ZM1009 511 815 638C800 653 780 653 765 638V536H561L357 893H51C25 893 0 868 0 843 0 817 26 792 51 792H306L509 435H765V332C780 317 800 317 815 332L1009 465C1024 480 1024 501 1009 511Z + 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 + M 854 170 c -189 -189 -495 -189 -684 0 s -189 495 0 684 s 495 189 684 0 s 187 -495 0 -684 z M 213 706 c -89 -137 -74 -325 48 -444 c 122 -122 307 -137 444 -48 L 213 706 z m 106 105 l 493 -493 c 89 137 74 325 -48 444 c -120 122 -307 137 -444 48 z + M289 477l147 266C582 460 733 198 1014 20c0 93-15 174 4 247 23 93-23 123-82 180-152 144-287 307-427 463-20 22-35 48-47 67L39 625l250-148z + M727 641c-78 0-142 55-157 128H256V320h251c16 108 108 192 221 192 124 0 224-100 224-224S851 64 727 64c-113 0-205 84-221 192H96c-18 0-32 14-32 32s14 32 32 32h96v482c0 18 14 32 32 32h347c15 73 79 128 157 128 88 0 160-72 160-160s-72-160-160-160zm0 256c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96z + 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 + M566 585l37-146-145 0-37 146 145 0zM1005 297l-32 128q-4 14-18 14l-187 0-37 146 178 0q9 0 14 7 6 8 3 16l-32 128q-3 14-18 14l-187 0-46 187q-4 14-18 14l-128 0q-9 0-15-7-5-7-3-16l45-178-145 0-46 187q-4 14-18 14l-129 0q-9 0-14-7-5-7-3-16l45-178-178 0q-9 0-14-7-5-7-3-16l32-128q4-14 18-14l187 0 37-146-178 0q-9 0-14-7-6-8-3-16l32-128q3-14 18-14l187 0 46-187q4-14 18-14l128 0q9 0 14 7 5 7 3 16l-45 178 145 0 46-187q4-14 18-14l128 0q9 0 14 7 5 7 3 16l-45 178 178 0q9 0 14 7 5 7 3 16z + 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 + M688 544a48 48 0 110-96 48 48 0 010 96M512 544a48 48 0 110-96 48 48 0 010 96m-176 0a48 48 0 11-0-96A48 48 0 01336 544M805 192H219C186 192 160 219 160 253v471c0 34 26 61 59 61h189c6 0 16 6 18 11l38 72 3 5c11 14 27 23 44 23h0c17 0 33-8 45-23l41-77c2-5 12-11 18-11h190c32 0 59-27 59-61V253C864 219 838 192 805 192 + M544 150H418v-127h127v126zM799 530h-635c-35 0-64-29-64-64v-180c0-35 29-64 64-64h637l119 151-121 157zm-196-210H358v114h247v-114zM416 1021h127v-420H417v420z + M0 512M1024 512M512 0M512 1024M64 576h896V448H64z + 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 + 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 + M133 256A93 93 0 01226 163h499a93 93 0 0193 93v164h76v-164A169 169 0 00725 87H226A169 169 0 0057 256v451a169 169 0 00169 169h214v-76H226A93 93 0 01133 708V256zm458 369a58 58 0 0158-58h180a58 58 0 0158 58v177a58 58 0 01-58 58h-180a58 58 0 01-58-58v-177zm58-134a134 134 0 00-134 134v177a134 134 0 00134 134h180a134 134 0 00134-134v-177a134 134 0 00-134-134h-180zm-262-41H320v76h135a62 62 0 0062-62v-134h-76v67l-150-149-53 54 149 148z + 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 + M834 0H190C85 0 0 85 0 189v646c0 104 85 189 189 189h645c104 0 189-85 189-189V189C1024 85 939 0 834 0zM658 748c-25 29-62 47-111 54v54h-66v-56c-38-4-72-19-101-44-29-26-43-71-43-135v-28h144v35c0 39 1 63 4 72 3 9 10 14 22 14 10 0 17-3 22-10 5-7 7-16 7-29 0-32-2-55-7-69-5-14-20-29-46-45-44-28-74-48-90-61-16-13-29-31-41-55-12-24-17-50-17-80 0-43 12-77 37-101 24-24 61-40 110-45v-46h66v46c44 6 78 21 100 45 22 24 33 57 33 100 0 6-0 15-1 27H535v-24c0-25-2-42-5-50-3-8-10-12-21-12-9 0-15 3-20 10-4 7-7 17-7 30 0 23 5 38 14 47 9 9 35 27 78 53 37 22 62 39 75 51 13 12 25 28 34 50 9 22 14 48 14 80 0 51-12 92-37 121z + 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 + M470 722q-23 3-43 3T384 722v-150l-106 106q-34-26-60-59L324 512H174q-3-23-3-43t3-42h150L218 320q16-20 28-32t32-27L384 367V217q23-4 43-4t43 4v150l106-106q34 26 60 59l-106 107h150q3 22 3 42T680 512h-150l106 107q-16 20-28 32t-32 27l-106-106v150zM0 811q0-36 25-61t61-25 61 25 25 61-25 61-61 25-61-25T0 811z + M576 64H448v384H64v128h384v384h128V576h384V448H576z 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 - 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 - M652 157a113 113 0 11156 161L731 395 572 236l80-80 1 1zM334 792v0H175v-159l358-358 159 159-358 358zM62 850h900v113H62v-113z - 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 - M762 1024C876 818 895 504 448 514V768L64 384l384-384v248c535-14 595 472 314 776z - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 + 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 - M0 586l404 119 498-410-386 441-2 251 155-205 279 83L1170 37z - 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 - 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 + M912 382l-32-94-184 75L708 160H607l12 202-184-75-32 94 193 53-133 150 78 61 116-168L773 646l78-61-133-150zM256 769m-71 0a71 71 0 10141 0 71 71 0 10-141 0Z + 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 + M155 143h715v81H155V143zm358 94 358 369H662l1 278H363V605H155l358-369z + 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 + M622 718v137q0 9-7 16t-16 7H462q-9 0-16-7t-7-16v-137q0-9 7-16t16-7h137q9 0 16 7t7 16zm181-343q0 31-9 58t-20 44-31 34-33 25-35 20q-23 13-39 37t-16 38q0 10-7 19t-16 9H459q-9 0-15-11T439 626v-26q0-47 37-89T558 449q34-15 48-32t14-43q0-24-27-42T532 313q-37 0-62 17-20 14-61 66-7 9-18 9-7 0-14-5L283 329q-7-6-9-14t3-16q91-152 265-152 46 0 92 18t83 47 61 73 23 91z + 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 + 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 + M0 512M1024 512M512 0M512 1024M213 290V213H43V811h171v-77H128V290zM811 290V213h171V811h-171v-77h85V290zM751 393H245v85h506v-85zM614 546H245v85h369v-85z + 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 + M1097 372h-460l-146-299H146a73 73 0 00-73 73v731a73 73 0 0073 73h878a73 73 0 0073-73V372zM146 0h390l146 299h488V878a146 146 0 01-146 146H146a146 146 0 01-146-146V146a146 146 0 01146-146zm439 0h195l146 246h-195l-146-246zm244 0h195a146 146 0 01146 146v100h-195l-146-246z 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 + 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 - M768 800V685L512 480 256 685V800l256-205L768 800zM512 339 768 544V429L512 224 256 429V544l256-205z - M599 425 599 657 425 832 425 425 192 192 832 192Z - 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 - 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 - 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 - M293 122v244h439V146l171 175V829a73 73 0 01-73 73h-98V536H293v366H195a73 73 0 01-73-73V195a73 73 0 0173-73h98zm366 512v268H366V634h293zm-49 49h-195v73h195v-73zm49-561v171H366V122h293z - 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 - 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 - M719 85 388 417l-209-165L87 299v427l92 47 210-164L720 939 939 850V171zM186 610V412l104 104zm526 55L514 512l198-153z - 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 + 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 + M0 0M32 512H936v96H32z + M153 154h768v768h-768v-768zm64 64v640h640v-640h-640z + M796 231v727H64V231h732zm-82 78H146V880h567V309zM229 66H960v732H796v-82h82V148h-567v82h-82V66z + 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 + M0 512M1024 512M512 0M512 1024M128 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/Images/ExternalToolIcons/JetBrains/CL.png b/src/Resources/Images/ExternalToolIcons/JetBrains/CL.png new file mode 100644 index 000000000..01902a920 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 000000000..694713cd0 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 000000000..fc76eb93b 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 000000000..f456aab92 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 000000000..72cea44ca 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 000000000..73a7b8a94 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 000000000..f3e15a637 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 000000000..e06fe6618 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 000000000..f3e15a637 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 000000000..a962becf1 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 000000000..d2b7c4200 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 000000000..871988c39 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 000000000..5fe13b620 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 000000000..5f72b09b9 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 000000000..5ea6cd56a 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 000000000..6b5cb0f2d 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 000000000..dc05f32c3 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 000000000..58c49bcc6 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/codium.png differ diff --git a/src/Resources/Images/ExternalToolIcons/cursor.png b/src/Resources/Images/ExternalToolIcons/cursor.png new file mode 100644 index 000000000..1e0092bb3 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/cursor.png differ diff --git a/src/Resources/Images/ExternalToolIcons/fleet.png b/src/Resources/Images/ExternalToolIcons/fleet.png new file mode 100644 index 000000000..552327844 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 000000000..034f3b9c3 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 000000000..640aacbee 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 000000000..59cdc7c5d 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 000000000..352fc21d3 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 000000000..53d2e336a 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 000000000..411d77320 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 000000000..ca0cdebf0 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 000000000..10ffe0ef6 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/tortoise_merge.png differ diff --git a/src/Resources/Images/ExternalToolIcons/vs-preview.png b/src/Resources/Images/ExternalToolIcons/vs-preview.png new file mode 100644 index 000000000..8ce5499d0 Binary files /dev/null and b/src/Resources/Images/ExternalToolIcons/vs-preview.png differ diff --git a/src/Resources/Images/ExternalToolIcons/vs.png b/src/Resources/Images/ExternalToolIcons/vs.png new file mode 100644 index 000000000..072ee5cbc 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 000000000..9c15c8d72 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 000000000..b48ed6679 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 000000000..410776914 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 000000000..6c7d72028 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 000000000..f2b5ceada 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 000000000..aa1184342 Binary files /dev/null and b/src/Resources/Images/ShellIcons/cmd.png differ diff --git a/src/Resources/Images/ShellIcons/cmux.png b/src/Resources/Images/ShellIcons/cmux.png new file mode 100644 index 000000000..b847bbb70 Binary files /dev/null and b/src/Resources/Images/ShellIcons/cmux.png differ diff --git a/src/Resources/Images/ShellIcons/custom.png b/src/Resources/Images/ShellIcons/custom.png new file mode 100644 index 000000000..6b11e3d39 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 000000000..3bc708cd2 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 000000000..a5ce6fdf5 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 000000000..e394a5170 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 000000000..e48485cba 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 000000000..3fa56a816 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 000000000..c9410f56a 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 000000000..6ed703196 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 000000000..f9fe06eae 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 000000000..19f29475d 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 000000000..62099f3fd 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 000000000..7dc2945ab Binary files /dev/null and b/src/Resources/Images/ShellIcons/mate-terminal.png differ diff --git a/src/Resources/Images/ShellIcons/mux0.png b/src/Resources/Images/ShellIcons/mux0.png new file mode 100644 index 000000000..e16b37400 Binary files /dev/null and b/src/Resources/Images/ShellIcons/mux0.png differ diff --git a/src/Resources/Images/ShellIcons/otty.png b/src/Resources/Images/ShellIcons/otty.png new file mode 100644 index 000000000..e2ce5897f Binary files /dev/null and b/src/Resources/Images/ShellIcons/otty.png differ diff --git a/src/Resources/Images/ShellIcons/ptyxis.png b/src/Resources/Images/ShellIcons/ptyxis.png new file mode 100644 index 000000000..71d62d5a5 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 000000000..ebb9e0dab 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 000000000..e053468d0 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 000000000..9a80820b1 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 000000000..01e48f0b0 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 000000000..eed9223e1 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 000000000..54658efa9 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 000000000..85d199995 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 000000000..57a344c63 --- /dev/null +++ b/src/Resources/Locales/de_DE.axaml @@ -0,0 +1,936 @@ + + + + + + Info + Über SourceGit + Veröffentlichungsdatum: {0} + Versionshinweise + Open Source & freier Git GUI-Client + Zu ignorierende Datei(en) hinzufügen + Muster: + Speichern in Datei: + Worktree hinzufügen + Ordner: + Pfad für diesen Worktree. Relative Pfade werden unterstützt. + Branch-Name: + Optional. Standard ist der Name des Zielordners. + Branch verfolgen: + Remote-Branch verfolgen + Was auschecken: + Neuen Branch erstellen + Existierender Branch + AI-Assistent + Modell + NEU GENERIEREN + Verwende AI, um Commit-Nachrichten zu generieren + SourceGit minimieren + Alles anzeigen + 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 einen Archivpfad aus + Revision: + Archiv erstellen + SourceGit-Askpass + Passphrase eingeben: + ALS UNVERÄNDERT ANGENOMMENE DATEIEN + KEINE ALS UNVERÄNDERT ANGENOMMENEN DATEIEN + Bild laden… + Aktualisieren + BINÄRE DATEI WIRD NICHT UNTERSTÜTZT!!! + Bisect + Abbrechen + Schlecht + Gut + Überspringen + Bisecting. Aktuellen Commit als gut oder schlecht markieren und einen anderen auschecken. + Bisecting. Ist der aktuelle HEAD gut oder schlecht? + Blame + Blame auf vorheriger Revision + Leerzeichen-Änderungen ignorieren + BLAME WIRD BEI DIESER DATEI NICHT UNTERSTÜTZT!!! + Auschecken von ${0}$… + Ausgewählte 2 Branches vergleichen + Vergleichen mit… + Vergleichen mit HEAD + Branch-Namen kopieren + PR erstellen… + PR für Upstream ${0}$ erstellen… + Benutzerdefinierte Aktion + Lösche ${0}$… + Lösche ausgewählte {0} Branches + Beschreibung für ${0}$ bearbeiten… + Fast-Forward zu ${0}$ + Fetche ${0}$ in ${1}$ hinein… + Git Flow – Abschließen ${0}$ + Interaktives Rebase von ${0}$ auf ${1}$ + 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 ${0}$ zurück auf ${1}$… + Zu ${0}$ wechseln (Worktree) + Setze verfolgten Branch… + {0} Commit(s) voraus + {0} Commit(s) voraus, {1} Commit(s) zurück + {0} Commit(s) zurück + Ungültig + REMOTE + STATUS + VERFOLGT + URL + WORKTREE + ABBRECHEN + Auf Vorgänger-Revision zurücksetzen + Auf diese Revision zurücksetzen + Generiere Commit-Nachricht + Merge (integriert) + Merge (extern) + Datei(en) auf ${0}$ zurücksetzen + ANZEIGEMODUS ÄNDERN + Zeige als Datei- und Ordnerliste + Zeige als Pfadliste + Zeige als Dateisystem-Baumstruktur + Ändere URL des Submoduls + Submodul: + URL: + Branch auschecken + Lokale Änderungen: + Branch: + Dein aktueller HEAD enthält Commit(s) ohne Verbindung zu einem Branch / Tag. Möchtest du trotzdem fortfahren? + Die folgenden Submodule müssen aktualisiert werden:{0}Möchtest du sie aktualisieren? + Auschecken & Fast Forward + Fast Forward zu: + Cherry Pick + Quelle an Commit-Nachricht anhängen + Commit(s): + Alle Änderungen committen + Hauptlinie: + Normalerweise ist es nicht möglich, einen Merge zu cherry-picken, da unklar ist, welche Seite des Merges als Hauptlinie anzusehen 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 Repositorys. Optional. + Lokaler Name: + Repository-Name. Optional. + Übergeordnetes Verzeichnis: + Submodule initialisieren und aktualisieren + Repository-URL: + SCHLIESSEN + Editor + Commit auschecken + Diesen Commit cherry-picken + Mehrere cherry-picken… + Vergleiche mit HEAD + Vergleiche mit Worktree + Autor + Commit-Nachricht + Committer + SHA + Betreff + Benutzerdefinierte Aktion + Interaktives Rebase + Entfernen… + Bearbeiten… + Fixup in den Vorgänger… + Interaktives Rebase von ${0}$ auf ${1}$ + Umformulieren… + Squash in den Vorgänger… + Merge… + Push ${0}$ zu ${1}$ + Rebase ${0}$ auf ${1}$ + Setze ${0}$ auf ${1}$ zurück + Commit rückgängig machen + Als Patch speichern… + ÄNDERUNGEN + geänderte Datei(en) + Änderungen durchsuchen… + DATEIEN + LFS-DATEI + Dateien durchsuchen… + Submodul + INFORMATION + AUTOR + COMMITTER + Prüfe Refs, die diesen Commit enthalten + COMMIT ENTHALTEN IN + E-Mail-Adresse kopieren + Namen kopieren + Namen & E-Mail-Adresse kopieren + Zeigt nur die ersten 100 Änderungen. Alle Änderungen siehe Registerkarte ‚ÄNDERUNGEN‘. + Schlüssel: + COMMIT-NACHRICHT + VORGÄNGER + REFS + SHA + Unterzeichner: + Im Browser öffnen + Commit-Nachricht eingeben. Leerzeile zum Trennen von Betreff und Beschreibung verwenden! + BETREFF + Vergleichen + Repository-Einstellungen + COMMIT-VORLAGE + Vordefinierte Parameter: + +${branch_name} Name des aktuellen lokalen Branches. +${files_num} Anzahl der geänderten Dateien +${files} Pfade der geänderten Dateien +${files:N} Maximale Anzahl N an Pfaden geänderter Dateien +${pure_files} Wie ${files}, aber nur die reinen Dateinamen +${pure_files:N} Wie ${files:N}, aber ohne Ordner + Vorlageninhalt: + Vorlagenname: + BENUTZERDEFINIERTE AKTION + Argumente: + Vordefinierte Parameter: + +${REPO} Repository-Pfad +${REMOTE} Selektierter Remote oder selektierter Branch-Remote +${BRANCH} Selektierter Branch, ohne ${REMOTE}-Teil für Remote-Branches +${BRANCH_FRIENDLY_NAME} Benutzerfreundlicher Name des selektierten Branches, enthält ${REMOTE}-Teil für Remote-Branches +${SHA} Hash des selektierten Commits +${TAG} Ausgewählter Tag +${FILE} Ausgewählte Datei, relativ zum Stammverzeichnis des Repositorys +$1, $2, … Werte der Eingabe-Steuerelemente + Ausführbare Datei: + Eingabe-Steuerelemente: + Bearbeiten + Name: + Geltungsbereich: + Branch + Commit + Datei + Remote + Repository + Tag + Auf Beenden der Aktion warten + E-Mail-Adresse + E-Mail-Adresse + GIT + Vor dem Auto-Aktualisieren von Submodulen fragen + Remotes automatisch fetchen + Minute(n) + Konventionelle Commit-Typen + Standard-Remote + Bevorzugter Merge-Modus + TICKETSYSTEM + Beispiel für Azure-DevOps-Regel hinzufügen + Beispiel für Gerrit-Change-ID hinzufügen + Beispiel für Gitee-Issue-Regel hinzufügen + Beispiel für Gitee-Pull-Request-Regel hinzufügen + Beispiel für GitHub-Regel hinzufügen + Beispiel für Gitlab-Issue-Regel hinzufügen + Beispiel für Gitlab-Merge-Request-Regel hinzufügen + Beispiel für Jira-Regel hinzufügen + Neue Regel + Regulärer Ausdruck für Ticket-ID: + Name: + Diese Regel per .issuetracker-Datei teilen + Ergebnis-URL: + Verwende bitte $1, $2, um auf Regex-Gruppenwerte zuzugreifen. + OPEN AI + Bevorzugter Dienst: + Der ausgewählte ‚Bevorzugte Dienst‘ wird nur in diesem Repository gesetzt und verwendet. Wenn keiner gesetzt ist und mehrere Dienste verfügbar sind, wird ein Kontextmenü zur Auswahl angezeigt. + HTTP-Proxy + HTTP-Proxy für dieses Repository + Benutzername + Benutzername für dieses Repository + Bearbeite Steuerelemente für benutzerdefinierte Aktionen + Wert bei Markierung: + Falls ausgewählt, wird dieser Wert in Kommandozeilen-Argumenten benutzt + Beschreibung: + Standardwert: + Auf Ordner einschränken + Bezeichnung: + Einträge: + Nutze ‚|‘, um Einträge zu trennen + Die vordefinierten Parameter ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE} und ${TAG} bleiben hier verwendbar + Typ: + Arbeitsumgebungen + Farbe + Name + Offene Registerkarten beim Programmstart 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? + Neustart erforderlich + Du musst dieses Programm neustarten, um die Änderungen zu übernehmen. + Konventionelle Commit-Hilfe + Breaking Change: + Geschlossenes Ticket: + Änderungen im Detail: + Geltungsbereich: + Kurzbeschreibung: + Typ der Änderung: + Kopieren + Gesamten Text kopieren + Ganzen Pfad kopieren + Pfad kopieren + Branch erstellen… + Basierend auf: + Erstellten Branch auschecken + Lokale Änderungen: + Neuer Branch-Name: + Einen Branch-Namen eingeben + Lokalen Branch erstellen + Überschreibe existierenden Branch + Tag erstellen… + Neuer Tag auf: + Mit GPG signieren + Anmerkung: + Optional. + Tag-Name: + Empfohlenes Format: v1.0.0-alpha + Nach Erstellung auf alle Remotes pushen + Neuen Tag anlegen + Art: + Mit Anmerkung + Ohne Anmerkung + Halte ‚Strg‘ gedrückt, um direkt auszuführen + Ausschneiden + Verwerfen + Nichts tun + Stashen & wieder anwenden + Deinitialisiere Submodul + Erzwinge Deinitialisierung, selbst wenn lokale Änderungen enthalten sind. + Submodul: + Branch löschen + Branch: + Du bist dabei, einen Remote-Branch zu löschen!!! + Mehrere Branches löschen + Du versuchst, mehrere Branches auf einmal zu löschen. Kontrolliere dies sorgfältig, bevor du fortfährst! + Mehrere Tags löschen + Von Remotes löschen + Du versuchst, mehrere Tags auf einmal zu löschen. Kontrolliere dies sorgfältig, bevor du fortfährst! + 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-Repositorys löschen + BINÄRER VERGLEICH + Erster Unterschied + Ignoriere Leerzeichen-Änderungen + ÜBEREINANDER + DIFFERENZ + NEBENEINANDER + WISCHEN + Letzter Unterschied + LFS-OBJEKT-ÄNDERUNG + NEU + Nächster Unterschied + KEINE ÄNDERUNG ODER NUR ZEILENENDE-ÄNDERUNGEN + ALT + Vorheriger Unterschied + Als Patch speichern + Zeige versteckte Symbole + Nebeneinander + SUBMODUL + GELÖSCHT + NEU + Seiten tauschen + Syntax-Hervorhebung + Zeilenumbruch + Öffne in Merge-Tool + Alle Zeilen anzeigen + Weniger Zeilen anzeigen + Mehr Zeilen anzeigen + WÄHLE EINE DATEI AUS, UM ÄNDERUNGEN ANZUZEIGEN + Verzeichnisverlauf + Hat lokale Änderungen + Abweichungen vom Upstream + Bereits aktuell + Änderungen verwerfen + Alle Änderungen in der Arbeitskopie. + Änderungen: + Ignorierte Dateien einbeziehen + Nicht-verfolgte Dateien einbeziehen + {0} Änderungen werden verworfen + Du kannst das nicht rückgängig machen!!! + Beschreibung des Branches bearbeiten + Ziel-Branch: + Lesezeichen: + Neuer Name: + Ziel: + Ausgewählte Gruppe bearbeiten + Ausgewähltes Repository bearbeiten + Ziel: + Dieses Repository + Fetch + Alle Remotes fetchen + Überschreiben lokaler Refs erzwingen + Ohne Tags fetchen + Remote: + Remote-Änderungen fetchen + Als unverändert betrachten + Benutzerdefinierte Aktion + Verwerfen… + Verwerfe {0} Dateien… + Löse mit ${0}$ + Als Patch speichern… + Stagen + Stage {0} Dateien + Stashen… + Stashe {0} Dateien… + Unstagen + {0} Dateien unstagen + Meine verwenden (checkout --ours) + Deren verwenden (checkout --theirs) + Dateiverlauf + ÄNDERUNG + INHALT + Git Flow + Development-Branch: + Feature: + Feature-Präfix: + FLOW – Feature fertigstellen + FLOW – Hotfix fertigstellen + FLOW – Release fertigstellen + Ziel: + Squash beim Merge + Hotfix: + Hotfix-Präfix: + Git Flow initialisieren + Branch behalten + Production-Branch: + Release: + Release-Präfix: + Feature beginnen… + FLOW – Feature beginnen + Hotfix beginnen… + FLOW – Hotfix beginnen + Namen eingeben + Release beginnen… + FLOW – Release beginnen + Versions-Tag-Präfix: + 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. Dies aktualisiert nicht die Arbeitskopie. + LFS-Objekte fetchen + Installiere Git-LFS-Hooks + Sperren anzeigen + Keine gesperrten Dateien + Sperre + Zeige nur meine Sperren + LFS-Sperren + Entsperren + Alle meine Sperrungen aufheben + Sollen alle deine Dateien entsperrt werden? + Erzwinge entsperren + Prune + Führt `git lfs prune` aus, um alte LFS-Dateien aus dem lokalen Speicher zu löschen + Pull + Führt `git lfs pull` aus, um alle Git-LFS-Dateien 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 + COMMIT-ZEITPUNKT + VERLAUF & COMMIT-NACHRICHT + SHA + {0} COMMITS AUSGEWÄHLT + SPALTEN ANZEIGEN + Halte ‚Strg‘ oder ‚Umschalt‘, um mehrere Commits auszuwählen. + Halte ⌘ oder ⇧, um mehrere Commits auszuwählen + TIPPS: + Tastaturkürzel-Referenz + GLOBAL + Klone neues Repository + Aktuelle Registerkarte schließen + Zur nächsten Registerkarte wechseln + Zur vorherigen Registerkarte wechseln + Neue Registerkarte erstellen + Einstellungen öffnen + Auswahlmenü für Arbeitsumgebungen anzeigen + Aktive Registerkarte wechseln + Herein- / herauszoomen + REPOSITORY + Gestagte Änderungen committen + Gestagte Änderungen committen und pushen + Alle Änderungen stagen und committen + Fetch, wird direkt ausgeführt + Dashboard-Modus (Standard) + Springe zum Vorgänger des ausgewählten Commits + Befehlspalette öffnen + Commit-Suchmodus + Pull, wird direkt ausgeführt + Push, wird direkt ausgeführt + Erzwinge Neuladen des Repositorys + Wechsle zu ‚Änderungen‘ + Wechsle zu ‚Verlauf‘ + Wechsle zu ‚Stashes‘ + 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 + Lokale Änderungen stashen & wieder anwenden + Auf: + Drag & Drop zum Anordnen der Commits + Ziel-Branch: + Link kopieren + Im Browser öffnen + Befehle + FEHLER + INFO + Repositorys öffnen + Registerkarten + Arbeitsumgebungen + Branch mergen + Merge-Nachricht anpassen + Ziel-Branch: + Merge-Option: + Quelle: + Zuerst meine, dann deren + Zuerst deren, dann meine + BEIDE VERWENDEN + Alle Konflikte gelöst + {0} Konflikt(e) verbleibend + MEINE + Nächster Konflikt + Vorheriger Konflikt + ERGEBNIS + SPEICHERN & STAGEN + DEREN + Merge-Konflikt – {0} + Ungespeicherte Änderungen verwerfen? + MEINE VERWENDEN + DEREN VERWENDEN + RÜCKGÄNGIG MACHEN + Merge (mehrere) + Alle Änderungen committen + Strategie: + Ziele: + Submodul verschieben + Verschieben zu: + Submodul: + Verschiebe Repository-Knoten + Wähle Vorgänger-Knoten für: + Name: + NEIN + Git wurde NICHT konfiguriert. Gehe bitte zunächst in die [Einstellungen] und konfiguriere es. + Öffnen + Standard-Texteditor (System) + Programmdaten-Ordner öffnen + Datei öffnen + Öffnen in externem Merge-Tool + Optional. + Neue Registerkarte erstellen + Registerkarte schließen + Andere Registerkarten schließen + Registerkarten zur Rechten schließen + Kopiere Repository-Pfad + Bearbeiten + In Arbeitsumgebung verschieben + Aktualisieren + Repositorys + Einfügen + Vor {0} Tagen + Vor 1 Stunde + Vor {0} Stunden + Gerade eben + Letzter Monat + Letztes Jahr + Vor {0} Minuten + Vor {0} Monaten + Vor {0} Jahren + Gestern + Einstellungen + AI + API-Schlüssel + Name + Der eingegebene Wert ist der Name der Umgebungsvariable, aus der der API-Schlüssel gelesen wird + Server + DARSTELLUNG + Standardschriftart + Editor-Tab-Breite + Schriftgröße + Standard + Texteditor + Festbreiten-Schriftart + Design + Design-Anpassungen + Bildlaufleisten automatisch ausblenden + Fixe Tab-Breite in Titelleiste verwenden + Nativen Fensterrahmen verwenden + DIFF- / MERGE-TOOL + Argumente für Diff + Verfügbare Variablen: $LOCAL, $REMOTE + Argumente für Merge + Verfügbare Variablen: $BASE, $LOCAL, $REMOTE, $MERGED + Installationspfad + Pfad zum Diff- / Merge-Tool + Tool + ALLGEMEIN + Beim Programmstart nach Aktualisierungen suchen + Datumsformat + Aktiviere kompakte Ordner im Änderungsbaum + Sprache + Commit-Historie + Standardmäßig ‚ÄNDERUNGEN‘-Ansicht anzeigen + Standardmäßig Registerkarte ‚ÄNDERUNGEN‘ in Commit-Details anzeigen + Zeige Tags im Commit-Verlauf + Längenvorgabe für Commit-Nachrichten + Standard-Avatar im GitHub-Stil generieren + GIT + Aktiviere Auto-CRLF + Standard Klon-Ordner + Benutzer-E-Mail + Globale Git Benutzer-E-Mail + Aktiviere --prune beim Fetchen + Aktiviere --ignore-cr-at-eol für Text-Diff + Dieses Programm setzt Git (>= 2.25.1) voraus + Installationspfad + Aktiviere HTTP-SSL-Verifizierung + git-credential-libsecret statt git-credential-manager verwenden + Benutzername + Globaler Git-Benutzername + Git-Version + GPG-SIGNIERUNG + Commit-Signierung + GPG-Format + GPG-Installationspfad + Pfad zum GPG-Programm + Tag-Signierung + Benutzer-Signierungsschlüssel + GPG-Signierungsschlüssel des Benutzers + INTEGRATIONEN + SHELL / TERMINAL + Argumente + Bitte verwende ‚.‘ für das aktuelle Verzeichnis + Pfad + Shell / Terminal + Remote bereinigen + Ziel: + Worktrees bereinigen + Worktree-Informationen in `$GIT_COMMON_DIR/worktrees` bereinigen + Pull + Remote-Branch: + Lokaler Branch: + Lokale Änderungen: + Remote: + Pull (Fetch & Merge) + Rebase anstatt Merge verwenden + Push + Sicherstellen, dass Submodule gepusht wurden + Erzwinge Push + Lokaler Branch: + NEU + Remote: + Revision: + Revision zu Remote-Branch pushen + Push + Remote-Branch: + Remote-Branch verfolgen + Alle Tags pushen + Tag zum Remote pushen + Zu allen Remotes pushen + Remote: + Tag: + Push zu einem NEUEN Branch + Name des neuen Remote-Branches: + Schließen + Aktuellen Branch rebasen + Lokale Änderungen stashen & wieder anwenden + Auf: + Remote hinzufügen + Remote bearbeiten + Name: + Remote-Name + Repository-URL: + Remote Git-Repository-URL + URL kopieren + Benutzerdefinierte Aktion + 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` in diesem Repository aus. + Filter aufheben + Löschen + Repository-Einstellungen + WEITER + Benutzerdefinierte Aktionen + Keine benutzerdefinierten Aktionen + Dashboard + Alle Änderungen verwerfen + Öffne im Datei-Browser + Suche Branches / Tags / Submodule + Sichtbarkeit im Verlauf + Aufheben + Im Verlauf ausblenden + Im Verlauf filtern + LAYOUT + Horizontal + Vertikal + COMMIT-SORTIERUNG + Commit-Zeitpunkt + Topologisch + LOKALE BRANCHES + Mehr Optionen… + Zum HEAD wechseln + Erstelle Branch + BENACHRICHTIGUNGEN LÖSCHEN + Als Ordner öffnen + Öffne in {0} + Öffne in externen Tools + REMOTES + Remote hinzufügen + AUFLÖSEN + Commit suchen + Autor + Inhalt + Commit-Nachricht + Pfad + SHA + Aktueller Branch + Nur dekorierte Commits + Nur ersten Vorgänger anzeigen + ANZEIGE-FLAGS + Verlorene Commits anzeigen + Zeige Submodule als Baumstruktur + Zeige Tags als Baumstruktur + ÜBERSPRINGEN + Statistiken + SUBMODULE + Submodul hinzufügen + Submodul aktualisieren + TAGS + Neuer Tag + Nach Erstelldatum + Nach Name + Sortieren + Öffne im Terminal + 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: + Branch zurücksetzen (ohne Checkout) + Auf: + Branch: + Zeigen im Datei-Explorer + Commit rückgängig machen + Commit: + Commit-Änderungen rückgängig machen + In Bearbeitung. Bitte warten… + SPEICHERN + Speichern als… + Patch wurde erfolgreich gespeichert! + Durchsuche Repositorys + Stammverzeichnis: + Anderes benutzerdefiniertes Verzeichnis durchsuchen + Suche nach Aktualisierungen… + Eine neue Version dieses Programms ist verfügbar: + Derzeitige Version: + Suche nach Aktualisierungen fehlgeschlagen! + Herunterladen + Diese Version überspringen + Veröffentlichungsdatum neue Version: + Software-Update + Momentan sind keine Aktualisierungen verfügbar. + Branch des Submoduls setzen + Submodul: + Aktueller: + Ändern zu: + Optional. Leer lassen für Standardwert. + Setze verfolgten Branch + Branch: + Upstream-Verfolgung aufheben + Upstream: + SHA kopieren + Zum Commit wechseln + Privater SSH-Schlüssel: + Pfad zum privaten SSH-Schlüssel + START + Stash + Nicht-verfolgte Dateien einbeziehen + Name: + Optional. Name des Stashes. + Modus: + Nur gestagte Änderungen + Gestagte und ungestagte Änderungen der ausgewählten Datei(en) werden gestasht!!! + Lokale Änderungen stashen + Anwenden + Kopiere Nachricht + Entfernen + Als Patch speichern… + Stash entfernen + Entfernen: + STASHES + ÄNDERUNGEN + STASHES + Statistiken + ÜBERSICHT + MONAT + WOCHE + AUTOREN: + COMMITS: + SUBMODULE + Submodul hinzufügen + BRANCH + Branch + Relativer Pfad + Deinitialisiere Submodul + Untergeordnete Submodule fetchen + Verlauf + Verschieben nach + Öffne Submodul-Repository + Relativer Pfad: + Relativer Pfad, um dieses Submodul zu speichern. + Submodul löschen + Branch setzen + URL ändern + STATUS + geändert + nicht initialisiert + Revision geändert + nicht gemerged + Aktualisieren + URL + OK + TAGGER + ZEIT + Vergleiche 2 Tags + Vergleichen mit… + Vergleichen mit HEAD + Nachricht + Name + Tagger + Namen des Tags kopieren + Benutzerdefinierte Aktion + Lösche ${0}$… + Lösche ausgewählte {0} Tags… + Pushe ${0}$… + Submodule aktualisieren + Alle Submodule + Bei Bedarf initialisieren + Submodul: + Auf verfolgten Remote-Branch des Submoduls aktualisieren + 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 + Verschiebe in eine andere Gruppe + Öffne alle Repositorys + Öffne Repository + Öffne Terminal + Standard Klon-Ordner erneut nach Repositorys durchsuchen + Suche Repositorys… + ÄNDERUNGEN + Git Ignore + Ignoriere alle *{0} Dateien + Ignoriere *{0} Dateien im selben Ordner + Ignoriere nicht-verfolgte Dateien in diesem Ordner + Ignoriere nur diese Datei + Nachbesserung + Du kannst diese Datei jetzt stagen. + Verlauf löschen + Soll wirklich der Verlauf aller Commit-Nachrichten gelöscht werden? Dies kann nicht rückgängig gemacht werden. + COMMIT + COMMIT & PUSH + Vorlage / Historie + Klick-Ereignis auslösen + Commit (Bearbeitung) + Alle Änderungen stagen und committen + Du erzeugst einen Commit auf einem losgelösten (‚detached‘) HEAD. Möchtest du trotzdem fortfahren? + Du hast {0} Datei(en) gestaged, aber nur {1} werden angezeigt ({2} sind herausgefiltert). Möchtest du trotzdem fortfahren? + KONFLIKTE ERKANNT + MERGE + Merge mit externem Tool + ALLE KONFLIKTE IN EXTERNEM MERGE-TOOL ÖFFNEN + DATEI-KONFLIKTE SIND GELÖST + MEINE VERSION VERWENDEN + DEREN VERSION VERWENDEN + NICHT-VERFOLGTE DATEIEN EINBEZIEHEN + KEINE BISHERIGEN COMMIT-NACHRICHTEN + KEINE COMMIT-VORLAGEN + Ohne Überprüfung + Autor zurücksetzen + Abzeichnen + GESTAGED + UNSTAGEN + ALLES UNSTAGEN + UNGESTAGED + STAGEN + ALLES STAGEN + ALS UNVERÄNDERT BETRACHTETE ANZEIGEN + Vorlage: ${0}$ + ARBEITSUMGEBUNG: + Arbeitsumgebungen konfigurieren… + WORKTREE + Pfad kopieren + Sperren + Öffnen + Entfernen + Entsperren + JA + diff --git a/src/Resources/Locales/el_GR.axaml b/src/Resources/Locales/el_GR.axaml new file mode 100644 index 000000000..4de2a9003 --- /dev/null +++ b/src/Resources/Locales/el_GR.axaml @@ -0,0 +1,1038 @@ + + + + + + Σχετικά + Σχετικά με το SourceGit + Ημερομηνία έκδοσης: {0} + Σημειώσεις έκδοσης + Ελεύθερο & ανοιχτού κώδικα γραφικό περιβάλλον για το Git + Προσθήκη αρχείου(-ων) στα αγνοούμενα + Μοτίβο: + Αρχείο αποθήκευσης: + Προσθήκη Worktree + Τοποθεσία: + Διαδρομή για αυτό το worktree. Υποστηρίζεται σχετική διαδρομή. + Όνομα κλάδου: + Προαιρετικό. Προεπιλογή είναι το όνομα του φακέλου προορισμού. + Παρακολούθηση κλάδου: + Παρακολούθηση απομακρυσμένου κλάδου + Τι να γίνει checkout: + Δημιουργία νέου κλάδου + Υπάρχων κλάδος + Βοηθός AI + ΜΟΝΤΕΛΟ + ΕΠΑΝΑΔΗΜΙΟΥΡΓΙΑ + Χρήση AI για δημιουργία μηνύματος commit + Χρήση + Απόκρυψη SourceGit + Απόκρυψη άλλων + Εμφάνιση όλων + Τριμερής συγχώνευση (3-Way) + Επιλέξτε αρχείο .patch για εφαρμογή + Αγνόηση αλλαγών κενών χαρακτήρων + Πηγή: + Αρχείο + Πρόχειρο + Εφαρμογή Patch + Κενά: + Εφαρμογή Stash + Διαγραφή μετά την εφαρμογή + Επαναφορά των αλλαγών του index + Stash: + Αρχειοθέτηση... + Αποθήκευση αρχείου σε: + Επιλέξτε διαδρομή για το αρχείο + Αναθεώρηση: + Αρχειοθέτηση + SourceGit Askpass + Εισαγωγή κωδικής φράσης: + ΑΡΧΕΙΑ ΠΟΥ ΘΕΩΡΟΥΝΤΑΙ ΑΜΕΤΑΒΛΗΤΑ + ΚΑΝΕΝΑ ΑΡΧΕΙΟ ΔΕΝ ΘΕΩΡΕΙΤΑΙ ΑΜΕΤΑΒΛΗΤΟ + Φόρτωση εικόνας... + Ανανέωση + ΤΟ ΔΥΑΔΙΚΟ ΑΡΧΕΙΟ ΔΕΝ ΥΠΟΣΤΗΡΙΖΕΤΑΙ!!! + Bisect + Ματαίωση + Κακό + Καλό + Παράλειψη + Εκτέλεση bisect. Κάντε checkout σε άλλο και μετά σημειώστε το ως καλό ή κακό. + Εκτέλεση bisect. Σημειώστε το τρέχον commit ως κακό ή κάντε checkout σε άλλο και μετά σημειώστε το ως κακό. + Εκτέλεση bisect. Σημειώστε το τρέχον commit ως καλό ή κακό και κάντε checkout σε άλλο. + Εκτέλεση bisect. Το τρέχον HEAD είναι καλό ή κακό; + Blame + Blame στην προηγούμενη αναθεώρηση + Αγνόηση αλλαγών κενών χαρακτήρων + ΤΟ BLAME ΣΕ ΑΥΤΟ ΤΟ ΑΡΧΕΙΟ ΔΕΝ ΥΠΟΣΤΗΡΙΖΕΤΑΙ!!! + Checkout ${0}$... + Σύγκριση των 2 επιλεγμένων κλάδων + Σύγκριση με... + Σύγκριση με το HEAD + Σύγκριση με ${0}$ + Αντιγραφή ονόματος κλάδου + Δημιουργία PR... + Δημιουργία PR για το upstream ${0}$... + Προσαρμοσμένη ενέργεια + Διαγραφή ${0}$... + Διαγραφή {0} επιλεγμένων κλάδων + Επεξεργασία περιγραφής για ${0}$... + Fast-Forward στο ${0}$ + Fetch ${0}$ στο ${1}$... + Git Flow - Ολοκλήρωση ${0}$ + Διαδραστικό rebase του ${0}$ πάνω στο ${1}$ + Συγχώνευση ${0}$ στο ${1}$... + Συγχώνευση {0} επιλεγμένων κλάδων στον τρέχοντα + Pull ${0}$ + Pull ${0}$ στο ${1}$... + Push ${0}$ + Rebase του ${0}$ πάνω στο ${1}$... + Μετονομασία ${0}$... + Επαναφορά ${0}$ στο ${1}$... + Μετάβαση στο ${0}$ (worktree) + Ορισμός κλάδου παρακολούθησης... + {0} commit μπροστά + {0} commit μπροστά, {1} commit πίσω + {0} commit πίσω + Μη έγκυρο + ΑΠΟΜΑΚΡΥΣΜΕΝΟ + ΚΑΤΑΣΤΑΣΗ + ΠΑΡΑΚΟΛΟΥΘΗΣΗ + URL + WORKTREE + ΑΚΥΡΟ + Επαναφορά στη γονική αναθεώρηση + Επαναφορά σε αυτή την αναθεώρηση + Δημιουργία μηνύματος commit + Συγχώνευση (ενσωματωμένη) + Συγχώνευση (εξωτερική) + Επαναφορά αρχείου(-ων) στο ${0}$ + ΑΛΛΑΓΗ ΤΡΟΠΟΥ ΕΜΦΑΝΙΣΗΣ + Εμφάνιση ως λίστα αρχείων και φακέλων + Εμφάνιση ως λίστα διαδρομών + Εμφάνιση ως δέντρο αρχείων + Αλλαγή URL της υπομονάδας + Υπομονάδα: + URL: + Checkout κλάδου + Τοπικές αλλαγές: + Κλάδος: + Το τρέχον HEAD περιέχει commit που δεν συνδέονται με κανέναν κλάδο/ετικέτα! Θέλετε να συνεχίσετε; + Οι ακόλουθες υπομονάδες πρέπει να ενημερωθούν:{0}Θέλετε να τις ενημερώσετε; + Checkout & Fast-Forward + Fast-Forward στο: + Checkout κλάδου από Stash + Νέος κλάδος: + Stash: + Checkout commit ή ετικέτας + Στόχος: + Προσοχή: Μετά από αυτό, το HEAD θα αποσυνδεθεί (detached) + Cherry-Pick + Προσθήκη πηγής στο μήνυμα commit + Commit: + Commit όλων των αλλαγών + Κύρια γραμμή: + Συνήθως δεν μπορείτε να κάνετε cherry-pick μια συγχώνευση, επειδή δεν γνωρίζετε ποια πλευρά της συγχώνευσης πρέπει να θεωρηθεί η κύρια γραμμή. Αυτή η επιλογή επιτρέπει στο cherry-pick να αναπαράγει την αλλαγή σε σχέση με τον καθορισμένο γονέα. + Εκκαθάριση Stashes + Πρόκειται να εκκαθαρίσετε όλα τα stashes. Είστε σίγουροι ότι θέλετε να συνεχίσετε; + Κλωνοποίηση απομακρυσμένου αποθετηρίου + Επιπλέον παράμετροι: + Πρόσθετα ορίσματα για την κλωνοποίηση του αποθετηρίου. Προαιρετικό. + Σελιδοδείκτης: + Ομάδα: + Τοπικό όνομα: + Όνομα αποθετηρίου. Προαιρετικό. + Γονικός φάκελος: + Αρχικοποίηση & ενημέρωση υπομονάδων + URL αποθετηρίου: + ΚΛΕΙΣΙΜΟ + Επεξεργαστής + Κλάδοι + Κλάδοι & Ετικέτες + Προσαρμοσμένες ενέργειες αποθετηρίου + Αρχεία αναθεώρησης + Checkout commit + Cherry-Pick commit + Cherry-Pick ... + Σύγκριση με το HEAD + Σύγκριση με το Worktree + Συγγραφέας + Ώρα συγγραφέα + Μήνυμα + Committer + Ώρα committer + SHA + Θέμα + Προσαρμοσμένη ενέργεια + Διαδραστικό rebase + Απόρριψη (Drop)... + Επεξεργασία... + Fixup στον γονέα... + Διαδραστικό rebase του ${0}$ πάνω στο ${1}$ + Επαναδιατύπωση... + Squash στον γονέα... + Συγχώνευση ... + Push ${0}$ στο ${1}$ + Rebase του ${0}$ πάνω στο ${1}$ + Επαναφορά ${0}$ στο ${1}$ + Αναίρεση commit + Αποθήκευση ως Patch... + ΑΛΛΑΓΕΣ + αλλαγμένο(-α) αρχείο(-α) + Αναζήτηση αλλαγών... + Σύμπτυξη λεπτομερειών + ΑΡΧΕΙΑ + Αρχείο LFS + Αναζήτηση αρχείων... + Υπομονάδα + ΠΛΗΡΟΦΟΡΙΕΣ + ΣΥΓΓΡΑΦΕΑΣ + COMMITTER + Έλεγχος refs που περιέχουν αυτό το commit + ΤΟ COMMIT ΠΕΡΙΕΧΕΤΑΙ ΣΕ + Αντιγραφή email + Αντιγραφή ονόματος + Αντιγραφή ονόματος & email + Εμφανίζονται μόνο οι πρώτες 100 αλλαγές. Δείτε όλες τις αλλαγές στην καρτέλα ΑΛΛΑΓΕΣ. + Κλειδί: + ΜΗΝΥΜΑ + ΓΟΝΕΙΣ + REFS + SHA + Υπογράφων: + Άνοιγμα στον περιηγητή + ΣΤΗΛΗ + Εισαγάγετε το μήνυμα commit. Χρησιμοποιήστε μια κενή γραμμή για να διαχωρίσετε το θέμα από την περιγραφή! + ΘΕΜΑ + Σύγκριση + ΑΛΛΑΓΕΣ + COMMITS + COMMITS ΜΟΝΟ ΑΡΙΣΤΕΡΑ + COMMITS ΜΟΝΟ ΔΕΞΙΑ + Συμβουλή: Μπορείτε να κάνετε cherry-pick τα επιλεγμένα commits αν η άλλη πλευρά είναι το HEAD (μέσω του μενού περιβάλλοντος). + Ρύθμιση αποθετηρίου + ΠΡΟΤΥΠΟ COMMIT + Ενσωματωμένες παράμετροι: + + ${branch_name} Όνομα τρέχοντος τοπικού κλάδου. + ${files_num} Πλήθος αλλαγμένων αρχείων + ${files} Διαδρομές αλλαγμένων αρχείων + ${files:N} Έως N διαδρομές αλλαγμένων αρχείων + ${pure_files} Όπως το ${files}, αλλά μόνο καθαρά ονόματα αρχείων + ${pure_files:N} Όπως το ${files:N}, αλλά χωρίς φακέλους + Περιεχόμενο προτύπου: + Όνομα προτύπου: + ΠΡΟΣΑΡΜΟΣΜΕΝΗ ΕΝΕΡΓΕΙΑ + Ορίσματα: + Ενσωματωμένες παράμετροι: + + ${REPO} Διαδρομή του αποθετηρίου + ${REMOTE} Επιλεγμένο remote ή το remote του επιλεγμένου κλάδου + ${BRANCH} Επιλεγμένος κλάδος, χωρίς το τμήμα ${REMOTE} για απομακρυσμένους κλάδους + ${BRANCH_FRIENDLY_NAME} Φιλικό όνομα του επιλεγμένου κλάδου, περιέχει το τμήμα ${REMOTE} για απομακρυσμένους κλάδους + ${SHA} Hash του επιλεγμένου commit + ${TAG} Επιλεγμένη ετικέτα + ${FILE} Επιλεγμένο αρχείο, σε σχέση με τη ρίζα του αποθετηρίου + $1, $2 ... Τιμές των στοιχείων εισόδου + Εκτελέσιμο αρχείο: + Στοιχεία εισόδου: + Επεξεργασία + Όνομα: + Εμβέλεια: + Κλάδος + Commit + Αρχείο + Απομακρυσμένο + Αποθετήριο + Ετικέτα + Αναμονή για έξοδο της ενέργειας + Διεύθυνση email + Διεύθυνση email + GIT + Ερώτηση πριν την αυτόματη ενημέρωση υπομονάδων + Αυτόματο fetch από τα απομακρυσμένα + Λεπτό(-ά) + Τύποι Conventional Commit + Προεπιλεγμένο απομακρυσμένο + Ενεργοποίηση `--recursive` κατά την αυτόματη ενημέρωση υπομονάδων + Προτιμώμενος τρόπος συγχώνευσης + ΣΥΣΤΗΜΑ ΠΑΡΑΚΟΛΟΥΘΗΣΗΣ ΖΗΤΗΜΑΤΩΝ + Προσθήκη κανόνα Azure DevOps + Προσθήκη κανόνα Gerrit Change-Id Commit + Προσθήκη κανόνα Gitee Issue + Προσθήκη κανόνα Gitee Pull Request + Προσθήκη κανόνα GitHub + Προσθήκη κανόνα GitLab Issue + Προσθήκη κανόνα GitLab Merge Request + Προσθήκη κανόνα Jira + Νέος κανόνας + Κανονική έκφραση ζητήματος: + Όνομα κανόνα: + Κοινή χρήση αυτού του κανόνα στο αρχείο .issuetracker + URL αποτελέσματος: + Χρησιμοποιήστε $1, $2 για πρόσβαση στις τιμές των ομάδων της κανονικής έκφρασης. + AI + Προτιμώμενη υπηρεσία: + Αν οριστεί η «Προτιμώμενη υπηρεσία», το SourceGit θα τη χρησιμοποιεί μόνο σε αυτό το αποθετήριο. Διαφορετικά, αν υπάρχουν περισσότερες από μία διαθέσιμες υπηρεσίες, θα εμφανίζεται μενού για να επιλέξετε μία. + HTTP Proxy + HTTP proxy που χρησιμοποιεί αυτό το αποθετήριο + Όνομα χρήστη + Όνομα χρήστη για αυτό το αποθετήριο + Επεξεργασία στοιχείων προσαρμοσμένης ενέργειας + Τιμή όταν επιλεγεί: + Όταν είναι επιλεγμένο, αυτή η τιμή θα χρησιμοποιείται στα ορίσματα της γραμμής εντολών + Περιγραφή: + Προεπιλογή: + Είναι φάκελος: + Ετικέτα: + Επιλογές: + Χρησιμοποιήστε το '|' ως διαχωριστικό για τις επιλογές + Μορφοποιητής συμβολοσειράς: + Προαιρετικό. Χρησιμοποιείται για μορφοποίηση της συμβολοσειράς εξόδου. Αγνοείται όταν η είσοδος είναι κενή. Χρησιμοποιήστε το `${VALUE}` για να αναπαραστήσετε τη συμβολοσειρά εισόδου. + Οι ενσωματωμένες μεταβλητές ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, και ${TAG} παραμένουν διαθέσιμες εδώ + Τύπος: + Χρήση φιλικού ονόματος: + Χώροι εργασίας + Χρώμα + Όνομα + Επαναφορά καρτελών κατά την εκκίνηση + ΣΥΝΕΧΕΙΑ + Εντοπίστηκε κενό commit! Θέλετε να συνεχίσετε (--allow-empty); + ΠΡΟΣΘΗΚΗ ΟΛΩΝ & COMMIT + ΠΡΟΣΘΗΚΗ ΕΠΙΛΕΓΜΕΝΩΝ & COMMIT + Εντοπίστηκε κενό commit! Θέλετε να συνεχίσετε (--allow-empty) ή να γίνει αυτόματη προσθήκη στο ευρετήριο και μετά commit; + Απαιτείται επανεκκίνηση + Πρέπει να επανεκκινήσετε την εφαρμογή για να εφαρμοστούν οι αλλαγές. + Βοηθός Conventional Commit + Breaking Change: + Ζήτημα που κλείνει: + Λεπτομέρειες αλλαγών: + Εμβέλεια: + Σύντομη περιγραφή: + Τύπος αλλαγής: + Αντιγραφή + Αντιγραφή όλου του κειμένου + Αντιγραφή ως Patch + Αντιγραφή πλήρους διαδρομής + Αντιγραφή διαδρομής + Δημιουργία κλάδου... + Με βάση: + Checkout του κλάδου που δημιουργήθηκε + Τοπικές αλλαγές: + Όνομα νέου κλάδου: + Εισαγάγετε όνομα κλάδου. + Δημιουργία τοπικού κλάδου + Αντικατάσταση υπάρχοντος κλάδου + Δημιουργία ετικέτας... + Νέα ετικέτα στο: + Υπογραφή GPG + Μήνυμα ετικέτας: + Προαιρετικό. + Όνομα ετικέτας: + Προτεινόμενη μορφή: v1.0.0-alpha + Push σε όλα τα απομακρυσμένα μετά τη δημιουργία + Δημιουργία ετικέτας + Είδος: + επισημειωμένη + ελαφριά + Κρατήστε Ctrl για άμεση εκκίνηση + Αποκοπή + Απόρριψη + Καμία ενέργεια + Stash & επανεφαρμογή + Απο-αρχικοποίηση υπομονάδας + Εξαναγκασμός απο-αρχικοποίησης ακόμη κι αν περιέχει τοπικές αλλαγές. + Υπομονάδα: + Διαγραφή κλάδου + Θέλετε να διαγράψετε και τον ακόλουθο απομακρυσμένο κλάδο; + Κλάδος: + Εξαναγκασμένη διαγραφή ακόμη κι αν περιέχει μη συγχωνευμένα commit + Πρόκειται να διαγράψετε έναν απομακρυσμένο κλάδο!!! + Διαγραφή πολλαπλών κλάδων + Προσπαθείτε να διαγράψετε πολλούς κλάδους ταυτόχρονα. Βεβαιωθείτε ότι ελέγξατε ξανά πριν προχωρήσετε! + Διαγραφή πολλαπλών ετικετών + Διαγραφή τους από τα απομακρυσμένα + Προσπαθείτε να διαγράψετε πολλές ετικέτες ταυτόχρονα. Βεβαιωθείτε ότι ελέγξατε ξανά πριν προχωρήσετε! + Διαγραφή απομακρυσμένου + Απομακρυσμένο: + Διαδρομή: + Στόχος: + Όλα τα παιδιά θα αφαιρεθούν από τη λίστα. + Αυτό θα το αφαιρέσει μόνο από τη λίστα, όχι από τον δίσκο! + Επιβεβαίωση διαγραφής ομάδας + Επιβεβαίωση διαγραφής αποθετηρίου + Διαγραφή υπομονάδας + Διαδρομή υπομονάδας: + Διαγραφή ετικέτας + Ετικέτα: + Διαγραφή από τα απομακρυσμένα αποθετήρια + ΔΥΑΔΙΚΟ DIFF + ΚΕΝΟ ΑΡΧΕΙΟ + Πρώτη διαφορά + Αγνόηση αλλαγών κενών χαρακτήρων + ΑΝΑΜΕΙΞΗ + ΔΙΑΦΟΡΑ + ΔΙΠΛΑ-ΔΙΠΛΑ + ΣΑΡΩΣΗ + Τελευταία διαφορά + ΑΛΛΑΓΗ ΑΝΤΙΚΕΙΜΕΝΟΥ LFS + ΝΕΟ + Επόμενη διαφορά + ΚΑΜΙΑ ΑΛΛΑΓΗ Ή ΜΟΝΟ ΑΛΛΑΓΕΣ EOL + ΠΑΛΙΟ + Προηγούμενη διαφορά + Αποθήκευση ως Patch + Εμφάνιση κρυφών συμβόλων + Diff δίπλα-δίπλα + ΥΠΟΜΟΝΑΔΑ + ΔΙΑΓΡΑΦΗΚΕ + ΝΕΟ + + {0} μη υποβληθείσες αλλαγές + Εναλλαγή + Επισήμανση σύνταξης + Αναδίπλωση γραμμών + Άνοιγμα στο εργαλείο συγχώνευσης + Εμφάνιση όλων των γραμμών + Μείωση αριθμού ορατών γραμμών + Αύξηση αριθμού ορατών γραμμών + ΕΠΙΛΕΞΤΕ ΑΡΧΕΙΟ ΓΙΑ ΠΡΟΒΟΛΗ ΑΛΛΑΓΩΝ + Ιστορικό φακέλου + Έχει τοπικές αλλαγές + Αναντιστοιχία με το upstream + Ήδη ενημερωμένο + Απόρριψη αλλαγών + Όλες οι τοπικές αλλαγές στο αντίγραφο εργασίας. + Αλλαγές: + Συμπερίληψη αγνοούμενων αρχείων + Συμπερίληψη τροποποιημένων/διαγραμμένων αρχείων + Συμπερίληψη μη παρακολουθούμενων αρχείων + {0} αλλαγές θα απορριφθούν + Δεν μπορείτε να αναιρέσετε αυτή την ενέργεια!!! + Επεξεργασία περιγραφής κλάδου + Στόχος: + Σελιδοδείκτης: + Νέο όνομα: + Στόχος: + Επεξεργασία επιλεγμένης ομάδας + Επεξεργασία επιλεγμένου αποθετηρίου + Στόχος: + Αυτό το αποθετήριο + Fetch + Fetch από όλα τα απομακρυσμένα + Εξαναγκασμός αντικατάστασης τοπικών refs + Fetch χωρίς ετικέτες + Απομακρυσμένο: + Fetch απομακρυσμένων αλλαγών + Θεώρηση ως αμετάβλητο + Προσαρμοσμένη ενέργεια + Απόρριψη... + Απόρριψη {0} αρχείων... + Επίλυση με χρήση ${0}$ + Αποθήκευση ως Patch... + Προσθήκη στο ευρετήριο + Προσθήκη {0} αρχείων στο ευρετήριο + Stash... + Stash {0} αρχείων... + Αφαίρεση από το ευρετήριο + Αφαίρεση {0} αρχείων από το ευρετήριο + Χρήση δικού μου (checkout --ours) + Χρήση δικού τους (checkout --theirs) + Ιστορικό αρχείου + ΑΛΛΑΓΗ + ΠΕΡΙΕΧΟΜΕΝΟ + Αλλαγή mode αρχείου: + mode διαγραμμένου αρχείου: + Κατάλογος + Εκτελέσιμο + mode νέου αρχείου: + Κανονικό + Υπομονάδα + Συμβολικός σύνδεσμος + Άγνωστο + Git-Flow + Κλάδος ανάπτυξης: + Feature: + Πρόθεμα Feature: + Ολοκλήρωση ${0}$ + FLOW - Ολοκλήρωση Feature + FLOW - Ολοκλήρωση Hotfix + FLOW - Ολοκλήρωση Release + Στόχος: + Rebase πριν τη συγχώνευση + Squash κατά τη συγχώνευση + Hotfix: + Πρόθεμα Hotfix: + Αρχικοποίηση Git-Flow + Διατήρηση κλάδου + Κλάδος παραγωγής: + Release: + Πρόθεμα Release: + Έναρξη στο: + Έναρξη Feature... + FLOW - Έναρξη Feature + Έναρξη Hotfix... + FLOW - Έναρξη Hotfix + Όνομα: + Εισαγάγετε όνομα + Έναρξη Release... + FLOW - Έναρξη Release + Πρόθεμα ετικέτας έκδοσης: + Git LFS + Προσθήκη μοτίβου παρακολούθησης... + Το μοτίβο είναι όνομα αρχείου + Προσαρμοσμένο μοτίβο: + Προσθήκη μοτίβου παρακολούθησης στο Git LFS + Fetch + Εκτελέστε `git lfs fetch` για να κατεβάσετε αντικείμενα Git LFS. Αυτό δεν ενημερώνει το αντίγραφο εργασίας. + Fetch αντικειμένων LFS + Εγκατάσταση hooks του Git LFS + Εμφάνιση κλειδωμάτων + Δεν υπάρχουν κλειδωμένα αρχεία + Κλείδωμα + Εμφάνιση μόνο των δικών μου κλειδωμάτων + Κλειδώματα LFS + Ξεκλείδωμα + Ξεκλείδωμα όλων των δικών μου κλειδωμάτων + Είστε σίγουροι ότι θέλετε να ξεκλειδώσετε όλα τα κλειδωμένα σας αρχεία; + Εξαναγκασμός ξεκλειδώματος + Εκκαθάριση + Εκτελέστε `git lfs prune` για να διαγράψετε παλιά αρχεία LFS από την τοπική αποθήκευση + Pull + Εκτελέστε `git lfs pull` για να κατεβάσετε όλα τα αρχεία Git LFS για το τρέχον ref & checkout + Pull αντικειμένων LFS + Push + Push των μεγάλων αρχείων που βρίσκονται σε αναμονή προς το endpoint του Git LFS + Push αντικειμένων LFS + Απομακρυσμένο: + Παρακολούθηση αρχείων με όνομα '{0}' + Παρακολούθηση όλων των αρχείων *{0} + Επιλογή commit + ΙΣΤΟΡΙΚΟ + ΣΥΓΓΡΑΦΕΑΣ + ΩΡΑ ΣΥΓΓΡΑΦΕΑ + ΩΡΑ COMMIT + ΓΡΑΦΗΜΑ & ΘΕΜΑ + SHA + ΕΠΙΣΗΜΑΝΣΕΙΣ ΣΤΟ ΓΡΑΦΗΜΑ + Όλα + Μόνο ο τρέχων κλάδος + Τρέχων κλάδος & επιλεγμένα commits + Μόνο τα επιλεγμένα commits + ΕΠΙΛΕΓΜΕΝΑ {0} COMMITS + ΕΜΦΑΝΙΣΗ ΣΤΗΛΩΝ + Κρατήστε 'Ctrl' ή 'Shift' για να επιλέξετε πολλαπλά commits. + Κρατήστε ⌘ ή ⇧ για να επιλέξετε πολλαπλά commits. + ΣΥΜΒΟΥΛΕΣ: + Άνοιγμα σε ξεχωριστό παράθυρο + Λεπτομέρειες commit + Σύγκριση αναθεωρήσεων + Αναφορά συντομεύσεων πληκτρολογίου + ΚΑΘΟΛΙΚΑ + Κλωνοποίηση νέου αποθετηρίου + Κλείσιμο τρέχουσας καρτέλας + Μετάβαση στην επόμενη καρτέλα + Μετάβαση στην προηγούμενη καρτέλα + Δημιουργία νέας καρτέλας + Άνοιγμα τοπικού αποθετηρίου + Άνοιγμα διαλόγου Προτιμήσεων + Εμφάνιση αναπτυσσόμενου μενού χώρων εργασίας + Εναλλαγή ενεργής καρτέλας + Μεγέθυνση/σμίκρυνση + ΑΠΟΘΕΤΗΡΙΟ + Commit των αλλαγών στο ευρετήριο + Commit και push των αλλαγών στο ευρετήριο + Προσθήκη όλων των αλλαγών στο ευρετήριο και commit + Δημιουργία νέου κλάδου + Fetch, ξεκινά άμεσα + Λειτουργία πίνακα ελέγχου (Προεπιλογή) + Μετάβαση στον απόγονο του επιλεγμένου commit + Μετάβαση στον γονέα του επιλεγμένου commit + Άνοιγμα παλέτας εντολών + Λειτουργία αναζήτησης commit + Pull, ξεκινά άμεσα + Push, ξεκινά άμεσα + Εξαναγκασμός επαναφόρτωσης αυτού του αποθετηρίου + Ανάπτυξη/σύμπτυξη πίνακα λεπτομερειών στο `ΙΣΤΟΡΙΚΟ` + Μετάβαση στις 'ΤΟΠΙΚΕΣ ΑΛΛΑΓΕΣ' + Μετάβαση στο 'ΙΣΤΟΡΙΚΟ' + Μετάβαση στα 'STASHES' + Απόρριψη + Προσθήκη στο ευρετήριο + Αφαίρεση από το ευρετήριο + Αρχικοποίηση αποθετηρίου + Θέλετε να εκτελέσετε την εντολή `git init` σε αυτή τη διαδρομή; + Το άνοιγμα του αποθετηρίου απέτυχε. Αιτία: + Διαδρομή: + Cherry-Pick σε εξέλιξη. + Επεξεργασία commit + Συγχώνευση σε εξέλιξη. + Συγχώνευση + Rebase σε εξέλιξη. + Σταμάτησε στο + Αναίρεση σε εξέλιξη. + Αναίρεση commit + Διαδραστικό rebase + Stash & επανεφαρμογή τοπικών αλλαγών + Παράκαμψη του pre-rebase hook + Πάνω στο: + Σύρετε και αφήστε για αναδιάταξη των commits + Κλάδος-στόχος: + Αντιγραφή συνδέσμου + Άνοιγμα στον περιηγητή + Εντολές + ΣΦΑΛΜΑ + ΕΙΔΟΠΟΙΗΣΗ + Άνοιγμα αποθετηρίων + Καρτέλες + Χώροι εργασίας + Συγχώνευση κλάδου + Προσαρμογή μηνύματος συγχώνευσης + Στο: + Τρόπος συγχώνευσης: + Πηγή: + Έλεγχος για συγχώνευση... + Η συγχώνευση μπορεί να γίνει χωρίς συγκρούσεις + Παρουσιάστηκε άγνωστο σφάλμα κατά τον έλεγχο για συγχώνευση + Η συγχώνευση θα προκαλέσει συγκρούσεις + Πρώτα το δικό μου, μετά το δικό τους + Πρώτα το δικό τους, μετά το δικό μου + ΧΡΗΣΗ ΚΑΙ ΤΩΝ ΔΥΟ + Όλες οι συγκρούσεις επιλύθηκαν + Απομένουν {0} συγκρούσεις + ΔΙΚΟ ΜΟΥ + Επόμενη σύγκρουση + Προηγούμενη σύγκρουση + ΑΠΟΤΕΛΕΣΜΑ + ΑΠΟΘΗΚΕΥΣΗ & ΠΡΟΣΘΗΚΗ + ΔΙΚΟ ΤΟΥΣ + Συγκρούσεις συγχώνευσης + Απόρριψη μη αποθηκευμένων αλλαγών; + ΧΡΗΣΗ ΔΙΚΟΥ ΜΟΥ + ΧΡΗΣΗ ΔΙΚΟΥ ΤΟΥΣ + ΑΝΑΙΡΕΣΗ + Συγχώνευση (πολλαπλή) + Commit όλων των αλλαγών + Στρατηγική: + Στόχοι: + Μετακίνηση υπομονάδας + Μετακίνηση σε: + Υπομονάδα: + Μετακίνηση κόμβου αποθετηρίου + Επιλέξτε γονικό κόμβο για: + Όνομα: + ΟΧΙ + Το Git ΔΕΝ έχει ρυθμιστεί. Μεταβείτε στις [Προτιμήσεις] και ρυθμίστε το πρώτα. + Άνοιγμα + Προεπιλεγμένος επεξεργαστής (συστήματος) + Άνοιγμα φακέλου αποθήκευσης δεδομένων + Άνοιγμα αρχείου + Άνοιγμα σε εξωτερικό εργαλείο συγχώνευσης + Άνοιγμα τοπικού αποθετηρίου + Σελιδοδείκτης: + Ομάδα: + Φάκελος: + Προαιρετικό. + Δημιουργία νέας καρτέλας + Κλείσιμο καρτέλας + Κλείσιμο άλλων καρτελών + Κλείσιμο καρτελών στα δεξιά + Αντιγραφή διαδρομής αποθετηρίου + Επεξεργασία + Μετακίνηση σε χώρο εργασίας + Ανανέωση + Αποθετήρια + Επικόλληση + {0} ημέρες πριν + 1 ώρα πριν + {0} ώρες πριν + Μόλις τώρα + Τον προηγούμενο μήνα + Πέρυσι + {0} λεπτά πριν + {0} μήνες πριν + {0} χρόνια πριν + Χθες + Προτιμήσεις + AI + Επιπλέον prompt (χρησιμοποιήστε `-` για να απαριθμήσετε τις απαιτήσεις σας) + Κλειδί API + Μοντέλο + Αυτόματη ανάκτηση διαθέσιμων model-ids + Όνομα + Η τιμή που εισήχθη είναι το όνομα για φόρτωση του κλειδιού API από το ENV + Διακομιστής + ΕΜΦΑΝΙΣΗ + Προεπιλεγμένη γραμματοσειρά + Πλάτος tab επεξεργαστή + Μέγεθος γραμματοσειράς + Προεπιλογή + Επεξεργαστής + Γραμματοσειρά σταθερού πλάτους + Θέμα + Παρακάμψεις θέματος + Χρήση μπαρών κύλισης αυτόματης απόκρυψης + Χρήση σταθερού πλάτους καρτελών στη γραμμή τίτλου + Χρήση εγγενούς πλαισίου παραθύρου + ΕΡΓΑΛΕΙΟ DIFF/MERGE + Ορίσματα Diff + Διαθέσιμες μεταβλητές: $LOCAL, $REMOTE + Ορίσματα Merge + Διαθέσιμες μεταβλητές: $BASE, $LOCAL, $REMOTE, $MERGED + Διαδρομή εγκατάστασης + Εισαγάγετε τη διαδρομή του εργαλείου diff/merge + Εργαλείο + ΓΕΝΙΚΑ + Έλεγχος για ενημερώσεις κατά την εκκίνηση + Μορφή ημερομηνίας + Ενεργοποίηση συμπαγών φακέλων στο δέντρο αλλαγών + Γλώσσα + Commits ιστορικού + Εμφάνιση της σελίδας `ΤΟΠΙΚΕΣ ΑΛΛΑΓΕΣ` από προεπιλογή + Εμφάνιση της καρτέλας `ΑΛΛΑΓΕΣ` στις λεπτομέρειες commit από προεπιλογή + Εμφάνιση σχετικού χρόνου στο γράφημα commit + Εμφάνιση ετικετών στο γράφημα commit + Μήκος οδηγού θέματος + 24ωρη μορφή + Χρήση συμπαγών ονομάτων κλάδων στο γράφημα commit + Δημιουργία προεπιλεγμένου avatar τύπου Github + GIT + Ενεργοποίηση αυτόματου CRLF + Προεπιλεγμένος φάκελος κλωνοποίησης + Email χρήστη + Καθολικό email χρήστη git + Εκκαθάριση νεκρών κλάδων μετά το fetch + Αγνόηση CR στο τέλος γραμμής στο diff κειμένου + Απαιτείται Git (>= 2.25.1) από αυτή την εφαρμογή + Διαδρομή εγκατάστασης + Ενεργοποίηση επαλήθευσης HTTP SSL + Χρήση git-credential-libsecret αντί για git-credential-manager + Όνομα χρήστη + Καθολικό όνομα χρήστη git + Stash & επανεφαρμογή αλλαγών από προεπιλογή κατά το checkout ή τη συγχώνευση κλάδων + Έκδοση Git + ΥΠΟΓΡΑΦΗ GPG + Υπογραφή GPG στα commit + Μορφή GPG + Διαδρομή εγκατάστασης προγράμματος + Εισαγάγετε τη διαδρομή του εγκατεστημένου προγράμματος gpg + Υπογραφή GPG στις ετικέτες + Κλειδί υπογραφής χρήστη + Κλειδί υπογραφής gpg του χρήστη + ΕΝΣΩΜΑΤΩΣΗ + SHELL/ΤΕΡΜΑΤΙΚΟ + Ορίσματα + Χρησιμοποιήστε το '.' για να υποδείξετε τον φάκελο εργασίας + Διαδρομή + Shell/Τερματικό + Εκκαθάριση απομακρυσμένου + Στόχος: + Εκκαθάριση Worktrees + Εκκαθάριση πληροφοριών worktree στο `$GIT_COMMON_DIR/worktrees` + Pull + Απομακρυσμένος κλάδος: + Στο: + Τοπικές αλλαγές: + Απομακρυσμένο: + Pull (Fetch & Merge) + Χρήση rebase αντί για συγχώνευση + Push + Βεβαιωθείτε ότι έχει γίνει push στις υπομονάδες + Εξαναγκασμένο push + Τοπικός κλάδος: + ΝΕΟ + Απομακρυσμένο: + Αναθεώρηση: + Push αναθεώρησης σε απομακρυσμένο + Push αλλαγών σε απομακρυσμένο + Απομακρυσμένος κλάδος: + Ορισμός ως κλάδου παρακολούθησης + Push όλων των ετικετών + Push ετικέτας σε απομακρυσμένο + Push σε όλα τα απομακρυσμένα + Απομακρυσμένο: + Ετικέτα: + Push σε ΝΕΟ κλάδο + Εισαγάγετε το όνομα του νέου απομακρυσμένου κλάδου: + Έξοδος + Rebase τρέχοντος κλάδου + Stash & επανεφαρμογή τοπικών αλλαγών + Παράκαμψη του pre-rebase hook + Πάνω στο: + Έλεγχος για rebase ... + Το rebase μπορεί να γίνει χωρίς συγκρούσεις + Παρουσιάστηκε άγνωστο σφάλμα κατά τον έλεγχο για rebase + Το rebase θα προκαλέσει συγκρούσεις + Προσθήκη απομακρυσμένου + Επεξεργασία απομακρυσμένου + Όνομα: + Όνομα απομακρυσμένου + URL αποθετηρίου: + URL απομακρυσμένου αποθετηρίου git + Αντιγραφή URL + Προσαρμοσμένη ενέργεια + Διαγραφή... + Επεξεργασία... + Ενεργοποίηση αυτόματου Fetch + Fetch + Άνοιγμα στον περιηγητή + Εκκαθάριση + Επιβεβαίωση αφαίρεσης Worktree + Ενεργοποίηση της επιλογής `--force` + Στόχος: + Μετονομασία κλάδου + Νέο όνομα: + Μοναδικό όνομα για αυτόν τον κλάδο + Κλάδος: + ΜΑΤΑΙΩΣΗ + Αυτόματο fetch αλλαγών από τα απομακρυσμένα... + Ταξινόμηση + Κατά ημερομηνία committer + Κατά όνομα + Εκκαθάριση (GC & Prune) + Εκτέλεση της εντολής `git gc` για αυτό το αποθετήριο. + Εκκαθάριση όλων + Εκκαθάριση + Ρύθμιση αυτού του αποθετηρίου + ΣΥΝΕΧΕΙΑ + Προσαρμοσμένες ενέργειες + Καμία προσαρμοσμένη ενέργεια + Πίνακας ελέγχου + Απόρριψη όλων των αλλαγών + Άνοιγμα στον περιηγητή αρχείων + Αναζήτηση κλάδων/ετικετών/υπομονάδων + Ορατότητα στο γράφημα + Μη ορισμένο + Απόκρυψη στο γράφημα commit + Φιλτράρισμα στο γράφημα commit + ΔΙΑΤΑΞΗ + Οριζόντια + Κάθετη + ΣΕΙΡΑ COMMITS + Ημερομηνία commit + Τοπολογικά + ΤΟΠΙΚΟΙ ΚΛΑΔΟΙ + Περισσότερες επιλογές... + Μετάβαση στο HEAD + Δημιουργία κλάδου + ΕΚΚΑΘΑΡΙΣΗ ΕΙΔΟΠΟΙΗΣΕΩΝ + Άνοιγμα ως φάκελος + Άνοιγμα σε {0} + Άνοιγμα σε εξωτερικά εργαλεία + ΑΠΟΜΑΚΡΥΣΜΕΝΑ + Προσθήκη απομακρυσμένου + ΕΠΙΛΥΣΗ + Αναζήτηση commit + Συγγραφέας + Περιεχόμενο + Μήνυμα + Διαδρομή + SHA + Τρέχων κλάδος + Μόνο commits με αναφορές + Μόνο πρώτος γονέας + ΕΜΦΑΝΙΣΗ ΣΗΜΑΙΩΝ + Εμφάνιση χαμένων commits + Εμφάνιση υπομονάδων ως δέντρο + Εμφάνιση ετικετών ως δέντρο + ΠΑΡΑΛΕΙΨΗ + Στατιστικά + ΥΠΟΜΟΝΑΔΕΣ + Προσθήκη υπομονάδας + Ενημέρωση υπομονάδας + ΕΤΙΚΕΤΕΣ + Νέα ετικέτα + Κατά ημερομηνία δημιουργίας + Κατά όνομα + Ταξινόμηση + Άνοιγμα στο τερματικό + Προβολή καταγραφών + Επίσκεψη '{0}' στον περιηγητή + WORKTREES + Προσθήκη Worktree + Εκκαθάριση + URL αποθετηρίου Git + Επαναφορά τρέχοντος κλάδου σε αναθεώρηση + Τρόπος επαναφοράς: + Μετακίνηση σε: + Τρέχων κλάδος: + Επαναφορά κλάδου (χωρίς checkout) + Μετακίνηση σε: + Κλάδος: + Εμφάνιση στον περιηγητή αρχείων + Αναίρεση commit + Commit: + Commit των αλλαγών αναίρεσης + Εκτελείται. Παρακαλώ περιμένετε... + ΑΠΟΘΗΚΕΥΣΗ + Αποθήκευση ως... + Το patch αποθηκεύτηκε με επιτυχία! + Σάρωση αποθετηρίων + Ριζικός φάκελος: + Σάρωση άλλου προσαρμοσμένου φακέλου + Έλεγχος για ενημερώσεις... + Είναι διαθέσιμη νέα έκδοση αυτού του λογισμικού: + Τρέχουσα έκδοση: + Ο έλεγχος για ενημερώσεις απέτυχε! + Λήψη + Παράλειψη αυτής της έκδοσης + Ημερομηνία έκδοσης νέας έκδοσης: + Ενημέρωση λογισμικού + Δεν υπάρχουν διαθέσιμες ενημερώσεις αυτή τη στιγμή. + Ορισμός κλάδου υπομονάδας + Υπομονάδα: + Τρέχων: + Αλλαγή σε: + Προαιρετικό. Όταν είναι κενό, ορίζεται στην προεπιλογή. + Ορισμός κλάδου παρακολούθησης + Κλάδος: + Κατάργηση upstream + Upstream: + Αντιγραφή SHA + Μετάβαση σε + Ιδιωτικό κλειδί SSH: + Διαδρομή αποθήκευσης ιδιωτικού κλειδιού SSH + ΕΝΑΡΞΗ + Stash + Συμπερίληψη μη παρακολουθούμενων αρχείων + Μήνυμα: + Προαιρετικό. Μήνυμα αυτού του stash + Λειτουργία: + Μόνο τα αρχεία στο ευρετήριο + Θα γίνουν stash τόσο οι αλλαγές στο ευρετήριο όσο και αυτές εκτός ευρετηρίου των επιλεγμένων αρχείων!!! + Stash τοπικών αλλαγών + Εφαρμογή + Εφαρμογή αλλαγών + Checkout νέου κλάδου + Αντιγραφή μηνύματος + Απόρριψη + Αποθήκευση ως Patch... + Απόρριψη Stash + Απόρριψη: + STASHES + ΑΛΛΑΓΕΣ + STASHES + Στατιστικά + ΕΠΙΣΚΟΠΗΣΗ + ΜΗΝΑΣ + ΕΒΔΟΜΑΔΑ + ΣΥΓΓΡΑΦΕΙΣ: + COMMITS: + ΥΠΟΜΟΝΑΔΕΣ + Προσθήκη υπομονάδας + ΚΛΑΔΟΣ + Κλάδος + Σχετική διαδρομή + Απο-αρχικοποίηση + Fetch ένθετων υπομονάδων + Ιστορικό + Μετακίνηση... + Άνοιγμα αποθετηρίου + Σχετική διαδρομή: + Σχετικός φάκελος για την αποθήκευση αυτής της υπομονάδας. + Διαγραφή + Ορισμός κλάδου + Αλλαγή URL + ΚΑΤΑΣΤΑΣΗ + τροποποιημένη + μη αρχικοποιημένη + η αναθεώρηση άλλαξε + μη συγχωνευμένη + Ενημέρωση + URL + Λεπτομέρειες αλλαγής υπομονάδας + ΑΝΟΙΓΜΑ ΛΕΠΤΟΜΕΡΕΙΩΝ + ΟΚ + ΔΗΜΙΟΥΡΓΟΣ ΕΤΙΚΕΤΑΣ + ΩΡΑ + Checkout ετικέτας... + Σύγκριση 2 ετικετών + Σύγκριση με... + Σύγκριση με το HEAD + Μήνυμα + Όνομα + Δημιουργός ετικέτας + Αντιγραφή ονόματος ετικέτας + Προσαρμοσμένη ενέργεια + Διαγραφή ${0}$... + Διαγραφή {0} επιλεγμένων ετικετών... + Συγχώνευση ${0}$ στο ${1}$... + Push ${0}$... + Ενημέρωση υπομονάδων + Όλες οι υπομονάδες + Αρχικοποίηση όπου χρειάζεται + Υπομονάδα: + Ενημέρωση ένθετων υπομονάδων + Ενημέρωση στον απομακρυσμένο κλάδο παρακολούθησης της υπομονάδας + URL: + Καταγραφές + ΕΚΚΑΘΑΡΙΣΗ ΟΛΩΝ + Αντιγραφή + Διαγραφή + Προειδοποίηση + Σελίδα υποδοχής + Δημιουργία ομάδας + Δημιουργία υπο-ομάδας + Κλωνοποίηση αποθετηρίου + Διαγραφή + ΥΠΟΣΤΗΡΙΖΕΤΑΙ ΜΕΤΑΦΟΡΑ & ΑΠΟΘΕΣΗ ΦΑΚΕΛΟΥ. ΥΠΟΣΤΗΡΙΖΕΤΑΙ ΠΡΟΣΑΡΜΟΣΜΕΝΗ ΟΜΑΔΟΠΟΙΗΣΗ. + Επεξεργασία + Μετακίνηση σε άλλη ομάδα + Άνοιγμα όλων των αποθετηρίων + Άνοιγμα αποθετηρίου + Άνοιγμα τερματικού + Επανασάρωση αποθετηρίων στον προεπιλεγμένο φάκελο κλωνοποίησης + Αναζήτηση αποθετηρίων... + ΤΟΠΙΚΕΣ ΑΛΛΑΓΕΣ + Git Ignore + Αγνόηση όλων των αρχείων *{0} + Αγνόηση αρχείων *{0} στον ίδιο φάκελο + Αγνόηση μη παρακολουθούμενων αρχείων σε αυτόν τον φάκελο + Αγνόηση μόνο αυτού του αρχείου + Αγνόηση όλων των μη παρακολουθούμενων αρχείων στον ίδιο φάκελο + Τροποποίηση + Μπορείτε να προσθέσετε αυτό το αρχείο στο ευρετήριο τώρα. + Εκκαθάριση ιστορικού + Είστε σίγουροι ότι θέλετε να εκκαθαρίσετε όλο το ιστορικό μηνυμάτων commit; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. + COMMIT + COMMIT & PUSH + Πρότυπο/Ιστορικό + Ενεργοποίηση συμβάντος κλικ + Commit (Επεξεργασία) + Προσθήκη όλων των αλλαγών στο ευρετήριο και commit + Δημιουργείτε commit σε αποσυνδεδεμένο (detached) HEAD. Θέλετε να συνεχίσετε; + Έχετε προσθέσει στο ευρετήριο {0} αρχείο(-α) αλλά εμφανίζονται μόνο {1} αρχείο(-α) ({2} αρχεία είναι φιλτραρισμένα). Θέλετε να συνεχίσετε; + ΕΝΤΟΠΙΣΤΗΚΑΝ ΣΥΓΚΡΟΥΣΕΙΣ + ΣΥΓΧΩΝΕΥΣΗ + Συγχώνευση με εξωτερικό εργαλείο + ΑΝΟΙΓΜΑ ΟΛΩΝ ΤΩΝ ΣΥΓΚΡΟΥΣΕΩΝ ΣΕ ΕΞΩΤΕΡΙΚΟ ΕΡΓΑΛΕΙΟ ΣΥΓΧΩΝΕΥΣΗΣ + ΟΙ ΣΥΓΚΡΟΥΣΕΙΣ ΑΡΧΕΙΩΝ ΕΠΙΛΥΘΗΚΑΝ + ΧΡΗΣΗ ΔΙΚΟΥ ΜΟΥ + ΧΡΗΣΗ ΔΙΚΟΥ ΤΟΥΣ + ΣΥΜΠΕΡΙΛΗΨΗ ΜΗ ΠΑΡΑΚΟΛΟΥΘΟΥΜΕΝΩΝ ΑΡΧΕΙΩΝ + ΔΕΝ ΥΠΑΡΧΟΥΝ ΠΡΟΣΦΑΤΑ ΜΗΝΥΜΑΤΑ ΕΙΣΟΔΟΥ + ΔΕΝ ΥΠΑΡΧΟΥΝ ΠΡΟΤΥΠΑ COMMIT + Χωρίς έλεγχο + Επαναφορά συγγραφέα + Προσυπογραφή + ΣΤΟ ΕΥΡΕΤΗΡΙΟ + ΑΦΑΙΡΕΣΗ + ΑΦΑΙΡΕΣΗ ΟΛΩΝ + ΕΚΤΟΣ ΕΥΡΕΤΗΡΙΟΥ + ΠΡΟΣΘΗΚΗ + ΠΡΟΣΘΗΚΗ ΟΛΩΝ + ΠΡΟΒΟΛΗ ΑΡΧΕΙΩΝ ΠΟΥ ΘΕΩΡΟΥΝΤΑΙ ΑΜΕΤΑΒΛΗΤΑ + Πρότυπο: ${0}$ + ΧΩΡΟΣ ΕΡΓΑΣΙΑΣ: + Ρύθμιση χώρων εργασίας... + WORKTREE + ΚΛΑΔΟΣ + Αντιγραφή διαδρομής + HEAD + Κλείδωμα + Άνοιγμα + ΔΙΑΔΡΟΜΗ + Αφαίρεση + Ξεκλείδωμα + ΝΑΙ + diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 9d068e943..676bd57a0 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -1,477 +1,1039 @@ - - START - SURE - SAVE - CLOSE - CANCEL - Reveal in File Explorer - Save As ... - Save File to ... - Copy Path - Bytes - FILTER - Optional. - SELECT FOLDER - NOTICE - Open With ... - Running. Please wait ... - Warnning + + About + About SourceGit + Release Date: {0} + Release Notes + Opensource & Free Git GUI Client + Add File(s) To Ignore + Pattern: + Storage File: + 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 + MODEL + RE-GENERATE + Use AI to generate commit message + Use + Hide SourceGit + Hide Others + Show All + 3-Way Merge + Select .patch file to apply + Ignore whitespace changes + Source: + File + Clipboard + 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 + Enter passphrase: + FILES ASSUME UNCHANGED + NO FILES ASSUMED AS UNCHANGED + Load Image... + Refresh + BINARY FILE NOT SUPPORTED!!! + Bisect + Abort + Bad + Good + Skip + Bisecting. Please checkout another one then mark it as good or bad. + Bisecting. Mark current commit as bad or checkout another one then mark it as bad. + Bisecting. Mark current commit as good or bad and checkout another one. + Bisecting. Is current HEAD good or bad? + Blame + Blame on Previous Revision + Ignore whitespace changes + BLAME ON THIS FILE IS NOT SUPPORTED!!! + Checkout ${0}$... + Compare selected 2 branches + Compare with... + Compare with HEAD + Compare with ${0}$ + Copy Branch Name + Create PR... + Create PR for upstream ${0}$... + Custom Action + Delete ${0}$... + Delete selected {0} branches... + Edit description for ${0}$... + Fast-Forward to ${0}$ + Fetch ${0}$ into ${1}$ + Git Flow - Finish ${0}$... + Interactively Rebase ${0}$ on ${1}$ + 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}$... + Switch to ${0}$ (worktree) + Set Tracking Branch... + {0} commit(s) ahead + {0} commit(s) ahead, {1} commit(s) behind + {0} commit(s) behind + Invalid + REMOTE + STATUS + TRACKING + URL + WORKTREE + CANCEL + Reset to Parent Revision + Reset to This Revision + Generate commit message + Merge (Built-in) + Merge (External) + Reset File(s) to ${0}$ + CHANGE DISPLAY MODE + Show as File and Dir List + Show as Path List + Show as Filesystem Tree + Change Submodule's URL + Submodule: + URL: + Checkout Branch + Local Changes: + Branch: + Your current HEAD contains commit(s) not connected to any branches/tags! Do you want to continue? + The following submodules need to be updated:{0}Do you want to update them? + Checkout & Fast-Forward + Fast-Forward to: + Checkout Branch From Stash + New Branch: + Stash: + Checkout Commit or Tag + Target: + Warning: After doing this, HEAD will be detached + 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. + Bookmark: + Group: + Local Name: + Repository name. Optional. + Parent Folder: + Initialize & update submodules + Repository URL: + CLOSE + Editor + Branches + Branches & Tags + Repository Custom Actions + Revision Files + Checkout Commit... + Cherry-Pick Commit... + Cherry-Pick... + Compare with HEAD + Compare with Worktree + Author + Author Time + Message + Committer + Committer Time + SHA + Subject + Custom Action + Interactive Rebase + Drop... + Edit... + Fixup into Parent... + Interactively Rebase ${0}$ on ${1}$ + Reword... + Squash into Parent... + Merge... + Push ${0}$ to ${1}$... + Rebase ${0}$ on ${1}$... + Reset ${0}$ to ${1}$... + Revert Commit... + Save as Patch... + CHANGES + changed file(s) + Search Changes... + Collapse details + FILES + LFS File + Search Files... + Submodule + INFORMATION + AUTHOR + COMMITTER + Check refs that contains this commit + COMMIT IS CONTAINED BY + Copy Email + Copy Name + Copy Name & Email + Shows only the first 100 changes. See all changes on the CHANGES tab. + Key: + MESSAGE + PARENTS + REFS + SHA + Signer: + Open in Browser + COL + Enter commit message. Please use an empty-line to separate subject and description! + SUBJECT + Compare + CHANGES + COMMITS + LEFT ONLY COMMITS + RIGHT ONLY COMMITS + Tips: You can cherry-pick selected commits if the other side is HEAD (using context menu). + Repository Configure + COMMIT TEMPLATE + Built-in parameters: - Copy - Paste - Cut + ${branch_name} Current local branch name. + ${files_num} Number of changed files + ${files} Paths of changed files + ${files:N} Max N number of paths of changed files + ${pure_files} Likes ${files}, but only pure file names + ${pure_files:N} Likes ${files:N}, but without folders + Template Content: + Template Name: + CUSTOM ACTION + Arguments: + Built-in parameters: - Refresh - - Name : - URL : - Git Repository URL - - SSH Private Key : - Private SSH key store path - - About - Copyright © 2024 sourcegit-scm. - • Build with - • TextEditor from - • Monospace fonts come from - - 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 - Relative Path : - Relative foler to store this module. - Fetch nested submodules - Open Submodule Repository - Copy Relative Path - Delete Submodule - - Checkout Branch - Target : - - Cherry-Pick This Commit - Cherry Pick - Commit : - Commit all changes - - Clone Remote Repository - Repository URL : - Parent Folder : - Local Name : - Repository name. Optional. - Extra Parameters : - Additional arguments to clone repository. Optional. - - INFORMATION - AUTHOR - COMMITTER - SHA - PARENTS - REFS - MESSAGE - CHANGED - CHANGES - Search Files ... - FILES - LFS File - Submodule - Tag - Tree - - 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 - - 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 (Ctrl+F) - Search Author/Committer/Message/SHA - Statistics - Cleanup(GC & Prune) - Run `gc` command and do `lfs prune` if LFS is installed. - Configure this repository - WORKSPACE - LOCAL BRANCHES - NEW BRANCH - REMOTES - ADD REMOTE - TAGS - NEW TAG - SUBMODULES - ADD SUBMODULE - UPDATE SUBMODULE - 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 ... - FLOW - Start Feature - FLOW - Start Release - FLOW - Start Hotfix - Enter name - FLOW - Finish Feature - FLOW - Finish Release - FLOW - Finish Hotfix - Target : - Keep branch - - Bookmark - Open - Explore in File Manager - - 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 - Target : - 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 SHA - - 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 - Stage Changes in Selected Line(s) - Discard Changes in Selected Line(s) - Unstage Changes in Selected Line(s) - - Delete Branch - Branch : - - Delete Remote - Remote : - - Delete Tag - Tag : - Delete from remote repositories - - 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 - - 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 - - Fast-Forward (without checkout) - - File History - - CHANGE DISPLAY MODE - Show as Grid - Show as List - Show as Tree - - 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 - NOTICE - - Create New Page (Ctrl+T) - Repositories - Close Tab - Close Other Tabs - Close Tabs to the Right - Bookmark - Copy Repository Path - - Merge Branch - Source Branch : - Into : - Merge Option : - - Open Repository - Open In Git Bash - Clone Repository - Edit - Create Group - Create Sub-Group - Delete - Search Repositories ... - Sort - DRAG & DROP FOLDER SUPPORTED - - Edit Selected Group - Edit Selected Repository - Target : - New Name : - Bookmark : - - Confirm Deleting Group - Confirm Deleting Repository - Target : - - 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 : - - Revert Commit - Commit : - Commit revert changes - - Preference - GENERAL - Language - Avatar Server - Theme - History Commits - Restore windows - Use fixed tab width in titlebar - Use macOS style titlebar - GIT - Install Path - Git version - Default Clone Dir - User Name - Global git user name - User Email - Global git user email - Enable Auto CRLF - Fetch remotes automatically - GPG SIGNING - Commit GPG signing - Install Path - Input path for installed gpg program - User Signing Key - User's gpg signing key - MERGE - Merger - Install Path - Input path for merge tool - Merge Command - Diff Command - - Stash - Stash Local Changes - Message : - Optional. Name of this stash - Include untracked files - - Stashes - STASHES - CHANGES - - 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 - - Conflict detected! Press 'Abort' to restore original HEAD - - Clear Stashes - You are trying to clear all stashes. Are you sure to continue? - - Reword Commit Message - On : - Message : - - Squash HEAD Into Parent - HEAD : - To : - Reword : - - FILES ASSUME UNCHANGED - REMOVE - NO FILES ASSUMED AS UNCHANGED - - Statistics - WEEK - MONTH - YEAR - Total Committers - Total Commits - COMMITTER - COMMITS - - Git has NOT been configured. Please to go [Preference] and configure it first. - BINARY FILE NOT SUPPORTED!!! - BLAME ON THIS FILE IS NOT SUPPORTED!!! - Patch has been saved successfully! + ${REPO} Repository's path + ${REMOTE} Selected remote or selected branch's remote + ${BRANCH} Selected branch, without ${REMOTE} part for remote branches + ${BRANCH_FRIENDLY_NAME} Friendly name of selected branch, contains ${REMOTE} part for remote branches + ${SHA} Selected commit's hash + ${TAG} Selected tag + ${FILE} Selected file, relative to repository root + $1, $2 ... Input control values + Executable File: + Input Controls: + Edit + Name: + Scope: + Branch + Commit + File + Remote + Repository + Tag + Wait for action exit + Email Address + Email address + GIT + Ask before auto-updating submodules + Fetch remotes automatically + Minute(s) + Conventional Commit Types + Default Remote + Enable `--recursive` when auto-updating submodules + Preferred Merge Mode + ISSUE TRACKER + Add Azure DevOps Rule + Add Gerrit Change-Id Commit Rule + Add Gitee Issue Rule + Add Gitee Pull Request Rule + Add GitHub Rule + Add GitLab Issue Rule + Add GitLab Merge Request Rule + Add Jira Rule + New Rule + Issue Regex Expression: + Rule Name: + Share this rule in .issuetracker file + 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 + Edit Custom Action Controls + Checked Value: + When checked, this value will be used in command-line arguments + Description: + Default: + Is Folder: + Label: + Options: + Use '|' as delimiter for options + String Formatter: + Optional. Used to format output string. Ignored when input is empty. Please use `${VALUE}` to represent the input string. + The built-in variables ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, and ${TAG} remain available here + Type: + Use Friendly Name: + Workspaces + Color + Name + Restore tabs on startup + CONTINUE + Empty commit detected! Do you want to continue (--allow-empty)? + STAGE ALL & COMMIT + STAGE SELECTED & COMMIT + Empty commit detected! Do you want to continue (--allow-empty) or auto-stage then commit? + Restart Required + You need to restart this app to apply changes. + Conventional Commit Helper + Breaking Change: + Closed Issue: + Detail Changes: + Scope: + Short Description: + Type of Change: + Copy + Copy All Text + Copy as Patch + Copy Full Path + Copy Path + Create Branch... + Based On: + Check out the created branch + Local Changes: + New Branch Name: + Enter branch name. + 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 Tag + Kind: + annotated + lightweight + Hold Ctrl to start directly + Cut + Discard + Do Nothing + Stash & Reapply + De-initialize Submodule + Force de-init even if it contains local changes. + Submodule: + Delete Branch + Do you want to also delete the following remote branch? + Branch: + Force delete even if it contains unmerged commit(s) + You are about to delete a remote branch!!! + Delete Multiple Branches + You are trying to delete multiple branches at one time. Be sure to double-check before taking action! + Delete Multiple Tags + Delete them from remotes + You are trying to delete multiple tags 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 + EMPTY FILE + First Difference + Ignore Whitespace Changes + BLEND + DIFFERENCE + SIDE-BY-SIDE + SWIPE + Last Difference + LFS OBJECT CHANGE + NEW + Next Difference + NO CHANGES OR ONLY EOL CHANGES + OLD + Previous Difference + Save as Patch + Show hidden symbols + Side-By-Side Diff + SUBMODULE + DELETED + NEW + + {0} Uncommitted Changes + Swap + Syntax Highlighting + Line Word Wrap + Open in Merge Tool + Show All Lines + Decrease Number of Visible Lines + Increase Number of Visible Lines + SELECT FILE TO VIEW CHANGES + Dir History + Has Local Changes + Mismatched with Upstream + Already Up-To-Date + Discard Changes + All local changes in working copy. + Changes: + Include ignored files + Include modified/deleted files + Include untracked files + {0} changes will be discarded + You can't undo this action!!! + Edit Branch's Description + Target: + Bookmark: + New Name: + Target: + Edit Selected Group + Edit Selected Repository + Target: + This repository + Fetch + Fetch all remotes + Force override local refs + Fetch without tags + Remote: + Fetch Remote Changes + Assume unchanged + Custom Action + Discard... + Discard {0} files... + Resolve Using ${0}$ + Save as Patch... + Stage + Stage {0} files + Stash... + Stash {0} files... + Unstage + Unstage {0} files + Use Mine (checkout --ours) + Use Theirs (checkout --theirs) + File History + CHANGE + CONTENT + File mode change: + Deleted file mode: + Directory + Executable + New file mode: + Normal + Submodule + Symlink + Unknown + Git-Flow + Development Branch: + Feature: + Feature Prefix: + Finish ${0}$ + Finish Feature + Finish Hotfix + Finish Release + Target: + Rebase before merging + Squash during merge + Hotfix: + Hotfix Prefix: + Initialize Git-Flow + Keep branch + Production Branch: + Release: + Release Prefix: + Start at: + Start Feature... + Start Feature + Start Hotfix... + Start Hotfix + Name: + Enter name + Start Release... + 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 + Unlock all of my locks + Are you sure you want to unlock all your locked files? + 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 + Select Commit + HISTORY + AUTHOR + AUTHOR TIME + COMMIT TIME + GRAPH & SUBJECT + SHA + HIGHLIGHTS IN GRAPH + All + Current Branch Only + Current Branch & Selected Commits + Selected Commits Only + SELECTED {0} COMMITS + SHOW COLUMNS + Hold 'Ctrl' or 'Shift' to select multiple commits. + Hold ⌘ or ⇧ to select multiple commits. + TIPS: + Open in separate window + Commit Detail + Revision Compare + Keyboard Shortcuts Reference + GLOBAL + Clone remote repository + Close current tab + Go to next tab + Go to previous tab + Create new tab + Open local repository + Open Preferences dialog + Show workspace dropdown menu + Switch active tab + Zoom in/out + REPOSITORY + Commit staged changes + Commit and push staged changes + Stage all changes and commit + Create new branch + Fetch, starts directly + Dashboard mode (Default) + Goto child of selected commit + Goto parent of selected commit + Open command palette + Commit search mode + Pull, starts directly + Push, starts directly + Force to reload this repository + Expand/Collapse details panel in `HISTORY` + Switch to 'LOCAL CHANGES' + Switch to 'HISTORY' + Switch to 'STASHES' + Discard + Stage + Unstage + Initialize Repository + Do you want to run `git init` command under this path? + Open repository failed. Reason: + Path: + Cherry-Pick in progress. + Processing commit + Merge in progress. + Merging + Rebase in progress. + Stopped at + Revert in progress. + Reverting commit + Interactive Rebase + Stash & reapply local changes + Bypass the pre-rebase hook + On: + Drag-drop to reorder commits + Target Branch: + Copy Link + Open in Browser + Commands + ERROR + NOTICE + New Version Available + Open Repositories + Tabs + Workspaces + Merge Branch + Customize merge message + Into: + Merge Option: + Source: + Testing for merge... + Merge can be done without conflicts + Unknown error occurs while testing for merge + Merge will cause conflicts + First Mine, then Theirs + First Theirs, then Mine + USE BOTH + All conflicts resolved + {0} conflict(s) remaining + MINE + Next Conflict + Previous Conflict + RESULT + SAVE & STAGE + THEIRS + Merge Conflicts + Discard unsaved changes? + USE MINE + USE THEIRS + UNDO + Merge (Multiple) + Commit all changes + Strategy: + Targets: + Move Submodule + Move To: + Submodule: + Move Repository Node + Select parent node for: + Name: + NO + Git has NOT been configured. Please to go [Preferences] and configure it first. + Open + Default Editor (System) + Open Data Storage Directory + Open File + Open in External Merge Tool + Open Local Repository + Bookmark: + Group: + Folder: + Optional. + Create New Tab + Close Tab + Close Other Tabs + Close Tabs to the Right + Copy Repository Path + Edit + Move to Workspace + Refresh + 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 + Additional Prompt (Use `-` to list your requirements) + API Key + Model + Fetch available model-ids automatically + Name + Entered value is the name to load API key from ENV + Server + APPEARANCE + Default Font + Editor Tab Width + Font Size + Default + Editor + Monospace Font + Theme + Theme Overrides + Use auto-hide scrollbars + Use fixed tab width in titlebar + Use native window frame + DIFF/MERGE TOOL + Diff Arguments + Available variables: $LOCAL, $REMOTE + Merge Arguments + Available variables: $BASE, $LOCAL, $REMOTE, $MERGED + Install Path + Input path for diff/merge tool + Tool + GENERAL + Check for updates on startup + Date Format + Enable compact folders in changes tree + Language + History Commits + Show `LOCAL CHANGES` page by default + Show `CHANGES` tab in commit detail by default + Show relative time in commit graph + Show tags in commit graph + Subject Guide Length + 24-Hours + Use compact branch names in commit graph + Generate Github style default avatar + GIT + Enable Auto CRLF + Default Clone Dir + User Email + Global git user email + Prune dead branches after fetching + Ignore CR at end-of-line in text diff + Git (>= 2.25.1) is required by this app + Install Path + Enable HTTP SSL Verify + Use git-credential-libsecret instead of git-credential-manager + User Name + Global git user name + Stash & reapply changes by default when checking-out or merging branches + 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 + Arguments + Please use '.' to indicate working directory + Path + Shell/Terminal + Prune Remote + Target: + Prune Worktrees + Prune worktree information in `$GIT_COMMON_DIR/worktrees` + Pull + Remote Branch: + Into: + Local Changes: + Remote: + Pull (Fetch & Merge) + Use rebase instead of merge + Push + Make sure submodules have been pushed + Force push + Local Branch: + NEW + Remote: + Revision: + Push Revision To Remote + Push Changes To Remote + Remote Branch: + Set as tracking branch + Push all tags + Push Tag To Remote + Push to all remotes + Remote: + Tag: + Push to a NEW branch + Input name of the new remote branch: + Quit + Rebase Current Branch + Stash & reapply local changes + Bypass the pre-rebase hook + On: + Testing for rebase... + Rebase can be done without conflicts + Unknown error occurs while testing for rebase + Rebase will cause conflicts + Add Remote + Edit Remote + Name: + Remote name + Repository URL: + Remote git repository URL + Copy URL + Custom Action + Delete... + Edit... + Enable Auto-Fetch + 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 + Dashboard + Discard all changes + Open in File Browser + Search Branches/Tags/Submodules + Visibility in Graph + Collapse Filters + Unset + Hide in commit graph + Expand Filters + Filter in commit graph + filter(s) enabled + LAYOUT + Horizontal + Vertical + COMMITS ORDER + Commit Date + Topologically + LOCAL BRANCHES + More options... + Navigate to HEAD + Create Branch + CLEAR NOTIFICATIONS + Open as Folder + Open in {0} + Open in External Tools + REMOTES + Add Remote + RESOLVE + Search Commit + Author + Content + Message + Path + SHA + Current Branch + Decorated commits only + First-parent only + SHOW FLAGS + Show lost commits + Show Submodules as Tree + Show Tags as Tree + SKIP + Statistics + SUBMODULES + Add Submodule + Update Submodule + TAGS + Create Tag + By Creator Date + By Name + Sort + Open in Terminal + 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 + Running. Please wait... + SAVE + Save As... + Patch has been saved successfully! + Scan Repositories + Root Dir: + Scan another custom directory + Check for Updates... + New version of this software is available: + Current Version: + Check for updates failed! + Download + Skip This Version + New Version Release Date: + Software Update + There are currently no updates available. + Set Submodule's Branch + Submodule: + Current: + Change To: + Optional. Set to default when it is empty. + Set Tracking Branch + Branch: + Unset upstream + Upstream: + Copy SHA + Go to + SSH Private Key: + Private SSH key store path + START + Stash + Include untracked files + Message: + Optional. Message of this stash + Mode: + Only staged files + Both staged and unstaged changes of selected file(s) will be stashed!!! + Stash Local Changes + Apply + Apply Changes + Checkout New Branch + Copy Message + Drop + Save as Patch... + Drop Stash + Drop: + STASHES + CHANGES + STASHES + Statistics + OVERVIEW + MONTH + WEEK + AUTHORS: + COMMITS: + SUBMODULES + Add Submodule + BRANCH + Branch + Relative Path + De-initialize + Fetch nested submodules + History + Move To + Open Repository + Relative Path: + Relative folder to store this module. + Delete + Set Branch + Change URL + STATUS + modified + not initialized + revision changed + unmerged + Update + URL + Submodule Change Details + OPEN DETAILS + OK + TAGGER + TIME + Checkout Tag... + Compare 2 tags + Compare with... + Compare with HEAD + Message + Name + Tagger + Copy Tag Name + Custom Action + Delete ${0}$... + Delete selected {0} tags... + Merge ${0}$ into ${1}$... + Push ${0}$... + Update Submodules + All submodules + Initialize as needed + Submodule: + Update nested submodules + Update to submodule's remote tracking branch + 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... + LOCAL CHANGES + Git Ignore + Ignore all *{0} files + Ignore *{0} files in the same folder + Ignore untracked files in this folder + Ignore this file only + Ignore all untracked files in the same folder + Amend + You can stage this file now. + Clear History + Are you sure you want to clear all commit message history? This action cannot be undone. + COMMIT + COMMIT & PUSH + Template/History + Trigger click event + Commit (Edit) + Stage all changes and commit + You are creating commit on a detached HEAD. Do you want to continue? + You have staged {0} file(s) but only {1} file(s) displayed ({2} files are filtered out). Do you want to continue? + CONFLICTS DETECTED + MERGE + Merge with External Tool + OPEN ALL CONFLICTS IN EXTERNAL MERGETOOL + FILE CONFLICTS ARE RESOLVED + USE MINE + USE THEIRS + Filter Changes... + INCLUDE UNTRACKED FILES + NO RECENT INPUT MESSAGES + NO COMMIT TEMPLATES + No-Verify + Reset Author + SignOff + STAGED + UNSTAGE + UNSTAGE ALL + UNSTAGED + STAGE + STAGE ALL + VIEW ASSUME UNCHANGED + Template: ${0}$ + WORKSPACE: + Configure Workspaces... + WORKTREE + BRANCH + Copy Path + HEAD + Lock + Open + PATH + Remove + Unlock + YES diff --git a/src/Resources/Locales/es_ES.axaml b/src/Resources/Locales/es_ES.axaml new file mode 100644 index 000000000..ac2e7d76f --- /dev/null +++ b/src/Resources/Locales/es_ES.axaml @@ -0,0 +1,1042 @@ + + + + + + Acerca de + Acerca de SourceGit + Fecha de Release: {0} + Notas de la versión (Release) + Cliente Git GUI de código abierto y gratuito + Agregar Archivo(s) Para Ignorar + Patrón: + Almacenar Archivo: + 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 + Modelo + RE-GENERAR + Usar OpenAI para generar mensaje de commit + Usar + Ocultar SourceGit + Ocultar Otros + Mostrar Todo + Merge a 3 vías (3-Way) + Seleccionar archivo .patch para aplicar + Ignorar cambios de espacios en blanco + Fuente: + Archivo + Portapapeles + 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 + Introduce la frase de contraseña: + ARCHIVOS ASUMIDOS COMO SIN CAMBIOS + NO HAY ARCHIVOS ASUMIDOS COMO SIN CAMBIOS + Cargar Imagen... + Refrescar + ¡ARCHIVO BINARIO NO SOPORTADO! + Bisect + Abortar + Malo + Bueno + Saltar + Bisecting. Por favor revisa otro y márcalo como bueno o malo. + Bisecting. Marca el commit actual como malo o revisa otro y márcalo como malo. + Bisecting. Marcar el commit actual como bueno o malo y revisar otro. + Bisecting. ¿Es el HEAD actual bueno o malo? + Blame + Blame sobre la Revisión Previa + Ignorar cambios de espacios en blanco + ¡BLAME EN ESTE ARCHIVO NO SOPORTADO! + Checkout ${0}$... + Comparar las 2 ramas seleccionadas + Comparar con... + Comparar con HEAD + Comparar con ${0}$ + Copiar Nombre de la Rama + Crear PR... + Crear PR para upstream ${0}$... + Acción personalizada + Eliminar ${0}$... + Eliminar {0} ramas seleccionadas + Editar la descripción para ${0}$... + Fast-Forward a ${0}$ + Fetch ${0}$ en ${1}$... + Git Flow - Finalizar ${0}$ + Hacer Rebase interactivamente ${0}$ en ${1}$ + 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}$... + Cambiar a ${0}$ (worktree) + Establecer Rama de Seguimiento... + {0} commit(s) adelante + {0} commit(s) adelante, {1} commit(s) detrás + {0} commit(s) detrás + Inválido + REMOTO + ESTADO + SEGUIMIENTO + URL + WORKTREE + CANCELAR + Resetear a Revisión Padre + Resetear a Esta Revisión + Generar mensaje de commit + Merge (Incorporado) + Merge (Externa) + Resetear Archivo(s) a ${0}$ + CAMBIAR MODO DE VISUALIZACIÓN + Mostrar como Lista de Archivos y Directorios + Mostrar como Lista de Rutas + Mostrar como Árbol de Sistema de Archivos + Cambiar la URL del Submódulo + Submódulo: + URL: + Checkout Rama + Cambios Locales: + Rama: + ¡Tu HEAD actual contiene commit(s) que no están conectados a ningunas ramas/etiquetas! ¿Quieres continuar? + Los siguientes submódulos necesitan ser actualizados:{0} ¿Quieres actualizarlos? + Checkout y Fast-Forward + Fast-Forward a: + Checkout a la Rama desde el Stash + Nueva Rama: + Stash: + Hacer checkout a Commit o Etiqueta + Objetivo: + Advertencia: Después de hacer esto, se deprenderá/separará de HEAD + 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. + Marcador: + Grupo: + Nombre Local: + Nombre del repositorio. Opcional. + Carpeta Padre: + Inicializar y actualizar submódulos + URL del Repositorio: + CERRAR + Editor + Ramas + Ramas y Etiquetas + Acciones Personalizadas del Repositorio + Archivos de Revisión + Checkout Commit + Cherry-Pick Este Commit + Cherry-Pick ... + Comparar con HEAD + Comparar con Worktree + Autor + Fecha/Hora del Autor + Mensaje + Committer + Fecha/Hora del Committer + SHA + Asunto + Acción personalizada + Rebase interactivo + Eliminar... + Editar... + Arreglar en el Padre... + Hacer Rebase interactivamente ${0}$ en ${1}$ + Reescribir... + Squash en el Padre... + Merge ... + Push ${0}$ a ${1}$ + Rebase ${0}$ en ${1}$ + Resetear ${0}$ a ${1}$ + Revertir Commit + Guardar como Parche... + CAMBIOS + archivo(s) modificado(s) + Buscar Cambios... + Colapsar detalles + ARCHIVOS + Archivo LFS + Buscar Archivos... + Submódulo + INFORMACIÓN + AUTOR + COMMITTER + Ver refs que contienen este commit + COMMIT ESTÁ CONTENIDO EN + Copiar Email + Copiar Nombre + Copiar Nombre y Email + Muestra solo los primeros 100 cambios. Ver todos los cambios en la pestaña CAMBIOS. + Clave: + MENSAJE + PADRES + REFS + SHA + Firmante: + Abrir en Navegador + COL + Ingresa el mensaje de commit. ¡Por favor usa una línea en blanco para separar el título y la descripción! + ASUNTO + Comparar + CAMBIOS + COMMITS + SOLO COMMITS A LA IZQUIERDA + SOLO COMMITS A LA DERECHA + Tips: Puedes hacer cherry-pick a los commits seleccionados si el otro lado está en HEAD (usando el menú contextual). + Configurar Repositorio + PLANTILLA DE COMMIT + Parámetros incorporados: + + ${branch_name} Nombre de la rama local actual. + ${files_num} Número de archivos modificados + ${files} Rutas de archivos modificados + ${files:N} Número N máximo de rutas de archivos modificados + ${pure_files} Cómo ${files}, pero solo nombres de archivos puros + ${pure_files:N} Cómo ${files:N}, pero sin carpetas + Contenido de la Plantilla: + Nombre de la Plantilla: + ACCIÓN PERSONALIZADA + Argumentos: + Parámetros incorporados: + + ${REPO} Ruta del repositorio + ${REMOTE} Remoto seleccionado o Remoto de la rama seleccionada + ${BRANCH} Rama seleccionada, sin la parte ${REMOTE} para ramas remotas + ${BRANCH_FRIENDLY_NAME} Nombre amigable de la rama seleccionada, contiene la parte ${REMOTE} para ramas remotas + ${SHA} Hash del commit seleccionado + ${TAG} Etiqueta seleccionada + ${FILE} Archivo seleccionado, relativo a la raíz del repositorio + $1, $2 ... Valores de control de entrada + Archivo Ejecutable: + Controles de entrada: + Editar + Nombre: + Alcance: + Rama + Commit + Archivo + Remoto + Repositorio + Etiqueta + Esperar la acción de salida + Dirección de Email + Dirección de email + GIT + Preguntar antes de actualizar automáticamente los submódulos + Fetch remotos automáticamente + Minuto(s) + Tipos de Commit Convencionales + Remoto por Defecto + Habilitar `--recursive` cuando se actualicen automáticamente los submódulos + Modo preferido de Merge + SEGUIMIENTO DE INCIDENCIAS + Añadir Regla de Ejemplo para Azure DevOps + Añadir Regla de "Gerrit Change-Id Commit" + 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: + Compartir esta regla en el archivo .issuetracker + 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 del Usuario + Nombre del usuario para este repositorio + Editar Controles de Acción Personalizados + Valor Comprobado: + Cuando sea comprobado, este valor será usado en argumentos de la línea de comandos + Descripción: + Por defecto: + Es Carpeta: + Etiqueta: + Opciones: + Usar '|' como delimitador para las opciones + Formateador de Cadenas de texto: + Opcional. Utilizado para formatear la cadena de salida. Ignorado cuando la entrada está vacía. Por favor utiliza `${VALUE}` para representar la cadena de entrada. + La variables incorporadas ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, y ${TAG} permanecen disponibles aquí + Tipo: + Utilizar Nombre Amigable: + Espacios de Trabajo + Color + Nombre + Restaurar pestañas al iniciar + CONTINUAR + ¡Commit vacío detectado! ¿Quieres continuar (--allow-empty)? + HACER STAGE A TODO y COMMIT + STAGE LO SELECCIONADO y COMMIT + ¡Commit vacío detectado! ¿Quieres continuar (--allow-empty) o hacer stage a todo y después commit? + Reinicio Requerido + Necesita reiniciar esta aplicación para aplicar los cambios. + Asistente de Commit Convencional + Cambio Importante: + Incidencia Cerrada: + Detalles del Cambio: + Alcance: + Descripción Corta: + Tipo de Cambio: + Copiar + Copiar Todo el Texto + Copiar como Parche + Copiar Ruta Completa + Copiar Ruta + Crear Rama... + Basado En: + Checkout de la rama creada + Cambios Locales: + Nombre de la Nueva Rama: + Introduzca el nombre de la rama. + 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 + Descartar + No Hacer Nada + Stash y Volver a aplicar + Desinicializar Submódulo + Forzar desinicialización incluso si contiene cambios locales. + Submódulo: + Eliminar Rama + ¿Quieres que también se elimine la siguiente rama remota? + Rama: + Forzar la eliminación incluso si contiene commit(s) sin fusionar + ¡Estás a punto de eliminar una rama remota! + Eliminar Múltiples Ramas + Estás intentando eliminar múltiples ramas a la vez. ¡Asegúrate de comprobar dos veces antes de realizar esta acción! + Eliminar Múltiples Etiquetas + Eliminarlas de los remotos + Estás intentando eliminar múltiples etiquetas a la vez. ¡Asegúrate de comprobar dos veces antes de realizar esta 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 + ARCHIVO VACÍO + Primera Diferencia + Ignorar Cambio de Espacios en Blanco + MEZCLAR + DIFERENCIA + LADO-A-LADO + DESLIZAR + Última Diferencia + CAMBIO DE OBJETO LFS + NUEVO + Siguiente Diferencia + SIN CAMBIOS O SOLO CAMBIOS DE EOL + VIEJO + Diferencia Anterior + Guardar como Parche + Mostrar símbolos ocultos + Diferencia Lado a Lado + SUBMÓDULO + BORRADO + NUEVO + + {0} Cambios Sin confirmar + Intercambiar + Resaltado de Sintaxis + Ajuste de Línea + 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 + Historial del directorio + Tiene Cambios Locales + No coincide con Upstream + Ya se encuentra actualizado + Descartar Cambios + Todos los cambios locales en la copia de trabajo. + Cambios: + Incluir archivos ignorados + Incluir archivos modificados/eliminados + Incluir archivos sin rastrear + Total {0} cambios serán descartados + ¡No puedes deshacer esta acción! + Editar la descripción de la rama + Destino: + Marcador: + Nuevo Nombre: + Destino: + Editar Grupo Seleccionado + Editar Repositorio Seleccionado + Destino: + Este repositorio + Fetch + Fetch todos los remotos + Utilizar opción '--force' + Fetch sin etiquetas + Remoto: + Fetch Cambios Remotos + Asumir como sin cambios + Acción Personalizada + Descartar... + Descartar {0} archivos... + Resolver usando ${0}$ + Guardar como Parche... + Stage + Stage {0} archivos + Stash... + Stash {0} archivos... + Unstage + Unstage {0} archivos + Usar Míos (checkout --ours) + Usar Suyos (checkout --theirs) + Historial de Archivos + CAMBIO + CONTENIDO + Cambiar modo archivo: + Modo archivo eliminado: + Directorio + Ejecutable + Modo archivo nuevo: + Normal + Submódulo + Enlace simbólico (Symlink) + Desconocido + Git-Flow + Rama de Desarrollo: + Feature: + Prefijo de Feature: + Finalizar ${0}$ + FLOW - Finalizar Feature + FLOW - Finalizar Hotfix + FLOW - Finalizar Release + Destino: + Hacer rebase antes de fusionar + Squash durante el merge + Hotfix: + Prefijo de Hotfix: + Inicializar Git-Flow + Mantener rama + Rama de Producción: + Release: + Prefijo de Release: + Iniciar en: + Iniciar Feature... + FLOW - Iniciar Feature + Iniciar Hotfix... + FLOW - Iniciar Hotfix + Nombre: + 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 + Desbloquear todos mis candados + ¿Estás seguro de querer desbloquear todos tus archivos bloqueados? + 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} + Seleccionar Commit + Historias + AUTOR + HORA DEL AUTOR + FECHA DE COMMIT + GRÁFICO y ASUNTO + SHA + DESTACADOS EN EL GRÁFICO + Todos + Solo la Rama Actual + Rama Actual y los Commits Seleccionados + Solo los Commits Seleccionados + {0} COMMITS SELECCIONADOS + MOSTRAR COLUMNAS + Mantén 'Ctrl' o 'Shift' para seleccionar múltiples commits. + Mantén ⌘ o ⇧ para seleccionar múltiples commits. + CONSEJOS: + Abrir en una ventana separada + Detalles del Commit + Comparación de Revisiones + Referencia de Atajos de Teclado + GLOBAL + Clonar repositorio nuevo + Cerrar página actual + Ir a la siguiente página + Ir a la página anterior + Crear nueva página + Abrir repositorio local + Abrir diálogo de preferencias + Mostrar menú desplegable del espacio de trabajo + Cambiar página activa + Acercar/Alejar + REPOSITORIO + Commit cambios staged + Commit y push cambios staged + Stage todos los cambios y commit + Crear nueva rama + Fetch, empieza directamente + Modo Dashboard (Por Defecto) + Ir al hijo del commit seleccionado + Ir al padre del commit seleccionado + Abrir paleta de comandos + Modo de búsqueda de commits + Pull, empieza directamente + Push, empieza directamente + Forzar a recargar este repositorio + Expandir/Colapsar el panel de detalles en el `HISTORIAL`/`HISTORY` + Cambiar a 'Cambios' + Cambiar a 'Historias' + Cambiar a 'Stashes' + Descartar + Stage + Unstage + Inicializar Repositorio + ¿Quieres correr el comando `git init` en esta ruta? + Falló la apertura del repositorio. Razón: + 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 + Stash y volver a aplicar cambios locales + Saltar el hook de pre-rebase + En: + Arrastrar y soltar para reordenar commits + Rama Objetivo: + Copiar Enlace + Abrir en el Navegador + Comandos + ERROR + AVISO + Nueva Versión Disponible + Abrir Repositorios + Páginas + Espacios de trabajo + Merge Rama + Personalizar mensaje de merge + En: + Opción de Merge: + Rama Fuente: + Pruebas para hacer merge... + El merge se puede realizar sin conflictos + Error desconocido ocurrido al probar el merge + Hacer merge causará conflictos + Primero Míos, después Suyos + Primero Suyos, después Míos + USAR AMBOS + Todos los conflictos resueltos + {0} conflicto(s) restantes + MÍOS + Conflicto Siguiente + Conflicto Previo + RESULTADO + GUARDAR y hacer STAGE + SUYOS + Conflictos de Merge + ¿Descartar cambios sin guardar? + USAR MÍOS + USAR SUYOS + DESHACER + Merge (Multiple) + Commit todos los cambios + Estrategia: + Destino: + Mover Submódulo + Mover A: + Submódulo: + Mover Nodo del Repositorio + Seleccionar nodo padre para: + Nombre: + NO + Git NO ha sido configurado. Por favor, ve a [Preferencias] y configúralo primero. + Abrir + Editor por defecto (Sistema) + Abrir Directorio de Datos de la App + Abrir Archivo + Abrir en Herramienta de Merge + Abrir Repositorio Local + Marcador: + Grupo: + Carpeta: + Opcional. + Crear Nueva Página + Cerrar Pestaña + Cerrar Otras Pestañas + Cerrar Pestañas a la Derecha + Copiar Ruta del Repositorio + Editar + Mover al Espacio de trabajo + Actualizar + 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 + Prompt adicional (Usa `-` para listar tus requerimientos) + Clave API + Modelo + Traer automáticamente los model-ids disponibles + Nombre + El valor ingresado es el nombre de la clave API a cargar desde ENV + Servidor + APARIENCIA + Fuente por defecto + Ancho de la Pestaña del Editor + Tamaño de fuente + Por defecto + Editor + Fuente Monospace + Tema + Sobreescritura de temas + Usar barras de desplazamiento que se oculten automáticamente + Usar ancho de pestaña fijo en la barra de título + Usar marco de ventana nativo + HERRAMIENTA DIFF/MERGE + Argumentos para Diff + Variables disponibles: $LOCAL, $REMOTE + Argumentos para Merge + Variables disponibles: $BASE, $LOCAL, $REMOTE, $MERGED + Ruta de instalación + Introducir ruta para la herramienta diff/merge + Herramienta + GENERAL + Buscar actualizaciones al iniciar + Formato de Fecha + Habilitar carpetas compactas en el árbol de cambios + Idioma + Commits en el historial + Mostrar la página `CAMBIOS LOCALES` por defecto + Mostrar pestaña de `CAMBIOS` en los detalles del commit por defecto + Mostrar hora relativa en el gráfico de commits + Mostrar etiquetas en el gráfico de commit + Longitud de la guía del asunto + 24-Horas + Utilizar nombres de ramas compactos en el gráfico de commits + Generar avatar con estilo por defecto de Github + GIT + Habilitar Auto CRLF + Directorio de clonado por defecto + Email del 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 + Usar git-credential-libsecret en lugar de git-credential-manager + Nombre del usuario + Nombre global del usuario git + Hacer Stash y volver a aplicar cambios por defecto cuando se haga checkout o fusionen las ramas + 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 + Argumentos + Por favor utiliza '.' para indicar el directorio de trabajo + Ruta + Shell/Terminal + Podar Remoto + Destino: + Podar Worktrees + Podar información de worktree en `$GIT_COMMON_DIR/worktrees` + Pull + Rama Remota: + En: + Cambios Locales: + 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: + NUEVO + Remoto: + Revisión: + Push Revisión al 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: + Push a una NUEVA rama + Nombre de entrada de la nueva rama remota: + Salir + Rebase Rama Actual + Stash y volver a aplicar cambios locales + Saltar el hook de pre-rebase + En: + Pruebas para hacer rebase ... + El rebase se puede realizar sin conflictos + Error desconocido ocurrido al probar el rebase + Hacer rebase causará conflictos + Añadir Remoto + Editar Remoto + Nombre: + Nombre remoto + URL del Repositorio: + URL del repositorio git remoto + Copiar URL + Acción Personalizada + Borrar... + Editar... + Habilitar Auto-Fetch + 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 + Dashboard + Descartar todos los cambios + Abrir en el Explorador + Buscar Ramas/Etiquetas/Submódulos + Visibilidad en el Gráfico + Colapsar Filtros + Desestablecer + Ocultar en el Gráfico de Commits + Expandir Filtros + Filtrar en el Gráfico de Commits + filtro(s) activado(s) + DISPOSICIÓN + Horizontal + Vertical + ORDEN DE COMMITS + Fecha de Commit + Topológicamente + RAMAS LOCALES + Más opciones... + Navegar a HEAD + Crear Rama + LIMPIAR NOTIFICACIONES + Abrir como Carpeta + Abrir en {0} + Abrir en Herramientas Externas + REMOTOS + AÑADIR REMOTO + RESOLVER + Buscar Commit + Autor + Contenido + Mensaje + Ruta + SHA + Rama Actual + Sólo los commits decorados + Mostrar sólo el primer padre + MOSTRAR FLAGS + Mostrar commits perdidos + 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 + 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 + Ejecutando. Por favor espera... + GUARDAR + Guardar Como... + ¡El parche se ha guardado exitosamente! + Escanear Repositorios + Directorio Raíz: + Escanear otro directorio personalizado + Buscar Actualizaciones... + Nueva versión de este software disponible: + Versión Actual: + ¡Error al buscar actualizaciones! + Descargar + Omitir Esta Versión + Fecha de Release de la Nueva Versión: + Actualización de Software + Actualmente no hay actualizaciones disponibles. + Establecer Rama del Submódulo + Submódulo: + Actual: + Cambiar A: + Opcional. Establecer por defecto cuando este vacío. + Establecer Rama de Seguimiento + Rama: + Desestablecer upstream + Upstream: + Copiar SHA + Ir a + Clave Privada SSH: + Ruta de almacenamiento de la clave privada SSH + INICIAR + Stash + Incluir archivos sin rastrear + Mensaje: + Opcional. Información de este stash + Modo: + Solo cambios staged + ¡Tanto los cambios staged como los no staged de los archivos seleccionados serán stashed! + Stash Cambios Locales + Aplicar + Aplicar Cambios + Checkout a Nueva Rama + Copiar Mensaje + Eliminar + Guardar como Parche... + Eliminar Stash + Eliminar: + Stashes + CAMBIOS + STASHES + Estadísticas + GENERAL + MES + SEMANA + AUTORES: + COMMITS: + SUBMÓDULOS + Añadir Submódulo + RAMA + Rama + Ruta Relativa + Desinicializar Submódulo + Fetch submódulos anidados + Historial + Mover A + Abrir Repositorio del Submódulo + Ruta Relativa: + Carpeta relativa para almacenar este módulo. + Eliminar Submódulo + Establecer Rama + Cambiar URL + ESTADO + modificado + no inicializado + revisión cambiada + unmerged + Actualizar + URL + Cambiar Detalles del Submódulo + ABRIR DETALLES + OK + ETIQUETADOR + HORA + Hacer checkout a Etiqueta... + Comparar 2 etiquetas + Comparar con... + Comparar con HEAD + Mensaje + Nombre + Etiquetador + Copiar Nombre de la Etiqueta + Acción Personalizada + Eliminar ${0}$... + Eliminar {0} etiquetas seleccionadas... + Hacer merge de ${0}$ a ${1}$... + Hacer push a ${0}$... + Actualizar Submódulos + Todos los submódulos + Inicializar según sea necesario + Submódulo: + Actualizar submódulos anidados + Actualizar la rama de seguimiento remota del submódulo + 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... + Cambios + Git Ignore + Ignorar todos los archivos *{0} + Ignorar archivos *{0} en la misma carpeta + Ignorar archivos sin rastrear en esta carpeta + Ignorar solo este archivo + Ignorar todos los archivos sin rastrear en la misma carpeta + Enmendar + Puedes hacer stage a este archivo ahora. + Limpiar Historial + ¿Estás seguro de querer limpiar todo el historial de los mensajes de commit? Esta acción no se puede deshacer. + COMMIT + COMMIT y PUSH + Plantilla/Historias + Activar evento de clic + Commit (Editar) + Hacer stage a todos los cambios y commit + Estas creando un commit en una HEAD separada. ¿Quieres continuar? + Tienes {0} archivo(s) en stage, pero solo {1} archivo(s) mostrado(s) ({2} archivo(s) están filtrados). ¿Quieres continuar? + CONFLICTOS DETECTADOS + MERGE + Merge con Herramienta Externa + ABRIR TODOS LOS CONFLICTOS EN HERRAMIENTA DE MERGE EXTERNA + LOS CONFLICTOS DE ARCHIVOS ESTÁN RESUELTOS + USAR MÍOS + USAR SUYOS + INCLUIR ARCHIVOS SIN RASTREAR + NO HAY MENSAJES DE ENTRADA RECIENTES + NO HAY PLANTILLAS DE COMMIT + Sin verificar + Restablecer Autor + Firmar + STAGED + UNSTAGE + UNSTAGE TODO + UNSTAGED + STAGE + STAGE TODO + VER ASUMIR COMO SIN CAMBIOS + Plantilla: ${0}$ + ESPACIO DE TRABAJO: + Configura Espacios de Trabajo... + WORKTREE + RAMA + Copiar Ruta + HEAD + Bloquear + Abrir + RUTA + Eliminar + Desbloquear + + diff --git a/src/Resources/Locales/fr_FR.axaml b/src/Resources/Locales/fr_FR.axaml new file mode 100644 index 000000000..a094fb2ae --- /dev/null +++ b/src/Resources/Locales/fr_FR.axaml @@ -0,0 +1,997 @@ + + + + + + À propos + À propos de SourceGit + Date de publication : {0} + Notes de Version + Client Git Open Source et Gratuit + Ajouter le(s) Fichier(s) à Ignorer + Modèle : + Fichier de Stockage : + 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 + Modèle + RE-GÉNÉRER + Utiliser l'IA pour générer un message de commit + Utiliser + Masquer SourceGit + Masquer les autres + Tout Afficher + Fusion à 3 voies + 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 + Saisir la phrase secrète : + FICHIERS PRÉSUMÉS INCHANGÉS + PAS DE FICHIERS PRÉSUMÉS INCHANGÉS + Charger l'Image... + Rafraîchir + FICHIER BINAIRE NON SUPPORTÉ !!! + Bisect + Annuler + Mauvais + Bon + Passer + Bisect en cours. Marquer le commit actuel comme bon ou mauvais et en récupérer un autre. + Bisect en cours. Le HEAD actuel est-il bon ou mauvais ? + Blâme + Blâmer sur la révision précédente + Ignorer les changements d'espaces + LE BLÂME SUR CE FICHIER N'EST PAS SUPPORTÉ!!! + Récupérer ${0}$... + Comparer les 2 branches sélectionnées + Comparer avec... + Comparer avec HEAD + Comparer avec ${0}$ + Copier le nom de la branche + Créer une PR... + Créer une PR pour l'upstream ${0}$... + Action personnalisée + Supprimer ${0}$... + Supprimer {0} branches sélectionnées + Modifier la description de ${0}$... + Fast-Forward vers ${0}$ + Fetch ${0}$ vers ${1}$... + Git Flow - Terminer ${0}$ + Rebaser Interactivement ${0}$ sur ${1}$ + 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}$... + Réinitialiser ${0}$ sur ${1}$... + Basculer vers ${0}$ (worktree) + Définir la branche de suivi... + {0} commit(s) en avance + {0} commit(s) en avance, {1} commit(s) en retard + {0} commit(s) en retard + Invalide + DISTANT + STATUT + SUIVI + URL + WORKTREE + ANNULER + Réinitialiser à la révision parente + Réinitialiser à cette révision + Générer un message de commit + Fusion (intégré) + Fusion (externe) + Réinitialiser le(s) fichier(s) à ${0}$ + CHANGER LE MODE D'AFFICHAGE + Afficher comme liste de dossiers/fichiers + Afficher comme liste de chemins + Afficher comme arborescence + Changer l'URL du sous-module + Sous-module : + URL : + Récupérer la branche + Changements locaux : + Branche : + Votre HEAD actuel contient un ou plusieurs commits non connectés à une branche/tag ! Voulez-vous continuer ? + Les sous-modules suivants doivent être mis à jour :{0}Voulez-vous les mettre à jour ? + Récupérer & Fast-Forward + Fast-Forward vers : + Créer une branche depuis le stash + Nouvelle branche : + Stash : + 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. + Signet : + Groupe : + Nom local : + Nom de dépôt. Optionnel. + Dossier parent : + Initialiser et mettre à jour les sous-modules + URL du dépôt : + FERMER + Éditeur + Branches + Branches et tags + Actions personnalisées du dépôt + Fichiers de révision + Récupérer ce commit + Cherry-Pick ce commit + Cherry-Pick ... + Comparer avec HEAD + Comparer avec le worktree + Auteur + Message + Committer + SHA + Sujet + Action personnalisée + Rebase Interactif + Supprimer... + Modifier... + Fixup dans le Parent... + Rebaser Interactivement ${0}$ sur ${1}$ + Reformuler... + Squash dans le Parent... + Fusionner ... + Pousser ${0}$ vers ${1}$ + Rebaser ${0}$ sur ${1}$ + Réinitialiser ${0}$ sur ${1}$ + Annuler le commit + Enregistrer en tant que patch... + CHANGEMENTS + fichier(s) modifié(s) + Rechercher les changements... + Réduire les détails + FICHIERS + Fichier LFS + Rechercher des fichiers... + Sous-module + INFORMATIONS + AUTEUR + COMMITTER + Vérifier les références contenant ce commit + LE COMMIT EST CONTENU PAR + Copier l'E-mail + Copier le Nom + Copier le Nom & l'E-mail + Afficher seulement les 100 premiers changements. Voir tous les changements dans l'onglet CHANGEMENTS. + Clé : + MESSAGE + PARENTS + REFS + SHA + Signataire : + Ouvrir dans le navigateur + COL + Saisissez le message de commit. Utilisez une ligne vide pour séparer le sujet et la description ! + SUJET + Comparer + CHANGEMENTS + COMMITS + COMMITS UNIQUEMENT À GAUCHE + COMMITS UNIQUEMENT À DROITE + Astuce : vous pouvez cherry-pick les commits sélectionnés si l'autre côté est HEAD (via le menu contextuel). + Configurer le dépôt + MODÈLE DE COMMIT + Paramètres intégrés : + + ${branch_name} Nom de la branche locale actuelle. + ${files_num} Nombre de fichiers modifiés + ${files} Chemins des fichiers modifiés + ${files:N} Nombre maximum N de chemins de fichiers modifiés + ${pure_files} Comme ${files}, mais uniquement les noms de fichiers purs + ${pure_files:N} Comme ${files:N}, mais sans les dossiers + Contenu de modèle: + Nom de modèle: + ACTION PERSONNALISÉE + Arguments : + Paramètres intégrés : + + ${REPO} Chemin du dépôt + ${REMOTE} Dépôt distant sélectionné ou dépôt distant de la branche sélectionnée + ${BRANCH} Branche sélectionnée, sans la partie ${REMOTE} pour les branches distantes + ${BRANCH_FRIENDLY_NAME} Nom d'affichage de la branche sélectionnée, contient la partie ${REMOTE} pour les branches distantes + ${SHA} Hash du commit sélectionné + ${TAG} Tag sélectionné + ${FILE} Fichier sélectionné, relatif à la racine du dépôt + $1, $2 ... Paramètres d'entrée utilisateur + Fichier exécutable : + Paramètres d'entrée utilisateur : + Modifier + Nom : + Portée : + Branche + Commit + Fichier + Remote + Dépôt + Tag + Attendre la fin de l'action + Adresse e-mail + Adresse e-mail + GIT + Demander avant mise à jour automatique des sous-modules + Fetch les dépôts distants automatiquement + minute(s) + Types de commit conventionnels + Dépôt par défaut + Mode de fusion préféré + SUIVI DES PROBLÈMES + Ajouter une règle d'exemple Azure DevOps + Ajouter une règle de commit Gerrit Change-Id + 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 : + Partager cette règle dans le fichier .issuetracker + 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 + Modifier les Contrôles d'Action Personnalisée + Valeur Cochée : + Lorsque cochée, cette valeur sera utilisée dans les arguments de la ligne de commande + Description : + Défaut : + Est un dossier : + Libellé : + Options : + Utiliser '|' comme délimiteur pour les options + Formateur de chaîne : + Optionnel. Utilisé pour formater la sortie. Ignoré si vide. Utilisez `${VALUE}` pour représenter la chaîne d'entrée. + Les variables intégrées ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, et ${TAG} restent disponibles ici + Type : + Utiliser le nom convivial : + Espaces de travail + Couleur + Nom + Restaurer les onglets au démarrage + CONTINUER + Commit vide détecté ! Voulez-vous continuer (--allow-empty) ? + TOUT INDEXER & COMMIT + INDEXER LA SÉLECTION & COMMIT + Commit vide détecté ! Voulez-vous continuer (--allow-empty) ou tout indexer puis commit ? + Redémarrage Requis + Vous devez redémarrer cette application pour appliquer les changements. + 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 : + Nom de la nouvelle branche : + Entrez le nom de la branche. + Créer une branche locale + Écraser la branche existante + 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 + Annuler + Ne rien faire + Mettre en stash et réappliquer + Désinitialiser le sous-module + Forcer la désinitialisation même s'il contient des modifications locales. + Sous-module : + Supprimer la branche + Branche : + Vous êtes sur le point de supprimer une branche distante !!! + Supprimer plusieurs branches + Vous essayez de supprimer plusieurs branches à la fois. Assurez-vous de revérifier avant de procéder ! + Supprimer plusieurs tags + Les supprimer des dépôts distants + Vous essayez de supprimer plusieurs tags à la fois. Assurez-vous de bien vérifier avant d'agir ! + 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 + Première différence + Ignorer les changements d'espaces + FUSIONNER + DIFFÉRENCE + CÔTE À CÔTE + BALAYER + Dernière différence + CHANGEMENT D'OBJET LFS + NOUVEAU + Différence suivante + PAS DE CHANGEMENT OU SEULEMENT EN FIN DE LIGNE + ANCIEN + Différence précédente + Enregistrer en tant que patch + Afficher les caractères invisibles + Diff côte-à-côte + SOUS-MODULE + SUPPRIMÉ + NOUVEAU + + {0} changements non commités + Permuter + Coloration syntaxique + Retour à la ligne + 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 + Historique du Répertoire + A des Modifications Locales + Divergence avec le Dépôt Distant + Déjà à jour + Rejeter les changements + Tous les changements dans la copie de travail. + Changements : + Inclure les fichiers ignorés + Inclure les fichiers modifiés/supprimés + Inclure les fichiers non suivis + {0} changements seront rejetés + Vous ne pouvez pas annuler cette action !!! + Modifier la description de la branche + Cible : + Signet : + Nouveau nom : + Cible : + Éditer le groupe sélectionné + Éditer le dépôt sélectionné + Cible : + Ce dépôt + 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é + Action personnalisée + Rejeter... + Rejeter {0} fichiers... + Résoudre en utilisant ${0}$ + Enregistrer en tant que patch... + Indexer + Indexer {0} fichiers + Stash... + Stash {0} fichiers... + Désindexer + Désindexer {0} fichiers + 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: + Squash lors de la fusion + 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 + Déverrouiller tous mes verrous + Êtes-vous sûr de vouloir déverrouiller tous vos fichiers verrouillés ? + 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} + Sélectionner un commit + Historique + AUTEUR + HEURE DE L'AUTEUR + HEURE DE COMMIT + GRAPHE & SUJET + SHA + MISE EN ÉVIDENCE AU GRAPHE + Tout + Branche courante uniquement + Branche courante et commits sélectionnés + Commits sélectionnés uniquement + {0} COMMITS SÉLECTIONNÉS + AFFICHER LES COLONNES + Maintenir 'Ctrl' ou 'Shift' enfoncée pour sélectionner plusieurs commits. + Maintenir ⌘ ou ⇧ enfoncée pour sélectionner plusieurs commits. + CONSEILS: + Ouvrir dans une fenêtre séparée + Détails du commit + Comparaison de révision + Référence des raccourcis clavier + GLOBAL + 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 un dépôt local + Ouvrir le dialogue des préférences + Afficher le menu des espaces de travail + Changer d'onglet actif + Zoom avant/arrière + 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 + Fetch, démarre directement + Mode tableau de bord (Défaut) + Aller au commit enfant + Aller au commit parent + Ouvrir la palette de commandes + Recherche de commit + Pull, démarre directement + Push, démarre directement + Forcer le rechargement du dépôt + Afficher/Masquer le panneau de détails du panneau`HISTORIQUE` + Basculer vers 'Changements' + Basculer vers 'Historique' + Basculer vers 'Stashes' + Rejeter + Indexer + Retirer de l'index + Initialiser le repository + Voulez-vous exécuter `git init` dans ce dossier ? + Échec de l'ouverture du dépôt. Raison : + 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 + Stash & réappliquer changements locaux + Contourner le hook de pre-rebase + Sur : + Glisser-déposer pour réorganiser les commits + Branche cible : + Copier le lien + Ouvrir dans le navigateur + Commandes + ERREUR + NOTICE + Ouvrir des dépôts + Onglets + Espaces de travail + Merger la branche + Personnaliser le message de fusion + Dans : + Option de merge: + Source: + D'abord les miens, puis les leurs + D'abord les leurs, puis les miens + UTILISER LES DEUX + Tous les conflits sont résolus + {0} conflit(s) restant(s) + MIENS + Conflit suivant + Conflit précédent + RÉSULTAT + SAUVER ET INDEXER + LEURS + Conflits de fusion + Abandonner les modifications non sauvegardées ? + UTILISER LES MIENS + UTILISER LES LEURS + ANNULER + Fusionner (Plusieurs) + Commit tous les changement + Stratégie: + Cibles: + Déplacer le sous-module + Déplacer vers : + Sous-module : + Déplacer le noeud du repository + Sélectionnier le noeud parent pour : + Nom : + NON + Git n'a PAS été configuré. Veuillez d'abord le faire dans le menu Préférence. + Ouvrir + Éditeur par défaut (Système) + Ouvrir le dossier AppData + Ouvrir un fichier + Ouvrir dans l'outil de fusion + Ouvrir un dépôt local + Signet : + Groupe : + Dossier : + Optionnel. + Créer un nouvel onglet + Fermer l'onglet + Fermer les autres onglets + Fermer les onglets à droite + Copier le chemin vers le dépôt + Éditer + Se déplacer vers l'espace de travail + Actualiser + 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 + Prompt supplémentaire (utilisez `-` pour lister vos exigences) + Clé d'API + Modèle + Récupérer automatiquement les modèles disponibles + Nom + La valeur saisie est le nom pour charger la clé API depuis l'ENV + Serveur + APPARENCE + Police par défaut + Largeur de tab dans l'éditeur + Taille de police + Défaut + Éditeur + Police monospace + Thème + Dérogations de thème + Utiliser les barres de défilement masquées automatiquement + Utiliser des onglets de taille fixe dans la barre de titre + Utiliser un cadre de fenêtre natif + OUTIL DIFF/MERGE + Arguments de diff + Variables disponibles : $LOCAL, $REMOTE + Arguments de merge + Variables disponibles : $BASE, $LOCAL, $REMOTE, $MERGED + 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 + Activer les dossiers compacts dans l'arborescence des changements + Language + Historique de commits + Afficher la page 'CHANGEMENTS LOCAUX' par défaut + Afficher l'onglet 'CHANGEMENTS' dans les détails du commit par défaut + Afficher le temps relatif dans le graphe de commits + Afficher les tags dans le graphique des commits + Guide de longueur du sujet + Format 24 heures + Générer un avatar par défaut de style GitHub + GIT + Activer auto CRLF + Répertoire de clônage par défaut + E-mail utilsateur + E-mail utilsateur global + Activer --prune pour fetch + Activer --ignore-cr-at-eol dans la diff + Cette application requière Git (>= 2.25.1) + Chemin d'installation + Activer la vérification HTTP SSL + Utiliser git-credential-libsecret au lieu de git-credential-manager + Nom d'utilisateur + Nom d'utilisateur global + Utiliser stash & réappliquer par défaut lors des checkout/merge + 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 + Arguments + Utilisez '.' pour représenter le répertoire de travail + 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 : + 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 : + NOUVEAU + Dépôt distant : + Révision : + Pousser la Révision vers le 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 : + Pousser vers une NOUVELLE branche + Saisir le nom de la nouvelle branche distante : + Quitter + Rebase la branche actuelle + Stash & réappliquer changements locaux + Contourner le hook de pre-rebase + Sur : + 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 + Action personnalisée + Supprimer... + Editer... + Activer l'auto-fetch + Fetch + Ouvrir dans le navigateur + Elaguer + Confirmer la suppression du Worktree + Activer l'option `--force` + Cible : + Renommer la branche + Nouveau nom : + Nom unique pour cette branche + Branche : + ABORT + Fetch automatique des changements depuis les dépôts... + Trier + Par date du Committer + Par Nom + Nettoyage(GC & Elaguage) + Lancer `git gc` pour ce repository. + Tout effacer + Nettoyer + Configurer ce repository + CONTINUER + Actions personnalisées + Pas d'actions personnalisées + Tableau de bord + Rejeter tous les changements + 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 + DISPOSITION + Horizontal + Vertical + ORDRE DES COMMITS + Date du commit + Topologiquement + BRANCHES LOCALES + Plus d'options... + Naviguer vers le HEAD + Créer une branche + EFFACER LES NOTIFICATIONS + Ouvrir comme dossier + Ouvrir dans {0} + Ouvrir dans un outil externe + DEPOTS DISTANTS + AJOUTER DEPOT DISTANT + RÉSOUDRE + Rechercher un commit + Auteur + Contenu + Message + Chemin + SHA + Branche actuelle + Commits décorés uniquement + Premier parent uniquement + AFFICHER LES FLAGS + Afficher les commits perdus + Afficher les sous-modules en tant qu'arbre + 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 + Voir les Logs + Visiter '{0}' dans le navigateur + WORKTREES + AJOUTER WORKTREE + ELAGUER + URL du repository Git + Reset branche actuelle à la révision + Reset Mode: + Déplacer vers : + Branche actuelle : + Réinitialiser la Branche (Sans Récupération) + Déplacer Vers : + Branche : + Ouvrir dans l'explorateur de fichier + Annuler le Commit + Commit : + Commit les changements de l'annulation + En exécution. Veuillez patienter... + SAUVEGARDER + Sauvegarder en tant que... + Le patch a été sauvegardé ! + Analyser les repositories + Dossier racine : + Scanner un autre répertoire personnalisé + Rechercher des mises à jour... + Une nouvelle version du logiciel est disponible : + Version actuelle : + La vérification de mise à jour à échouée ! + Télécharger + Passer cette version + Date de la nouvelle version : + Mise à jour du logiciel + Il n'y a pas de mise à jour pour le moment. + Définir la Branche du Sous-module + Sous-module : + Actuel : + Changer pour : + Optionnel. Défini par défaut si vide. + Définir la branche suivie + Branche: + Retirer la branche amont + En amont: + Copier le SHA + Aller à + Clé privée SSH : + Chemin du magasin de clés privées SSH + START + Stash + Inclure les fichiers non-suivis + Message : + Optionnel. Information de ce stash + Mode : + 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 + Appliquer les changements + Créer une branche + Copier le Message + Effacer + Sauver comme Patch... + Effacer le Stash + Effacer : + Stashes + CHANGEMENTS + STASHES + Statistiques + APERCU + MOIS + SEMAINE + AUTEURS : + COMMITS: + SOUS-MODULES + Ajouter un sous-module + BRANCHE + Branche + Chemin relatif + Désinitialiser + Fetch les sous-modules imbriqués + Historique + Déplacer Vers + Ouvrir le dépôt de sous-module + Chemin relatif : + Dossier relatif pour stocker ce module. + Supprimer le sous-module + Définir la Branche + Changer l'URL + STATUT + modifié + non initialisé + révision changée + non fusionné + Mettre à jour + URL + Détails des changements du sous-module + OUVRIR LES DÉTAILS + OK + TAGUEUR + HEURE + Comparer 2 tags + Comparer avec... + Comparer avec HEAD + Message + Nom + Tagueur + Copier le nom du tag + Action personnalisée + Supprimer ${0}$... + Supprimer les {0} tags sélectionnés... + Pousser ${0}$... + Actualiser les sous-modules + Tous les sous-modules + Initialiser au besoin + Sous-module : + Mettre à jour vers la branche de suivi distante du sous-module + URL : + Logs + TOUT EFFACER + Copier + Supprimer + 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... + Changements + Git Ignore + Ignorer tous les *{0} fichiers + Ignorer *{0} fichiers dans le même dossier + Ignorer les fichiers non suivis dans ce dossier + N'ignorer que ce fichier + Amender + Vous pouvez indexer ce fichier. + Effacer l'historique + Êtes-vous sûr de vouloir effacer tout l'historique des messages de commit ? Cette action est irréversible. + COMMIT + COMMIT & POUSSER + Modèles/Historiques + Trigger click event + Commit (Modifier) + Indexer tous les changements et commit + Vous êtes en train de créer un commit sur un HEAD détaché. Voulez-vous continuer ? + Vous avez indexé {0} fichier(s) mais seulement {1} fichier(s) sont affichés ({2} fichiers sont filtrés). Voulez-vous continuer ? + CONFLITS DÉTECTÉS + FUSIONNER + Fusion avec un outil externe + OUVRIR TOUS LES CONFLITS DANS L'OUTIL DE FUSION EXTERNE + LES CONFLITS DE FICHIER SONT RÉSOLUS + UTILISER LES MIENS + UTILISER LES LEURS + INCLURE LES FICHIERS NON-SUIVIS + PAS DE MESSAGE D'ENTRÉE RÉCENT + PAS DE MODÈLES DE COMMIT + No-Verify + Réinitialiser l'Auteur + Signer + 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 + BRANCHE + Copier le chemin + HEAD + Verrouiller + Ouvrir + CHEMIN + Supprimer + Déverrouiller + OUI + diff --git a/src/Resources/Locales/he_IL.axaml b/src/Resources/Locales/he_IL.axaml new file mode 100644 index 000000000..64346545d --- /dev/null +++ b/src/Resources/Locales/he_IL.axaml @@ -0,0 +1,997 @@ + + + + + + אודות + אודות SourceGit + תאריך שחרור: {0} + הערות שחרור + לקוח Git גרפי, חופשי ובקוד פתוח + הוסף קבצים ל־Ignore + תבנית: + קובץ אחסון: + הוסף Worktree + מיקום: + נתיב ל־worktree זה. נתיב יחסי נתמך. + שם Branch: + לא חובה. ברירת המחדל היא שם תיקיית היעד. + Branch למעקב: + מעקב אחר branch מרוחק + מה לבצע לו checkout: + צור Branch חדש + Branch קיים + עוזר AI + מודל + צור מחדש + שימוש ב־AI ליצירת הודעת commit + שימוש + הסתר SourceGit + הסתר אחרים + הצג הכול + 3-Way Merge + בחר קובץ .patch להחלה + התעלמות משינויי whitespace + החל Patch + Whitespace: + החל Stash + מחק לאחר ההחלה + שחזר שינויי ה־index + Stash: + ארכב... + שמור ארכיון אל: + בחר נתיב לקובץ הארכיון + Revision: + ארכב + SourceGit Askpass + הזן passphrase: + קבצים בהנחת assume-unchanged + אין קבצים בהנחת assume-unchanged + טען תמונה... + רענן + קובץ בינארי אינו נתמך!!! + Bisect + ביטול + פגום (Bad) + תקין (Good) + דלג + מתבצע Bisect. יש לסמן את ה־commit הנוכחי כתקין או כפגום ולבצע checkout לאחר. + מתבצע Bisect. האם ה־HEAD הנוכחי תקין או פגום? + Blame + Blame ל־revision הקודם + התעלמות משינויי whitespace + לא ניתן לבצע Blame על קובץ מסוג זה!!! + Checkout ל־${0}$... + השווה בין 2 ה־branches שנבחרו + השווה עם... + השווה עם HEAD + השווה עם ${0}$ + העתק שם ה־Branch + צור PR... + צור PR ל־upstream ${0}$... + פעולה מותאמת אישית + מחק ${0}$... + מחק {0} ה־branches שנבחרו + ערוך תיאור ל־${0}$... + Fast-Forward אל ${0}$ + Fetch של ${0}$ אל ${1}$... + Git Flow - סיים ${0}$ + Interactive Rebase של ${0}$ על ${1}$ + Merge של ${0}$ אל ${1}$... + Merge של {0} ה־branches שנבחרו אל הנוכחי + Pull ${0}$ + Pull של ${0}$ אל ${1}$... + Push ${0}$ + Rebase של ${0}$ על ${1}$... + שנה שם ${0}$... + Reset של ${0}$ אל ${1}$... + עבור אל ${0}$ (worktree) + הגדר Tracking Branch... + {0} commits לפני + {0} commits לפני, {1} commits אחרי + {0} commits אחרי + לא תקין + REMOTE + סטטוס + TRACKING + URL + WORKTREE + ביטול + Checkout ל־revision של ההורה + Checkout ל־revision זה + צור הודעת commit + Merge (מובנה) + Merge (כלי חיצוני) + Reset של הקבצים אל ${0}$ + שנה מצב תצוגה + הצג כרשימת קבצים ותיקיות + הצג כרשימת נתיבים + הצג כעץ מערכת קבצים + שנה ה־URL של Submodule + Submodule: + URL: + Checkout של Branch + שינויים מקומיים: + Branch: + ה־HEAD הנוכחי מכיל commits שאינם מקושרים לאף branch או tag! האם להמשיך? + יש לעדכן את ה־submodules הבאים:{0}לעדכן אותם? + Checkout ו־Fast-Forward + Fast-Forward אל: + Checkout של Branch מתוך Stash + Branch חדש: + Stash: + Cherry-Pick + הוסף המקור להודעת ה־commit + Commits: + צור commit לכל השינויים + Mainline: + בדרך כלל לא ניתן לבצע cherry-pick ל־merge מכיוון שאין דרך לדעת איזה צד של ה־merge צריך להיחשב כ־mainline. אפשרות זו מאפשרת ל־cherry-pick לשחזר את השינוי ביחס להורה שצוין. + נקה Stashes + פעולה זו תנקה את כל ה־stashes. להמשיך? + שכפל מאגר מרוחק + פרמטרים נוספים: + ארגומנטים נוספים ל־clone. לא חובה. + סימנייה: + קבוצה: + שם מקומי: + שם המאגר. לא חובה. + תיקיית אב: + אתחול ועדכון submodules + URL של המאגר: + סגור + עורך + Branches + Branches ו־Tags + פעולות מותאמות של המאגר + קבצי Revision + Checkout של Commit + Cherry-Pick של Commit + Cherry-Pick ... + השווה עם HEAD + השווה עם ה־Worktree + Author + הודעה + Committer + SHA + Subject + פעולה מותאמת אישית + Interactive Rebase + Drop... + Edit... + Fixup לתוך ההורה... + Interactive Rebase של ${0}$ על ${1}$ + Reword... + Squash לתוך ההורה... + Merge ... + Push של ${0}$ אל ${1}$ + Rebase של ${0}$ על ${1}$ + Reset של ${0}$ אל ${1}$ + Revert של Commit + שמור כ־Patch... + שינויים + קבצים שונו + חיפוש בשינויים... + כיווץ הפרטים + קבצים + קובץ LFS + חיפוש קבצים... + Submodule + מידע + AUTHOR + COMMITTER + בדוק refs המכילים את ה־commit הזה + ה־COMMIT כלול ב־ + העתק דוא"ל + העתק שם + העתק שם ודוא"ל + מוצגים רק 100 השינויים הראשונים. כל השינויים זמינים בלשונית CHANGES. + מפתח: + הודעה + הורים + REFS + SHA + חותם: + פתח בדפדפן + עמודה + הזן הודעת commit. יש להפריד בין subject לתיאור באמצעות שורה ריקה! + SUBJECT + השווה + שינויים + COMMITS + COMMITS בצד שמאל בלבד + COMMITS בצד ימין בלבד + טיפ: ניתן לבצע cherry-pick ל־commits שנבחרו אם הצד השני הוא HEAD (דרך תפריט ההקשר). + הגדרות מאגר + תבנית COMMIT + פרמטרים מובנים: + + ${branch_name} שם ה־local branch הנוכחי. + ${files_num} מספר הקבצים ששונו + ${files} נתיבי הקבצים ששונו + ${files:N} עד N נתיבים של קבצים ששונו + ${pure_files} כמו ${files}, אך רק שמות קבצים נקיים + ${pure_files:N} כמו ${files:N}, אך ללא תיקיות + תוכן התבנית: + שם התבנית: + פעולה מותאמת אישית + ארגומנטים: + פרמטרים מובנים: + + ${REPO} נתיב המאגר + ${REMOTE} ה־remote שנבחר או ה־remote של ה־branch שנבחר + ${BRANCH} ה־branch שנבחר, ללא חלק ה־${REMOTE} עבור branches מרוחקים + ${BRANCH_FRIENDLY_NAME} שם ידידותי של ה־branch שנבחר, כולל את חלק ה־${REMOTE} עבור branches מרוחקים + ${SHA} ה־hash של ה־commit שנבחר + ${TAG} ה־tag שנבחר + ${FILE} הקובץ שנבחר, יחסי לשורש המאגר + $1, $2 ... ערכי שדות קלט + קובץ הרצה: + שדות קלט: + ערוך + שם: + תחום: + Branch + Commit + קובץ + Remote + מאגר + Tag + המתן לסיום הפעולה + כתובת דוא"ל + כתובת דוא"ל + GIT + לבקש אישור לפני עדכון אוטומטי של submodules + Fetch אוטומטי מ־remotes + דקות + סוגי Conventional Commit + Remote ברירת מחדל + מצב Merge מועדף + ISSUE TRACKER + הוסף כלל Azure DevOps + הוסף כלל Gerrit Change-Id Commit + הוסף כלל Gitee Issue + הוסף כלל Gitee Pull Request + הוסף כלל GitHub + הוסף כלל GitLab Issue + הוסף כלל GitLab Merge Request + הוסף כלל Jira + כלל חדש + ביטוי Regex לזיהוי Issue: + שם הכלל: + שיתוף כלל זה בקובץ .issuetracker + URL התוצאה: + השתמש ב־$1, $2 לגישה לערכי קבוצות ה־regex. + AI + שירות מועדף: + אם מוגדר 'שירות מועדף', SourceGit ישתמש רק בו במאגר זה. אחרת, אם זמינים מספר שירותים, יוצג תפריט הקשר לבחירת אחד מהם. + HTTP Proxy + HTTP proxy לשימוש המאגר הזה + שם משתמש + שם משתמש למאגר זה + ערוך שדות קלט של פעולה מותאמת אישית + ערך כאשר מסומן: + כאשר מסומן, ערך זה ישמש בארגומנטים של שורת הפקודה + תיאור: + ברירת מחדל: + תיקייה: + תווית: + אפשרויות: + השתמש ב־'|' כמפריד בין אפשרויות + String Formatter: + לא חובה. משמש לעיצוב מחרוזת הפלט. מתעלם כאשר הקלט ריק. השתמש ב־`${VALUE}` לייצוג מחרוזת הקלט. + המשתנים המובנים ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, ו־${TAG} זמינים גם כאן + סוג: + שימוש בשם ידידותי: + סביבות עבודה + צבע + שם + שחזר לשוניות בעת ההפעלה + המשך + זוהה commit ריק! האם להמשיך (--allow-empty)? + Stage לכל ו־Commit + Stage לנבחרים ו־Commit + זוהה commit ריק! האם להמשיך (--allow-empty) או לבצע stage אוטומטי ואז commit? + נדרשת הפעלה מחדש + יש להפעיל את האפליקציה מחדש כדי להחיל את השינויים. + עוזר Conventional Commit + Breaking Change: + Issue שנסגר: + פירוט השינויים: + תחום: + תיאור קצר: + סוג השינוי: + העתק + העתק כל הטקסט + העתק נתיב מלא + העתק נתיב + צור Branch... + מבוסס על: + בצע checkout ל־branch שייווצר + שינויים מקומיים: + שם ה־Branch החדש: + הזן שם branch. + צור Branch מקומי + דריסת branch קיים + צור Tag... + Tag חדש על: + חתימת GPG + הודעת Tag: + לא חובה. + שם Tag: + פורמט מומלץ: v1.0.0-alpha + Push לכל ה־remotes לאחר היצירה + צור Tag + סוג: + annotated + lightweight + החזק Ctrl להפעלה ישירה + גזור + השלך + לא לעשות דבר + Stash והחלה מחדש + ביטול אתחול של Submodule + לכפות ביטול אתחול גם אם יש בו שינויים מקומיים. + Submodule: + מחק Branch + Branch: + פעולה זו תמחק branch מרוחק!!! + מחק מספר Branches + פעולה זו תמחק מספר branches בו זמנית. בדוק היטב לפני הביצוע! + מחק מספר Tags + מחק גם מה־remotes + פעולה זו תמחק מספר tags בו זמנית. בדוק היטב לפני הביצוע! + מחק Remote + Remote: + נתיב: + יעד: + כל הצאצאים יוסרו מהרשימה. + פעולה זו רק תסיר מהרשימה, לא תמחק מהדיסק! + אישור מחיקת קבוצה + אישור מחיקת מאגר + מחק Submodule + נתיב Submodule: + מחק Tag + Tag: + מחק גם ממאגרים מרוחקים + DIFF בינארי + ההבדל הראשון + התעלמות משינויי Whitespace + מיזוג + הבדל + זה לצד זה + החלקה + ההבדל האחרון + שינוי אובייקט LFS + חדש + ההבדל הבא + אין שינויים או רק שינויי EOL + ישן + ההבדל הקודם + שמור כ־Patch + הצג סמלים מוסתרים + Diff זה לצד זה + SUBMODULE + נמחק + חדש + + {0} שינויים שלא בוצע להם commit + החלף + הדגשת תחביר + גלישת שורות + פתח בכלי Merge + הצג כל השורות + הקטנת מספר השורות הנראות + הגדלת מספר השורות הנראות + בחר קובץ להצגת השינויים + היסטוריית תיקייה + קיימים שינויים מקומיים + אינו תואם ל־upstream + כבר מעודכן + השלך שינויים + כל השינויים המקומיים ב־working copy. + שינויים: + כולל קבצים שב־ignore + כולל קבצים ששונו או נמחקו + כולל קבצים untracked + {0} שינויים יושלכו + לא ניתן לבטל פעולה זו!!! + ערוך תיאור ה־Branch + יעד: + סימנייה: + שם חדש: + יעד: + ערוך הקבוצה שנבחרה + ערוך המאגר שנבחר + יעד: + מאגר זה + Fetch + Fetch מכל ה־remotes + כפה דריסה של refs מקומיים + Fetch ללא tags + Remote: + Fetch של שינויים מרוחקים + Assume unchanged + פעולה מותאמת אישית + השלך... + השלך של {0} קבצים... + פתור באמצעות ${0}$ + שמור כ־Patch... + Stage + Stage של {0} קבצים + Stash... + Stash של {0} קבצים... + Unstage + Unstage של {0} קבצים + שימוש בשלי (checkout --ours) + שימוש בשלהם (checkout --theirs) + היסטוריית קובץ + שנה + תוכן + Git-Flow + Branch לפיתוח: + Feature: + קידומת Feature: + FLOW - סיים Feature + FLOW - סיים Hotfix + FLOW - סיים Release + יעד: + Squash בעת ה־merge + Hotfix: + קידומת Hotfix: + אתחול Git-Flow + השארת ה־branch + Branch ייצור: + Release: + קידומת Release: + התחל Feature... + FLOW - התחל Feature + התחל Hotfix... + FLOW - התחל Hotfix + הזן שם + התחל Release... + FLOW - התחל Release + קידומת Version Tag: + Git LFS + הוסף תבנית למעקב... + התבנית היא שם קובץ + תבנית מותאמת אישית: + הוסף תבנית למעקב ב־Git LFS + Fetch + הרץ `git lfs fetch` להורדת אובייקטי Git LFS. אינה מעדכנת את ה־working copy. + Fetch של אובייקטי LFS + התקן hooks של Git LFS + הצג Locks + אין קבצים נעולים + נעל + הצג הנעילות שלי בלבד + LFS Locks + שחרר נעילה + שחרר כל הנעילות שלי + האם לשחרר את כל הקבצים שנעלת? + שחרר נעילה בכוח + Prune + הרץ `git lfs prune` למחיקת קבצי LFS ישנים מהאחסון המקומי + Pull + הרץ `git lfs pull` להורדת כל קבצי Git LFS עבור ה־ref הנוכחי וביצוע checkout + Pull של אובייקטי LFS + Push + Push של קבצים גדולים בתור אל endpoint של Git LFS + Push של אובייקטי LFS + Remote: + מעקב אחר קבצים בשם '{0}' + מעקב אחר כל הקבצים *{0} + בחר Commit + היסטוריה + AUTHOR + זמן AUTHOR + זמן COMMIT + גרף ו־SUBJECT + SHA + הדגשות בגרף + הכול + ה־Branch הנוכחי בלבד + ה־Branch הנוכחי ו־Commits שנבחרו + Commits שנבחרו בלבד + נבחרו {0} COMMITS + הצג עמודות + החזק 'Ctrl' או 'Shift' לבחירת מספר commits. + החזק ⌘ או ⇧ לבחירת מספר commits. + טיפים: + פתח בחלון נפרד + פרטי Commit + השווה Revisions + קיצורי מקלדת + גלובלי + שכפל מאגר חדש + סגור הלשונית הנוכחית + עבור ללשונית הבאה + עבור ללשונית הקודמת + צור לשונית חדשה + פתח מאגר מקומי + פתח חלון ההעדפות + הצג תפריט נפתח של סביבת העבודה + החלף לשונית פעילה + הגדל/הקטן + מאגר + Commit לשינויים ב־staged + Commit ו־push לשינויים ב־staged + Stage לכל השינויים ו־commit + צור branch חדש + Fetch, מתחיל מיידית + מצב Dashboard (ברירת מחדל) + עבור לצאצא של ה־commit שנבחר + עבור להורה של ה־commit שנבחר + פתח command palette + מצב חיפוש commits + Pull, מתחיל מיידית + Push, מתחיל מיידית + טען את המאגר מחדש בכוח + הרחבה/כיווץ של חלונית הפרטים ב־`HISTORY` + עבור ל־'LOCAL CHANGES' + עבור ל־'HISTORY' + עבור ל־'STASHES' + השלך + Stage + Unstage + אתחול מאגר + האם להריץ `git init` בנתיב זה? + פתיחת המאגר נכשלה. סיבה: + נתיב: + Cherry-Pick בתהליך. + מעבד commit + Merge בתהליך. + מתבצע Merge + Rebase בתהליך. + נעצר ב־ + Revert בתהליך. + מבצע Revert ל־commit + Interactive Rebase + Stash והחלה מחדש של שינויים מקומיים + דלג על pre-rebase hook + על: + גרור כדי לסדר מחדש commits + Branch יעד: + העתק קישור + פתח בדפדפן + פקודות + שגיאה + הודעה + מאגרים פתוחים + לשוניות + סביבות עבודה + Merge של Branch + התאם הודעת merge + אל: + אפשרות Merge: + מקור: + תחילה שלי, אחר כך שלהם + תחילה שלהם, אחר כך שלי + שימוש בשניהם + כל הקונפליקטים נפתרו + נותרו {0} קונפליקטים + שלי + הקונפליקט הבא + הקונפליקט הקודם + תוצאה + שמור ו־Stage + שלהם + קונפליקטי Merge + להשליך שינויים שלא נשמרו? + שימוש בשלי + שימוש בשלהם + ביטול + Merge (מרובה) + Commit לכל השינויים + אסטרטגיה: + יעדים: + העבר Submodule + העבר אל: + Submodule: + העבר רכיב מאגר + בחר רכיב אב עבור: + שם: + לא + Git אינו מוגדר. עבור ל־[העדפות] והגדר אותו תחילה. + פתח + עורך ברירת מחדל (מערכת) + פתח תיקיית אחסון נתונים + פתח קובץ + פתח בכלי Merge חיצוני + פתח מאגר מקומי + סימנייה: + קבוצה: + תיקייה: + לא חובה. + צור לשונית חדשה + סגור לשונית + סגור לשוניות אחרות + סגור לשוניות מימין + העתק נתיב המאגר + ערוך + העבר לסביבת עבודה + רענן + מאגרים + הדבק + לפני {0} ימים + לפני שעה + לפני {0} שעות + הרגע + בחודש שעבר + בשנה שעברה + לפני {0} דקות + לפני {0} חודשים + לפני {0} שנים + אתמול + העדפות + AI + Prompt נוסף (השתמש ב־`-` לרשימת דרישות) + API Key + מודל + Fetch אוטומטי של model-ids זמינים + שם + הערך שהוזן הוא שם משתנה סביבה לטעינת ה־API key + שרת + מראה + גופן ברירת מחדל + רוחב Tab בעורך + גודל גופן + ברירת מחדל + עורך + גופן Monospace + ערכת נושא + דריסות ערכת נושא + שימוש בפסי גלילה המוסתרים אוטומטית + רוחב לשונית קבוע בשורת הכותרת + שימוש במסגרת חלון מקומית + כלי DIFF/MERGE + ארגומנטים ל־Diff + משתנים זמינים: $LOCAL, $REMOTE + ארגומנטים ל־Merge + משתנים זמינים: $BASE, $LOCAL, $REMOTE, $MERGED + נתיב התקנה + הזן נתיב לכלי diff/merge + כלי + כללי + בדוק עדכונים בעת ההפעלה + תבנית תאריך + תיקיות מצומצמות בעץ השינויים + שפה + Commits בהיסטוריה + הצג דף `LOCAL CHANGES` כברירת מחדל + הצג לשונית `CHANGES` בפרטי ה־commit כברירת מחדל + הצג זמן יחסי בגרף ה־commits + הצג tags בגרף ה־commits + אורך מנחה ל־Subject + 24 שעות + צור אווטאר ברירת מחדל בסגנון GitHub + GIT + הפעל CRLF אוטומטי + תיקיית clone ברירת מחדל + דוא"ל משתמש + דוא"ל משתמש גלובלי של git + Prune ל־branches מתים לאחר fetch + התעלמות מ־CR בסוף שורה ב־diff טקסט + נדרש Git (>= 2.25.1) עבור אפליקציה זו + נתיב התקנה + הפעל אימות HTTP SSL + שימוש ב־git-credential-libsecret במקום git-credential-manager + שם משתמש + שם משתמש גלובלי של git + Stash והחלה מחדש של שינויים כברירת מחדל בעת checkout או merge של branches + גרסת Git + חתימת GPG + חתימת GPG ל־commits + פורמט GPG + נתיב התקנת התוכנה + הזן נתיב לתוכנת gpg מותקנת + חתימת GPG ל־tags + מפתח חתימה של המשתמש + מפתח חתימת gpg של המשתמש + אינטגרציה + SHELL/טרמינל + ארגומנטים + השתמש ב־'.' לציון תיקיית העבודה + נתיב + Shell/טרמינל + Prune Remote + יעד: + Prune Worktrees + Prune למידע על worktrees ב־`$GIT_COMMON_DIR/worktrees` + Pull + Branch מרוחק: + אל: + שינויים מקומיים: + Remote: + Pull (Fetch ו־Merge) + שימוש ב־rebase במקום merge + Push + לוודא ש־submodules בוצע להם push + Force push + Branch מקומי: + חדש + Remote: + Revision: + Push של Revision ל־Remote + Push של שינויים ל־Remote + Branch מרוחק: + הגדר כ־tracking branch + Push לכל ה־tags + Push של Tag ל־Remote + Push לכל ה־remotes + Remote: + Tag: + Push ל־branch חדש + הזן שם ל־branch המרוחק החדש: + יציאה + Rebase של ה־Branch הנוכחי + Stash והחלה מחדש של שינויים מקומיים + דלג על pre-rebase hook + על: + הוסף Remote + ערוך Remote + שם: + שם Remote + URL של המאגר: + URL של מאגר git מרוחק + העתק URL + פעולה מותאמת אישית + מחק... + ערוך... + הפעל Auto-Fetch + Fetch + פתח בדפדפן + Prune + אישור הסרת Worktree + הפעל אפשרות `--force` + יעד: + שנה שם Branch + שם חדש: + שם ייחודי ל־branch זה + Branch: + ביטול + Fetch אוטומטי של שינויים מ־remotes... + מיון + לפי תאריך Committer + לפי שם + נקה (GC ו־Prune) + הרץ `git gc` עבור המאגר. + נקה הכול + נקה + הגדר מאגר זה + המשך + פעולות מותאמות אישית + אין פעולות מותאמות אישית + Dashboard + השלך כל השינויים + פתח בסייר הקבצים + חיפוש Branches/Tags/Submodules + נראות בגרף + לא מוגדר + הסתר בגרף ה־commits + סינון בגרף ה־commits + פריסה + אופקי + אנכי + סדר COMMITS + תאריך Commit + טופולוגי + BRANCHES מקומיים + אפשרויות נוספות... + ניווט אל HEAD + צור Branch + נקה התראות + פתח כתיקייה + פתח ב־{0} + פתח בכלים חיצוניים + REMOTES + הוסף Remote + פתור + חיפוש Commit + Author + תוכן + הודעה + נתיב + SHA + ב־Branch הנוכחי + Commits מעוטרים בלבד + First-parent בלבד + הצג דגלים + הצג commits אבודים + הצג Submodules כעץ + הצג Tags כעץ + דלג + סטטיסטיקות + SUBMODULES + הוסף Submodule + עדכון Submodule + TAGS + Tag חדש + לפי תאריך צור + לפי שם + מיון + פתח בטרמינל + הצג לוגים + בקר ב־'{0}' בדפדפן + WORKTREES + הוסף Worktree + Prune + URL של מאגר Git + Reset של ה־Branch הנוכחי ל־Revision + מצב Reset: + העבר אל: + Branch נוכחי: + Reset של Branch (ללא Checkout) + העבר אל: + Branch: + הצג בסייר הקבצים + Revert של Commit + Commit: + Commit לשינויי ה־revert + פועל. נא להמתין... + שמור + שמור בשם... + ה־Patch נשמר בהצלחה! + סרוק מאגרים + תיקיית שורש: + סרוק תיקייה מותאמת אישית אחרת + בדוק עדכונים... + גרסה חדשה של התוכנה זמינה: + גרסה נוכחית: + בדוק העדכונים נכשלה! + הורד + דלג על גרסה זו + תאריך שחרור הגרסה החדשה: + עדכון תוכנה + כרגע אין עדכונים זמינים. + הגדר Branch של Submodule + Submodule: + נוכחי: + שנה אל: + לא חובה. אם ריק, יוגדר לברירת המחדל. + הגדר Tracking Branch + Branch: + ביטול upstream + Upstream: + העתק SHA + עבור אל + מפתח SSH פרטי: + נתיב לאחסון מפתח SSH פרטי + התחל + Stash + כולל קבצים untracked + הודעה: + לא חובה. הודעה ל־stash זה + מצב: + קבצים ב־staged בלבד + גם השינויים שב־staged וגם אלו שלא יוכנסו ל־stash עבור הקבצים שנבחרו!!! + Stash לשינויים מקומיים + החל + החל שינויים + Checkout ל־Branch חדש + העתק הודעה + מחק + שמור כ־Patch... + מחק Stash + מחק: + STASHES + שינויים + STASHES + סטטיסטיקות + סקירה כללית + חודש + שבוע + AUTHORS: + COMMITS: + SUBMODULES + הוסף Submodule + BRANCH + Branch + נתיב יחסי + ביטול אתחול + Fetch ל־submodules מקוננים + היסטוריה + העבר אל + פתח מאגר + נתיב יחסי: + תיקייה יחסית לאחסון המודול. + מחק + הגדר Branch + שנה URL + סטטוס + שונה + לא אותחל + ה־revision שונה + unmerged + עדכון + URL + פרטי שינויים ב־Submodule + פתח פרטים + אישור + TAGGER + זמן + השווה בין 2 tags + השווה עם... + השווה עם HEAD + הודעה + שם + Tagger + העתק שם ה־Tag + פעולה מותאמת אישית + מחק ${0}$... + מחק {0} ה־tags שנבחרו... + Push ${0}$... + עדכון Submodules + כל ה־submodules + אתחול לפי הצורך + Submodule: + עדכון ל־remote tracking branch של ה־submodule + URL: + לוגים + נקה הכול + העתק + מחק + אזהרה + דף פתח + צור קבוצה + צור תת־קבוצה + שכפל מאגר + מחק + נתמכת גרירה ושחרור של תיקיות. נתמך קיבוץ מותאם אישית. + ערוך + העבר לקבוצה אחרת + פתח כל המאגרים + פתח מאגר + פתח טרמינל + סרוק מחדש של מאגרים בתיקיית ה־Clone ברירת מחדל + חיפוש מאגרים... + שינויים מקומיים + Git Ignore + התעלמות מכל הקבצים *{0} + התעלמות מקבצי *{0} באותה תיקייה + התעלמות מקבצים untracked בתיקייה זו + התעלמות מקובץ זה בלבד + Amend + ניתן לבצע stage לקובץ זה כעת. + נקה היסטוריה + האם לנקות את כל היסטוריית הודעות ה־commit? לא ניתן לבטל פעולה זו. + COMMIT + COMMIT ו־PUSH + תבנית/היסטוריה + הפעל אירוע לחיצה + Commit (ערוך) + Stage לכל השינויים ו־commit + פעולה זו תיצור commit על HEAD שמצבו detached. האם להמשיך? + ביצעת stage ל־{0} קבצים אך מוצגים רק {1} קבצים ({2} מסוננים החוצה). האם להמשיך? + זוהו קונפליקטים + MERGE + Merge עם כלי חיצוני + פתח כל הקונפליקטים בכלי Merge חיצוני + קונפליקטי הקובץ נפתרו + שימוש בשלי + שימוש בשלהם + כולל קבצים UNTRACKED + אין הודעות שהוזנו לאחרונה + אין תבניות commit + No-Verify + Reset ל־Author + SignOff + STAGED + UNSTAGE + UNSTAGE לכול + UNSTAGED + STAGE + STAGE לכול + הצג ASSUME UNCHANGED + תבנית: ${0}$ + סביבת עבודה: + הגדר סביבות עבודה... + WORKTREE + BRANCH + העתק נתיב + HEAD + נעל + פתח + נתיב + הסר + שחרר נעילה + כן + diff --git a/src/Resources/Locales/id_ID.axaml b/src/Resources/Locales/id_ID.axaml new file mode 100644 index 000000000..383ae256d --- /dev/null +++ b/src/Resources/Locales/id_ID.axaml @@ -0,0 +1,1042 @@ + + + + + + Tentang + Tentang SourceGit + Tanggal Rilis: {0} + Catatan Rilis + Klien Git GUI Opensource & Gratis + Tambahkan Berkas ke Abaikan + Pola: + Berkas Penyimpanan: + Tambah Worktree + Lokasi: + Jalur untuk worktree ini. Jalur relatif didukung. + Nama Branch: + Opsional. Standar adalah nama folder tujuan. + Track Branch: + Track remote branch + Yang Akan Di-Checkout: + Buat Branch Baru + Branch Yang Ada + Asisten AI + Model + BUAT ULANG + Gunakan AI untuk membuat pesan commit + Gunakan + Sembunyikan SourceGit + Sembunyikan Lainnya + Tampilkan Semua + Merge 3 Arah + Pilih berkas .patch untuk diterapkan + Abaikan perubahan whitespace + Sumber: + Berkas + Papan Klip + Terapkan Patch + Whitespace: + Terapkan Stash + Hapus setelah diterapkan + Pulihkan perubahan indeks + Stash: + Arsip... + Simpan Arsip Ke: + Pilih jalur berkas arsip + Revisi: + Arsip + SourceGit Askpass + Masukkan passphrase: + BERKAS DIASUMSIKAN TIDAK BERUBAH + TIDAK ADA BERKAS YANG DIASUMSIKAN TIDAK BERUBAH + Muat Gambar... + Segarkan + BERKAS BINARY TIDAK DIDUKUNG!!! + Bisect + Batalkan + Buruk + Baik + Lewati + Bisect sedang berjalan. Checkout commit lain, lalu tandai sebagai baik atau buruk. + Bisect sedang berjalan. Tandai commit saat ini sebagai buruk atau checkout commit lain, lalu tandai sebagai buruk. + Bisect berjalan. Tandai commit saat ini sebagai baik atau buruk dan checkout yang lain. + Bisect berjalan. Apakah HEAD saat ini baik atau buruk? + Blame + Blame pada Revisi Sebelumnya + Abaikan perubahan whitespace + BLAME PADA BERKAS INI TIDAK DIDUKUNG!!! + Checkout ${0}$... + Bandingkan 2 branch yang dipilih + Bandingkan dengan... + Bandingkan dengan HEAD + Bandingkan dengan ${0}$ + Salin Nama Branch + Buat PR... + Buat PR untuk upstream ${0}$... + Aksi Kustom + Hapus ${0}$... + Hapus {0} branch yang dipilih + Sunting deskripsi ${0}$... + Fast-Forward ke ${0}$ + Fetch ${0}$ ke ${1}$... + Git Flow - Selesaikan ${0}$ + Rebase ${0}$ pada ${1}$ secara Interaktif + Merge ${0}$ ke ${1}$... + Merge {0} branch yang dipilih ke saat ini + Pull ${0}$ + Pull ${0}$ ke ${1}$... + Push ${0}$ + Rebase ${0}$ pada ${1}$... + Ganti Nama ${0}$... + Reset ${0}$ ke ${1}$... + Pindah ke ${0}$ (worktree) + Atur Tracking Branch... + {0} commit di depan + {0} commit di depan, {1} commit di belakang + {0} commit di belakang + Tidak Valid + REMOTE + STATUS + TRACKING + URL + WORKTREE + BATAL + Reset ke Revisi Parent + Reset ke Revisi Ini + Buat pesan commit + Merge (Bawaan) + Merge (Eksternal) + Reset Berkas ke ${0}$ + UBAH MODE TAMPILAN + Tampilkan sebagai Daftar Berkas dan Direktori + Tampilkan sebagai Daftar Jalur + Tampilkan sebagai Pohon Sistem Berkas + Ubah URL Submodule + Submodule: + URL: + Checkout Branch + Perubahan Lokal: + Branch: + HEAD saat ini mengandung commit yang tidak terhubung ke branch/tag manapun! Lanjutkan? + Submodule berikut perlu diperbarui:{0}Apakah Anda ingin memperbaruinya? + Checkout & Fast-Forward + Fast-Forward ke: + Checkout Branch dari Stash + Branch Baru: + Stash: + Checkout Commit atau Tag + Target: + Peringatan: Setelah proses ini, HEAD akan terlepas + Cherry Pick + Tambahkan sumber ke pesan commit + Commit: + Commit semua perubahan + Mainline: + Biasanya Anda tidak dapat cherry-pick merge karena tidak tahu sisi mana dari merge yang dianggap mainline. Opsi ini memungkinkan cherry-pick untuk memutar ulang perubahan relatif terhadap parent yang ditentukan. + Hapus Stash + Anda akan menghapus semua stash. Lanjutkan? + Clone Repositori Remote + Parameter Tambahan: + Argumen tambahan untuk clone repositori. Opsional. + Markah: + Grup: + Nama Lokal: + Nama repositori. Opsional. + Folder Parent: + Inisialisasi & perbarui submodule + URL Repositori: + TUTUP + Editor + Branch + Branch & Tag + Aksi Kustom Repositori + Berkas Revisi + Checkout Commit + Cherry-Pick Commit + Cherry-Pick ... + Bandingkan dengan HEAD + Bandingkan dengan Worktree + Author + Waktu Penulis + Pesan + Committer + Waktu Committer + SHA + Subjek + Aksi Kustom + Interactive Rebase + Drop... + Edit... + Fixup ke Parent... + Rebase ${0}$ pada ${1}$ secara Interaktif + Reword... + Squash ke Parent... + Merge ... + Push ${0}$ ke ${1}$ + Rebase ${0}$ pada ${1}$ + Reset ${0}$ ke ${1}$ + Revert Commit + Simpan sebagai Patch... + PERUBAHAN + berkas berubah + Cari Perubahan... + Ciutkan detail + BERKAS + Berkas LFS + Cari Berkas... + Submodule + INFORMASI + AUTHOR + COMMITTER + Periksa ref yang mengandung commit ini + COMMIT TERKANDUNG DALAM + Salin Email + Salin Nama + Salin Nama & Email + Menampilkan hanya 100 perubahan pertama. Lihat semua perubahan di tab PERUBAHAN. + Kunci: + PESAN + PARENTS + REFS + SHA + Penanda Tangan: + Buka di Browser + KOL + Masukkan pesan commit. Gunakan baris kosong untuk memisahkan subjek dan deskripsi! + SUBJEK + Perbandingan + PERUBAHAN + COMMIT + COMMIT HANYA DI KIRI + COMMIT HANYA DI KANAN + Tips: Anda dapat melakukan cherry-pick pada commit yang dipilih jika sisi lainnya adalah HEAD (melalui menu konteks). + Konfigurasi Repositori + TEMPLATE COMMIT + Parameter bawaan: + + ${branch_name} Nama branch lokal saat ini. + ${files_num} Jumlah berkas yang berubah + ${files} Jalur berkas yang berubah + ${files:N} Maksimal N jalur berkas yang berubah + ${pure_files} Seperti ${files}, tetapi hanya nama berkas + ${pure_files:N} Seperti ${files:N}, tetapi tanpa folder + Konten Template: + Nama Template: + AKSI KUSTOM + Argumen: + Parameter bawaan: + + ${REPO} Jalur repositori + ${REMOTE} Remote yang dipilih atau remote dari branch yang dipilih + ${BRANCH} Branch yang dipilih, tanpa bagian ${REMOTE} untuk remote branch + ${BRANCH_FRIENDLY_NAME} Nama ramah dari branch yang dipilih, mengandung bagian ${REMOTE} untuk remote branch + ${SHA} Hash commit yang dipilih + ${TAG} Tag yang dipilih + ${FILE} Berkas yang dipilih, relatif terhadap akar repositori + $1, $2 ... Nilai kontrol input + Berkas Eksekusi: + Kontrol Input: + Sunting + Nama: + Lingkup: + Branch + Commit + Berkas + Remote + Repositori + Tag + Tunggu aksi selesai + Alamat Email + Alamat email + GIT + Tanyakan sebelum memperbarui submodule secara otomatis + Fetch remote secara otomatis + Menit + Tipe Conventional Commit + Remote Default + Aktifkan `--recursive` saat memperbarui submodule secara otomatis + Mode Merge Pilihan + ISSUE TRACKER + Tambah Aturan Azure DevOps + Tambah Aturan Gerrit Change-Id Commit + Tambah Aturan Gitee Issue + Tambah Aturan Gitee Pull Request + Tambah Aturan GitHub + Tambah Aturan GitLab Issue + Tambah Aturan GitLab Merge Request + Tambah Aturan Jira + Aturan Baru + Ekspresi Regex Issue: + Nama Aturan: + Bagikan aturan ini di berkas .issuetracker + URL Hasil: + Gunakan $1, $2 untuk mengakses nilai grup regex. + AI + Layanan Pilihan: + Jika 'Layanan Pilihan' diatur, SourceGit hanya akan menggunakannya di repositori ini. Jika tidak, jika ada lebih dari satu layanan yang tersedia, menu konteks untuk memilih salah satunya akan ditampilkan. + Proksi HTTP + Proksi HTTP yang digunakan oleh repositori ini + Nama Pengguna + Nama pengguna untuk repositori ini + Sunting Kontrol Aksi Kustom + Nilai Tercentang: + Saat dicentang, nilai ini akan digunakan dalam argumen command-line + Deskripsi: + Default: + Adalah Folder: + Label: + Opsi: + Gunakan '|' sebagai pembatas untuk opsi + Pemformat String: + Opsional. Digunakan untuk memformat string keluaran. Diabaikan jika input kosong. Gunakan `${VALUE}` untuk mewakili string input. + Variabel bawaan ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, dan ${TAG} tetap tersedia di sini + Jenis: + Gunakan Nama Ramah: + Workspace + Warna + Nama + Pulihkan tab saat startup + LANJUTKAN + Commit kosong terdeteksi! Lanjutkan (--allow-empty)? + STAGE SEMUA & COMMIT + STAGE PILIHAN & COMMIT + Commit kosong terdeteksi! Lanjutkan (--allow-empty) atau stage semua lalu commit? + Perlu Mulai Ulang + Anda perlu memulai ulang aplikasi ini untuk menerapkan perubahan. + Pembantu Conventional Commit + Breaking Change: + Issue Ditutup: + Detail Perubahan: + Lingkup: + Deskripsi Singkat: + Jenis Perubahan: + Salin + Salin Semua Teks + Salin sebagai Patch + Salin Jalur Lengkap + Salin Jalur + Buat Branch... + Berdasarkan: + Checkout branch yang dibuat + Perubahan Lokal: + Nama Branch Baru: + Masukkan nama branch. + Buat Branch Lokal + Timpa branch yang ada + Buat Tag... + Tag Baru Pada: + Tanda tangan GPG + Pesan Tag: + Opsional. + Nama Tag: + Format rekomendasi: v1.0.0-alpha + Push ke semua remote setelah dibuat + Buat Tag Baru + Jenis: + annotated + lightweight + Tahan Ctrl untuk memulai langsung + Potong + Buang + Jangan Lakukan Apa Pun + Stash & Terapkan Ulang + De-initialize Submodule + Paksa de-init meski mengandung perubahan lokal. + Submodule: + Hapus Branch + Apakah Anda juga ingin menghapus remote branch berikut? + Branch: + Paksa hapus meskipun berisi commit yang belum di-merge + Anda akan menghapus remote branch!!! + Hapus Beberapa Branch + Anda akan menghapus beberapa branch sekaligus. Pastikan untuk memeriksa ulang sebelum bertindak! + Hapus Beberapa Tag + Hapus dari remote + Anda akan menghapus beberapa tag sekaligus. Pastikan untuk memeriksa ulang sebelum bertindak! + Hapus Remote + Remote: + Jalur: + Target: + Semua anak akan dihapus dari daftar. + Ini hanya akan menghapusnya dari daftar, bukan dari disk! + Konfirmasi Hapus Grup + Konfirmasi Hapus Repositori + Hapus Submodule + Jalur Submodule: + Hapus Tag + Tag: + Hapus dari repositori remote + DIFF BINARY + BERKAS KOSONG + Perbedaan Pertama + Abaikan Perubahan Whitespace + BLEND + DIFFERENCE + SIDE-BY-SIDE + SWIPE + Perbedaan Terakhir + PERUBAHAN OBJEK LFS + BARU + Perbedaan Berikutnya + TIDAK ADA PERUBAHAN ATAU HANYA PERUBAHAN EOL + LAMA + Perbedaan Sebelumnya + Simpan sebagai Patch + Tampilkan Simbol Tersembunyi + Diff Side-By-Side + PERUBAHAN SUBMODULE + DIHAPUS + BARU + + {0} Perubahan Belum Di-commit + Tukar + Syntax Highlighting + Word Wrap Baris + Buka di Merge Tool + Tampilkan Semua Baris + Kurangi Jumlah Baris yang Tampak + Tambah Jumlah Baris yang Tampak + PILIH BERKAS UNTUK MELIHAT PERUBAHAN + Riwayat Direktori + Memiliki Perubahan Lokal + Tidak Cocok dengan Upstream + Sudah Up-To-Date + Buang Perubahan + Semua perubahan lokal dalam working copy. + Perubahan: + Termasuk berkas yang diabaikan + Sertakan berkas yang diubah/dihapus + Termasuk berkas yang tidak dilacak + {0} perubahan akan dibuang + Anda tidak dapat membatalkan aksi ini!!! + Sunting Deskripsi Branch + Target: + Bookmark: + Nama Baru: + Target: + Sunting Grup yang Dipilih + Sunting Repositori yang Dipilih + Target: + Repositori ini + Fetch + Fetch semua remote + Paksa override ref lokal + Fetch tanpa tag + Remote: + Fetch Perubahan dari Remote + Asumsikan tidak berubah + Aksi Kustom + Buang... + Buang {0} berkas... + Selesaikan Menggunakan ${0}$ + Simpan sebagai Patch... + Stage + Stage {0} berkas + Stash... + Stash {0} berkas... + Unstage + Unstage {0} berkas + Gunakan Milik Saya (checkout --ours) + Gunakan Milik Mereka (checkout --theirs) + Riwayat Berkas + PERUBAHAN + KONTEN + Perubahan mode berkas: + Mode berkas yang dihapus: + Direktori + Dapat Dieksekusi + Mode berkas baru: + Normal + Submodule + Symlink + Tidak Diketahui + Git-Flow + Branch Development: + Feature: + Prefix Feature: + Selesaikan ${0}$ + FLOW - Selesaikan Feature + FLOW - Selesaikan Hotfix + FLOW - Selesaikan Release + Target: + Rebase sebelum merge + Squash saat merge + Hotfix: + Prefix Hotfix: + Inisialisasi Git-Flow + Simpan branch + Branch Production: + Release: + Prefix Release: + Mulai dari: + Mulai Feature... + FLOW - Mulai Feature + Mulai Hotfix... + FLOW - Mulai Hotfix + Nama: + Masukkan nama + Mulai Release... + FLOW - Mulai Release + Prefix Tag Versi: + Git LFS + Tambah Pola Track... + Pola adalah nama berkas + Pola Kustom: + Tambah Pola Track ke Git LFS + Fetch + Jalankan `git lfs fetch` untuk mengunduh objek Git LFS. Ini tidak memperbarui working copy. + Fetch Objek LFS + Instal hook Git LFS + Tampilkan Lock + Tidak Ada Berkas Terkunci + Lock + Hanya tampilkan lock saya + Lock LFS + Unlock + Buka semua lock saya + Yakin ingin membuka lock semua berkas yang Anda kunci? + Paksa Unlock + Prune + Jalankan `git lfs prune` untuk menghapus berkas LFS lama dari penyimpanan lokal + Pull + Jalankan `git lfs pull` untuk mengunduh semua berkas Git LFS untuk ref & checkout saat ini + Pull Objek LFS + Push + Push berkas besar yang diantre ke endpoint Git LFS + Push Objek LFS + Remote: + Track berkas bernama '{0}' + Track semua berkas *{0} + Pilih Commit + RIWAYAT + AUTHOR + WAKTU AUTHOR + WAKTU COMMIT + GRAFIK & SUBJEK + SHA + SOROTAN DALAM GRAFIK + Semua + Hanya Branch Saat Ini + Branch Saat Ini & Commit Terpilih + Hanya Commit Terpilih + DIPILIH {0} COMMIT + TAMPILKAN KOLOM + Tahan 'Ctrl' atau 'Shift' untuk memilih beberapa commit. + Tahan ⌘ atau ⇧ untuk memilih beberapa commit. + TIPS: + Buka di jendela terpisah + Detail Commit + Perbandingan Revisi + Referensi Shortcut Keyboard + GLOBAL + Clone repositori baru + Tutup tab saat ini + Ke tab berikutnya + Ke tab sebelumnya + Buat tab baru + Buka repositori lokal + Buka dialog Preferensi + Tampilkan menu tarik-turun workspace + Ganti tab aktif + Perbesar/perkecil + REPOSITORI + Commit perubahan yang di-stage + Commit dan push perubahan yang di-stage + Stage semua perubahan dan commit + Buat branch baru + Fetch, langsung dimulai + Mode dashboard (Default) + Ke turunan commit yang dipilih + Ke induk commit yang dipilih + Buka palet perintah + Mode pencarian commit + Pull, langsung dimulai + Push, langsung dimulai + Paksa muat ulang repositori ini + Bentangkan/Ciutkan panel detail di `RIWAYAT` + Pindah ke 'Changes' + Pindah ke 'History' + Pindah ke 'Stashes' + Buang + Stage + Unstage + Inisialisasi Repositori + Apakah Anda ingin menjalankan perintah `git init` di jalur ini? + Gagal membuka repositori. Alasan: + Jalur: + Cherry-Pick sedang berjalan. + Memproses commit + Merge sedang berjalan. + Melakukan merge + Rebase sedang berjalan. + Berhenti di + Revert sedang berjalan. + Melakukan revert commit + Interactive Rebase + Stash & terapkan ulang perubahan lokal + Lewati hook pre-rebase + Pada: + Drag-drop untuk mengurutkan ulang commit + Branch Target: + Salin Link + Buka di Browser + Perintah + ERROR + PEMBERITAHUAN + Versi Baru Tersedia + Buka Repositori + Tab + Workspace + Merge Branch + Sesuaikan pesan merge + Ke: + Opsi Merge: + Sumber: + Menguji merge... + Merge dapat dilakukan tanpa konflik + Terjadi kesalahan yang tidak diketahui saat menguji merge + Merge akan menyebabkan konflik + Milik Saya dahulu, lalu Milik Mereka + Milik Mereka dahulu, lalu Milik Saya + GUNAKAN KEDUANYA + Semua konflik terselesaikan + {0} konflik tersisa + MILIK SAYA + Konflik Berikutnya + Konflik Sebelumnya + HASIL + SIMPAN & STAGE + MILIK MEREKA + Konflik Merge + Buang perubahan yang belum disimpan? + GUNAKAN MILIK SAYA + GUNAKAN MILIK MEREKA + URUNGKAN + Merge (Beberapa) + Commit semua perubahan + Strategi: + Target: + Pindahkan Submodule + Pindahkan Ke: + Submodule: + Pindahkan Node Repositori + Pilih node parent untuk: + Nama: + TIDAK + Git BELUM dikonfigurasi. Silakan ke [Preferences] dan konfigurasikan terlebih dahulu. + Buka + Editor Default (Sistem) + Buka Direktori Penyimpanan Data + Buka Berkas + Buka di Merge Tool + Buka Repositori Lokal + Markah: + Grup: + Folder: + Opsional. + Buat Tab Baru + Tutup Tab + Tutup Tab Lain + Tutup Tab di Kanan + Salin Jalur Repositori + Sunting + Pindahkan ke Workspace + Segarkan + Repositori + Tempel + {0} hari lalu + 1 jam lalu + {0} jam lalu + Baru saja + Bulan lalu + Tahun lalu + {0} menit lalu + {0} bulan lalu + {0} tahun lalu + Kemarin + Preferensi + AI + Prompt Tambahan (Gunakan `-` untuk mencantumkan kebutuhan Anda) + API Key + Model + Ambil model-id yang tersedia secara otomatis + Nama + Nilai yang dimasukkan adalah nama untuk memuat API key dari ENV + Server + TAMPILAN + Font Default + Lebar Tab Editor + Ukuran Font + Default + Editor + Font Monospace + Tema + Override Tema + Gunakan scrollbar auto-hide + Gunakan lebar tab tetap di titlebar + Gunakan frame window native + DIFF/MERGE TOOL + Argumen Diff + Variabel tersedia: $LOCAL, $REMOTE + Argumen Merge + Variabel tersedia: $BASE, $LOCAL, $REMOTE, $MERGED + Jalur Instalasi + Masukkan jalur untuk diff/merge tool + Tool + UMUM + Periksa pembaruan saat startup + Format Tanggal + Aktifkan folder kompak di pohon perubahan + Bahasa + Commit Riwayat + Tampilkan halaman `LOCAL CHANGES` secara default + Tampilkan tab `CHANGES` di detail commit secara default + Tampilkan waktu relatif pada grafik commit + Tampilkan tag di grafik commit + Panjang Panduan Subjek + Format 24 Jam + Gunakan nama branch ringkas pada grafik commit + Generate avatar default bergaya GitHub + GIT + Aktifkan Auto CRLF + Direktori Clone Default + Email Pengguna + Email pengguna git global + Aktifkan --prune saat fetch + Aktifkan --ignore-cr-at-eol di diff + Git (>= 2.25.1) diperlukan oleh aplikasi ini + Jalur Instalasi + Aktifkan HTTP SSL Verify + Gunakan git-credential-libsecret alih-alih git-credential-manager + Nama Pengguna + Nama pengguna git global + Stash & terapkan ulang perubahan secara default saat checkout atau merge branch + Versi Git + PENANDATANGANAN GPG + Penandatanganan GPG commit + Format GPG + Jalur Instalasi Program + Masukkan jalur untuk program gpg yang terinstal + Penandatanganan GPG tag + Kunci Penandatanganan Pengguna + Kunci penandatanganan gpg pengguna + INTEGRASI + SHELL/TERMINAL + Argumen + Gunakan '.' untuk menunjukkan direktori kerja + Jalur + Shell/Terminal + Prune Remote + Target: + Prune Worktree + Prune informasi worktree di `$GIT_COMMON_DIR/worktrees` + Pull + Remote Branch: + Ke: + Perubahan Lokal: + Remote: + Pull (Fetch & Merge) + Gunakan rebase alih-alih merge + Push + Pastikan submodule sudah di-push + Paksa push + Branch Lokal: + BARU + Remote: + Revisi: + Push Revisi ke Remote + Push Perubahan ke Remote + Remote Branch: + Atur sebagai tracking branch + Push semua tag + Push Tag ke Remote + Push ke semua remote + Remote: + Tag: + Push ke branch BARU + Masukkan nama remote branch baru: + Keluar + Rebase Branch Saat Ini + Stash & terapkan ulang perubahan lokal + Lewati hook pre-rebase + Pada: + Menguji rebase... + Rebase dapat dilakukan tanpa konflik + Terjadi kesalahan yang tidak diketahui saat menguji rebase + Rebase akan menyebabkan konflik + Tambah Remote + Sunting Remote + Nama: + Nama remote + URL Repositori: + URL repositori git remote + Salin URL + Aksi Kustom + Hapus... + Sunting... + Aktifkan Fetch Otomatis + Fetch + Buka di Browser + Prune + Konfirmasi Hapus Worktree + Aktifkan Opsi `--force` + Target: + Ganti Nama Branch + Nama Baru: + Nama unik untuk branch ini + Branch: + BATALKAN + Auto fetch perubahan dari remote... + Urut + Berdasarkan Tanggal Committer + Berdasarkan Nama + Bersihkan (GC & Prune) + Jalankan perintah `git gc` untuk repositori ini. + Bersihkan semua + Bersihkan + Konfigurasikan repositori ini + LANJUTKAN + Aksi Kustom + Tidak Ada Aksi Kustom + Dashboard + Buang semua perubahan + Buka di File Browser + Cari Branch/Tag/Submodule + Visibilitas di Grafik + Ciutkan Filter + Tidak Diatur + Sembunyikan di grafik commit + Bentangkan Filter + Filter di grafik commit + filter aktif + TATA LETAK + Horizontal + Vertikal + URUTAN COMMIT + Tanggal Commit + Topologis + BRANCH LOKAL + Opsi lainnya... + Navigasi ke HEAD + Buat Branch + BERSIHKAN NOTIFIKASI + Buka sebagai Folder + Buka di {0} + Buka di Tool Eksternal + REMOTE + Tambah Remote + SELESAIKAN + Cari Commit + Author + Konten + Pesan + Jalur + SHA + Branch Saat Ini + Hanya commit yang didekorasi + Hanya first-parent + TAMPILKAN FLAG + Tampilkan commit yang hilang + Tampilkan Submodule sebagai Pohon + Tampilkan Tag sebagai Pohon + LEWATI + Statistik + SUBMODULE + Tambah Submodule + Perbarui Submodule + TAG + Tag Baru + Berdasarkan Tanggal Pembuat + Berdasarkan Nama + Urut + Buka di Terminal + Lihat Log + Kunjungi '{0}' di Browser + WORKTREE + Tambah Worktree + Prune + URL Repositori Git + Reset Branch Saat Ini ke Revisi + Mode Reset: + Pindah Ke: + Branch Saat Ini: + Reset Branch (Tanpa Checkout) + Pindah Ke: + Branch: + Tampilkan di File Explorer + Revert Commit + Commit: + Commit perubahan revert + Sedang berjalan. Harap tunggu... + SIMPAN + Simpan Sebagai... + Patch berhasil disimpan! + Pindai Repositori + Direktori Root: + Pindai direktori kustom lain + Periksa Pembaruan... + Versi baru dari perangkat lunak ini tersedia: + Versi Saat Ini: + Pemeriksaan pembaruan gagal! + Unduh + Lewati Versi Ini + Tanggal Rilis Versi Baru: + Pembaruan Perangkat Lunak + Saat ini tidak ada pembaruan yang tersedia. + Atur Branch Submodule + Submodule: + Saat Ini: + Ubah Ke: + Opsional. Atur ke default jika kosong. + Atur Tracking Branch + Branch: + Hapus upstream + Upstream: + Salin SHA + Ke + Kunci SSH Privat: + Jalur penyimpanan kunci SSH privat + MULAI + Stash + Termasuk berkas yang tidak dilacak + Pesan: + Opsional. Pesan untuk stash ini + Mode: + Hanya perubahan yang di-stage + Perubahan staged dan unstaged dari berkas yang dipilih akan di-stash!!! + Stash Perubahan Lokal + Terapkan + Terapkan Perubahan + Checkout Branch Baru + Salin Pesan + Drop + Simpan sebagai Patch... + Drop Stash + Drop: + STASH + PERUBAHAN + STASH + Statistik + IKHTISAR + BULAN + MINGGU + AUTHOR: + COMMIT: + SUBMODULE + Tambah Submodule + BRANCH + Branch + Jalur Relatif + De-initialize + Fetch submodule bersarang + Riwayat + Pindahkan Ke + Buka Repositori + Jalur Relatif: + Folder relatif untuk menyimpan modul ini. + Hapus + Atur Branch + Ubah URL + STATUS + dimodifikasi + tidak diinisialisasi + revisi berubah + belum di-merge + Perbarui + URL + Detail Perubahan Submodule + BUKA DETAIL + OK + TAGGER + WAKTU + Checkout Tag... + Bandingkan 2 tag + Bandingkan dengan... + Bandingkan dengan HEAD + Pesan + Nama + Tagger + Salin Nama Tag + Aksi Kustom + Hapus ${0}$... + Hapus {0} tag yang dipilih... + Merge ${0}$ ke ${1}$... + Push ${0}$... + Perbarui Submodule + Semua submodule + Inisialisasi sesuai kebutuhan + Submodule: + Perbarui submodule bertingkat + Perbarui ke remote tracking branch submodule + URL: + Log + BERSIHKAN SEMUA + Salin + Hapus + Peringatan + Halaman Selamat Datang + Buat Grup + Buat Sub-Grup + Clone Repositori + Hapus + DRAG & DROP FOLDER DIDUKUNG. PENGELOMPOKAN KUSTOM DIDUKUNG. + Sunting + Pindah ke Grup Lain + Buka Semua Repositori + Buka Repositori + Buka Terminal + Pindai Ulang Repositori di Direktori Clone Default + Cari Repositori... + PERUBAHAN LOKAL + Git Ignore + Abaikan semua berkas *{0} + Abaikan berkas *{0} di folder yang sama + Abaikan berkas yang tidak dilacak di folder ini + Abaikan hanya berkas ini + Abaikan semua berkas yang tidak dilacak di folder yang sama + Amend + Anda dapat stage berkas ini sekarang. + Bersihkan Riwayat + Yakin ingin membersihkan semua riwayat pesan commit? Aksi ini tidak dapat dibatalkan. + COMMIT + COMMIT & PUSH + Template/Riwayat + Picu event klik + Commit (Sunting) + Stage semua perubahan dan commit + Anda membuat commit pada HEAD yang terlepas. Lanjutkan? + Anda telah stage {0} berkas tetapi hanya {1} berkas yang ditampilkan ({2} berkas disaring). Lanjutkan? + KONFLIK TERDETEKSI + MERGE + Merge dengan Alat Eksternal + BUKA SEMUA KONFLIK DI MERGETOOL EKSTERNAL + KONFLIK BERKAS DISELESAIKAN + GUNAKAN MILIK SAYA + GUNAKAN MILIK MEREKA + TERMASUK BERKAS YANG TIDAK DILACAK + TIDAK ADA PESAN INPUT TERBARU + TIDAK ADA TEMPLATE COMMIT + No-Verify + Reset Author + SignOff + STAGED + UNSTAGE + UNSTAGE SEMUA + UNSTAGED + STAGE + STAGE SEMUA + LIHAT ASSUME UNCHANGED + Template: ${0}$ + WORKSPACE: + Konfigurasikan Workspace... + WORKTREE + BRANCH + Salin Jalur + HEAD + Lock + Buka + JALUR + Hapus + Unlock + YA + diff --git a/src/Resources/Locales/it_IT.axaml b/src/Resources/Locales/it_IT.axaml new file mode 100644 index 000000000..46b2fa9a5 --- /dev/null +++ b/src/Resources/Locales/it_IT.axaml @@ -0,0 +1,931 @@ + + + + + + Informazioni + Informazioni su SourceGit + Data di Rilascio: {0} + Note di Rilascio + Client GUI Git open source e gratuito + Aggiungi file a Ignora + Pattern: + File di storage: + 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 + Modello + RIGENERA + Usa AI per generare il messaggio di commit + Nascondi SourceGit + Mostra Tutto + 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 + Inserisci passphrase: + FILE ASSUNTI COME INVARIATI + NESSUN FILE ASSUNTO COME INVARIATO + Carico l'Immagine... + Aggiorna + FILE BINARIO NON SUPPORTATO!!! + Biseca + Annulla + Cattiva + Buona + Salta + Bisecando. Marca il commit corrente come buono o cattivo e fai checkout di un altro. + Bisecando. La HEAD corrente è buona o cattiva? + Attribuisci + Attribuisci sulla Revisione Precedente + Ignora modifiche agli spazi + L'ATTRIBUZIONE SU QUESTO FILE NON È SUPPORTATA!!! + Checkout ${0}$... + Confronta i 2 branch selezionati + Confronta con... + Confronta con HEAD + Copia Nome Branch + Crea PR... + Crea PR per upstream ${0}$... + Azione personalizzata + Elimina ${0}$... + Elimina i {0} branch selezionati + Modifica descrizione per ${0}$... + Avanzamento Veloce a ${0}$ + Recupera ${0}$ in ${1}$... + Git Flow - Completa ${0}$ + Ribasare interattivamente ${0}$ su ${1}$ + 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}$... + Resetta ${0}$ a ${1}$... + Passa a ${0}$ (worktree) + Imposta Branch di Tracciamento... + {0} commit avanti + {0} commit avanti, {1} commit indietro + {0} commit indietro + Invalido + REMOTO + STATO + TRACCIAMENTO + URL + WORKTREE + ANNULLA + Ripristina la Revisione Padre + Ripristina Questa Revisione + Genera messaggio di commit + Merge (Integrato) + Merge (Esterno) + CAMBIA MODALITÀ DI VISUALIZZAZIONE + Mostra come elenco di file e cartelle + Mostra come elenco di percorsi + Mostra come albero del filesystem + Cambia l'URL del Sottomodulo + Sottomodulo: + URL: + Checkout Branch + Modifiche Locali: + Branch: + Il tuo HEAD attuale contiene commit non connessi ad alcun branch/tag! Sicuro di voler continuare? + I seguenti sottomoduli devono essere aggiornati:{0}Vuoi aggiornarli? + Checkout & Avanzamento Veloce + Avanzamento Veloce verso: + 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 + Messaggio + Committer + SHA + Oggetto + Azione Personalizzata + Rebase Interattivo + Scarta... + Modifica... + Correggi nel Genitore... + Ribasare interattivamente ${0}$ su ${1}$ + Riformula... + Compatta nel Genitore... + Unisci ... + Invia ${0}$ a ${1}$ + Ribasa ${0}$ su ${1}$ + Resetta ${0}$ su ${1}$ + Annulla Commit + Salva come Patch... + MODIFICHE + file modificati + Cerca Modifiche... + FILE + File LFS + Cerca File... + Sottomodulo + INFORMAZIONI + AUTORE + CHI HA COMMITTATO + Controlla i riferimenti che contengono questo commit + IL COMMIT È CONTENUTO DA + Copia Email + Copia Nome + Copia Nome ed Email + Mostra solo le prime 100 modifiche. Vedi tutte le modifiche nella scheda MODIFICHE. + Chiave: + MESSAGGIO + GENITORI + RIFERIMENTI + SHA + Firmatario: + Apri nel Browser + Inserisci il messaggio di commit. Usa una riga vuota per separare oggetto e descrizione! + OGGETTO + Confronto + Configura Repository + TEMPLATE DI COMMIT + Parametri integrati: + +${branch_name} Nome del branch locale corrente. +${files_num} Numero di file modificati +${files} Percorsi dei file modificati +${files:N} Massimo N percorsi di file modificati +${pure_files} Come ${files}, ma solo nomi file puri +${pure_files:N} Come ${files:N}, ma senza cartelle + Contenuto Template: + Nome Template: + AZIONE PERSONALIZZATA + Argomenti: + Parametri integrati: + + ${REPO} Percorso del repository + ${REMOTE} Remoto selezionato o remoto del branch selezionato + ${BRANCH} Branch selezionato, senza la parte ${REMOTE} per i branch remoti + ${BRANCH_FRIENDLY_NAME} Nome amichevole del branch selezionato, contiene la parte ${REMOTE} per i branch remoti + ${SHA} Hash del commit selezionato + ${TAG} Tag selezionato + ${FILE} File selezionato, relativo alla radice del repository + $1, $2 ... Valori dei controlli di input + File Eseguibile: + Controlli di Input: + Modifica + Nome: + Ambito: + Branch + Commit + File + Remoto + Repository + Tag + Attendi la fine dell'azione + Indirizzo Email + Indirizzo email + GIT + Chiedi prima di aggiornare automaticamente i sottomoduli + Recupera automaticamente i remoti + Minuto/i + Tipi di Commit Convenzionali + Remoto Predefinito + Modalità di Merge Preferita + TRACCIAMENTO ISSUE + Aggiungi una regola di esempio per Azure DevOps + Aggiungi regola per Gerrit Change-Id Commit + 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: + Condividi questa regola nel file .issuetracker + 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 + Modifica Controlli Azione Personalizzata + Valore Selezionato: + Quando selezionato, questo valore sarà usato negli argomenti della riga di comando + Descrizione: + Predefinito: + È una Cartella: + Etichetta: + Opzioni: + Usa '|' come delimitatore per le opzioni + Le variabili integrate ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE} e ${TAG} rimangono disponibili qui + Tipo: + 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? + Riavvio Necessario + È necessario riavviare l'applicazione per applicare le modifiche. + 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: + Nome Nuovo Branch: + Inserisci il nome del branch. + Crea Branch Locale + Sovrascrivi branch esistente + 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 + Scarta + Non fare nulla + Stash e Ripristina + Deinizializza Sottomodulo + Forza deinizializzazione anche se contiene modifiche locali. + Sottomodulo: + Elimina Branch + Branch: + Stai per eliminare un branch remoto!!! + Elimina Branch Multipli + Stai per eliminare più branch contemporaneamente. Controlla attentamente prima di procedere! + Elimina Tag Multipli + Eliminali dai remoti + Stai cercando di eliminare più tag contemporaneamente. Assicurati di controllare 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 + Prima differenza + Ignora Modifiche agli Spazi + FUSIONE + DIFFERENZA + AFFIANCATI + SCORRIMENTO + Ultima differenza + MODIFICA OGGETTO LFS + NUOVO + Differenza Successiva + NESSUNA MODIFICA O SOLO CAMBIAMENTI DI FINE LINEA + VECCHIO + Differenza Precedente + Salva come Patch + Mostra Simboli Nascosti + Diff Affiancato + SOTTOMODULO + ELIMINATO + NUOVO + Scambia + Evidenziazione Sintassi + Avvolgimento delle Parole + 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 + Cronologia Cartella + Ha Modifiche Locali + Non allineato con Upstream + Già Aggiornato + Scarta Modifiche + Tutte le modifiche locali nella copia di lavoro. + Modifiche: + Includi file ignorati + Includi file non tracciati + Un totale di {0} modifiche saranno scartate + Questa azione non può essere annullata!!! + Modifica Descrizione del Branch + Destinazione: + Segnalibro: + Nuovo Nome: + Destinazione: + Modifica Gruppo Selezionato + Modifica Repository Selezionato + Destinazione: + Questo repository + Recupera + Recupera da tutti i remoti + Forza la sovrascrittura dei riferimenti locali + Recupera senza tag + Remoto: + Recupera Modifiche Remote + Presumi invariato + Azione Personalizzata + Scarta... + Scarta {0} file... + Risolvi Usando ${0}$ + Salva come Patch... + Stage + Stage di {0} file + Stasha... + Stasha {0} file... + Rimuovi da Stage + Rimuovi da Stage {0} file + 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: + 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 + Sblocca tutti i miei blocchi + Sei sicuro di voler sbloccare tutti i tuoi file bloccati? + 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 + ORA COMMIT + GRAFICO E OGGETTO + SHA + {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 + 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 + Mostra menu a tendina workspace + Cambia scheda attiva + Ingrandisci/Rimpicciolisci + REPOSITORY + Committa le modifiche in tsage + Committa e invia le modifiche in stage + Fai lo stage di tutte le modifiche e committa + Recupera, avvia direttamente + Modalità Dashboard (Predefinita) + Apri tavolozza comandi + Modalità ricerca commit + Scarica, avvia direttamente + Invia, avvia direttamente + Forza l'aggiornamento di questo repository + Passa a 'Modifiche' + Passa a 'Storico' + Passa a 'Stashes' + 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 + Stasha e Riapplica modifiche locali + Su: + Trascina per riordinare i commit + Branch di destinazione: + Copia il Link + Apri nel Browser + Comandi + ERRORE + AVVISO + Apri Repository + Schede + Workspaces + Unisci Branch + Personalizza messaggio di merge + In: + Opzione di Unione: + Sorgente: + Prima Il Mio, poi Il Loro + Prima Il Loro, poi Il Mio + USA ENTRAMBI + Tutti i conflitti risolti + {0} conflitto/i rimanente/i + IL MIO + Conflitto Successivo + Conflitto Precedente + RISULTATO + SALVA E STAGE + IL LORO + Conflitti di Merge + Scartare le modifiche non salvate? + USA IL MIO + USA IL LORO + ANNULLA + Unione (multipla) + Commit di tutte le modifiche + Strategia: + Obiettivi: + Sposta Sottomodulo + Sposta Verso: + Sottomodulo: + Sposta Nodo Repository + Seleziona nodo padre per: + Nome: + NO + Git NON è configurato. Prima vai su [Preferenze] per configurarlo. + Apri + Editor Predefinito (Sistema) + Apri Cartella Dati App + Apri File + Apri nello Strumento di Merge + Opzionale. + Crea Nuova Pagina + Chiudi Tab + Chiudi Altri Tab + Chiudi i Tab a Destra + Copia Percorso Repository + Modifica + Sposta nel Workspace + Aggiorna + 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 + Chiave API + Nome + Il valore inserito è il nome per caricare la chiave API da ENV + Server + ASPETTO + Font Predefinito + Larghezza della Tab Editor + Dimensione Font + Dimensione Font Predefinita + Dimensione Font Editor + Font Monospaziato + Tema + Sostituzioni Tema + Usa barre di scorrimento a scomparsa automatica + Usa larghezza fissa per i tab nella barra del titolo + Usa cornice finestra nativa + STRUMENTO DI DIFFERENZA/UNIONE + Argomenti Diff + Variabili disponibili: $LOCAL, $REMOTE + Argomenti Merge + Variabili disponibili: $BASE, $LOCAL, $REMOTE, $MERGED + Percorso Installazione + Inserisci il percorso per lo strumento di differenza/unione + Strumento + GENERALE + Controlla aggiornamenti all'avvio + Formato data + Abilita cartelle compatte nell'albero delle modifiche + Lingua + Numero massimo di commit nella cronologia + Mostra pagina `MODIFICHE LOCALI` per impostazione predefinita + Mostra scheda `MODIFICHE` nei dettagli del commit per impostazione predefinita + Mostra i tag nel grafico dei commit + Lunghezza Guida Oggetto + Genera avatar predefinito stile GitHub + 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 + Usa git-credential-libsecret invece di git-credential-manager + 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 + Argomenti + Usa '.' per indicare la cartella di lavoro + Percorso + Shell/Terminale + Potatura Remota + Destinazione: + Potatura Worktrees + Potatura delle informazioni di worktree in `$GIT_COMMON_DIR/worktrees` + Scarica + Branch Remoto: + In: + Modifiche Locali: + Remoto: + Scarica (Recupera e Unisci) + Riallineare anziché unire + Invia + Assicurati che i sottomoduli siano stati inviati + Forza l'invio + Branch Locale: + NUOVO + Remoto: + Revisione: + Invia Revisione Al Remoto + Invia modifiche al remoto + Branch Remoto: + Imposta come branch di tracciamento + Invia tutti i tag + Invia Tag al Remoto + Invia a tutti i remoti + Remoto: + Tag: + Push su un NUOVO branch + Inserisci il nome del nuovo branch remoto: + Esci + Riallinea Branch Corrente + Stasha e Riapplica modifiche locali + Su: + Aggiungi Remoto + Modifica Remoto + Nome: + Nome del remoto + URL del Repository: + URL del repository Git remoto + Copia URL + Azione Personalizzata + 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 + Cancella + Configura questo repository + CONTINUA + Azioni Personalizzate + Nessuna Azione Personalizzata + Dashboard + Scarta tutte le modifiche + Apri nell'Esplora File + Cerca Branch/Tag/Sottomodulo + Visibilità nel grafico + Non impostato + Nascondi nel grafico dei commit + Filtra nel grafico dei commit + LAYOUT + Orizzontale + Verticale + Ordine dei commit + Per data del commit + Topologicamente + BRANCH LOCALI + Altre opzioni... + Vai a HEAD + Crea Branch + CANCELLA LE NOTIFICHE + Apri come Cartella + Apri in {0} + Apri in Strumenti Esterni + REMOTI + AGGIUNGI REMOTO + RISOLVI + Cerca Commit + Autore + Contenuto + Messaggio + Percorso + SHA + Branch Corrente + Solo commit decorati + Solo primo genitore + MOSTRA FLAG + Mostra commit persi + 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 + 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: + Resetta Branch (Senza Checkout) + Sposta Verso: + Branch: + Mostra nell'Esplora File + Ripristina Commit + Commit: + Commit delle modifiche di ripristino + In esecuzione. Attendere... + SALVA + Salva come... + La patch è stata salvata con successo! + Scansiona Repository + Cartella Principale: + Scansiona un'altra cartella personalizzata + 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 Branch del Sottomodulo + Sottomodulo: + Attuale: + Cambia In: + Opzionale. Imposta al valore predefinito quando è vuoto. + Imposta il Branch + Branch: + Rimuovi upstream + Upstream: + Copia SHA + Vai a + Chiave Privata SSH: + Percorso per la chiave SSH privata + AVVIA + Stasha + Includi file non tracciati + Messaggio: + Opzionale. Informazioni di questo stash + Modalità: + Solo modifiche in stage + Sia le modifiche in stage che quelle non in stage dei file selezionati saranno stashate!!! + Stasha Modifiche Locali + Applica + Copia Messaggio + Elimina + Salva come Patch... + Elimina Stash + Elimina: + STASH + MODIFICHE + STASH + Statistiche + PANORAMICA + MESE + SETTIMANA + AUTORI: + COMMIT: + SOTTOMODULI + Aggiungi Sottomodulo + BRANCH + Branch + Percorso Relativo + Deinizializza + Recupera sottomoduli annidati + Cronologia + Sposta + Apri Repository del Sottomodulo + Percorso Relativo: + Cartella relativa per memorizzare questo modulo. + Elimina Sottomodulo + Imposta Branch + Cambia URL + STATO + modificato + non inizializzato + revisione cambiata + non unito + Aggiorna + URL + OK + AUTORE TAG + DATA + Confronta 2 tag + Confronta con... + Confronta con HEAD + Messaggio + Nome + Autore + Copia Nome Tag + Azione Personalizzata + Elimina ${0}$... + Elimina i {0} tag selezionati... + Invia ${0}$... + Aggiorna Sottomoduli + Tutti i sottomoduli + Inizializza se necessario + Sottomodulo: + Aggiorna al branch di tracciamento remoto del sottomodulo + 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... + MODIFICHE LOCALI + Git Ignore + Ignora tutti i file *{0} + Ignora i file *{0} nella stessa cartella + Ignora file non tracciati in questa cartella + Ignora solo questo file + Modifica + Puoi aggiungere in stage questo file ora. + Cancella Cronologia + Sei sicuro di voler cancellare tutta la cronologia dei messaggi di commit? Questa azione non può essere annullata. + COMMIT + COMMIT E INVIA + Template/Storico + Attiva evento click + Commit (Modifica) + Stage di tutte le modifiche e fai il commit + Stai creando un commit su un HEAD distaccato. Vuoi continuare? + Hai stageato {0} file ma solo {1} file mostrati ({2} file sono stati filtrati). Vuoi procedere? + CONFLITTI RILEVATI + MERGE + 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 + No-Verify + Reimposta Autore + 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 + Apri + Rimuovi + Sblocca + + diff --git a/src/Resources/Locales/ja_JP.axaml b/src/Resources/Locales/ja_JP.axaml new file mode 100644 index 000000000..844e70874 --- /dev/null +++ b/src/Resources/Locales/ja_JP.axaml @@ -0,0 +1,1038 @@ + + + + + + 情報 + SourceGit について + リリース日: {0} + リリースノート + オープンソース & フリーな Git GUI クライアント + ファイルの変更を無視 + パターン: + 登録先のファイル: + 作業ツリーを追加 + 場所: + この作業ツリーへのパス (相対パスも使用できます) + ブランチの名前: + 省略可能 - 既定では宛先のフォルダーと同じ名前が使用されます + 追跡するブランチ: + 追跡するリモートブランチを設定 + チェックアウトする内容: + 新しいブランチを作成 + 既存のブランチ + AI アシスタント + モデル + 再生成 + AI を使用してコミットメッセージを生成 + 使用 + SourceGit を隠す + 他を隠す + すべて表示 + 3 分割マージ + 適用する .patch ファイルを選択 + 空白文字の変更を無視 + 適用元: + ファイル + クリップボード + パッチを適用 + 空白文字: + スタッシュを適用 + 適用後に削除 + インデックスの変更を復元 + スタッシュ: + アーカイブ化... + アーカイブの保存先: + アーカイブファイルへのパスを選択 + リビジョン: + アーカイブ化 + SourceGit Askpass + パスフレーズを入力: + 未変更と見なしたファイル + 未変更と見なしたファイルはありません + 画像を選択... + 再読み込み + バイナリファイルはサポートされていません!!! + 問題の発生源を特定 + 中止 + 問題あり + 問題なし + スキップ + 問題の発生源となったコミットを特定しています。別のコミットをチェックアウトし、そのコミットに問題があるかどうかを調査してください。 + 問題の発生源となったコミットを特定しています。問題があるコミットをチェックアウト・選択してください。 + 問題の発生源となったコミットを特定しています。現在のコミットに問題があるかどうかを調査したあと、別のコミットをチェックアウトしてみてください。 + 問題の発生源となったコミットを特定しています。現在の HEAD に問題はありませんか? + 著者の履歴 + 前のリビジョンの著者の履歴 + 空白文字の変更を無視 + このファイルに著者の履歴は表示できません!!! + ${0}$ をチェックアウト... + 選択した 2 つのブランチを比較 + 比較対象を選択... + HEAD と比較 + ${0}$ と比較 + ブランチ名をコピー + プルリクエストを作成... + 上流の ${0}$ にプルリクエストを作成... + カスタムアクション + ${0}$ を削除... + 選択した {0} 個のブランチを削除... + ${0}$ の説明を編集... + ${0}$ まで早送り + ${0}$ から ${1}$ にフェッチ + Git フロー - ${0}$ を完了... + ${0}$ を ${1}$ で対話式リベース + ${0}$ を ${1}$ にマージ... + 現在のブランチに、選択した {0} 個のブランチをマージ... + ${0}$ からプル... + ${0}$ から ${1}$ にプル... + ${0}$ をプッシュ... + ${0}$ を ${1}$ でリベース... + ${0}$ の名前を変更... + ${0}$ を ${1}$ にリセット... + ${0}$ に切り替え (作業ツリー) + 追跡するブランチを設定... + ローカルに {0} コミット済み + ローカルに {0} コミット済み、リモートに {1} コミットあり + リモートに {0} コミットあり + 無効 + リモート + 状態 + 追跡対象 + URL + 作業ツリー + キャンセル + 親リビジョンにリセット + このリビジョンにリセット + コミットメッセージを生成 + マージ (組み込みツール) + マージ (外部ツール) + ファイルを ${0}$ にリセット + 表示形式を変更 + ファイルとディレクトリの一覧で表示 + パスの一覧で表示 + ファイルシステムのツリーで表示 + サブモジュールの URL を変更 + サブモジュール: + URL: + ブランチをチェックアウト + ローカルの変更: + ブランチ: + 現在の HEAD には、どのブランチやタグにも繋がっていないコミットが含まれています!それでも続行しますか? + これらのサブモジュールを更新する必要があります:{0}更新しますか? + チェックアウト & 早送り + 早送り先: + スタッシュからブランチをチェックアウト + 新しいブランチ: + スタッシュ: + コミットまたはタグをチェックアウト + 対象: + 警告: これを行うと、HEAD が切断されます + コミットを取り込む + 取り込み元をコミットメッセージに明記 + コミット: + すべての変更をコミット + メインライン: + 通常、マージコミットを取り込むことはできません。どちらのマージ元をメインラインとして扱うべきかが分からないためです。このオプションを使用すると、指定した親コミットに対して、変更を再適用する形で取り込みを実行できます。 + スタッシュを消去 + すべてのスタッシュを消去します。よろしいですか? + リモートリポジトリをクローン + 追加の引数: + リポジトリをクローンする際の追加の引数 (省略可能) + ブックマーク: + グループ: + ローカル名: + リポジトリの名前 (省略可能) + 親フォルダー: + サブモジュールを初期化して更新 + リポジトリの URL: + 閉じる + エディター + ブランチ + ブランチ & タグ + リポジトリのカスタムアクション + リビジョンのファイル + コミットをチェックアウト... + コミットを取り込む... + 取り込む... + HEAD と比較 + 作業ツリーと比較 + 著者 + 著者の日時 + メッセージ + コミッター + コミット日時 + SHA + タイトル + カスタムアクション + 対話式リベース + 削除... + 編集... + 親コミットに統合... + ${0}$ を ${1}$ で対話式リベース + 書き直す... + 親コミットに記録付きで統合... + マージ... + ${0}$ を ${1}$ にプッシュ... + ${0}$ を ${1}$ でリベース... + ${0}$ を ${1}$ にリセット... + コミットを取り消す... + パッチとして保存... + 変更 + 個の変更されたファイル + 変更を検索... + 詳細を折りたたむ + ファイル + LFS ファイル + ファイルを検索... + サブモジュール + コミットの情報 + 著者 + コミッター + このコミットが含まれる参照を確認 + これらの参照にコミットが含まれています + メールアドレスをコピー + 名前をコピー + 名前 & メールアドレスをコピー + 最初の 100 件の変更のみが表示されています。'変更' タブですべての変更を確認できます。 + 鍵: + メッセージ + 親コミット + 参照 + SHA + 署名者: + ブラウザーで開く + + コミットメッセージを入力してください。タイトルと説明は、空行で分けて記述してください! + タイトル + 比較 + 変更 + コミット + 左側のみに含まれるコミット + 右側のみに含まれるコミット + ヒント: もう片側が HEAD を指しているのであれば、(コンテキストメニューから) コミットを選択して取り込むことができます。 + リポジトリの設定 + コミットテンプレート + 組み込みのパラメーター: + + ${branch_name} 現在のローカルブランチ名 + ${files_num} 変更されたファイル数 + ${files} 変更されたファイルへのパス + ${files:N} 変更されたファイルへのパス (最大 N 件) + ${pure_files} 変更されたファイルの名前 + ${pure_files:N} 変更されたファイルの名前 (最大 N 件) + テンプレートの内容: + テンプレート名: + カスタムアクション + 引数: + 組み込みのパラメーター: + + ${REPO} リポジトリへのパス + ${REMOTE} 選択したリモート名、または選択したブランチのリモート名 + ${BRANCH} 選択したブランチ名 (リモートブランチの場合に ${REMOTE} の部分を含まない) + ${BRANCH_FRIENDLY_NAME} 選択したブランチの分かりやすい名前 (リモートブランチの場合に ${REMOTE} の部分を含む) + ${SHA} 選択したコミットのハッシュ + ${TAG} 選択したタグ名 + ${FILE} リポジトリのルートから辿った、選択したファイルへの相対パス + $1, $2 ... 入力プロンプトの値 + 実行ファイル: + 入力プロンプト: + 編集 + 名前: + 利用範囲: + ブランチ + コミット + ファイル + リモート + リポジトリ + タグ + アクションの終了を待機 + メールアドレス + E メールアドレス + Git + サブモジュールを自動更新する前に尋ねる + リモートから + 分ごとに自動フェッチ + コンベンショナルコミットの種類定義 + 既定のリモート + サブモジュールを自動更新する際は再帰的に実行 + 優先するマージ方式 + イシュートラッカー + Azure DevOps のルールを追加 + Gerrit Change-Id コミットのルールを追加 + Gitee イシューのルールを追加 + Gitee プルリクエストのルールを追加 + GitHub のルールを追加 + GitLab イシューのルールを追加 + GitLab マージリクエストのルールを追加 + Jira のルールを追加 + 新しいルール + イシューの正規表現: + ルール名: + このルールを .issuetracker ファイルで共有 + 最終的な URL: + 正規表現のグループ値は $1, $2 で取得してください。 + AI + 優先するサービス: + '優先するサービス' を設定すると、このリポジトリではそのサービスのみを使用するようになります。そうでなければ、複数のサービスが存在する場合に限り、その中からひとつを選択できるコンテキストメニューが表示されます。 + HTTP プロキシ + このリポジトリで使用する HTTP プロキシ + ユーザー名 + このリポジトリにおけるユーザー名 + カスタムアクションのプロンプトを編集 + チェック時の値: + チェックされたときにのみ、この値がコマンドライン引数として渡されます + 説明: + 既定値: + フォルダー選択: + ラベル: + 選択肢: + 選択肢は '|' で区切って記述します + 文字列の整形ツール: + 省略可能 - 最終的な出力文字列を整形するために使用されます (入力がなければ何もしません)。入力された値は `${VALUE}` で参照してください。 + ここでも組み込みの変数 ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, ${TAG} がそのまま利用できます + 種類: + リモート名付きの名前を使用: + ワークスペース + + 名前 + 起動時にタブを復元 + 続行 + コミットの内容が何もありません!それでも続行しますか (--allow-empty)? + すべてステージに上げてからコミット + 選択した変更をステージに上げてからコミット + コミットの内容が何もありません!コミットする前に、変更をステージに上げますか?それともこのまま続行しますか (--allow-empty)? + 再起動が必要です + この変更は、アプリを再起動したあとに反映されます。 + コンベンショナルコミットの生成補助ツール + 破壊的変更: + 閉じるイシュー: + 詳細な変更: + スコープ: + 簡単な説明: + 変更の種類: + コピー + すべてのテキストをコピー + パッチとしてコピー + 絶対パスをコピー + パスをコピー + ブランチを作成... + 派生元: + 作成したブランチにチェックアウト + ローカルの変更: + 新しいブランチ名: + ブランチの名前を入力 + ローカルブランチを作成 + 既存のブランチを上書き + タグを作成... + 付与するコミット: + GPG で署名 + タグメッセージ: + 省略可能 + タグ名: + 推奨される形式: v1.0.0-alpha + タグの作成後、すべてのリモートにプッシュ + タグを作成 + 種類: + 注釈付き + 軽量 + Ctrl キーを押しながらで直接実行できます + 切り取り + 破棄 + 何もしない + スタッシュして再適用 + サブモジュールの初期化を解除 + ローカルの変更の有無に関わらず、強制的に解除 + サブモジュール: + ブランチを削除 + 次のリモートブランチも削除しますか? + ブランチ: + 未マージのコミットがあっても強制的に削除 + リモートブランチを削除しようとしています!!! + 複数のブランチを削除 + 複数のブランチをまとめて削除しようとしています。操作を行う前によく確認してください! + 複数のタグを削除 + リモートからも削除 + 複数のタグをまとめて削除しようとしています。操作を行う前によく確認してください! + リモートを削除 + リモート: + パス: + 対象: + このグループ配下の項目も含め、すべて一覧から削除されます。 + 一覧から削除されるのみで、ディスクから削除されるわけではありません! + グループの削除を確認 + リポジトリの削除を確認 + サブモジュールを削除 + サブモジュールへのパス: + タグを削除 + タグ: + リモートリポジトリからも削除 + バイナリの差分 + 空のファイル + 最初の差分 + 空白文字の変更を無視 + ブレンド + 色差 + 左右に並べる + スライド + 最後の差分 + LFS オブジェクトの変更 + 新版 + 次の差分 + 変更なし、または行末コードの変更のみ + 旧版 + 前の差分 + パッチとして保存 + 空白文字を可視化 + 左右に並べて差分を表示 + サブモジュール + 削除 + 新規 + さらに {0} 件のコミットされていない変更があります + 入れ替え + 構文を強調表示 + 行を折り返す + マージツールで開く + すべての行を表示 + 表示する行数を減らす + 表示する行数を増やす + ファイルを選択すると、変更内容が表示されます + ディレクトリの履歴 + ローカルの変更あり + 上流のブランチとの相違あり + すでに最新です + 変更を破棄 + 作業コピーに対するローカルのすべての変更 + 変更: + 無視されたファイルを含める + 変更/削除されたファイルを含める + 未追跡のファイルを含める + {0} 件の変更が破棄されます + この操作は元に戻せません!!! + ブランチの説明を編集 + 対象: + ブックマーク: + 新しい名前: + 対象: + 選択したグループを編集 + 選択したリポジトリを編集 + 対象: + このリポジトリ + フェッチ + すべてのリモートをフェッチ + ローカルの参照を強制的に上書き + タグなしでフェッチ + リモート: + リモートの変更をフェッチ + 未変更と見なす + カスタムアクション + 破棄... + {0} 個のファイルを破棄... + ${0}$ を使用して解決 + パッチとして保存... + ステージに上げる + {0} 個のファイルをステージに上げる + スタッシュ... + {0} 個のファイルをスタッシュ... + ステージから降ろす + {0} 個のファイルをステージから降ろす + 自分の変更を使用 (checkout --ours) + 相手の変更を使用 (checkout --theirs) + ファイルの履歴 + 変更 + ファイルの内容 + ファイルモードの変更: + 削除されたファイルのモード: + ディレクトリ + 実行可能 + 新しいファイルのモード: + 通常 + サブモジュール + シンボリックリンク + 不明 + Git フロー + 開発ブランチ: + 新機能の実装: + 新機能の実装用のプレフィックス: + ${0}$ を完了 + 新機能の実装を完了 + 緊急のバグ修正を完了 + リリース作業を完了 + 対象: + マージする前にリベース + スカッシュしてマージ + 緊急のバグ修正: + 緊急のバグ修正用のプレフィックス: + Git フローを初期化 + ブランチを維持 + 本番ブランチ: + リリース作業: + リリース作業用のプレフィックス: + 開始地点: + 新機能の実装を開始... + 新機能の実装を開始 + 緊急のバグ修正を開始... + 緊急のバグ修正を開始 + 名前: + 名前を入力 + リリース作業を開始... + リリース作業を開始 + バージョンタグのプレフィックス: + 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' キーを押しながらで、複数のコミットを選択できます。 + ⌘ または ⇧ キーを押しながらで、複数のコミットを選択できます。 + ヒント: + 別のウィンドウで開く + コミットの詳細 + リビジョンを比較 + キーボードショートカットを確認 + 総合 + 新しいリポジトリをクローン + 現在のタブを閉じる + 次のタブに移動 + 前のタブに移動 + 新しいタブを作成 + ローカルリポジトリを開く + 設定ダイアログを開く + ワークスペースのドロップダウンメニューを表示 + アクティブなタブを切り替え + 拡大/縮小 + リポジトリ + ステージに上げた変更をコミット + ステージに上げた変更をコミットしてプッシュ + すべての変更をステージに上げてコミット + 新しいブランチを作成 + フェッチ (直接実行) + ダッシュボードモード (既定) + 選択したコミットの子コミットに移動 + 選択したコミットの親コミットに移動 + コマンドパレットを開く + コミット検索モード + プル (直接実行) + プッシュ (直接実行) + 現在のリポジトリを強制的に再読み込み + `履歴` の詳細パネルを広げる/折りたたむ + 'ローカルの変更' に切り替え + '履歴' に切り替え + 'スタッシュ' に切り替え + 破棄 + ステージに上げる + ステージから降ろす + リポジトリを初期化 + このパスで `git init` コマンドを実行しますか? + リポジトリを開けませんでした。理由: + パス: + 取り込み作業が進行中です。 + 取り込み対象のコミット: + マージ作業が進行中です。 + マージ対象: + リベース作業が進行中です。 + 停止地点: + 取り消し作業が進行中です。 + 取り消し対象のコミット: + 対話式リベース + ローカルの変更をスタッシュして再適用 + リベース前のフックを実行しない + リベース地点: + ドラッグ & ドロップでコミットを並べ替えられます + 対象のブランチ: + リンクをコピー + ブラウザーで開く + コマンド + エラー + 通知 + リポジトリを開く + タブ + ワークスペース + ブランチをマージ + マージメッセージを編集 + マージ先: + マージオプション: + マージ元: + マージができるかを確認しています... + マージは衝突せずに行えます + マージ時に何らかのエラーが発生します + マージに衝突が発生します + 自分の変更 → 相手の変更の順で適用 + 相手の変更 → 自分の変更の順で適用 + 両方を使用 + すべての衝突が解決されました + {0} 件の衝突が残っています + 自分 + 次の衝突 + 前の衝突 + 結果 + 保存 & ステージに上げる + 相手 + マージ衝突 + 保存されていない変更を破棄しますか? + 自分の変更を使用 + 相手の変更を使用 + 元に戻す + マージ (複数) + すべての変更をコミット + マージ方式: + 対象: + サブモジュールを移動 + 移動先: + サブモジュール: + リポジトリを別のグループに移動 + このリポジトリの新しい所属先を選択: + 名前: + いいえ + Git の設定が行われていません。[設定] を開いて初期設定をしてください。 + 開く + 既定のエディター (システム) + データの保管ディレクトリを開く + ファイルを開く + 外部のマージツールで開く + ローカルリポジトリを開く + ブックマーク: + グループ: + フォルダー: + 省略可能 + 新しいタブを作成 + タブを閉じる + 他のタブを閉じる + 右側のタブを閉じる + リポジトリへのパスをコピー + 編集 + ワークスペースに移動 + 再読み込み + リポジトリ + 貼り付け + {0} 日前 + 1 時間前 + {0} 時間前 + たった今 + 先月 + 昨年 + {0} 分前 + {0} ヶ月前 + {0} 年前 + 昨日 + 設定 + AI + 追加のプロンプト (`-` で必要事項を羅列してください) + API キー + モデル + 利用できるモデル ID を自動的に取得 + 名前 + この値を環境変数の名前とし、そこから API キーを読み込む + サーバー + 外観 + 既定のフォント + エディターのタブ幅 + フォントの大きさ + 既定 + エディター + 等幅フォント + テーマ + テーマの上書き + 自動的にスクロールバーを隠す + タイトルバーに固定タブ幅を使用 + ネイティブなウィンドウフレームを使用 + 差分/マージツール + 差分時の引数 + 利用できる変数: $LOCAL, $REMOTE + マージ時の引数 + 利用できる変数: $BASE, $LOCAL, $REMOTE, $MERGED + インストールパス + 差分/マージツールへのパスを入力 + ツール + 総合 + 起動時に更新を確認 + 日付の書式 + 変更ツリーのフォルダー階層をまとめる + 言語 + コミット履歴 + 初めから `ローカルの変更` ページを表示 + 初めからコミットの詳細の `変更` タブを表示 + コミットグラフを相対時間で表記 + コミットグラフにタグを表示 + 適切とするコミットタイトルの長さ + 24 時間 + コミットグラフのブランチ名をまとめる + GitHub のような既定のアバターを生成 + Git + 自動 CRLF 変換を有効化 + 既定のクローンディレクトリ + ユーザーのメールアドレス + Git ユーザーのメールアドレス (グローバル設定) + フェッチ時に不要なブランチを掃除 + テキストの差分で行末の CR コードを無視 + このアプリには Git (>= 2.25.1) が必須です + インストールパス + HTTP の SSL 検証を有効化 + git-credential-manager ではなく git-credential-libsecret を使用 + ユーザー名 + Git ユーザーの名前 (グローバル設定) + ブランチのチェックアウトやマージ時に、既定で変更をスタッシュして再適用 + Git バージョン + GPG 署名 + コミットを GPG で署名 + GPG 形式 + プログラムのインストールパス + インストールされている gpg プログラムへのパスを入力 + タグを GPG で署名 + ユーザーの署名鍵 + ユーザーの GPG 署名鍵 + 統合 + シェル/端末 + 引数 + 作業ディレクトリの明示には '.' を使用してください + パス + シェル/端末 + リモートを掃除 + 対象: + 作業ツリーを掃除 + `$GIT_COMMON_DIR/worktrees` 内の作業ツリー情報を掃除 + プル + リモートブランチ: + プル先: + ローカルの変更: + リモート: + プル (フェッチ & マージ) + マージではなくリベースを使用 + プッシュ + サブモジュールがプッシュされていることを確認 + 強制的にプッシュ + ローカルブランチ: + 新規 + リモート: + リビジョン: + リビジョンをリモートにプッシュ + 変更をリモートにプッシュ + リモートブランチ: + 追跡するブランチとして設定 + すべてのタグをプッシュ + タグをリモートにプッシュ + すべてのリモートにプッシュ + リモート: + タグ: + 新しいブランチにプッシュ + 新しいリモートブランチ名を入力: + 終了 + 現在のブランチをリベース + ローカルの変更をスタッシュして再適用 + リベース前のフックを実行しない + リベース地点: + リベースができるかを確認しています... + リベースは衝突せずに行えます + リベース時に何らかのエラーが発生します + リベースに衝突が発生します + リモートを追加 + リモートを編集 + 名前: + リモート名 + リポジトリの URL: + リモートの Git リポジトリの URL + URL をコピー + カスタムアクション + 削除... + 編集... + 自動フェッチを有効化 + フェッチ + ブラウザーで開く + 掃除 + 作業ツリーの削除を確認 + `--force` オプションを有効化 + 対象: + ブランチ名を変更 + 新しい名前: + このブランチに付ける一意な名前 + ブランチ: + 中止 + リモートから変更を自動取得しています... + 並べ替え + コミット日時順 + 名前順 + クリーンアップ (GC & Prune) + このリポジトリに `git gc` コマンドを実行します。 + すべて消去 + 消去 + このリポジトリの設定 + 続行 + カスタムアクション + カスタムアクションがありません + ダッシュボード + すべての変更を破棄 + ファイルブラウザーで開く + ブランチ/タグ/サブモジュールを検索 + グラフでの可視性 + 解除 + コミットグラフから隠す + コミットグラフで絞り込む + レイアウト + 水平 + 垂直 + コミットの並び順 + コミット日時 + トポロジカル + ローカルブランチ + その他のオプション... + HEAD に移動 + ブランチを作成 + 通知を消去 + フォルダーとして開く + {0} で開く + 外部ツールで開く + リモート + リモートを追加 + 解決 + コミットを検索 + 著者 + ファイルの内容 + メッセージ + パス + SHA + 現在のブランチ + 参照付きのコミットのみ + 最初の親コミットのみ + 表示フラグ + 消失したコミットを表示 + サブモジュールをツリー形式で表示 + タグをツリー形式で表示 + スキップ + 統計 + サブモジュール + サブモジュールを追加 + サブモジュールを更新 + タグ + タグを作成 + 作成者の日時順 + 名前順 + 並べ替え + 端末で開く + ログを表示 + ブラウザーで '{0}' を訪問 + 作業ツリー + 作業ツリーを追加 + 掃除 + Git リポジトリの URL + 現在のブランチをリビジョンにリセット + リセット方式: + 移動先: + 現在のブランチ: + ブランチをリセット (チェックアウトなし) + 移動先: + ブランチ: + ファイルエクスプローラーで表示 + コミットを取り消す + コミット: + 取り消しの変更をコミット + 実行しています。お待ちください... + 保存 + 名前を付けて保存... + パッチが正常に保存されました! + リポジトリをスキャン + ルートディレクトリ: + 任意の別ディレクトリをスキャン + 更新を確認... + このソフトウェアの新しいバージョンが利用できます: + 現在のバージョン: + 更新の確認に失敗しました! + ダウンロード + このバージョンをスキップ + 新しいバージョンのリリース日: + ソフトウェアの更新 + 利用できる更新はありません。 + サブモジュールのブランチを設定 + サブモジュール: + 現在: + 変更先: + 省略可能 - 空欄で既定値を使用します + 追跡するブランチを設定 + ブランチ: + 上流のブランチを解除 + 上流のブランチ: + SHA をコピー + このコミットに移動 + SSH の秘密鍵: + SSH の秘密鍵が保管されているパス + 開始 + スタッシュ + 未追跡のファイルを含める + メッセージ: + 省略可能 - このスタッシュのメッセージ + 方式: + ステージに上げたファイルのみ + 選択したファイルの、ステージに上がっている変更とそうではない変更の両方がスタッシュされます!!! + ローカルの変更をスタッシュ + 適用 + 変更を適用 + 新しいブランチをチェックアウト + メッセージをコピー + 削除 + パッチとして保存... + スタッシュを削除 + 削除: + スタッシュ + 変更 + スタッシュ + 統計 + 概要 + 月間 + 週間 + 著者: + コミット: + サブモジュール + サブモジュールを追加 + ブランチ + ブランチ + 相対パス + 初期化を解除 + 入れ子になったサブモジュールも取得 + 履歴 + 移動 + リポジトリを開く + 相対パス: + このモジュールを保存するフォルダーへの相対パス + 削除 + ブランチを設定 + URL を変更 + 状態 + 変更あり + 未初期化 + リビジョンに更新あり + 未マージ + 更新 + URL + サブモジュールの変更の詳細 + 詳細を開く + OK + タグの作成者 + 日時 + タグをチェックアウト... + 2 つのタグを比較 + 比較対象を選択... + HEAD と比較 + メッセージ + 名前 + タグの作成者 + タグ名をコピー + カスタムアクション + ${0}$ を削除... + 選択した {0} 個のタグを削除... + ${0}$ を ${1}$ にマージ... + ${0}$ をプッシュ... + サブモジュールを更新 + すべてのサブモジュール + 必要に応じて初期化 + サブモジュール: + 入れ子になったサブモジュールも更新 + サブモジュールのリモート追跡ブランチに更新 + URL: + ログ + すべて消去 + コピー + 削除 + 警告 + ようこそページ + グループを作成 + サブグループを作成 + リポジトリをクローン + 削除 + ドラッグ & ドロップでフォルダーを追加できます。また、グループの作成・編集もできます。 + 編集 + 別のグループに移動 + すべてのリポジトリを開く + リポジトリを開く + 端末を開く + 既定のクローンディレクトリ内のリポジトリを再スキャン + リポジトリを検索... + ローカルの変更 + 無視 + *{0} ファイルをすべて無視 + 同じフォルダーの *{0} ファイルを無視 + このフォルダーで未追跡のファイルを無視 + このファイルのみを無視 + 同じフォルダーで未追跡のファイルをすべて無視 + 最後のコミットをやり直す + このファイルをステージに上げられるようになりました。 + 履歴を消去 + コミットメッセージの履歴をすべて消去しますか?この操作は元に戻せません。 + コミット + コミットしてプッシュ + テンプレート/履歴 + クリックイベントを発動 + コミット (編集) + すべての変更をステージに上げてコミット + 切断された HEAD にコミットを作成しようとしています。それでも続行しますか? + ステージに上げた {0} 個のファイルのうち、{1} 個のファイルのみが表示されています ({2} 個のファイルは非表示状態です)。続行しますか? + 衝突が検出されました + マージ + 外部ツールでマージ + すべての衝突を外部マージツールで開く + ファイルの衝突が解決されました + 自分の変更を使用 + 相手の変更を使用 + 未追跡のファイルを含める + 最近の入力メッセージはありません + コミットテンプレートがありません + 検証しない + 著者をリセット + 署名の行を追記 + ステージに上げたファイル + ステージから降ろす + すべてステージから降ろす + 未ステージのファイル + ステージに上げる + すべてステージに上げる + 未変更と見なしたファイルを表示 + テンプレート: ${0}$ + ワークスペース: + ワークスペースの設定... + 作業ツリー + ブランチ + パスをコピー + HEAD + ロック + 開く + パス + 削除 + ロックを解除 + はい + diff --git a/src/Resources/Locales/ko_KR.axaml b/src/Resources/Locales/ko_KR.axaml new file mode 100644 index 000000000..5a1d7b053 --- /dev/null +++ b/src/Resources/Locales/ko_KR.axaml @@ -0,0 +1,1005 @@ + + + + + + 정보 + SourceGit 정보 + 배포일: {0} + 릴리스 노트 + 오픈소스 & 무료 Git GUI 클라이언트 + 무시할 파일 추가 + 패턴: + 저장 파일: + 워크트리 추가 + 위치: + 이 워크트리의 경로입니다. 상대 경로를 지원합니다. + 브랜치 이름: + 선택 사항. 기본값은 대상 폴더 이름입니다. + 추적할 브랜치: + 원격 브랜치 추적 + 체크아웃할 대상: + 새 브랜치 생성 + 기존 브랜치 + AI 어시스턴트 + 모델 + 재생성 + AI를 사용하여 커밋 메시지 생성 + 사용 + SourceGit 숨기기 + 다른 항목 숨기기 + 모두 보기 + 3방향 병합 + 적용할 .patch 파일을 선택하세요 + 공백 변경 사항 무시 + 원본: + 파일 + 클립보드 + 패치 적용 + 공백: + 스태시 적용 + 적용 후 삭제 + 인덱스의 변경 사항 복원 + 스태시: + 아카이브... + 아카이브 저장 위치: + 아카이브 파일 경로 선택 + 리비전: + 아카이브 + SourceGit Askpass + 암호 입력: + 변경되지 않음으로 간주된 파일 + 변경되지 않음으로 간주된 파일 없음 + 이미지 불러오기... + 새로 고침 + 바이너리 파일은 지원되지 않습니다!!! + 이진 탐색 + 중단 + 나쁨 + 좋음 + 건너뛰기 + 이진 탐색 중. 현재 커밋을 '좋음' 또는 '나쁨'으로 표시하고 다른 커밋을 체크아웃하세요. + 이진 탐색 중. 현재 HEAD가 '좋음' 상태입니까, '나쁨' 상태입니까? + 블레임 + 이전 리비전으로 책임 추적 + 공백 변경 무시 + 이 파일은 책임 추적 기능을 지원하지 않습니다 + ${0}$(으)로 체크아웃... + 선택한 두 브랜치 비교하기 + 다음과 비교... + HEAD와 비교 + ${0}$와 비교 + 브랜치 이름 복사 + PR 생성 + 업스트림 ${0}$에 대한 PR 생성... + 사용자 지정 작업 + ${0}$ 삭제... + 선택한 {0}개의 브랜치 삭제 + ${0}$ 설명 편집... + ${0}$(으)로 Fast-Forward + ${0}$에서 ${1}$(으)로 Fetch... + Git Flow - ${0}$ 완료 + ${1}$을(를) 기반으로 ${0}$ 대화형 리베이스 + ${0}$을(를) ${1}$(으)로 병합... + 선택한 {0}개의 브랜치를 현재 브랜치로 병합 + ${0}$ Pull + ${0}$에서 ${1}$(으)로 Pull... + ${0}$(으)로 Push + ${1}$을(를) 기반으로 ${0}$ 리베이스... + ${0}$ 이름 바꾸기... + ${0}$을(를) ${1}$(으)로 리셋... + ${0}$(워크트리)로 전환 + 추적 브랜치 설정... + {0}개 커밋 앞섬 + {0}개 커밋 앞섬, {1}개 커밋 뒤처짐 + {0}개 커밋 뒤처짐 + 유효하지 않음 + 원격 + 상태 + 추적 중 + URL + 워크트리 + 취소 + 부모 리비전으로 리셋 + 이 리비전으로 리셋 + 커밋 메시지 생성 + 병합(내장) + 병합(외부 도구) + 파일을 ${0}$(으)로 되돌리기 + 표시 모드 변경 + 파일 및 디렉터리 목록으로 보기 + 경로 목록으로 보기 + 파일 시스템 트리로 보기 + 서브모듈 URL 변경 + 서브모듈: + URL: + 브랜치 체크아웃 + 로컬 변경 사항: + 브랜치: + 현재 HEAD에 브랜치/태그에 연결되지 않은 커밋이 있습니다! 계속하시겠습니까? + 다음 서브모듈을 업데이트해야 합니다:{0}업데이트하시겠습니까? + 체크아웃 & Fast-Forward + Fast-Forward 대상: + 스태시에서 브랜치 체크아웃 + 새로운 브랜치: + 스태시: + 체리픽 + 커밋 메시지에 원본 추가 + 커밋: + 모든 변경 사항 커밋 + 메인라인: + 어느 쪽을 메인라인으로 간주해야 할지 알 수 없기 때문에 일반적으로 병합(merge)을 체리픽할 수 없습니다. 이 옵션을 사용하면 지정된 부모를 기준으로 변경 사항을 다시 적용할 수 있습니다. + 모든 스태시 지우기 + 모든 스태시를 지우려고 합니다. 계속하시겠습니까? + 원격 저장소 복제 + 추가 파라미터: + 저장소 복제 시 추가 인수. 선택 사항. + 북마크: + 그룹: + 로컬 이름: + 저장소 이름. 선택 사항. + 상위 폴더: + 서브모듈 초기화 & 업데이트 + 저장소 URL: + 닫기 + 에디터 + 브랜치 + 브랜치 & 태그 + 저장소 사용자 지정 작업 + 리비전 파일 + 커밋 체크아웃 + 커밋 체리픽 + 체리픽... + HEAD와 비교 + 워크트리와 비교 + 작성자 + 작성 시간 + 메시지 + 커밋터 + 커밋 시간 + SHA + 제목 + 사용자 지정 작업 + 대화형 리베이스 + 삭제(Drop)... + 수정(Edit)... + 부모에 합치기(Fixup)... + ${1}$을(를) 기반으로 ${0}$(을)를 대화형 리베이스 + 메시지 수정(Reword)... + 부모에 합치기(Squash)... + 병합... + ${0}$을(를) ${1}$(으)로 푸시 + ${1}$을(를) 기반으로 ${0}$(을)를 리베이스 + ${0}$을(를) ${1}$(으)로 리셋 + 커밋 되돌리기 + 패치로 저장... + 변경 사항 + 변경된 파일 + 변경 사항 검색... + 세부 정보 접기 + 파일 + LFS 파일 + 파일 검색... + 서브모듈 + 정보 + 작성자 + 커밋터 + 이 커밋을 포함하는 ref 확인 + 커밋 포함 REF + 이메일 복사 + 이름 복사 + 이름 & 이메일 복사 + 처음 100개의 변경 사항만 표시합니다. 모든 변경 사항은 '변경 사항' 탭에서 확인하세요. + 키: + 메시지 + 부모 + REFS + SHA + 서명자: + 브라우저에서 열기 + + 커밋 메시지를 입력하세요. 제목과 설명은 빈 줄로 구분하세요! + 제목 + 비교 + 변경점 + 커밋 + 왼쪽에만 있는 커밋 + 오른쪽에만 있는 커밋 + 팁: 반대편이 HEAD인 경우 선택한 커밋을 체리픽할 수 있습니다(컨텍스트 메뉴 사용). + 저장소 설정 + 커밋 템플릿 + ${files_num}, ${branch_name}, ${files} 및 ${files:N} (N은 출력할 최대 파일 경로 수)을(를) 사용할 수 있습니다. + 템플릿 내용: + 템플릿 이름: + 사용자 지정 작업 + 인수: + 내장 파라미터: + + ${REPO} 저장소 경로 + ${REMOTE} 선택한 원격 또는 선택한 브랜치의 원격 + ${BRANCH} 선택한 브랜치 (원격 브랜치의 경우 ${REMOTE} 부분 제외) + ${BRANCH_FRIENDLY_NAME} 선택한 브랜치의 식별하기 쉬운 이름 (원격 브랜치의 경우 ${REMOTE} 부분 포함) + ${SHA} 선택한 커밋의 해시 + ${TAG} 선택한 태그 + ${FILE} 저장소 루트에 상대적인 선택된 파일 + $1, $2 ... 입력 컨트롤 값 + 실행 파일: + 입력 컨트롤: + 편집 + 이름: + 범위: + 브랜치 + 커밋 + 파일 + 원격 + 저장소 + 태그 + 작업이 끝날 때까지 대기 + 이메일 주소 + 이메일 주소 + GIT + 서브모듈 자동 업데이트 전에 확인 + 원격 자동 Fetch + + Conventional Commit 유형 + 기본 원격 + 선호하는 병합 모드 + 이슈 트래커 + Azure DevOps 규칙 추가 + Gerrit Change-Id 커밋 규칙 추가 + Gitee 이슈 규칙 추가 + Gitee Pull Request 규칙 추가 + GitHub 규칙 추가 + GitLab 이슈 규칙 추가 + GitLab Merge Request 규칙 추가 + Jira 규칙 추가 + 새 규칙 + 이슈 정규식: + 규칙 이름: + .issuetracker 파일에 이 규칙 공유 + 결과 URL: + 정규식 그룹 값에 접근하려면 $1, $2를 사용하세요. + AI + 선호하는 서비스: + '선호하는 서비스'가 설정되면, SourceGit은 이 저장소에서 해당 서비스만 사용합니다. 그렇지 않고 사용 가능한 서비스가 두 개 이상인 경우, 하나를 선택할 수 있는 컨텍스트 메뉴가 표시됩니다. + HTTP 프록시 + 이 저장소에서 사용하는 HTTP 프록시 + 사용자 이름 + 이 저장소의 사용자 이름 + 사용자 지정 작업 컨트롤 편집 + 선택 시 값: + 선택 시, 이 값이 명령줄 인수로 사용됩니다 + 설명: + 기본값: + 폴더 여부: + 레이블: + 옵션: + 옵션 구분자로 '|'를 사용하세요 + 문자열 포매터: + 선택 사항. 출력 문자열 형식 지정에 사용합니다. 입력이 비어 있으면 무시됩니다. 입력 문자열은 `${VALUE}`로 표시하세요. + 여기에서도 내장 변수 ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, ${TAG}를 사용할 수 있습니다 + 유형: + 표시용 이름 사용: + 작업 공간 + 색상 + 이름 + 시작 시 탭 복원 + 계속 + 빈 커밋이 감지되었습니다! 계속하시겠습니까 (--allow-empty)? + 모두 스테이징 & 커밋 + 선택 항목 스테이징 후 커밋 + 빈 커밋이 감지되었습니다! 계속하시겠습니까 (--allow-empty) 아니면 모두 스테이징 후 커밋하시겠습니까? + 재시작 필요 + 변경 사항을 적용하려면 앱을 다시 시작해야 합니다. + Conventional Commit 도우미 + 주요 변경 사항(Breaking Change): + 종료된 이슈: + 상세 변경 내역: + 범위: + 간단한 설명: + 변경 유형: + 복사 + 전체 텍스트 복사 + 패치로 복사 + 전체 경로 복사 + 경로 복사 + 브랜치 생성... + 기준: + 생성된 브랜치로 체크아웃 + 로컬 변경 사항: + 새 브랜치 이름: + 브랜치 이름을 입력하세요. + 로컬 브랜치 생성 + 기존 브랜치 덮어쓰기 + 태그 생성... + 태그 생성 위치: + GPG 서명 + 태그 메시지: + 선택 사항. + 태그 이름: + 권장 형식: v1.0.0-alpha + 생성 후 모든 원격에 푸시 + 새 태그 생성 + 종류: + 주석 태그 + 경량 태그 + Ctrl을 누른 채 클릭하면 바로 시작합니다 + 잘라내기 + 폐기 + 아무것도 하지 않기 + 스태시 & 재적용 + 서브모듈 초기화 해제 + 로컬 변경 사항이 있어도 강제로 초기화 해제합니다. + 서브모듈: + 브랜치 삭제 + 브랜치: + 원격 브랜치를 삭제하려고 합니다!!! + 여러 브랜치 삭제 + 한 번에 여러 브랜치를 삭제하려고 합니다. 실행하기 전에 다시 한번 확인하세요! + 여러 태그 삭제 + 원격 저장소에서도 삭제 + 한 번에 여러 태그를 삭제하려고 합니다. 실행하기 전에 다시 한번 확인하세요! + 원격 삭제 + 원격: + 경로: + 대상: + 모든 하위 항목이 목록에서 제거됩니다. + 목록에서만 제거되며, 디스크에서 삭제되지 않습니다! + 그룹 삭제 확인 + 저장소 삭제 확인 + 서브모듈 삭제 + 서브모듈 경로: + 태그 삭제 + 태그: + 원격 저장소에서도 삭제 + 바이너리 비교 + 첫 번째 차이점 + 공백 변경 사항 무시 + 혼합 + 차이점 + 나란히 보기 + 스와이프 + 마지막 차이점 + LFS 객체 변경 + 신규 + 다음 차이점 + 변경 사항 없음 또는 줄바꿈(EOL) 변경만 있음 + 기존 + 이전 차이점 + 패치로 저장 + 숨겨진 기호 표시 + 나란히 비교 + 서브모듈 + 삭제됨 + 신규 + 커밋 안 한 변경 사항 + {0}개 + 전환 + 구문 강조 + 줄 바꿈 + 병합 도구에서 열기 + 모든 줄 표시 + 표시 줄 수 줄이기 + 표시 줄 수 늘리기 + 파일을 선택하여 변경 사항 보기 + 디렉터리 히스토리 + 로컬 변경 사항 있음 + 업스트림과 불일치 + 이미 최신 상태 + 변경 사항 폐기 + 작업 사본의 모든 로컬 변경 사항. + 변경 사항: + 무시된 파일 포함 + 수정/삭제된 파일 포함 + 추적하지 않는 파일 포함 + {0}개의 변경 사항이 폐기됩니다 + 이 작업은 되돌릴 수 없습니다!!! + 브랜치 설명 수정 + 대상: + 북마크: + 새 이름: + 대상: + 선택한 그룹 편집 + 선택한 저장소 편집 + 대상: + 이 저장소 + Fetch + 모든 원격 Fetch + 로컬 ref 강제 덮어쓰기 + 태그 없이 Fetch + 원격: + 원격 변경 사항 Fetch + 변경되지 않음으로 간주 + 사용자 지정 작업 + 폐기... + {0}개 파일 폐기... + ${0}$을(를) 사용하여 해결 + 패치로 저장... + 스테이지 + {0}개 파일 스테이지 + 스태시... + {0}개 파일 스태시... + 언스테이지 + {0}개 파일 언스테이지 + 내 것 사용 (checkout --ours) + 상대방 것 사용 (checkout --theirs) + 파일 히스토리 + 변경 사항 + 내용 + Git-Flow + 개발 브랜치: + Feature: + Feature 접두사: + FLOW - Feature 완료 + FLOW - Hotfix 완료 + FLOW - Release 완료 + 대상: + 병합 시 스쿼시 + 핫픽스: + Hotfix 접두사: + Git-Flow 초기화 + 브랜치 유지 + 운영 브랜치: + 릴리스: + Release 접두사: + Feature 시작... + FLOW - Feature 시작 + Hotfix 시작... + FLOW - Hotfix 시작 + 이름 입력 + Release 시작... + FLOW - Release 시작 + 버전 태그 접두사: + Git LFS + 추적 패턴 추가... + 패턴이 파일 이름임 + 사용자 정의 패턴: + Git LFS에 추적 패턴 추가 + Fetch + Git LFS 객체를 다운로드하려면 `git lfs fetch`를 실행하세요. 이 작업은 작업 사본을 업데이트하지 않습니다. + LFS 객체 Fetch + Git LFS 훅(hook) 설치 + 잠금 보기 + 잠긴 파일 없음 + 잠금 + 내 잠금만 보기 + LFS 잠금 + 잠금 해제 + 내 잠금 모두 해제 + 잠근 파일을 모두 해제하시겠습니까? + 강제 잠금 해제 + 정리 + 로컬 저장소에서 오래된 LFS 파일을 삭제하려면 `git lfs prune`을 실행하세요 + Pull + 현재 ref 및 체크아웃에 대한 모든 Git LFS 파일을 다운로드하려면 `git lfs pull`을 실행하세요 + LFS 객체 Pull + 푸시 + 대기 중인 대용량 파일을 Git LFS 엔드포인트로 푸시합니다 + LFS 객체 푸시 + 원격: + '{0}' 이름의 파일 추적 + 모든 *{0} 파일 추적 + 커밋 선택 + 히스토리 + 작성자 + 작성 시간 + 커밋 시간 + 그래프 & 제목 + SHA + 그래프 강조 표시 + 모두 + 현재 브랜치만 + 현재 브랜치와 선택한 커밋 + 선택한 커밋만 + {0}개 커밋 선택됨 + 표시할 역 + 'Ctrl' 또는 'Shift' 키를 누른 채로 여러 커밋을 선택하세요. + ⌘ 또는 ⇧ 키를 누른 채로 여러 커밋을 선택하세요. + 팁: + 별도의 창으로 열기 + 커밋 세부 정보 + 리비전 비교 + 키보드 단축키 참조 + 전역 + 새 저장소 복제 + 현재 탭 닫기 + 다음 탭으로 이동 + 이전 탭으로 이동 + 새 탭 만들기 + 로컬 저장소 열기 + 환경설정 대화상자 열기 + 작업 공간 드롭다운 메뉴 열기 + 활성 탭 전환 + 확대 / 축소 + 저장소 + 스테이징된 변경 사항 커밋 + 스테이징된 변경 사항 커밋 및 푸시 + 모든 변경 사항 스테이징 후 커밋 + 브랜치 생성 + Fetch (바로 시작) + 대시보드 모드 (기본) + 선택한 커밋의 자식으로 이동 + 선택한 커밋의 부모로 이동 + 명령 팔레트 열기 + 커밋 검색 모드 열기 + Pull (바로 시작) + 푸시 (바로 시작) + 이 저장소 강제 새로고침 + 히스토리에서 세부 정보 패널 펼치기/접기 + '변경 사항'으로 전환 + '히스토리'로 전환 + '스태시'로 전환 + 폐기 + 스테이지 + 언스테이지 + 저장소 초기화 + 이 경로에서 `git init` 명령을 실행하시겠습니까? + 저장소를 열지 못했습니다. 원인: + 경로: + 체리픽 진행 중. + 커밋 처리 중 + 병합 진행 중. + 병합 중 + 리베이스 진행 중. + 중단 지점 + 되돌리기 진행 중. + 커밋 되돌리는 중 + 대화형 리베이스 + 로컬 변경 사항 스태시 & 재적용 + pre-rebase 훅 건너뛰기 + 기준: + 드래그 앤 드롭으로 커밋 순서 변경 + 대상 브랜치: + 링크 복사 + 브라우저에서 열기 + 명령 + 오류 + 알림 + 저장소 열기 + + 작업 공간 + 브랜치 병합 + 병합 메시지 수정 + 대상: + 병합 옵션: + 소스: + 병합 가능 여부 확인 중... + 충돌 없이 병합할 수 있습니다 + 병합 테스트 중 알 수 없는 오류가 발생했습니다 + 병합 시 충돌이 발생합니다 + 내 변경 먼저, 상대 변경 나중 + 상대 변경 먼저, 내 변경 나중 + 둘 다 사용 + 모든 충돌 해결됨 + 남은 충돌 {0}개 + 내 변경 + 다음 충돌 + 이전 충돌 + 결과 + 저장 후 스테이징 + 상대 변경 + 병합 충돌 + 저장하지 않은 변경사항을 버리시겠습니까? + 내 변경 사용 + 상대 변경 사용 + 실행 취소 + 병합 (다중) + 모든 변경 사항 커밋 + 전략: + 대상: + 서브모듈 이동 + 이동 위치: + 서브모듈: + 저장소 노드 이동 + 상위 노드 선택: + 이름: + 아니요 + Git이 구성되지 않았습니다. [환경설정]으로 이동하여 먼저 구성하세요. + 열기 + 시스템 기본 편집기 + 데이터 저장 디렉터리 열기 + 파일 열기 + 병합 도구에서 열기 + 로컬 저장소 열기 + 북마크: + 그룹: + 폴더: + 선택 사항. + 새 탭 만들기 + 탭 닫기 + 다른 탭 닫기 + 오른쪽 탭 닫기 + 저장소 경로 복사 + 편집 + 작업 공간으로 이동 + 새로 고침 + 저장소 + 붙여넣기 + {0}일 전 + 1시간 전 + {0}시간 전 + 방금 전 + 지난 달 + 작년 + {0}분 전 + {0}개월 전 + {0}년 전 + 어제 + 환경설정 + AI + 추가 프롬프트(`-`로 요구 사항 나열) + API 키 + 모델 + 사용 가능한 모델 ID 자동 가져오기 + 이름 + 입력된 값은 환경변수(ENV)에서 API 키를 불러올 이름입니다 + 서버 + 모양 + 기본 글꼴 + 에디터 탭 너비 + 글꼴 크기 + 기본 + 에디터 + 고정폭 글꼴 + 테마 + 테마 재정의 + 스크롤바 자동 숨기기 사용 + 제목 표시줄에서 고정 탭 너비 사용 + 네이티브 윈도우 프레임 사용 + DIFF/MERGE 도구 + 비교 인수 + 사용 가능한 변수: $LOCAL, $REMOTE + 병합 인수 + 사용 가능한 변수: $BASE, $LOCAL, $REMOTE, $MERGED + 설치 경로 + diff/merge 도구 경로 입력 + 도구 + 일반 + 시작 시 업데이트 확인 + 날짜 형식 + 변경 사항 트리에서 폴더 압축 활성화 + 언어 + 히스토리 커밋 수 + 기본으로 `로컬 변경 사항` 페이지 표시 + 커밋 세부 정보에서 기본으로 `변경 사항` 탭 표시 + 커밋 그래프에 상대 시간 표시 + 커밋 그래프에 태그 표시 + 제목 가이드 길이 + 24시간 형식 + 커밋 그래프에서 축약 브랜치 이름 사용 + GitHub 스타일 기본 아바타 생성 + GIT + 자동 CRLF 활성화 + 기본 복제 디렉터리 + 사용자 이메일 + 전역 git 사용자 이메일 + Fetch 시 --prune 활성화 + diff 시 --ignore-cr-at-eol 활성화 + 이 앱은 Git (>= 2.25.1)을(를) 필요로 합니다 + 설치 경로 + HTTP SSL 검증 활성화 + git-credential-manager 대신 git-credential-libsecret 사용 + 사용자 이름 + 전역 git 사용자 이름 + 브랜치 체크아웃 또는 병합 시 기본적으로 변경 사항을 스태시한 뒤 다시 적용 + Git 버전 + GPG 서명 + 커밋 GPG 서명 + GPG 형식 + 프로그램 설치 경로 + 설치된 gpg 프로그램 경로 입력 + 태그 GPG 서명 + 사용자 서명 키 + 사용자의 gpg 서명 키 + 연동 + 셸/터미널 + 인수 + 작업 디렉터리는 `.`으로 표시하세요 + 경로 + 셸/터미널 + 원격 정리 + 대상: + 워크트리 정리 + `$GIT_COMMON_DIR/worktrees`의 워크트리 정보 정리 + Pull + 원격 브랜치: + 대상: + 로컬 변경 사항: + 원격: + Pull (Fetch & 병합) + 병합 대신 리베이스 사용 + 푸시 + 서브모듈이 푸시되었는지 확인 + 강제 푸시 + 로컬 브랜치: + 신규 + 원격: + 리비전: + 리비전을 원격에 푸시 + 변경 사항을 원격에 푸시 + 원격 브랜치: + 추적 브랜치로 설정 + 모든 태그 푸시 + 태그를 원격에 푸시 + 모든 원격에 푸시 + 원격: + 태그: + 새 브랜치로 푸시 + 새 원격 브랜치 이름을 입력하세요: + 종료 + 현재 브랜치 리베이스 + 로컬 변경 사항 스태시 & 재적용 + pre-rebase 훅 건너뛰기 + 기준: + 리베이스 가능 여부 확인 중... + 충돌 없이 리베이스할 수 있습니다 + 리베이스 테스트 중 알 수 없는 오류가 발생했습니다 + 리베이스 시 충돌이 발생합니다 + 원격 추가 + 원격 편집 + 이름: + 원격 이름 + 저장소 URL: + 원격 git 저장소 URL + URL 복사 + 사용자 지정 작업 + 삭제... + 편집... + 자동 Fetch 사용 + Fetch + 브라우저에서 열기 + 정리 + 워크트리 제거 확인 + `--force` 옵션 활성화 + 대상: + 브랜치 이름 변경 + 새 이름: + 이 브랜치의 고유한 이름 + 브랜치: + 중단 + 원격에서 변경 사항 자동 Fetch 중... + 정렬 + 커밋 날짜 순 + 이름 순 + 정리 (GC & Prune) + 이 저장소에 대해 `git gc` 명령을 실행합니다. + 모두 지우기 + 지우기 + 이 저장소 설정 + 계속 + 사용자 지정 작업 + 사용자 지정 작업 없음 + 대시보드 + 모든 변경 사항 폐기 + 파일 탐색기에서 열기 + 브랜치/태그/서브모듈 검색 + 그래프에 표시 여부 + 설정 안 함 + 커밋 그래프에서 숨기기 + 커밋 그래프에서 필터링 + 레이아웃 + 수평 + 수직 + 커밋 순서 + 커밋 날짜 + 위상 정렬 + 로컬 브랜치 + 추가 옵션... + HEAD로 이동 + 브랜치 생성 + 알림 지우기 + 폴더로 열기 + {0}에서 열기 + 외부 도구에서 열기 + 원격 + 원격 추가 + 해결 + 커밋 검색 + 작성자 + 내용 + 메시지 + 경로 + SHA + 현재 브랜치 + 장식된(Decorated) 커밋만 + 첫 번째 부모만 + 플래그 표시 + 유실된(Lost) 커밋 표시 + 서브모듈을 트리로 표시 + 태그를 트리로 표시 + 건너뛰기 + 통계 + 서브모듈 + 서브모듈 추가 + 서브모듈 업데이트 + 태그 + 새 태그 + 생성 날짜 순 + 이름 순 + 정렬 + 터미널에서 열기 + 로그 보기 + 브라우저에서 '{0}' 방문 + 워크트리 + 워크트리 추가 + 정리 + Git 저장소 URL + 현재 브랜치를 리비전으로 리셋 + 리셋 모드: + 이동 대상: + 현재 브랜치: + 브랜치 리셋 (체크아웃 없음) + 이동 대상: + 브랜치: + 파일 탐색기에서 보기 + 커밋 되돌리기 + 커밋: + 되돌린 변경 사항 커밋 + 실행 중. 잠시만 기다려주세요... + 저장 + 다른 이름으로 저장... + 패치가 성공적으로 저장되었습니다! + 저장소 스캔 + 루트 디렉터리: + 다른 사용자 정의 디렉터리 스캔 + 업데이트 확인... + 이 소프트웨어의 새 버전을 사용할 수 있습니다: + 현재 버전: + 업데이트 확인 실패! + 다운로드 + 이 버전 건너뛰기 + 새 버전 릴리스 날짜: + 소프트웨어 업데이트 + 현재 사용 가능한 업데이트가 없습니다. + 서브모듈 브랜치 설정 + 서브모듈: + 현재: + 변경: + 선택 사항. 비어 있으면 기본값으로 설정됩니다. + 추적 브랜치 설정 + 브랜치: + 업스트림 설정 해제 + 업스트림: + SHA 복사 + 이동 + SSH 개인 키: + 개인 SSH 키 저장 경로 + 시작 + 스태시 + 추적하지 않는 파일 포함 + 메시지: + 선택 사항. 이 스태시의 메시지 + 모드: + 스테이징된 변경 사항만 + 선택한 파일의 스테이징된 변경 사항과 스테이징되지 않은 변경 사항이 모두 스태시됩니다!!! + 로컬 변경 사항 스태시 + 적용 + 변경 사항 적용 + 새 브랜치 체크아웃 + 메시지 복사 + 삭제 + 패치로 저장... + 스태시 삭제 + 삭제: + 스태시 + 변경 사항 + 스태시 + 통계 + 개요 + 이번 달 + 이번 주 + 작성자: + 커밋: + 서브모듈 + 서브모듈 추가 + 브랜치 + 브랜치 + 상대 경로 + 초기화 해제 + 중첩된 서브모듈 Fetch + 히스토리 + 이동 + 저장소 열기 + 상대 경로: + 이 모듈을 저장할 상대 폴더입니다. + 삭제 + 브랜치 설정 + URL 변경 + 상태 + 수정됨 + 초기화 안 됨 + 리비전 변경됨 + 병합되지 않음 + 업데이트 + URL + 서브모듈 변경 상세 + 상세 열기 + 확인 + 태그 생성자 + 시간 + 태그 2개 비교 + 다음과 비교... + HEAD와 비교 + 메시지 + 이름 + 태그 생성자 + 태그 이름 복사 + 사용자 지정 작업 + ${0}$ 삭제... + 선택한 {0}개의 태그 삭제... + ${0}$ 푸시... + 서브모듈 업데이트 + 모든 서브모듈 + 필요시 초기화 + 서브모듈: + 서브모듈의 원격 추적 브랜치로 업데이트 + URL: + 로그 + 모두 지우기 + 복사 + 삭제 + 경고 + 시작 페이지 + 그룹 생성 + 하위 그룹 생성 + 저장소 복제 + 삭제 + 폴더 끌어다 놓기 지원. 사용자 정의 그룹화 지원. + 편집 + 다른 그룹으로 이동 + 모든 저장소 열기 + 저장소 열기 + 터미널 열기 + 기본 복제 디렉터리의 저장소 다시 스캔 + 저장소 검색... + 로컬 변경 사항 + Git 무시 + 모든 *{0} 파일 무시 + 같은 폴더의 *{0} 파일 무시 + 이 폴더의 추적하지 않는 파일 무시 + 이 파일만 무시 + 수정 + 이제 이 파일을 스테이징할 수 있습니다. + 히스토리 지우기 + 모든 커밋 메시지 히스토리를 지우시겠습니까? 이 작업은 되돌릴 수 없습니다. + 커밋 + 커밋 & 푸시 + 템플릿/히스토리 + 클릭 이벤트 트리거 + 커밋 (수정) + 모든 변경 사항 스테이징 후 커밋 + 분리된(detached) HEAD에 커밋을 생성하고 있습니다. 계속하시겠습니까? + {0}개의 파일을 스테이징했지만 {1}개의 파일만 표시됩니다 ({2}개의 파일은 필터링됨). 계속하시겠습니까? + 충돌 감지됨 + 병합 + 외부 도구로 병합 + 모든 충돌을 외부 병합 도구에서 열기 + 파일 충돌 해결됨 + 내 것 사용 + 상대방 것 사용 + 추적하지 않는 파일 포함 + 최근 입력한 메시지 없음 + 커밋 템플릿 없음 + 검증 안 함 + 작성자 리셋 + 서명(SignOff) + 스테이징됨 + 언스테이지 + 모두 언스테이지 + 스테이징 안 됨 + 스테이지 + 모두 스테이지 + 변경되지 않음으로 간주된 파일 보기 + 템플릿: ${0}$ + 작업 공간: + 작업 공간 설정... + 워크트리 + 브랜치 + 경로 복사 + HEAD + 잠금 + 열기 + 경로 + 제거 + 잠금 해제 + + diff --git a/src/Resources/Locales/pt_BR.axaml b/src/Resources/Locales/pt_BR.axaml new file mode 100644 index 000000000..8155dc647 --- /dev/null +++ b/src/Resources/Locales/pt_BR.axaml @@ -0,0 +1,647 @@ + + + + + + Sobre + Sobre o SourceGit + Data de Lançamento + Notas de Lançamento + Cliente Git GUI Livre e de Código Aberto + Adicionar Arquivo(s) ao Ignore + Padrão: + Salvar arquivo: + 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 + Modelo + Gerar novamente + Utilizar IA para gerar mensagem de commit + Esconder SourceGit + Mostrar Todos + Selecione o arquivo .patch para aplicar + Ignorar mudanças de espaço em branco + Aplicar Patch + Espaço em Branco: + Aplicar Stash + Apagar depois de aplicar + Restabelecer as alterações do índice + Stash: + Arquivar... + Salvar Arquivo Como: + Selecione o caminho do arquivo de arquivo + Revisão: + Arquivar + SourceGit Askpass + Entre com a senha + ARQUIVOS CONSIDERADOS SEM ALTERAÇÕES + NENHUM ARQUIVO CONSIDERADO SEM ALTERAÇÕES + Carregar imagem... + Atualizar + ARQUIVO BINÁRIO NÃO SUPORTADO!!! + Bisect + Abortar + Inválido + Válido + Pular + Bisect. Marcar o commit atual como válido ou inválido e checar outro commit. + Bisect. O HEAD atual é válido ou inválido? + Blame + Ignorar espaços em branco + BLAME NESSE ARQUIVO NÃO É SUPORTADO!!! + Checkout ${0}$... + Comparar ambos os branches selecionados + Comparar com... + Comparar com HEAD + Copiar Nome do Branch + Criar PR... + Criar PR para o upstream ${0}$... + Ação personalizada + Excluir ${0}$... + Excluir {0} branches selecionados + Editar descri ção para %{0}%... + Fast-Forward para ${0}$ + Buscar ${0}$ em ${1}$... + Git Flow - Finalizar ${0}$ + Mesclar ${0}$ em ${1}$... + Mesclar os {0} branches no branch atual + Puxar ${0}$ + Puxar ${0}$ para ${1}$... + Subir ${0}$ + Rebase ${0}$ em ${1}$... + Renomear ${0}$... + Restaurar ${0}$ para ${1}$... + Mudar para ${0}$ (worktree) + Definir Branch de Rastreamento... + {0} commit(s) à frente + Inválido + REMOTO + ESTADO + 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 + Alterações Locais: + 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 + SHA + Ação customizada + Reverter Commit + Salvar como Patch... + ALTERAÇÕES + Buscar Alterações... + ARQUIVOS + Arquivo LFS + Submódulo + INFORMAÇÃO + AUTOR + 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 + Comparação + Configurar Repositório + TEMPLATE DE COMMIT + Conteúdo do Template: + Nome do Template: + AÇÃO CUSTOMIZADA + Argumentos: + 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: + 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 + Descartar + Nada + Stash & Reaplicar + Excluir Branch + Branch: + Você está prestes a excluir uma branch remota!!! + 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 + 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 + 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 + Buscar + Buscar todos os remotos + Buscar sem tags + Remoto: + Buscar Alterações Remotas + Assumir não alterado + Descartar... + Descartar {0} arquivos... + Salvar Como Patch... + Preparar + Preparar {0} arquivos + Stash... + Stash {0} arquivos... + Desfazer Preparação + Desfazer Preparação de {0} arquivos + 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 + HORA DO COMMIT + GRÁFICO & ASSUNTO + SHA + 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 + 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 + Buscar, imediatamente + Modo de Dashboard (Padrão) + Modo de busca de commits + Puxar, imediatamente + Enviar, imediatamente + Forçar recarregamento deste repositório + Alternar para 'Mudanças' + Alternar para 'Históricos' + Alternar para 'Stashes' + Descartar + Preparar + Despreparar + Inicializar Repositório + Caminho: + Cherry-Pick em andamento. + Merge em andamento. + Rebase em andamento. + Revert em andamento. + Rebase Interativo + Guardar & reaplicar alterações locais + 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 na Ferramenta de Mesclagem + Opcional. + Criar Nova Página + Fechar Aba + Fechar Outras Abas + Fechar Abas à Direita + Copiar Caminho do Repositório + Editar + 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 + Chave da API + Nome + Servidor + APARÊNCIA + Fonte Padrão + Tamanho da Fonte + Padrão + Editor + Fonte Monoespaçada + 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 + 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: + 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: + 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 + Abrir no Navegador de Arquivos + Pesquisar Branches/Tags/Submódulos + Desfazer + Esconder no gráfico de commit + Incluir no gráfico de commit + Data do Commit + Topologicamente + BRANCHES LOCAIS + Navegar para HEAD + Criar Branch + Abrir em {0} + Abrir em Ferramentas Externas + REMOTOS + ADICIONAR REMOTO + Pesquisar Commit + Autor + 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 + 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 + Chave SSH Privada: + Caminho para a chave SSH privada + INICIAR + Stash + Incluir arquivos não rastreados + Mensagem: + Opcional. Informações 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 + VISÃO GERAL + MÊS + SEMANA + AUTORES: + COMMITS: + SUBMÓDULOS + Adicionar Submódulo + 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 + Excluir ${0}$... + Enviar ${0}$... + Atualizar Submódulos + Todos os submódulos + Inicializar conforme necessário + Submódulo: + 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... + Alterações + Git Ignore + Ignorar todos os arquivos *{0} + Ignorar arquivos *{0} 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 + 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 000000000..525796412 --- /dev/null +++ b/src/Resources/Locales/ru_RU.axaml @@ -0,0 +1,1043 @@ + + + + + + О программе + О SourceGit + Дата выпуска: {0} + Примечания выпуска + Бесплатный графический клиент Git с исходным кодом + Добавить файл(ы) к игнорируемым + Шаблон: + Файл хранилища: + Добавить рабочий каталог + Расположение: + Путь к рабочему каталогу (поддерживается относительный путь) + Имя ветки: + Имя целевого каталога по умолчанию (необязательно) + Отслеживание ветки: + Отслеживание внешней ветки + Переключиться на: + Создать новую ветку + Ветку из списка + Помощник OpenAI + Модель + ПЕРЕСОЗДАТЬ + Использовать OpenAI для создания сообщения о ревизии + Использовать + Скрыть SourceGit + Скрыть остальные + Показать все + Трехстороннее слияние + Выберите файл .patch для применения + Игнорировать изменения пробелов + Источник: + Файл + Буфер обмена + Применить заплатку + Пробел: + Отложить + Удалить после применения + Восстановить изменения индекса + Отложенный: + Архивировать... + Сохранить архив в: + Выберите путь к архивному файлу + Ревизия: + Архив + Спросить разрешения SourceGit + Введите парольную фразу: + НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ + СПИСОК ПУСТ + Загрузить картинку... + Обновить + ДВОИЧНЫЙ ФАЙЛ НЕ ПОДДЕРЖИВАЕТСЯ!!! + Раздвоить + О + Плохая + Хорошая + Пропустить + Раздвоение. Пожалуйста, переключитесь на другой, затем отметьте его как хороший или плохой. + Раздвоение. Отметить текущую ревизию как плохую или переключиться на другую, а затем отметить её как плохую. + Раздвоение. Отметить текущую ревизию хорошей или плохой и переключиться на другой. + Раздвоение. Текущая ГОЛОВА (HEAD) хорошая или плохая? + Расследование + Расследование на предыдущей редакции + Игнорировать изменения пробелов + РАССЛЕДОВАНИЕ В ЭТОМ ФАЙЛЕ НЕ ПОДДЕРЖИВАЕТСЯ!!! + Переключиться на ${0}$... + Сравнить две выбранные ветки + Сравнить с ... + Сравнить с ГОЛОВОЙ + Сравнить с ${0}$ + Копировать имя ветки + Создать PR... + Создать PR для основной ветки ${0}$... + Изменить действие + Удалить ${0}$... + Удалить выбранные {0} ветки + Править описание для ${0}$... + Перемотать вперёд к ${0}$ + Извлечь ${0}$ в ${1}$... + Git-процесс - Завершение ${0}$ + Интерактивное перемещение ${0}$ в ${1}$ + Влить ${0}$ в ${1}$... + Влить {0} выделенных веток в текущую + Загрузить ${0}$ + Загрузить ${0}$ в ${1}$... + Выложить ${0}$ + Переместить ${0}$ на ${1}$... + Переименовать ${0}$... + Сбросить ${0}$ к ${1}$... + Переключить на ${0}$ (рабочий каталог) + Отслеживать ветку... + {0} ревизий вперёд + {0} ревизий вперёд, {1} ревизий назад + {0} ревизий назад + Неверно + УДАЛЁННЫЙ + СОСТОЯНИЕ + ОТСЛЕЖИВАНИЕ + URL-АДРЕС + РАБОЧИЙ КАТАЛОГ + ОТМЕНА + Сбросить родительскую ревизию + Сбросить эту ревизию + Произвести сообщение о ревизии + Слить (встроенный) + Слить (внешний) + Сбросить файл(ы) в ${0}$ + ИЗМЕНИТЬ РЕЖИМ ОТОБРАЖЕНИЯ + Показывать в виде списка файлов и каталогов + Показывать в виде списка путей + Показывать в виде дерева файловой системы + Изменить URL-адрес подмодуля + Подмодуль: + URL-адрес: + Переключить ветку + Локальные изменения: + Ветка: + Ваша текущая ГОЛОВА содержит ревизию(и), не связанные ни с к какими ветками или метками! Вы хотите продолжить? + Подмодулям требуется обновление:{0}Обновить их? + Переключиться и перемотать + Перемотать к: + Переключить ветку из отложенного + Новая ветка: + Отложенный: + Переключить ревизию или метку + Цель: + Предупреждение: После этого ГОЛОВА будет отсоединена + Частичный выбор + Добавить источник для ревизии сообщения + Ревизия(и): + Ревизия всех изменений. + Основной: + Обычно вы не можете выделить слияние, потому что не знаете, какую сторону слияния следует считать основной. Эта опция позволяет отобразить изменения относительно указанного родительского элемента. + Очистить отложенные + Вы пытаетесь очистить все отложенные. Вы уверены, что хотите продолжить? + Клонировать внешний репозиторий + Параметры: + Аргументы git clone (необязательно) + Закладка: + Группа: + Локальное имя: + Имя репозитория (необязательно) + Родительский каталог: + Создать и обновить подмодули + Адрес репозитория: + ЗАКРЫТЬ + Редактор + Ветки + Ветки и метки + Пользовательские действия репозитория + Ревизия файлов + Переключиться на эту ревизию + Применить эту ревизию (cherry-pick) + Применить несколько ревизий ... + Сравнить c ГОЛОВОЙ (HEAD) + Сравнить с рабочим каталогом + Автор + Автор (время) + Сообщение + Ревизор + Ревизор (время) + SHA + Субъект + Пользовательское действие + Интерактивное перемещение + Бросить... + Редактировать... + Исправить в родительском... + Интерактивное перемещение ${0}$ в ${1}$ + Изменить комментарий... + Втиснуть в родительский... + Влить ... + Выложить ${0}$ в ${1}$ + Переместить ${0}$ в ${1}$ + Сбросить ${0}$ к ${1}$ + Отменить ревизию + Сохранить как заплатки... + ИЗМЕНЕНИЯ + изменённый(х) файл(ов) + Найти изменения.... + Свернуть подробности + ФАЙЛЫ + Файл LFS + Поиск файлов... + Подмодуль + ИНФОРМАЦИЯ + АВТОР + РЕВИЗОР (ИСПОЛНИТЕЛЬ) + Найти все ветки с этой ревизией + ВЕТКИ С ЭТОЙ РЕВИЗИЕЙ + Копировать адрес почты + Копировать имя + Копировать имя и адрес почты + Отображаются только первые 100 изменений. Смотрите все изменения на вкладке ИЗМЕНЕНИЯ. + Ключ: + СООБЩЕНИЕ + РОДИТЕЛИ + ССЫЛКИ + SHA + Подписант: + Открыть в браузере + СТБ + Введите сообщение ревизии. Пожалуйста, используйте пустые строки для разделения субъекта и описания! + СУБЪЕКТ + Сравнить + ИЗМЕНЕНИЯ + РЕВИЗИИ + РЕВИЗИИ ТОЛЬКО СЛЕВА + РЕВИЗИИ ТОЛЬКО СПРАВА + Подсказка: Если другая сторона является ГОЛОВОЙ (используя контекстное меню), вы можете использовать частично выбранные ревизии (cherry-pick). + Настройка репозитория + ШАБЛОН РЕВИЗИИ + Встроенные параметры: + + ${branch_name} Имя текущей локальной ветки + ${files_num} Количество изменённых файлов + ${files} Пути изменённых файлов + ${files:N} Пути изменённых файлов, не более N + ${pure_files} То же, что и ${files}, но только имена файлов + ${pure_files:N} То же, что и ${files:N}, но только имена файлов + Cодержание: + Название: + ПОЛЬЗОВАТЕЛЬСКОЕ ДЕЙСТВИЕ + Аргументы: + Встроенные параметры: + + ${REPO} Путь репозитория + ${REMOTE} Выбранная удаённая ветка + ${BRANCH} Выбранная ветка, без ${REMOTE} удалённых веток + ${BRANCH_FRIENDLY_NAME} Понятное имя выбранной ветки, содержащую ${REMOTE} удалённые ветки + ${SHA} Хеш выбранной ревизии + ${TAG} Выбранная метка + ${FILE} Выбранный файл, относительно корня репозитория + $1, $2 ... Ввод управляющих значений + Исполняемый файл: + Элементы управления вводом: + Редактор + Имя: + Диапазон: + Ветка + Ревизия + Файл + Удалённый + Репозиторий + Метка + Ждать для выполения выхода + Адрес электронной почты + Адрес электронной почты + GIT + Спрашивать перед автоматическим обновлением подмодулей. + Автозагрузка изменений + Минут(а/ы) + Общепринятые типы ревизии + Внешний репозиторий по умолчанию + Включить (--recursive) при автоматическом обновлении подмодулей + Предпочтительный режим слияния + ОТСЛЕЖИВАНИЕ ПРОБЛЕМ + Добавить пример правила Azure DevOps + Добавить правило Gerrit ревизии идентификатора изменения + Добавить пример правила для тем в Gitea + Добавить пример правила запроса скачивания из Gitea + Добавить пример правила для Git + Добавить пример правила выдачи GitLab + Добавить пример правила запроса на слияние в GitLab + Добавить пример правила Jira + Новое правило + Проблема с регулярным выражением: + Имя правила: + Опубликовать это правило в файле .issuetracker + Адрес результата: + Пожалуйста, используйте $1, $2 для доступа к значениям групп регулярных выражений. + ОТКРЫТЬ ИИ + Предпочтительный сервис: + Если «Предпочтительный сервис» установлен, SourceGit будет использовать только этот репозиторий. В противном случае, если доступно более одной услуги, будет отображено контекстное меню для выбора одной из них. + HTTP-прокси + HTTP-прокси для репозитория + Имя пользователя + Имя пользователя репозитория + Пользовательское редактирование элементами действий + Проверяемое значение: + Если установлено, то данное значение будет использоваться в аргументах командной строки + Описание: + По умолчанию: + Это каталог: + Метка: + Опции: + Используйте разделитель «|» для опций + Строка форматирования: + Не обязательно. Использовать для форматирования выходной строки. Игнорировать пустые входные данные. Пожалуйста, используйте «${VALUE}» для представления входной строки. + Встроенные переменные ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, и ${TAG} останутся здесь доступными + Тип: + Используйте понятное имя: + Рабочие пространства + Цвет + Имя + Восстанавливать вкладки при запуске + ПРОДОЛЖИТЬ + Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty)? + Сформировать всё и зафиксировать ревизию + Сформировать выбранные и зафиксировать + Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty) или отложить всё, а затем зафиксировать ревизию? + Требуется перезапуск + Вы должны перезапустить приложение после применения изменений. + Общепринятый помощник по ревизии + Кардинальные изменения: + Закрытая тема: + Детали изменений: + Область: + Короткое описание: + Тип изменения: + Копировать + Копировать весь текст + Копировать как исправление + Копировать полный путь + Копировать путь + Создать ветку... + Основан на: + Переключиться на созданную ветку + Локальные изменения: + Имя новой ветки: + Введите имя ветки. + Создать локальную ветку + Перезаписать существующую ветку + Создать метку... + Новая метка у: + GPG подпись + Сообщение с меткой: + Необязательно. + Имя метки: + Рекомендуемый формат: v1.0.0-alpha + Выложить на все внешние репозитории после создания + Создать новую метку + Вид: + С примечаниями + Простой + Удерживайте Ctrl, чтобы сразу начать + Вырезать + Отклонить + Ничего не делать + Отложить и примненить повторно + Удалить подмодуль + Принудительно удалить даже если содержит локальные изменения. + Подмодуль: + Удалить ветку + Вы хотите также удалить следующую удалённую ветку? + Ветка: + Принудительно удалить, даже если содержит неслитые ревизии + Вы собираетесь удалить внешнюю ветку!!! + Удаление нескольких веток + Вы пытаетесь удалить несколько веток одновременно. Обязательно перепроверьте, прежде чем предпринимать какие-либо действия! + Удалить несколько меток + Удалить их с удалённого + Вы пытаетесь удалить сразу несколько меток. Перепроверьте обязательно перед выполнением! + Удалить внешний репозиторий + Внешний репозиторий: + Путь: + Цель: + Все дочерние элементы будут удалены из списка. + Будет удалён из списка. На диске останется. + Подтвердите удаление группы + Подтвердите удаление репозитория + Удалить подмодуль + Путь подмодуля: + Удалить метку + Метка: + Удалить из внешнего репозитория + СРАВНЕНИЕ БИНАРНИКОВ + ФАЙЛ ПУСТ + Первое сравнение + Игнорировать изменения пробелов + СМЕСЬ + СРАВНЕНИЕ + РЯДОМ + СМАХИВАНИЕ + Последнее сравнение + ИЗМЕНЕНИЕ ОБЪЕКТА LFS + НОВЫЙ + Следующее сравнение + НИКАКИХ ИЗМЕНЕНИЙ ИЛИ МЕНЯЕТСЯ ТОЛЬКО EOL + СТАРЫЙ + Предыдущее сравнение + Сохранить как заплатку + Показывать скрытые символы + Сравнение рядом + ПОДМОДУЛЬ + УДАЛЁН + НОВЫЙ + + {0} Незафиксированных изменний + Обмен + Подсветка синтаксиса + Перенос слов в строке + Открыть в инструменте слияния + Показывать все строки + Меньше видимых строк + Больше видимых строк + ВЫБЕРИТЕ ФАЙЛ ДЛЯ ПРОСМОТРА ИЗМЕНЕНИЙ + Каталог историй + Есть локальные изменения + Не соответствует с исходящим потоком + В актуальном состоянии + Отклонить изменения + Все локальные изменения в рабочей копии. + Изменения: + Включить игнорируемые файлы + Включить изменённые/удалённые файлы + Включить неотслеживаемые файлы + {0} изменений будут отменены + Вы не можете отменить это действие!!! + Править описание ветки + Цель: + Закладка: + Новое имя: + Цель: + Редактировать выбранную группу + Редактировать выбранный репозиторий + Цель: + Этот репозиторий + Извлечь + Извлечь все внешние репозитории + Разрешить опцию (--force) + Извлечь без меток + Внешний репозиторий: + Извлечь внешние изменения + Не отслеживать + Пользовательское действие + Отклонить... + Отклонить файлы ({0})... + Взять версию ${0}$ + Сохранить как файл заплатки... + Сформировать + Сформировать файлы ({0}) + Отложить... + Отложить файлы ({0})... + Расформировать + Расформировать файлы ({0}) + Использовать мой (checkout --ours) + Использовать их (checkout --theirs) + История файлов + ИЗМЕНИТЬ + СОДЕРЖИМОЕ + Изменение режима файла: + Режим удалённого файла: + Каталог + Исполняемый + Новый режим файла: + Обычный + Подмодуль + Символическая сскла + Неизвестно + Git-процесс + Ветка разработчика: + Свойство: + Свойство префикса: + Завершение ${0}$ + ПРОЦЕСС - Свойства завершения + ПРОЦЕСС - Закончить исправление + ПРОЦЕСС - Завершить выпуск + Цель: + Перенос перед слиянием + Втиснуть при слиянии + Исправление: + Префикс исправлений: + Создать 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, чтобы выбрать несколько ревизий. + Удерживайте ⌘ или ⇧, чтобы выбрать несколько ревизий. + ПОДСКАЗКИ: + Открыть в отдельном окне + Подробности ревизии + Сравнить ревизию + Справка по сочетаниям клавиш + ГЛОБАЛЬНЫЕ + Клонировать репозиторий + Закрыть текущую вкладку + Перейти на следующую вкладку + Перейти на предыдущую вкладку + Создать новую вкладку + Открыть локальный репозиторий + Открыть диалоговое окно настроек + Показать рабочее пространство в выпадющем меню + Переключиться на вкладку + Масштабирование + РЕПОЗИТОРИЙ + Зафиксировать сформированные изменения + Зафиксировать и выложить сформированные изменения + Сформировать все изменения и зафиксировать + Создать новую ветку + Извлечь (fetch), запускается сразу + Режим доски (по умолчанию) + К дочернему выбранной ревизии + К родительскому выбранной ревизии + Открыть палитру команд + Режим поиска ревизий + Загрузить (pull), запускается сразу + Выложить (push), запускается сразу + Принудительно перечитать репозиторий + Развернуть/Свернуть панель подробностей в «ИСТОРИИ» + Переключить на «Изменения» + Переключить на «Историю» + Переключить на «Отложенные» + Отклонить + Сформировать + Расформировать + Создать репозиторий + Вы действительно хотите запуститб команду «git init» по этому пути? + Не удалось открыть репозиторий. Причина: + Путь: + Выполняется частичный перенос ревизий (cherry-pick). + Обрабтка ревизии. + Выполняется слияние. + Выполяется. + Выполняется перенос. + Остановлен на + Выполняется отмена ревизии. + Выполняется отмена + Интерактивное перемещение + Отложить и применить повторно локальные изменения + Обходной зацеп предварительного перемещения + На: + Перетаскивайте для переупорядочивания ревизий + Целевая ветка: + Копировать ссылку + Открыть в браузере + Команды + ОШИБКА + УВЕДОМЛЕНИЕ + Доступна новая версия + Открыть репозитории + Вкладки + Рабочие места + Влить ветку + Изменить сообщение слияния + В: + Опции слияния: + Источник: + Тестировать на слияние... + Слияние может быть выполнено без конфликтов + Неизвестная ошибка при тестировании слияния + Слияние вызовет конфликты + Сначала мои, а потом их + Сначала их, а потом мои + ИСПОЛЬЗОВАТЬ ОБА + Все конфликты разрешены + Осталось конфликтов: {0} + MINE + Следующий конфликт + Предыдущий конфликт + РЕЗУЛЬТАТ + СОХРАНИТЬ И СФОРМИРОВАТЬ + ИХ + Конфликты при слиянии + Отменить несохранённые изменения? + ИСПОЛЬЗОВАТЬ МОИ + ИСПОЛЬЗОВАТЬ ИХ + Отменить + Влить несколько веток + Зафиксировать все изменения + Стратегия: + Цели: + Переместить подмодуль + Переместить в: + Подмодуль: + Переместить репозиторий в другую группу + Выбрать группу для: + Имя: + Нет + Git НЕ был настроен. Пожалуйста, перейдите в [Настройки] и сначала настройте его. + Открыть + Редактор по умолчанию (Системный) + Открыть каталог данных программы + Открыть файл + Открыть в инструменте слияния + Открыть локальный репозиторий + Закладка: + Группа: + Каталог: + Необязательно. + Создать новую вкладку + Закрыть вкладку + Закрыть другие вкладки + Закрыть вкладки справа + Копировать путь репозитория + Редактировать... + Переместить в рабочее пространство + Обновить + Репозитории + Вставить + {0} дней назад + 1 час назад + {0} часов назад + Сейчас + Последний месяц + В прошлом году + {0} минут назад + {0} месяцев назад + {0} лет назад + Вчера + Параметры + ОТКРЫТЬ ИИ + Дополнительная подсказка (Для перечисления ваших требований используйте `-`) + Ключ API + Модель + Автоматическое получение доступных идентификаторов моделей + Имя: + Введённое значение — это имя для загрузки API-ключа из ENV + Сервер + ВИД + Шрифт по умолчанию + Редактировать ширину вкладки + Размер шрифта + По умолчанию + Редактор + Моноширный шрифт + Тема + Переопределение темы + Автоматически скрывать прокрутку + Использовать фиксированную ширину табуляции в строке заголовка. + Использовать системное окно + ИНСТРУМЕНТ СРАВНЕНИЙ/СЛИЯНИЯ + Аргументы сравнения + Доступны переменные: $LOCAL, $REMOTE + Слить аргументы + Доступны переменные: $BASE, $LOCAL, $REMOTE, $MERGED + Путь установки + Введите путь для инструмента сравнения/слияния + Инструмент + ОСНОВНЫЕ + Проверить обновления при старте + Формат даты + Включить компактные каталоги в дереве изменений + Язык + Максимальная длина истории + Показывать вкладку «ЛОКАЛЬНЫЕ ИЗМЕНЕНИЯ» по умолчанию + Показывать вкладку «Изменения» в сведении ревизии по умолчанию + Отображение относительного времени на графе ревизии + Показывать метки на графике + Длина темы ревизии + 24-часовой + Использовать компактные имена веток в графе ревизии + Создать Github-подобный аватар по умолчанию + GIT + Включить автозавершение CRLF + Каталог клонирования по умолчанию + Электроная почта пользователя + Общая электроная почта пользователя git + Разрешить (--prune) при скачивании + Разрешить (--ignore-cr-at-eol) в сравнении + Для работы программы требуется версия Git (>= 2.25.1) + Путь установки + Разрешить верификацию HTTP SSL + Использовать git-credential-libsecret вместо git-credential-manager + Имя пользователя + Общее имя пользователя git + Отложить и повторно применить изменения по умолчанию при извлечении или объединении веток + Версия Git + GPG ПОДПИСЬ + GPG подпись ревизии + Формат GPG + Путь установки программы + Введите путь для установленной программы GPG + GPG подпись метки + Ключ подписи пользователя + Ключ GPG подписи пользователя + ВНЕДРЕНИЕ + ОБОЛОЧКА/ТЕРМИНАЛ + Аргументы + Используйте, пожалуйста, точку «.» для индикации рабочего каталога + Путь + Оболочка/Терминал + Удалить внешний репозиторий + Цель: + Удалить рабочий каталог + Информация об обрезке рабочего каталога в «$GIT_COMMON_DIR/worktrees» + Загрузить + Ветка внешнего репозитория: + В: + Локальные изменения: + Внешний репозиторий: + Загрузить (Получить и слить) + Использовать перемещение вместо слияния + Выложить + Убедитесь, что подмодули были вставлены + Принудительно выложить + Локальная ветка: + НОВЫЙ + Внешний репозиторий: + Ревизия: + Выложить ревизию на удалёную + Выложить изменения на внешний репозиторий + Ветка внешнего репозитория: + Отслеживать ветку + Выложить все метки + Выложить метку на внешний репозиторий + Выложить на все внешние репозитории + Внешний репозиторий: + Метка: + Отправить к НОВОЙ ветке + Введитте имя для новой удалённой ветки: + Выйти + Перемещение текущей ветки + Отложить и применить повторно локальные изменения + Обходной зацеп предварительного перемещения + На: + Тестировать на перемещение... + Перемещение может быть выполнено без конфликтов + Неизвестная ошибка при тестировании перемещения + Перемещение вызовет конфликты + Добавить внешний репозиторий + Редактировать внешний репозиторий + Имя: + Имя внешнего репозитория + Адрес: + Адрес внешнего репозитория git + Копировать адрес + Пользовательские действия + Удалить... + Редактировать... + Включить автоизвлечение + Извлечь + Открыть в браузере + Удалить + Подтвердить удаление рабочего каталога + Включить опцию (--force) + Цель: + Переименовать ветку + Новое имя: + Уникальное имя для данной ветки + Ветка: + Отказ + Автоматическое извлечение изменений с внешних репозиторий... + Сортировать + По дате ревизора (исполнителя) + По имени + Очистить (Сбор мусора и удаление) + Запустить команду (git gc) для данного репозитория. + Очистить всё + Очистить + Настройка репозитория + ПРОДОЛЖИТЬ + Изменить действия + Не изменять действия + Панель + Отклонить все изменения. + Открыть в файловом менеджере + Поиск веток, меток и подмодулей + Видимость на графике + Свернуть фильтры + Не установлен (по умолчанию) + Скрыть в графе ревизии + Развернуть фильтры + Фильтр в графе ревизии + фильтры включены + РАСПОЛОЖЕНИЕ + Горизонтально + Вертикально + ЗАПРОС РЕВИЗИЙ + Дата ревизии + Топологически + ЛОКАЛЬНЫЕ ВЕТКИ + Больше опций... + Навигация по ГОЛОВЕ (HEAD) + Создать ветку + ОЧИСТКА УВЕДОМЛЕНИЙ + Открыть как каталог + Открыть в {0} + Открыть в расширенном инструменте + ВНЕШНИЕ РЕПОЗИТОРИИ + ДОБАВИТЬ ВНЕШНИЙ РЕПОЗИТОРИЙ + РАЗРЕШИТЬ + Поиск ревизии + Автор + Содержимое + Сообщение + Путь + SHA + Текущая ветка + Только оформленные ревизии + Показывать только первый родительский + ПОКАЗЫВАТЬ ФЛАГИ + Показывать потерянные ревизии + Показывать подмодули как дерево + Показывать метки как катлог + ПРОПУСТИТЬ + Статистикa + ПОДМОДУЛИ + ДОБАВИТЬ ПОДМОДУЛЬ + ОБНОВИТЬ ПОДМОДУЛЬ + МЕТКИ + НОВАЯ МЕТКА + По дате создания + По имени + Сортировать + Открыть в терминале + Просмотр журналов + Посетить '{0}' в браузере + РАБОЧИЕ КАТАЛОГИ + ДОБАВИТЬ РАБОЧИЙ КАТАЛОГ + ОБРЕЗАТЬ + Адрес репозитория Git + Сбросить текущую ветку до версии + Режим сброса: + Переместить в: + Текущая ветка: + Сброс ветки (без переключения) + Переместить в: + Ветка: + Открыть в файловом менеджере + Отменить ревизию + Ревизия: + Отмена ревизии + Запуск. Подождите пожалуйста... + СОХРАНИТЬ + Сохранить как... + Заплатка успешно сохранена! + Обнаружение репозиториев + Корневой каталог: + Сканировать другой пользовательский каталог + Проверить обновления... + Доступна новая версия программного обеспечения: + Текущая версия: + Не удалось проверить наличие обновлений! + Загрузка + Пропустить эту версию + Дата выпуска новой версии: + Обновление ПО + Сейчас нет обновлений. + Установить ветку подмодуля + Подмодуль: + Текущий: + Изменить в: + Не обязательно. Установить по умолчанию, когда пусто. + Отслеживать ветку + Ветка: + Снять основную ветку + Основная ветка: + Копировать SHA + Перейти + Приватный ключ SSH: + Путь хранения приватного ключа SSH + ЗАПУСК + Отложить + Включить неотслеживаемые файлы + Сообщение: + Имя тайника (необязательно) + Режим: + Только сформированные изменения + Сформированные так и несформированные изменения выбранных файлов будут сохранены!!! + Отложить локальные изменения + Принять + Применить изменения + Переключить на новую ветку + Копировать сообщение + Отбросить + Сохранить как заплатку... + Отбросить тайник + Отбросить: + Отложенные + ИЗМЕНЕНИЯ + ОТЛОЖЕННЫЕ + Статистика + ОБЗОР + МЕСЯЦ + НЕДЕЛЯ + АВТОРЫ: + РЕВИЗИИ: + ПОДМОДУЛИ + Добавить подмодули + ВЕТКА + Ветка + Каталог + Удалить подмодуль + Извлечение вложенных подмодулей + Истории + Переместить в + Открыть подмодуль репозитория + Каталог: + Относительный путь для хранения подмодуля. + Удалить подмодуль + Установить ветку + Изменить URL-адрес + СОСТОЯНИЕ + изменён + не создан + ревизия изменена + не слита + Обновить + URL-адрес + Подробности изменения подмодуля + ОТКРЫТЬ ПОДРОБНОСТИ + ОК + РАЗМЕТЧИК + ВРЕМЯ + Переключить метку... + Сравнить две ветки + Сравнить с ... + Сравнить с ГОЛОВОЙ + Сообщение + Имя + Разметчик + Копировать имя метки + Пользовательское действие + Удалить ${0}$... + Удалить выбранные метки ({0})... + Влить ${0}$ в ${1}$... + Выложить ${0}$... + Обновление подмодулей + Все подмодули + Создавать по необходимости + Подмодуль: + Обновить вложенные подмодули + Обновить до отслеживаемой ветки удалённого подмодуля + Сетевой адрес: + Журналы + ОЧИСТИТЬ ВСЁ + Копировать + Удалить + Предупреждение + Приветствие + Создать группу + Создать подгруппу... + Клонировать репозиторий + Удалить... + ПОДДЕРЖИВАЕТСЯ: ПЕРЕТАСКИВАНИЕ КАТАЛОГОВ, ПОЛЬЗОВАТЕЛЬСКАЯ ГРУППИРОВКА. + Редактировать... + Переместить в другую группу... + Открыть все репозитории + Открыть репозиторий + Открыть терминал + Обнаружить репозитории в каталоге клонирования по умолчанию + Найти репозиторий... + Изменения + Игнорировать 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 000000000..5cbea045b --- /dev/null +++ b/src/Resources/Locales/ta_IN.axaml @@ -0,0 +1,664 @@ + + + + + + பற்றி + மூலஅறிவிலி பற்றி + திறந்தமூல & கட்டற்ற அறிவிலி இடைமுக வாடிக்கயாளர் + பணிமரத்தைச் சேர் + இடம்: + இந்த பணிமரத்திற்கான பாதை. தொடர்புடைய பாதை ஆதரிக்கப்படுகிறது. + கிளை பெயர்: + விருப்பத்தேர்வு. இயல்புநிலை இலக்கு கோப்புறை பெயர். + கிளை கண்காணி: + தொலை கிளையைக் கண்காணித்தல் + என்ன சரிபார்க்க வேண்டும்: + புதிய கிளையை உருவாக்கு + ஏற்கனவே உள்ள கிளை + செநு உதவியாளர் + மாதிரி + மறு-உருவாக்கு + உறுதிமொழி செய்தியை உருவாக்க செநுவைப் பயன்படுத்து + .ஒட்டு இடுவதற்கு கோப்பைத் தேர்ந்தெடு + வெள்ளைவெளி மாற்றங்களைப் புறக்கணி + ஒட்டு இடு + வெள்ளைவெளி: + பதுக்கிவைத்ததை இடு + பயன்படுத்திய பின் நீக்கு + குறியீட்டின் மாற்றங்களை மீண்டும் நிறுவு + பதுக்கிவை: + காப்பகம்... + இதற்கு காப்பகத்தை சேமி: + காப்பகக் கோப்பு பாதையைத் தேர்ந்தெடு + திருத்தம்: + காப்பகம் + மூலஅறிவிலி கடவுகேள் + கோப்புகள் மாற்றப்படவில்லை எனக் கருதப்படுகிறது + எந்த கோப்புகளும் மாற்றப்படவில்லை எனக் கருதப்படுகிறது + புதுப்பி + இருமம் கோப்பு ஆதரிக்கப்படவில்லை!!! + குற்றச்சாட்டு + இந்த கோப்பில் குற்றம் சாட்ட ஆதரிக்கப்படவில்லை!!! + ${0}$ சரிபார்... + கிளை பெயரை நகலெடு + தனிப்பயன் செயல் + ${0}$ ஐ நீக்கு... + தேர்ந்தெடுக்கப்பட்ட {0} கிளைகளை நீக்கு + ${0}$ இதற்கு வேகமாக முன்னோக்கிச் செல் + ${0}$ ஐ ${1}$இல் பெறு... + அறிவிலி ஓட்டம் - முடி ${0}$ + ${0}$ ஐ ${1}$இல் இணை... + தேர்ந்தெடுக்கப்பட்ட {0} கிளைகளை தற்பொதையதில் இணை + இழு ${0}$ + இழு ${0}$ஐ ${1}$-க்குள்... + தள்ளு ${0}$ + மறுதளம் ${0}$ இதன்மேல் ${1}$... + மறுபெயரிடு ${0}$... + கண்காணிப்பு கிளையை அமை... + விடு + பெற்றோர் திருத்தத்திற்கு மீட்டமை + இந்த திருத்தத்திற்கு மீட்டமை + உறுதிமொழி செய்தி உருவாக்கு + காட்சி பயன்முறையை மாற்று + கோப்பு மற்றும் கோப்புறை பட்டியலாக காட்டு + பாதை பட்டியலாகக் காட்டு + கோப்பு முறைமை மரமாகக் காட்டு + கிளை சரிபார் + உள்ளக மாற்றங்கள்: + கிளை: + கனி பறி + உறுதிமொழி செய்திக்கு மூலத்தைச் சேர் + உறுதிமொழி(கள்): + அனைத்து மாற்றங்களையும் உறுதிமொழி + முதன்மைகோடு: + பொதுவாக நீங்கள் ஒரு ஒன்றிணையை கனி-பறிக்க முடியாது, ஏனெனில் இணைப்பின் எந்தப் பக்கத்தை முதன்மையாகக் கருத வேண்டும் என்பது உங்களுக்குத் தெரியாது. இந்த விருப்பம் குறிப்பிட்ட பெற்றோருடன் தொடர்புடைய மாற்றத்தை மீண்டும் இயக்க கனி-பறி அனுமதிக்கிறது. + பதுக்கிவைத்தையும் அழி + நீங்கள் அனைத்து பதுக்கிவைத்தையும் அழிக்க முயற்சிக்கிறீர்கள் தொடர விரும்புகிறீர்களா? + நகலி தொலை களஞ்சியம் + கூடுதல் அளவுருக்கள்: + நகலி களஞ்சியத்திற்கான கூடுதல் வாதங்கள். விருப்பத்தேர்வு. + உள்ளக பெயர்: + களஞ்சியப் பெயர். விருப்பத்தேர்வு. + பெற்றோர் கோப்புறை: + துவக்கு & துணை தொகுதிகளைப் புதுப்பி + களஞ்சிய முகவரி: + மூடு + திருத்தி + உறுதிமொழி சரிபார் + கனி-பறி உறுதிமொழி + கனி-பறி ... + தலையுடன் ஒப்பிடுக + பணிமரத்துடன் ஒப்பிடுக + பாகொவ-வை + தனிப்பயன் செயல் + ஒன்றிணை ... + உறுதிமொழி திரும்பபெறு + ஒட்டாக சேமி... + மாற்றங்கள் + மாற்றங்களைத் தேடு... + கோப்புகள் + பெகோஅ கோப்பு + கோப்புகளைத் தேடு... + துணைத்தொகுதி + தகவல் + ஆசிரியர் + உறுதிமொழியாளர் + இந்த உறுதிமொழிடைக் கொண்ட குறிப்புகளைச் சரிபார் + உறுதிமொழி இதில் உள்ளது + முதல் 100 மாற்றங்களை மட்டும் காட்டுகிறது மாற்றங்கள் தாவலில் அனைத்து மாற்றங்களையும் காண்க. + செய்தி + பெற்றோர்கள் + குறிகள் + பாகொவ + உலாவியில் திற + ஒப்பிடு + களஞ்சியம் உள்ளமை + உறுதிமொழி வளர்புரு + வார்ப்புரு உள்ளடக்கம்: + வார்ப்புரு பெயர்: + தனிப்பயன் செயல் + வாதங்கள்: + இயக்கக்கூடிய கோப்பு: + பெயர்: + நோக்கம்: + கிளை + உறுதிமொழி + களஞ்சியம் + செயல்பாட்டிலிருந்து வெளியேற காத்திரு + மின்னஞ்சல் முகவரி + மின்னஞ்சல் முகவரி + அறிவிலி + தொலைகளை தானாக எடு + நிமையங்கள் + இயல்புநிலை தொலை + சிக்கல் கண்காணி + மாதிரி அசூர் வளர்பணிகள் விதியைச் சேர் + மாதிரி அறிவிலிஈ சிக்கலுக்கான விதியைச் சேர் + மாதிரி அறிவிலிஈ இழு கோரிக்கை விதியைச் சேர் + மாதிரி அறிவிலிமையம் விதியைச் சேர் + மாதிரி அறிவிலிஆய்வு சிக்கலுக்கான விதியைச் சேர் + மாதிரி அறிவிலிஆய்வு இணைப்பு கோரிக்கை விதியைச் சேர் + மாதிரி சீரா விதியைச் சேர் + புதிய விதி + வழக்கவெளி வெளிப்பாடு வெளியீடு: + விதியின் பெயர்: + முடிவு முகவரி: + வழக்கவெளி குழுக்கள் மதிப்புகளை அணுக $1, $2 ஐப் பயன்படுத்து + செநு + விருப்பமான சேவை: + 'விருப்பமான சேவை' அமைக்கப்பட்டிருந்தால், மூலஅறிவிலி இந்த களஞ்சியத்தில் மட்டுமே அதைப் பயன்படுத்தும். இல்லையெனில், ஒன்றுக்கு மேற்பட்ட சேவைகள் இருந்தால், அவற்றில் ஒன்றைத் தேர்ந்தெடுப்பதற்கான சூழல் பட்டயல் காண்பிக்கப்படும். + உஉபநெ பதிலாள் + இந்த களஞ்சியத்தால் பயன்படுத்தப்படும் உஉபநெ பதிலாள் + பயனர் பெயர் + இந்த களஞ்சியத்திற்கான பயனர் பெயர் + பணியிடங்கள் + நிறம் + பெயர் + தாவல்களை மீட்டமை + வழக்கமான உறுதிமொழி உதவியாளர் + உடைக்கும் மாற்றம்: + மூடப்பட்ட வெளியீடு சிக்கல்: + மாற்ற விவரங்கள்: + நோக்கம்: + குறுகிய விளக்கம்: + மாற்ற வகை: + நகல் + அனைத்து உரையையும் நகலெடு + முழு பாதையை நகலெடு + நகல் பாதை + கிளையை உருவாக்கு... + இதன் அடிப்படையில்: + உருவாக்கப்பட்ட கிளையைப் சரிபார் + உள்ளக மாற்றங்கள்: + புதிய கிளை பெயர்: + கிளை பெயரை உள்ளிடவும். + உள்ளக கிளையை உருவாக்கு + குறிச்சொல்லை உருவாக்கு... + இங்கு புதிய குறிச்சொல்: + சீபிசீ கையொப்பமிடுதல் + குறிச்சொல் செய்தி: + விருப்பத்தேர்வு. + குறிச்சொல் பெயர்: + பரிந்துரைக்கப்பட்ட வடிவம்: ப1.0.0-ஆனா + உருவாக்கப்பட்ட பிறகு அனைத்து தொலைகளுக்கும் தள்ளு + புதிய குறிசொல் உருவாக்கு + வகை: + annotated + குறைந்தஎடை + நேரடியாகத் தொடங்க கட்டுப்பாட்டை அழுத்திப் பிடி + வெட்டு + நிராகரி + பதுக்கிவை & மீண்டும் இடு + கிளையை நீக்கு + கிளை: + நீங்கள் ஒரு தொலை கிளையை நீக்கப் போகிறீர்கள்!!! + பல கிளைகளை நீக்கு + நீங்கள் ஒரே நேரத்தில் பல கிளைகளை நீக்க முயற்சிக்கிறீர்கள் நடவடிக்கை எடுப்பதற்கு முன் மீண்டும் சரிபார்! + தொலையை நீக்கு + தொலை: + பாதை: + இலக்கு: + எல்லா குழந்தைகளும் பட்டியலிலிருந்து நீக்கப்படுவார்கள். + இது பட்டியலிலிருந்து மட்டுமே அகற்றும், வட்டிலிருந்து அல்ல! + குழுவை நீக்குவதை உறுதிப்படுத்து + களஞ்சியத்தை நீக்குவதை உறுதிப்படுத்து + துணைத்தொகுதியை நீக்கு + துணைத்தொகுதி பாதை: + குறிச்சொல்லை நீக்கு + குறிசொல்: + தொலை களஞ்சியங்களிலிருந்து நீக்கு + இருமம் வேறுபாடு + முதல் வேறுபாடு + வெள்ளைவெளி மாற்றத்தை புறக்கணி + கடைசி வேறுபாடு + பெகோஅ பொருள் மாற்றம் + அடுத்த வேறுபாடு + மாற்றங்கள் இல்லை அல்லது வரிமுடிவு மாற்றங்கள் மட்டும் + முந்தைய வேறுபாடு + ஒட்டாகச் சேமி + மறைக்கப்பட்ட சின்னங்களைக் காட்டு + பக்கவாட்டு வேறுபாடு + துணைத் தொகுதி + புதிய + இடமாற்று + தொடரியல் சிறப்பம்சமாக்கல் + வரி சொல் மடக்கு + ஒன்றிணை கருவியில் திற + அனைத்து வரிகளையும் காட்டு + தெரியும் வரிகளின் எண்ணிக்கையைக் குறை + தெரியும் வரிகளின் எண்ணிக்கையை அதிகரி + மாற்றங்களைக் காண கோப்பைத் தேர்ந்தெடு + மாற்றங்களை நிராகரி + செயல்படும் நகலில் உள்ள அனைத்து உள்ளக மாற்றங்கள். + மாற்றங்கள்: + புறக்கணிக்கப்பட்ட கோப்புகளைச் சேர் + {0} மாற்றங்கள் நிராகரிக்கப்படும் + இந்தச் செயலை நீங்கள் செயல்தவிர்க்க முடியாது!!! + புத்தகக்குறி: + புதிய பெயர்: + இலக்கு: + தேர்ந்தெடுக்கப்பட்ட குழுவைத் திருத்து + தேர்ந்தெடுக்கப்பட்ட களஞ்சியத்தைத் திருத்து + பெறு + எல்லா தொலைகளையும் பெறு + உள்ளக குறிப்புகளை கட்டாயமாக மீறு + குறிச்சொற்கள் இல்லாமல் பெறு + தொலை: + தொலை மாற்றங்களைப் பெறு + மாறாமல் என கருது + நிராகரி... + {0} கோப்புகளை நிராகரி... + ${0}$ஐப் பயன்படுத்தி தீர் + ஒட்டு என சேமி... + நிலைபடுத்து + {0} fகோப்புகள் நிலைபடுத்து + பதுக்கிவை... + {0} கோப்புகள் பதுக்கிவை... + நிலைநீக்கு + நிலைநீக்கு {0} கோப்புகள் + என்னுடையதைப் பயன்படுத்து (சரிபார் --நமது) + அவர்களுடையதைப் பயன்படுத்து (சரிபார் --அவர்களது) + கோப்பு வரலாறு + மாற்றம் + உள்ளடக்கம் + அறிவிலி-ஓட்டம் + மேம்பாட்டு கிளை: + நற்பொருத்தம்: + நற்பொருத்தம் முன்னொட்டு: + ஓட்டம் - நற்பொருத்தம் முடி + ஓட்டம் - சூடானதிருத்தம் முடி + ஓட்டம் - வெளியீட்டை முடி + இலக்கு: + சூடானதிருத்தம்: + சூடானதிருத்தம் முன்னொட்டு: + அறிவிலி-ஓட்டம் துவக்கு + கிளையை வைத்திரு + உற்பத்தி கிளை: + வெளியீடு: + வெளியீடு முன்னொட்டு: + நற்பொருத்தம் தொடங்கு... + ஓட்டம் - நற்பொருத்தம் தொடங்கு + சூடானதிருத்தம் தொடங்கு... + ஓட்டம் - சூடானதிருத்தம் தொடங்கு + பெயரை உள்ளிடு + வெளியீட்டைத் தொடங்கு... + ஓட்டம் - வெளியீட்டைத் தொடங்கு + பதிப்பு குறிச்சொல் முன்னொட்டு: + அறிவிலி பெகோஅ + அறிவிலி கண்காணி வடிவத்தைச் சேர்... + வடிவம் என்பது கோப்பு பெயர் + தனிப்பயன் வடிவம்: + அறிவிலி பெகோஅ இல் கண்காணி வடிவங்களைச் சேர் + பெறு + அறிவிலி பெகோஅ பொருள்களைப் பதிவிறக்க `அறிவிலி பெகோஅ பெறு` ஐ இயக்கவும் இது செயல்படும் நகலை புதுப்பிக்காது. + அறிவிலி பெகோஅ பொருள்களைப் பெறு + அறிவிலி பெகோஅ கொக்கிகளை நிறுவு + பூட்டுகளைக் காட்டு + பூட்டப்பட்ட கோப்புகள் இல்லை + பூட்டு + எனது பூட்டுகளை மட்டும் காட்டு + பெகோஅ பூட்டுகள் + திற + கட்டாயம் திற + கத்தரி + உள்ளக சேமிப்பகத்திலிருந்து பழைய பெகோஅ கோப்புகளை நீக்க `அறிவிலி பெகோஅ கத்தரி` ஐ இயக்கு + இழு + தற்போதைய குறிக்கு அனைத்து அறிவிலி பெகோஅ கோப்புகளையும் பதிவிறக்கி சரிபார்க்க `அறிவிலி பெகோஅ இழு`ஐ இயக்கு + பெகோஅ பொருள்களை இழு + தள்ளு + வரிசைப்படுத்தப்பட்ட பெரிய கோப்புகளை அறிவிலி பெகோஅ முடிவுபுள்ளிக்கு தள்ளு + பெகோஅ பொருள்கள் தள்ளு + தொலை: + '{0}' என பெயரிடப்பட்ட கோப்புகளைக் கண்காணி + அனைத்து *{0} கோப்புகளையும் கண்காணி + வரலாறு + ஆசிரியர் + ஆசிரியர் நேரம் + உறுதிமொழி நேரம் + வரைபடம் & பொருள் + பாகொவ + தேர்ந்தெடுக்கப்பட்ட {0} உறுதிமொழிகள் + பல உறுதிமொழிகளைத் தேர்ந்தெடுக்க 'கட்டுப்பாடு' அல்லது 'உயர்த்து'ஐ அழுத்திப் பிடி. + பல உறுதிமொழிகளைத் தேர்ந்தெடுக்க ⌘ அல்லது ⇧ ஐ அழுத்திப் பிடி. + குறிப்புகள்: + விசைப்பலகை குறுக்குவழிகள் குறிப்பு + உலகளாவிய + புதிய களஞ்சியத்தை நகலி செய் + தற்போதைய பக்கத்தை மூடு + அடுத்த பக்கத்திற்குச் செல் + முந்தைய பக்கத்திற்குச் செல் + புதிய பக்கத்தை உருவாக்கு + விருப்பத்தேர்வுகள் உரையாடலைத் திற + களஞ்சியம் + நிலைபடுத்திய மாற்றங்களை உறுதிமொழி + நிலைபடுத்திய மாற்றங்களை உறுதிமொழி மற்றும் தள்ளு + அனைத்து மாற்றங்களையும் நிலைபடுத்தி உறுதிமொழி + எடு, நேரடியாகத் தொடங்குகிறது + முகப்பலகை பயன்முறை (இயல்புநிலை) + உறுதிமொழி தேடல் பயன்முறை + இழு, நேரடியாகத் தொடங்குகிறது + தள்ளு, நேரடியாகத் தொடங்குகிறது + இந்த களஞ்சியத்தை மீண்டும் ஏற்ற கட்டாயப்படுத்து + 'மாற்றங்கள்' என்பதற்கு மாறு + 'வரலாறுகள்' என்பதற்கு மாறு + 'பதுகிவைத்தவை' என்பதற்கு மாறு + நிராகரி + நிலைபடுத்து + நிலைநீக்கு + களஞ்சியத்தைத் துவக்கு + பாதை: + கனி-பறி செயல்பாட்டில் உள்ளது. + உறுதிமொழி செயலாக்குதல் + இணைத்தல் செயல்பாட்டில் உள்ளது. + இணைத்தல் + மறுதளம் செயல்பாட்டில் உள்ளது + இல் நிறுத்தப்பட்டது + திரும்ப்பெறும் செயல்பாட்டில் உள்ளது. + திரும்பபெறும் உறுதிமொழி + ஊடாடும் மறுதளம் + உள்ளக மாற்றங்களை பதுக்கிவை & மீண்டும் இடு + மேல்: + இலக்கு கிளை: + இணைப்பை நகலெடு + உலாவியில் திற + பிழை + அறிவிப்பு + கிளையை ஒன்றிணை + Into: + இணைப்பு விருப்பம்: + இதனுள்: + ஒன்றிணை (பல) + அனைத்து மாற்றங்களையும் உறுதிமொழி + சூழ்ச்சிமுறை: + இலக்குகள்: + களஞ்சிய முனையை நகர்த்து + இதற்கான பெற்றோர் முனையைத் தேர்ந்தெடு + பெயர்: + அறிவிலி உள்ளமைக்கப்படவில்லை. [விருப்பத்தேர்வுகள்]க்குச் சென்று முதலில் அதை உள்ளமை. + தரவு சேமிப்பக கோப்பகத்தைத் திற + ஒன்றிணை கருவியில் திற + விருப்பத்தேர்வு. + புதிய பக்கத்தை உருவாக்கு + மூடு தாவல் + பிற தாவல்களை மூடு + வலதுபுறத்தில் உள்ள தாவல்களை மூடு + களஞ்சிய பாதை நகலெடு + திருத்து + களஞ்சியங்கள் + ஒட்டு + {0} நாட்களுக்கு முன்பு + 1 மணி நேரத்திற்கு முன்பு + {0} மணி நேரத்திற்கு முன்பு + சற்றுமுன் + கடந்த திங்கள் + கடந்த ஆண்டு + {0} நிமையங்களுக்கு முன்பு + {0} திங்களுக்கு முன்பு + {0} ஆண்டுகளுக்கு முன்பு + நேற்று + விருப்பத்தேர்வுகள் + செநு + பநிஇ திறவுகோல் + பெயர் + சேவையகம் + தோற்றம் + இயல்புநிலை எழுத்துரு + திருத்தி தாவல் அகலம் + எழுத்துரு அளவு + இயல்புநிலை + திருத்தி + ஒற்றைவெளி எழுத்துரு + கருப்பொருள் + கருப்பொருள் மேலெழுதப்படுகிறது + தலைப்புப்பட்டியில் நிலையான தாவல் அகலத்தைப் பயன்படுத்து + சொந்த சாளர சட்டத்தைப் பயன்படுத்து + வேறு/ஒன்றிணை கருவி + நிறுவல் பாதை + வேறு/ஒன்றிணை கருவிக்கான பாதை உள்ளிடு + கருவி + பொது + தொடக்கத்தில் புதுப்பிப்புகளைச் சரிபார் + தேதி வடிவம் + மொழி + வரலாற்று உறுதிமொழிகள் + உறுதிமொழி வரைபடத்தில் குறிச்சொற்களைக் காட்டு + பொருள் வழிகாட்டி நீளம் + அறிவிலி + தானியங்கி வரிமுடிவை இயக்கு + இயல்புநிலை நகலி அடைவு + பயனர் மின்னஞ்சல் + உலகளாவிய அறிவிலி பயனர் மின்னஞ்சல் + --prune எடுக்கும்போது இயக்கு + அறிவிலி (>= 2.25.1) இந்த பயன்பாட்டிற்கு தேவைப்படுகிறது + நிறுவல் பாதை + உஉபநெ பாகுஅ சரிபார்ப்பை இயக்கு + பயனர் பெயர் + உலகளாவிய அறிவிலி பயனர் பெயர் + அறிவிலி பதிப்பு + சிபிசி கையொப்பமிடுதல் + சிபிசி கையொப்பமிடுதல் உறுதிமொழி + சிபிசி வடிவம் + நிரல் நிறுவல் பாதை + நிறுவப்பட்ட சிபிசி நிரலுக்கான உள்ளீட்டு பாதை + சிபிசி கையொப்பமிடுதலை குறிச்சொலிடு + பயனர் கையொப்பமிடும் திறவுகோல் + பயனரின் கையொப்பமிடும் திறவுகோல் + ஒருங்கிணைப்பு + ஓடு/முனையம் + பாதை + ஓடு/முனையம் + தொலை கத்தரி + இலக்கு: + பணிமரங்கள் கத்தரி + `$GIT_COMMON_DIR/பணிமரங்கள்` இதில் பணிமரம் தகவலை கத்தரி + இழு + தொலை கிளை: + இதனுள்: + உள்ளக மாற்றங்கள்: + தொலை: + இழு (எடுத்து ஒன்றிணை) + ஒன்றிணை என்பதற்குப் பதிலாக மறுதளத்தைப் பயன்படுத்து + தள்ளு + துணைத் தொகுதிகள் தள்ளப்பட்டது என்பதை உறுதிசெய் + கட்டாயமாக தள்ளு + உள்ளக கிளை: + தொலை: + மாற்றங்களை தொலைக்கு தள்ளு + தொலை கிளை: + கண்காணிப்பு கிளையாக அமை + அனைத்து குறிச்சொற்களையும் தள்ளு + தொலைக்கு குறிச்சொல்லை தள்ளு + அனைத்து தொலைகளுக்கும் தள்ளு + தொலை: + குறிச்சொல்: + வெளியேறு + தற்போதைய கிளையை மறுதளம் செய் + உள்ளக மாற்றங்களை பதுக்கிவை & மீண்டும் இடு + மேல்: + தொலையைச் சேர் + தொலையைத் திருத்து + பெயர்: + களஞ்சிய பெயர் + களஞ்சிய முகவரி: + தொலை அறிவிலி களஞ்சிய முகவரி: + முகவரியை நகலெடு + நீக்கு... + திருத்து... + பெறு + உலாவியில் திற + கத்தரித்தல் + பணிமரத்தை அகற்றுவதை உறுதிப்படுத்து + `--கட்டாயம்` விருப்பத்தை இயக்கு + இலக்கு: + கிளையை மறுபெயரிடு + புதிய பெயர்: + இந்தக் கிளைக்கான தனித்துவமான பெயர் + கிளை: + நிறுத்து + தொலைகளிலிருந்து மாற்றங்களைத் தானாகப் பெறுதல்... + சுத்தப்படுத்தல்(சீசி & கத்தரித்தல்) + இந்த களஞ்சியத்திற்கு `அறிவிலி சீசி` கட்டளையை இயக்கு. + அனைத்தையும் அழி + இந்த களஞ்சியத்தை உள்ளமை + தொடர்க + தனிப்பயன் செயல்கள் + தனிப்பயன் செயல்கள் இல்லை + எல்லா மாற்றங்களையும் நிராகரி + கோப்பு உலாவியில் திற + கிளைகள்/குறிச்சொற்கள்/துணைத் தொகுதிகளைத் தேடு + வரைபடத்தில் தெரிவுநிலை + அமைவை நீக்கு + உறுதிமொழி வரைபடத்தில் மறை + உறுதிமொழி வரைபடத்தில் வடிகட்டு + தளவமைப்பு + கிடைமட்டம் + செங்குத்து + உறுதிமொழி வரிசை + உறுதிமொழி தேதி + இடவியல் மூலமாக + உள்ளக கிளைகள் + தலைக்கு செல் + கிளையை உருவாக்கு + அறிவிப்புகளை அழி + {0} இல் திற + வெளிப்புற கருவிகளில் திற + தொலைகள் + தொலையைச் சேர் + உறுதிமொழி தேடு + ஆசிரியர் + செய்தி + பாகொவ + தற்போதைய கிளை + குறிச்சொற்களை மரமாகக் காட்டு + தவிர் + புள்ளிவிவரங்கள் + துணைத் தொகுதிகள் + துணைத் தொகுதியைச் சேர் + துணைத் தொகுதியைப் புதுப்பி + குறிசொற்கள் + புதிய குறிசொல் + படைப்பாளர் தேதியின்படி + பெயர் மூலம் + வரிசைப்படுத்து + முனையத்தில் திற + பணிமரங்கள் + பணிமரத்தைச் சேர் + கத்தரித்தல் + அறிவிலி களஞ்சிய முகவரி + தற்போதைய கிளையை திருத்தத்திற்கு மீட்டமை + மீட்டமை பயன்முறை: + இதற்கு நகர்த்து: + தற்போதைய கிளை: + கோப்பு உலாவியில் வெளிப்படுத்து + பின்வாங்கு உறுதிமொழி + உறுதிமொழி: + பின்வாங்கு மாற்றங்களை உறுதிமொழி + இயங்குகிறது. காத்திருக்கவும்... + சேமி + எனச் சேமி... + ஒட்டு வெற்றிகரமாக சேமிக்கப்பட்டது! + களஞ்சியங்களை வருடு + வேர் அடைவு: + புதுப்பிப்புகளைச் சரிபார்... + இந்த மென்பொருளின் புதிய பதிப்பு கிடைக்கிறது: + புதுப்பிப்புகளைச் சரிபார்க்க முடியவில்லை! + பதிவிறக்கம் + இந்தப் பதிப்பைத் தவிர் + மென்பொருள் புதுப்பி + தற்போது புதுப்பிப்புகள் எதுவும் கிடைக்கவில்லை. + கண்காணிப்பு கிளையை அமை + கிளை: + மேல்ஓடையை நீக்கு + மேல்ஓடை: + SHA ஐ நகலெடு + இதற்கு செல் + பாஓடு தனியார் திறவுகோல்: + தனியார் பாஓடு திறவுகோல் கடை பாதை + தொடங்கு + பதுக்கிவை + கண்காணிக்கப்படாத கோப்புகளைச் சேர் + செய்தி: + விருப்பத்தேர்வு. இந்த பதுக்கலின் பெயர் + நிலைப்படுத்தப்பட்ட மாற்றங்கள் மட்டும் + தேர்ந்தெடுக்கப்பட்ட கோப்புகளின் நிலைப்படுத்தப்பட்ட மற்றும் நிலைப்படுத்தப்படாத மாற்றங்கள் இரண்டும் பதுக்கிவைக்கப்படும்!!! + உள்ளக மாற்றங்களை பதுக்கிவை + இடு + கைவிடு + ஒட்டாகச் சேமி... + பதுக்கிவைத்தவை கைவிடு + கைவிடு: + பதுக்கிவைத்தவைகள் + மாற்றங்கள் + பதுக்கிவைத்தவைகள் + புள்ளிவிவரங்கள் + மேலோட்டப் பார்வை + திங்கள் + வாரம் + ஆசிரியர்கள்: + உறுதிமொழிகள்: + துணைத் தொகுதி + துணைத் தொகுதியைச் சேர் + உறவு பாதை + உள்ளமைக்கப்பட்ட துணைத் தொகுதிகளை எடு + துணைத் தொகுதி களஞ்சியத்தைத் திற + உறவு பாதை: + இந்த தொகுதியை சேமிப்பதற்கான தொடர்புடைய கோப்புறை. + துணை தொகுதியை நீக்கு + சரி + நீக்கு ${0}$... + தள்ளு ${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 000000000..9cad040c7 --- /dev/null +++ b/src/Resources/Locales/uk_UA.axaml @@ -0,0 +1,672 @@ + + + + + + Про програму + Про SourceGit + Безкоштовний Git GUI клієнт з відкритим кодом + Додати робоче дерево + Розташування: + Шлях для цього робочого дерева. Відносний шлях підтримується. + Назва гілки: + Необов'язково. За замовчуванням — назва кінцевої папки. + Відстежувати гілку: + Відстежувати віддалену гілку + Що перемкнути: + Створити нову гілку + Наявна гілка + AI Асистент + Модель + ПЕРЕГЕНЕРУВАТИ + Використати AI для генерації повідомлення коміту + Виберіть файл .patch для застосування + Ігнорувати зміни пробілів + Застосувати Патч + Пробіли: + Застосувати схованку + Видалити після застосування + Відновити зміни індексу + Схованка: + Архівувати... + Зберегти архів у: + Виберіть шлях до файлу архіву + Ревізія: + Архівувати + SourceGit Askpass + ФАЙЛИ, ЩО ВВАЖАЮТЬСЯ НЕЗМІНЕНИМИ + НЕМАЄ ФАЙЛІВ, ЩО ВВАЖАЮТЬСЯ НЕЗМІНЕНИМИ + Оновити + БІНАРНИЙ ФАЙЛ НЕ ПІДТРИМУЄТЬСЯ!!! + Автор рядка + ПОШУК АВТОРА РЯДКА ДЛЯ ЦЬОГО ФАЙЛУ НЕ ПІДТРИМУЄТЬСЯ!!! + Перейти на ${0}$... + Копіювати назву гілки + Спеціальна дія + Видалити ${0}$... + Видалити вибрані {0} гілок + Перемотати до ${0}$ + Отримати ${0}$ в ${1}$... + Git Flow - Завершити ${0}$ + Злиття ${0}$ в ${1}$... + Злити вибрані {0} гілок в поточну + Витягти ${0}$ + Витягти ${0}$ в ${1}$... + Надіслати ${0}$ + Перебазувати ${0}$ на ${1}$... + Перейменувати ${0}$... + Встановити відстежувану гілку... + СКАСУВАТИ + Скинути до батьківської ревізії + Скинути до цієї ревізії + Згенерувати повідомлення коміту + ЗМІНИТИ РЕЖИМ ВІДОБРАЖЕННЯ + Показати як список файлів та тек + Показати як список шляхів + Показати як дерево файлової системи + Перейти на гілку + Локальні зміни: + Гілка: + Cherry-pick + Додати джерело до повідомлення коміту + Коміт(и): + Закомітити всі зміни + Батьківський коміт: + Зазвичай неможливо cherry-pick злиття, бо невідомо, яку сторону злиття вважати батьківською (mainline). Ця опція дозволяє відтворити зміни відносно вказаного батьківського коміту. + Очистити схованки + Ви намагаєтеся очистити всі схованки. Ви впевнені? + Клонувати віддалене сховище + Додаткові параметри: + Додаткові аргументи для клонування сховища. Необов'язково. + Локальна назва: + Назва сховища. Необов'язково. + Батьківська тека: + Ініціалізувати та оновити підмодулі + URL сховища: + ЗАКРИТИ + Редактор + Перейти на коміт + Cherry-pick коміт + Cherry-pick ... + Порівняти з HEAD + Порівняти з робочим деревом + SHA + Спеціальна дія + Злити ... + Скасувати коміт + Зберегти як патч... + ЗМІНИ + Пошук змін... + ФАЙЛИ + LFS Файл + Пошук файлів... + Підмодуль + ІНФОРМАЦІЯ + АВТОР + КОМІТЕР + Перевірити посилання, що містять цей коміт + КОМІТ МІСТИТЬСЯ В + Показано лише перші 100 змін. Дивіться всі зміни на вкладці ЗМІНИ. + ПОВІДОМЛЕННЯ + БАТЬКІВСЬКІ + ПОСИЛАННЯ (Refs) + 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 для запуску без діалогу + Вирізати + Скасувати + Сховати та Застосувати + Видалити гілку + Гілка: + Ви збираєтеся видалити віддалену гілку!!! + Видалити кілька гілок + Ви намагаєтеся видалити кілька гілок одночасно. Перевірте ще раз перед виконанням! + Видалити віддалене сховище + Віддалене сховище: + Шлях: + Ціль: + Усі дочірні елементи будуть видалені зі списку. + Це видалить сховище лише зі списку, а не з диска! + Підтвердити видалення групи + Підтвердити видалення сховища + Видалити підмодуль + Шлях до підмодуля: + Видалити тег + Тег: + Видалити з віддалених сховищ + РІЗНИЦЯ ДЛЯ БІНАРНИХ ФАЙЛІВ + Перша відмінність + Ігнорувати зміни пробілів + Остання відмінність + ЗМІНА ОБ'ЄКТА 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` для цього сховища. + Очистити все + Налаштувати це сховище + ПРОДОВЖИТИ + Спеціальні дії + Немає спеціальних дій + Скасувати всі зміни + Відкрити у файловому менеджері + Пошук гілок/тегів/підмодулів + Видимість у графі + Не встановлено + Приховати в графі комітів + Фільтрувати в графі комітів + РОЗТАШУВАННЯ + Горизонтальне + Вертикальне + ПОРЯДОК КОМІТІВ + За датою коміту + Топологічний + ЛОКАЛЬНІ ГІЛКИ + Перейти до HEAD + Створити гілку + ОЧИСТИТИ СПОВІЩЕННЯ + Відкрити в {0} + Відкрити в зовнішніх інструментах + ВІДДАЛЕНІ СХОВИЩА + ДОДАТИ ВІДДАЛЕНЕ СХОВИЩЕ + Пошук коміту + Автор + Повідомлення + SHA + Поточна гілка + Показати теги як дерево + ПРОПУСТИТИ + Статистика + ПІДМОДУЛІ + ДОДАТИ ПІДМОДУЛЬ + ОНОВИТИ ПІДМОДУЛЬ + ТЕГИ + НОВИЙ ТЕГ + За датою створення + За назвою + Сортувати + Відкрити в терміналі + РОБОЧІ ДЕРЕВА + ДОДАТИ РОБОЧЕ ДЕРЕВО + PRUNE (ОЧИСТИТИ) + URL Git сховища + Скинути поточну гілку до ревізії + Режим скидання: + Перемістити до: + Поточна гілка: + Показати у файловому менеджері + Revert (Скасувати коміт) + Коміт: + Закомітити зміни скасування + Виконується. Будь ласка, зачекайте... + ЗБЕРЕГТИ + Зберегти як... + Патч успішно збережено! + Сканувати сховища + Коренева тека: + Перевірити оновлення... + Доступна нова версія програми: + Не вдалося перевірити оновлення! + Завантажити + Пропустити цю версію + Оновлення програми + У вас встановлена остання версія. + Встановити відстежувану гілку + Гілка: + Скасувати upstream + Upstream: + Копіювати SHA + Перейти до + Приватний ключ SSH: + Шлях до сховища приватного ключа SSH + ПОЧАТИ + Stash (Сховати) + Включити невідстежувані файли + Повідомлення: + Необов'язково. Назва цієї схованки + Лише проіндексовані зміни + Будуть сховані як проіндексовані, так і не проіндексовані зміни вибраних файлів!!! + Сховати локальні зміни + Застосувати + Видалити + Зберегти як патч... + Видалити схованку + Видалити: + СХОВАНКИ + ЗМІНИ + СХОВАНКИ + Статистика + ОГЛЯД + МІСЯЦЬ + ТИЖДЕНЬ + АВТОРІВ: + КОМІТІВ: + ПІДМОДУЛІ + Додати підмодуль + відносний шлях + Отримати вкладені підмодулі + Відкрити сховище підмодуля + Відносний шлях: + Відносна тека для зберігання цього модуля. + Видалити підмодуль + OK + Видалити ${0}$... + Надіслати ${0}$... + Оновити підмодулі + Усі підмодулі + Ініціалізувати за потреби + Підмодуль: + 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 index 3deb6206a..d77cd5418 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -1,476 +1,1043 @@ - - 开 始 - 确 定 - 保 存 - 关闭 - 取 消 - 在文件浏览器中查看 - 另存为... - 复制路径 - 字节 - 过滤 - 选填 - 选择文件夹 - 系统提示 - 打开文件... - 执行操作中,请耐心等待... - 警告 - - 复制 - 粘贴 - 剪切 - - 重新加载 - - 名称 : - 仓库地址 : - 远程仓库地址 - - SSH密钥 : - SSH密钥文件 - - 关于软件 - Copyright © 2024 sourcegit-scm. - • 使用的框架为 - • 文本编辑器使用 - • 等宽字体来自于 - - 补丁 - 应用补丁 - 补丁文件 : - 选择补丁文件 - 空白符号处理 : - 忽略空白符号 - 忽略 - 关闭所有警告 - 警告 - 应用补丁,输出关于空白符的警告 - 错误 - 输出错误,并终止应用补丁 - 更多错误 - 与【错误】级别相似,但输出内容更多 - - 存档 ... - 存档 - 指定的提交: - 存档文件路径: - 选择存档文件的存放路径 - - 逐行追溯 - - 子模块 - 添加子模块 - 相对仓库路径 : - 本地存放的相对路径 - 拉取子孙模块 - 打开仓库 - 复制路径 - 删除子模块 - - 检出分支 - 目标分支 : - - 挑选此提交 - 挑选提交 - 提交ID : - 提交变化 - - 克隆远程仓库 - 远程仓库 : - 父级目录 : - 本地仓库名 : - 本地仓库目录的名字,选填 - 额外参数 : - 其他克隆参数,选填 - - 基本信息 - 修改者 - 提交者 - 提交指纹 - 父提交 - 相关引用 - 提交信息 - 变更列表 - 变更对比 - 查找文件... - 文件列表 - LFS文件 - 子模块 - 标签文件 - 子树 - - 仓库配置 - 用户名 - 应用于本仓库的用户名 - 电子邮箱 - 邮箱地址 - HTTP代理 - HTTP网络代理 - - 新建分支 - 创建本地分支 - 新分支基于 : - 新分支名 : - 填写分支名称 - 未提交更改 : - 贮藏并自动恢复 - 忽略 - 完成后切换到新分支 - - 新建标签 - 标签位于 : - 标签名 : - 推荐格式 :v1.0.0-alpha - 标签描述 : - 选填 - - 在文件浏览器中打开 - 在Visual Studio Code中打开 - 在GIT终端中打开 - 重新加载 - 查找提交(Ctrl+F) - 支持搜索作者/提交者/主题/指纹 - 提交统计 - 清理本仓库(GC) - 本操作将执行`gc`,对于启用LFS的仓库也会执行`lfs prune` - 配置本仓库 - 工作区 - 本地分支 - 新建分支 - 远程列表 - 添加远程 - 标签列表 - 新建标签 - 子模块列表 - 添加子模块 - 更新子模块 - 解决冲突 - 下一步 - 终止冲突解决 - - GIT工作流 - 初始化GIT工作流 - 发布分支 : - 开发分支 : - 特性分支 : - 版本分支 : - 修复分支 : - 特性分支名前缀 : - 版本分支名前缀 : - 修复分支名前缀 : - 版本标签前缀 : - 开始特性分支... - 开始版本分支... - 开始修复分支... - 开始特性分支 - 开始版本分支 - 开始修复分支 - 输入分支名 - 结束特性分支 - 结束版本分支 - 结束修复分支 - 目标分支 : - 保留分支 - - 书签 - 打开 - 在浏览器中查看 - - 推送${0}$ - 放弃所有更改 - 快进到${0}$ - 拉回${0}$ - 拉回${0}$内容至${1}$ - 检出${0}$ - 合并${0}$到${1}$ - 变基${0}$分支至${1}$ - GIT工作流 - 完成${0}$ - 重命名${0}$ - 删除${0}$ - 切换上游分支... - 复制分支名 - 取消追踪 - - 拉取更新 ... - 清理远程已删除分支 - 目标 : - 编辑 ... - 删除 ... - 复制远程地址 - - 重置${0}$到此处 - 变基${0}$到此处 - 挑选此提交 - 编辑提交信息 - 合并此提交到上一个提交 - 回滚此提交 - 另存为补丁 ... - 复制提交指纹 - - 推送${0}$ - 删除${0}$ - 复制标签名 - - 应用 - 应用并删除 - 删除 - - 从暂存中移除 - 暂存... - 放弃更改... - 贮藏... - 从暂存中移除 {0} 个文件 - 暂存 {0} 个文件... - 放弃 {0} 个文件的更改... - 贮藏选中的 {0} 个文件... - 另存为补丁... - 不跟踪此文件的更改 - 暂存选中的更改 - 放弃选中的更改 - 从暂存中移除选中的更改 - - 确定要删除此分支吗? - 分支名 : - - 确定要移除该远程吗? - 远程名 : - - 确定要移除该标签吗? - 标签名 : - 同时删除远程仓库中的此标签 - - 确定要移除该子模块吗? - 子模块路径 : - - 下一个差异 - 上一个差异 - 切换显示模式 - 使用外部合并工具查看 - 请选择需要对比的文件 - 没有变更或仅有换行符差异 - 二进制文件 - 原始大小 - 当前大小 - LFS对象变更 - 复制 - - 放弃更改确认 - 需要放弃的变更 : - 本操作不支持回退,请确认后继续!!! - 所有本地址未提交的修改 - 总计{0}项选中更改 - - 拉取 - 拉取远程仓库内容 - 远程仓库 : - 拉取所有的远程仓库 - 自动清理远程已删除分支 - - 快进(无需Checkout) - - 文件历史 - - 切换变更显示模式 - 网格模式 - 列表模式 - 树形模式 - - 历史记录 - 查询提交指纹、信息、作者。回车键开始,ESC键取消 - 清空 - 切换曲线/折线显示 - 切换横向/纵向显示 - 已选中 {0} 项提交 - - 初始化新仓库 - 路径 : - 点击【确定】将在此目录执行`git init`操作 - - Source Git - 主菜单 - 出错了 - 系统提示 - - 新建空白页 (Ctrl+T) - 新标签页 - 关闭标签页 (Ctrl+W) - 关闭其他标签页 - 关闭右侧标签页 - 设置书签 - 复制仓库路径 - - 合并分支 - 合并分支 : - 目标分支 : - 合并方式 : - - 打开本地仓库 - 打开GIT终端 - 克隆远程仓库 - 编辑 - 新建分组 - 新建子分组 - 删除 - 快速查找仓库 - 排序 - 支持拖放目录添加 - - 编辑分组 - 编辑仓库 - 目标 : - 名称 : - 书签 : - - 删除分组确认 - 删除仓库确认 - 目标 : - - 拉回 - 拉回(拉取并合并) - 远程 : - 拉取分支 : - 本地分支 : - 使用变基方式合并分支 - 自动贮藏并恢复本地变更 - - 推送 - 推送到远程仓库 - 本地分支 : - 远程仓库 : - 远程分支 : - 同时推送标签 - 启用强制推送 - - 推送标签到远程仓库 - 标签 : - 远程仓库 : - - 变基操作 - 分支 : - 目标提交 : - 自动贮藏并恢复本地变更 - - 添加远程仓库 - 编辑远程仓库 - 远程名 : - 唯一远程名 - 仓库地址 : - 远程仓库的地址 - - 分支重命名 - 分支 : - 新的名称 : - 新的分支名不能与现有分支名相同 - - 重置当前分支到指定版本 - 当前分支 : - 提交 : - 重置模式 : - - 确定要回滚吗? - 目标提交 : - 回滚后提交更改 - - 偏好设置 - 通用配置 - 显示语言 - 头像服务 - 主题 - 最大历史提交数 - 启动时恢复上次打开的仓库 - 使用固定宽度的标题栏标签 - 使用macOS风格的标题栏 - GIT配置 - 安装路径 - Git 版本 - 默认克隆路径 - 用户名 - 默认GIT用户名 - 邮箱 - 默认GIT用户邮箱 - 自动换行转换 - 启用定时自动拉取远程更新 - GPG签名 - 启用提交签名 - 可执行文件位置 - gpg.exe所在路径 - 用户签名KEY - 输入签名提交所使用的KEY - 外部合并工具 - 工具 - 安装路径 - 填写工具可执行文件所在位置 - 合并模式启动参数 - 对比模式启动参数 - - 贮藏 - 贮藏本地变更 - 信息 : - 选填,用于命名此贮藏 - 包含未跟踪的文件 - - 贮藏列表 - 贮藏列表 - 查看变更 - - 丢弃贮藏确认 - 丢弃贮藏 : - - 对比提交 : {0} -> {1} - - 本地更改 - 未暂存 - 查看忽略变更文件 - 暂存选中 - 暂存所有 - 已暂存 - 从暂存区移除选中 - 从暂存区移除所有 - 检测到冲突 - 使用THEIRS - 使用MINE - 打开合并工具 - 填写提交信息 - 历史提交信息 - 修补 - 提交 - CTRL + Enter - 提交并推送 - 没有提交信息记录 - 最近输入的提交信息 - 显示未跟踪文件 - - 检测到本地冲突! - - 丢弃贮藏确认 - 您正在丢弃所有的贮藏,一经操作,无法回退,是否继续? - - 编辑提交信息 - 提交: - 提交信息: - - 合并HEAD到上一个提交 - 当前提交 : - 合并到 : - 修改提交信息: - - 不跟踪更改的文件 - 移除 - 没有不跟踪更改的文件 - - 提交统计 - 本周 - 本月 - 本年 - 提交者人数 - 总计提交次数 - 提交者 - 提交次数 - - GIT尚未配置。请打开【偏好设置】配置GIT路径。 - 二进制文件不支持该操作!!! - 选中文件不支持该操作!!! - 补丁已成功保存! + + + + + + 关于软件 + 关于本软件 + 发布日期:{0} + 浏览版本更新说明 + 开源免费的Git客户端 + 新增忽略文件 + 匹配模式 : + 保存位置 : + 新增工作树 + 工作树路径 : + 填写该工作树的路径。支持相对路径。 + 分支名 : + 选填。默认使用目标文件夹名称。 + 跟踪分支 + 设置上游跟踪分支 + 检出分支方式 : + 创建新分支 + 已有分支 + AI助手 + 模型 + 重新生成 + 使用AI助手生成提交信息 + 应用 + 隐藏 SourceGit + 隐藏其他 + 显示全部 + 尝试三路合并 + 选择补丁文件 + 忽略空白符号 + 补丁来源 : + 文件 + 剪贴板 + 应用补丁 + 空白符号处理 : + 应用贮藏 + 在成功应用后丢弃该贮藏 + 恢复索引中已暂存的变化 + 已选贮藏 : + 存档(archive)... + 存档文件路径: + 选择存档文件的存放路径 + 指定的提交: + 存档 + SourceGit Askpass + 请输入密码 : + 不跟踪更改的文件 + 没有不跟踪更改的文件 + 加载本地图片 + 重新加载 + 二进制文件不支持该操作!!! + 二分定位(bisect) + 终止 + 标记错误 + 标记正确 + 无法判定 + 二分定位进行中。请检出另一个提交并标记其是【正确】还是【错误】 + 二分定位进行中。请标记当前提交为【错误】,或检出另一个提交并标记为【错误】。 + 二分定位进行中。请标记当前的提交是【正确】还是【错误】,然后检出另一个提交。 + 二分定位进行中。当前提交是【正确】还是【错误】? + 逐行追溯(blame) + 对当前版本的前一版本执行逐行追溯操作 + 忽略空白符变化 + 选中文件不支持该操作!!! + 检出(checkout) ${0}$... + 比较选中的 2 个分支 + 与其他分支或标签比较... + 与当前 HEAD 比较 + 与 ${0}$ 比较 + 复制分支名 + 创建合并请求... + 为上游分支 ${0}$ 创建合并请求... + 自定义操作 + 删除 ${0}$... + 删除选中的 {0} 个分支... + 编辑 ${0}$ 的描述... + 快进(fast-forward) 到 ${0}$ + 拉取(fetch) ${0}$ 至 ${1}$... + GIT工作流 - 完成 ${0}$... + 交互式变基 ${0}$ 到 ${1}$ + 合并(merge) ${0}$ 到 ${1}$... + 合并(merge) {0} 个分支到当前分支... + 拉回(pull) ${0}$... + 拉回(pull) ${0}$ 内容至 ${1}$... + 推送(push) ${0}$... + 变基(rebase) ${0}$ 至 ${1}$... + 重命名 ${0}$... + 重置(reset) ${0}$ 到 ${1}$... + 切换到 ${0}$ (工作树) + 切换上游分支... + 领先 {0} 个提交 + 领先 {0} 个提交,落后 {1} 个提交 + 落后 {0} 个提交 + 不存在 + 远程 + 状态 + 上游分支 + 远程地址 + 工作树 + 取 消 + 重置文件到上一版本 + 重置文件到该版本 + 生成提交信息 + 解决冲突(内部工具) + 解决冲突(外部工具) + 重置文件到 ${0}$ + 切换变更显示模式 + 文件名+路径列表模式 + 全路径列表模式 + 文件目录树形结构模式 + 修改子模块远程地址 + 子模块 : + 远程地址 : + 检出(checkout)分支 + 未提交更改 : + 目标分支 : + 您当前游离的HEAD包含未被任何分支及标签引用的提交!是否继续? + 以下子模块需要更新:{0}是否立即更新? + 检出分支并快进 + 上游分支 : + 从所选贮藏检出分支 + 新分支 : + 所选贮藏 : + 检出提交或标签 + 目标 : + 注意:执行该操作后,当前HEAD会变为游离(detached)状态! + 挑选提交 + 提交信息中追加来源信息 + 提交列表 : + 提交变化 + 对比的父提交 : + 通常你不能对一个合并进行挑选,因为你不知道合并的哪一边应该被视为主线。这个选项指定了作为主线的父提交,允许挑选相对于该提交的修改。 + 丢弃贮藏确认 + 您正在丢弃所有的贮藏,一经操作,无法回退,是否继续? + 克隆远程仓库 + 额外参数 : + 其他克隆参数,选填。 + 书签 : + 自定义分组 : + 本地仓库名 : + 本地仓库目录的名字,选填。 + 父级目录 : + 初始化并更新子模块 + 远程仓库 : + 关闭 + 提交信息编辑器 + 分支列表 + 分支 & 标签 + 自定义操作列表 + 文件列表 + 检出此提交... + 挑选(cherry-pick)此提交... + 挑选(cherry-pick)... + 与当前HEAD比较 + 与本地工作树比较 + 作者 + 修改时间 + 提交信息 + 提交者 + 提交时间 + 提交指纹 + 主题 + 自定义操作 + 交互式变基(rebase -i) + 丢弃... + 编辑... + 修复至父提交... + 交互式变基 ${0}$ 到 ${1}$ + 修改提交信息... + 合并至父提交... + 合并(merge)... + 推送(push) ${0}$ 到 ${1}$... + 变基(rebase) ${0}$ 到 ${1}$... + 重置(reset) ${0}$ 到 ${1}$... + 回滚此提交... + 另存为补丁... + 变更对比 + 个文件发生变更 + 查找变更... + 折叠详细面板 + 文件列表 + LFS文件 + 查找文件... + 子模块 + 基本信息 + 修改者 + 提交者 + 查看包含此提交的分支/标签 + 本提交已被以下分支/标签包含 + 复制电子邮箱 + 复制用户名 + 复制用户名及邮箱 + 仅显示前100项变更。请前往【变更对比】页面查看全部。 + 签名密钥 : + 提交信息 + 父提交 + 相关引用 + 提交指纹 + 签名者 : + 浏览器中查看 + + 请输入提交的信息。注意:主题与具体描述中间需要空白行分隔! + 主题 + 比较 + 差异文件 + 差异提交 + 左侧独有提交 + 右侧独有提交 + 提示:如果比较的另一方是 HEAD,您可以挑选已选中的提交(使用右键菜单) + 仓库配置 + 提交信息模板 + 内置变量: + + ${branch_name} 当前分支名 + ${files_num} 变更文件数量 + ${files} 变更文件路径列表 + ${files:N} 变更文件路径列表(仅输出指定 N 条) + ${pure_files} 与 ${files} 类似,但仅输出文件名 + ${pure_files:N} 与 ${files:N} 类似,但仅输出文件名 + 模板内容 : + 模板名 : + 自定义操作 + 命令行参数 : + 内置变量: + + ${REPO} 仓库路径 + ${REMOTE} 选中的远程仓库或选中分支所属的远程仓库 + ${BRANCH} 选中的分支,对于远程分支不包含远程名 + ${BRANCH_FRIENDLY_NAME} 选中的分支,对于远程分支包含远程名 + ${SHA} 选中的提交哈希 + ${TAG} 选中的标签 + ${FILE} 选中的文件 + $1, $2 ... 输入控件中填写的值 + 可执行文件路径 : + 输入控件 : + 编辑 + 名称 : + 作用目标 : + 选中的分支 + 选中的提交 + 选中的文件 + 远程仓库 + 仓库 + 选中的标签 + 等待操作执行完成 + 电子邮箱 + 邮箱地址 + GIT配置 + 在自动更新子模块前询问用户 + 启用定时自动拉取远程更新 + 分钟 + 自定义规范化提交类型 + 默认远程 + 自动更新子模块时启用 '--recursive' 选项 + 默认合并方式 + ISSUE追踪 + 新增匹配Azure DevOps规则 + 新增匹配Gerrit Change-Id规则 + 新增匹配Gitee议题规则 + 新增匹配Gitee合并请求规则 + 新增匹配GitHub Issue规则 + 新增匹配GitLab议题规则 + 新增匹配GitLab合并请求规则 + 新增匹配Jira规则 + 新增自定义规则 + 匹配ISSUE的正则表达式 : + 规则名 : + 写入 .issuetracker 文件共享此规则 + 为ISSUE生成的URL链接 : + 可在URL中使用$1,$2等变量填入正则表达式匹配的内容 + AI + 启用特定服务 : + 当【启用特定服务】被设置时,SourceGit将在本仓库中仅使用该服务。否则将弹出可用的AI服务列表供用户选择。 + HTTP代理 + HTTP网络代理 + 用户名 + 应用于本仓库的用户名 + 编辑自定义操作输入控件 + 启用时命令行参数 : + 此CheckBox勾选后,该值会被应用于命令行参数 + 描述 : + 默认值 : + 目标是否是目录 : + 名称 : + 选项列表 : + 选项之间请使用英文 '|' 作为分隔符 + 输出内容格式化字串: + 可选。用于格式化输出结果。当用户输入为空时忽略该操作。请使用`${VALUE}`代替用户输入。 + 内置变量 ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE} 与 ${TAG} 在这里仍然可用 + 类型 : + 输出结果带有远程名: + 工作区 + 颜色 + 名称 + 启动时恢复打开的仓库 + 确认继续 + 提交未包含变更文件!是否继续(--allow-empty)? + 暂存所有变更并提交 + 仅暂存所选变更并提交 + 提交未包含变更文件!是否继续(--allow-empty)或是自动暂存变更并提交? + 系统提示 + 程序需要重新启动,以便修改生效! + 规范化提交信息生成 + 破坏性更新: + 关闭的ISSUE: + 详细说明: + 模块: + 简述: + 类型: + 复制 + 复制全部文本 + 复制为补丁 + 复制完整路径 + 复制路径 + 新建分支... + 新分支基于 : + 完成后切换到新分支 + 未提交更改 : + 新分支名 : + 填写分支名称。 + 创建本地分支 + 允许重置已存在的分支 + 新建标签... + 标签位于 : + 使用GPG签名 + 标签描述 : + 选填。 + 标签名 : + 推荐格式 :v1.0.0-alpha + 推送到所有远程仓库 + 新建标签 + 类型 : + 附注标签 + 轻量标签 + 按住Ctrl键点击将以默认参数运行 + 剪切 + 丢弃更改 + 不做处理 + 贮藏并自动恢复 + 取消初始化子模块 + 强制取消,即使包含本地变更 + 子模块 : + 删除分支确认 + 是否同时删除以下远程分支? + 分支名 : + 强制删除,即使包含未合并的提交 + 您正在删除远程上的分支,请务必小心!!! + 删除多个分支 + 您正在尝试一次性删除多个分支,请务必仔细检查后再执行操作! + 删除多个标签 + 同时在远程仓库中删除 + 您正在尝试一次性删除多个标签,请务必仔细检查后再执行操作! + 删除远程确认 + 远程名 : + 路径 : + 目标 : + 所有子节点将被同时从列表中移除。 + 仅从列表中移除,不会删除硬盘中的文件! + 删除分组确认 + 删除仓库确认 + 删除子模块确认 + 子模块路径 : + 删除标签确认 + 标签名 : + 同时删除远程仓库中的此标签 + 二进制文件 + 空文件 + 首个差异 + 忽略空白符号变化 + 混合对比 + 差异比较 + 分列对比 + 填充对比 + 最后一个差异 + LFS对象变更 + 变更后 + 下一个差异 + 没有变更或仅有换行符差异 + 变更前 + 上一个差异 + 保存为补丁文件 + 显示隐藏符号 + 分列对比 + 子模块 + 删除 + 新增 + + {0} 项未提交变更 + 交换比对双方 + 语法高亮 + 自动换行 + 使用外部合并工具查看 + 显示完整文件 + 减少可见的行数 + 增加可见的行数 + 请选择需要对比的文件 + 目录内容变更历史 + 未提交的本地变更 + 当前分支HEAD与远端不一致 + 已是最新 + 放弃更改确认 + 所有本仓库未提交的修改。 + 变更 : + 包括所有已忽略的文件 + 包括已修改或删除的文件 + 包括未跟踪的文件 + 总计{0}项选中更改 + 本操作不支持回退,请确认后继续!!! + 编辑分支描述 + 目标 : + 书签 : + 名称 : + 目标 : + 编辑分组 + 编辑仓库 + 目标: + 本仓库 + 拉取(fetch) + 拉取所有的远程仓库 + 强制覆盖本地REFs + 不拉取远程标签 + 远程仓库 : + 拉取远程仓库内容 + 不跟踪此文件的更改 + 自定义操作 + 放弃更改... + 放弃 {0} 个文件的更改... + 应用 ${0}$ + 另存为补丁... + 暂存(add) + 暂存(add){0} 个文件 + 贮藏(stash)... + 贮藏(stash)选中的 {0} 个文件... + 从暂存中移除 + 从暂存中移除 {0} 个文件 + 使用 MINE (checkout --ours) + 使用 THEIRS (checkout --theirs) + 文件历史 + 文件变更 + 文件内容 + 文件类型变更: + 删除: + 目录 + 可执行文件 + 新增文件类型: + 普通文件 + 子模块 + 符号链接 + 未知 + GIT工作流 + 开发分支 : + 特性分支 : + 特性分支名前缀 : + 完成 ${0}$ + 结束特性分支 + 结束修复分支 + 结束版本分支 + 目标分支 : + 合并前执行变基操作 + 压缩变更为单一提交后合并分支 + 修复分支 : + 修复分支名前缀 : + 初始化GIT工作流 + 保留分支 + 发布分支 : + 版本分支 : + 版本分支名前缀 : + 开始于 : + 开始特性分支... + 开始特性分支 + 开始修复分支... + 开始修复分支 + 分支名 : + 输入分支名 + 开始版本分支... + 开始版本分支 + 版本标签前缀 : + Git LFS + 添加追踪文件规则... + 匹配完整文件名 + 规则 : + 添加LFS追踪文件规则 + 拉取LFS对象 (fetch) + 执行`git lfs fetch`命令,下载远程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) 远程变更 + 推送本地变更到远程 + 重新加载仓库状态 + 展开/折叠历史提交中的详情面板 + 显示本地更改 + 显示历史记录 + 显示贮藏列表 + 丢弃 + 暂存 + 移出暂存区 + 初始化新仓库 + 是否在该路径下执行 `git init` 命令(初始化仓库)? + 打开本地仓库失败,原因: + 路径 : + 挑选(Cherry-Pick)操作进行中。 + 正在处理提交 + 合并操作进行中。 + 正在处理 + 变基(Rebase)操作进行中。 + 当前停止于 + 回滚提交操作进行中。 + 正在回滚提交 + 交互式变基 + 自动贮藏并恢复本地变更 + 绕过 pre-rebase 钩子 + 起始提交 : + 拖拽以便对提交重新排序 + 目标分支 : + 复制链接地址 + 在浏览器中访问 + 命令列表 + 出错了 + 系统提示 + 检测到新版本 + 打开其他仓库 + 页面列表 + 工作区列表 + 合并分支 + 编辑合并信息 + 目标分支 : + 合并方式 : + 合并目标 : + 检测合并冲突中... + 合并操作不会产生冲突 + 检测合并冲突时出现未知错误 + 合并操作将存在冲突文件 + 先应用 MINE 后 THEIRS + 先应用 THEIRS 后 MINE + 应用全部 + 所有冲突已解决 + {0} 个冲突未解决 + MINE + 下一个冲突 + 上一个冲突 + 合并结果 + 保存并暂存 + THEIRS + 合并冲突 + 放弃所有更改? + 仅应用 MINE + 仅应用 THEIRS + 撤销更改 + 合并(多目标) + 提交变化 + 合并策略 : + 目标列表 : + 移动子模块 + 移动到 : + 子模块 : + 调整仓库分组 + 请选择目标分组: + 名称 : + 不用了 + GIT尚未配置。请打开【偏好设置】配置GIT路径。 + 打开 + 系统默认编辑器 + 浏览应用数据目录 + 打开文件 + 使用外部对比工具查看 + 打开本地仓库 + 书签 : + 分组 : + 仓库位置 : + 选填。 + 新建空白页 + 关闭标签页 + 关闭其他标签页 + 关闭右侧标签页 + 复制仓库路径 + 编辑 + 移至工作区 + 刷新 + 新标签页 + 粘贴 + {0}天前 + 1小时前 + {0}小时前 + 刚刚 + 上个月 + 一年前 + {0}分钟前 + {0}个月前 + {0}年前 + 昨天 + 偏好设置 + AI + 附加提示词 (请使用 `-` 列出您的要求) + API密钥 + 模型 + 自动拉取可用模型列表 + 配置名称 + 从环境变量(填写环境变量名)中读取API密钥 + 服务地址 + 外观配置 + 缺省字体 + 编辑器制表符宽度 + 字体大小 + 默认 + 代码编辑器 + 等宽字体 + 主题 + 主题自定义 + 允许滚动条自动隐藏 + 主标签使用固定宽度 + 使用系统默认窗体样式 + 对比/合并工具 + 对比命令参数 + 可用参数:$LOCAL, $REMOTE + 合并命令参数 + 可用参数:$BASE, $LOCAL, $REMOTE, $MERGED + 安装路径 + 填写工具可执行文件所在位置 + 工具 + 通用配置 + 启动时检测软件更新 + 日期时间格式 + 在变更列表树中启用紧凑文件夹模式 + 显示语言 + 最大历史提交数 + 默认显示【本地更改】页 + 在提交详情页默认打开【变更对比】标签页 + 在提交路线图中显示相对时间 + 在提交路线图中显示标签 + SUBJECT字数检测 + 24小时制 + 在提交路线图中使用精简分支名 + 生成GitHub风格的默认头像 + GIT配置 + 自动换行转换 + 默认克隆路径 + 邮箱 + 默认GIT用户邮箱 + 拉取更新时启用修剪 + 对比文件时,默认忽略换行符变更 + 本软件要求GIT最低版本为2.25.1 + 安装路径 + 启用HTTP SSL验证 + 使用 git-credential-libsecret 替代 git-credential-manager + 用户名 + 默认GIT用户名 + 迁出或合并分支时,默认贮藏并自动恢复本地更改 + Git 版本 + GPG签名 + 启用提交签名 + 签名格式 + 签名程序位置 + 签名程序所在路径 + 启用标签签名 + 用户签名KEY + 输入签名提交所使用的KEY + 第三方工具集成 + 终端/SHELL + 启动参数 + 请使用 '.' 来指定工作目录 + 安装路径 + 终端/SHELL + 清理远程已删除分支 + 目标 : + 清理工作树 + 清理在`$GIT_COMMON_DIR/worktrees`中的无效工作树信息 + 拉回(pull) + 拉取分支 : + 本地分支 : + 未提交更改 : + 远程 : + 拉回(拉取并合并) + 使用变基方式合并分支 + 推送(push) + 确保子模块变更已推送 + 启用强制推送 + 本地分支 : + 新建 + 远程仓库 : + 修订 : + 推送指定修订到远程仓库 + 推送到远程仓库 + 远程分支 : + 跟踪远程分支 + 同时推送标签 + 推送标签到远程仓库 + 推送到所有远程仓库 + 远程仓库 : + 标签 : + 推送到新的分支 + 输入新的远端分支名 + 退出 + 变基(rebase)操作 + 自动贮藏并恢复本地变更 + 绕过 pre-rebase 钩子 + 目标提交 : + 检测变基冲突中... + 未检测到文件冲突 + 检测变基冲突时出现未知错误 + 检测到冲突文件 + 添加远程仓库 + 编辑远程仓库 + 远程名 : + 唯一远程名 + 仓库地址 : + 远程仓库的地址 + 复制远程地址 + 自定义操作 + 删除... + 编辑... + 启用自动拉取 + 拉取(fetch)更新 + 在浏览器中打开 + 清理远程已删除分支 + 移除工作树操作确认 + 启用`--force`选项 + 目标工作树 : + 分支重命名 + 新的名称 : + 新的分支名不能与现有分支名相同 + 分支 : + 终止合并 + 自动拉取远端变更中... + 排序方式 + 按提交时间 + 按名称 + 清理本仓库(GC) + 本操作将执行`git gc`命令。 + 清空过滤规则 + 清空 + 配置本仓库 + 下一步 + 自定义操作 + 自定义操作未设置 + 主页 + 放弃所有更改 + 在文件浏览器中打开 + 快速查找分支/标签/子模块 + 设置在列表中的可见性 + 折叠过滤项列表 + 不指定 + 在提交列表中隐藏 + 展开过滤项列表 + 使用其对提交列表过滤 + 个过滤项已被启用 + 布局方式 + 水平排布 + 竖直排布 + 提交列表排序规则 + 按提交时间 + 按拓扑排序 + 本地分支 + 更多选项... + 定位HEAD + 新建分支 + 清空通知列表 + 本仓库(文件夹) + 在 {0} 中打开 + 使用外部工具打开 + 远程列表 + 添加远程 + 解决冲突 + 查找提交 + 作者 + 变更内容 + 提交信息 + 路径 + 提交指纹 + 仅在当前分支中查找 + 仅显示分支、标签所引用的提交 + 仅显示合并提交中的第一个提交 + 显示内容 + 显示丢失引用的提交 + 以树型结构展示 + 以树型结构展示 + 跳过此提交 + 提交统计 + 子模块列表 + 添加子模块 + 更新子模块 + 标签列表 + 新建标签 + 按创建时间 + 按名称 + 排序 + 在终端中打开 + 查看命令日志 + 访问远程仓库 '{0}' + 工作树列表 + 新增工作树 + 清理 + 远程仓库地址 + 重置(reset)当前分支到指定版本 + 重置模式 : + 提交 : + 当前分支 : + 重置所选分支(非当前分支) + 重置点 : + 操作分支 : + 在文件浏览器中查看 + 回滚操作确认 + 目标提交 : + 回滚后提交更改 + 执行操作中,请耐心等待... + 保 存 + 另存为... + 补丁已成功保存! + 扫描仓库 + 根路径 : + 扫描其他自定义路径 + 检测更新... + 检测到软件有版本更新: + 当前版本 : + 获取最新版本信息失败! + 下 载 + 忽略此版本 + 新版发布时间 : + 软件更新 + 当前已是最新版本。 + 修改子模块追踪分支 + 子模块 : + 当前追踪分支 : + 修改为 : + 可选。当不填写时,恢复默认追踪分支。 + 切换上游分支 + 本地分支 : + 取消追踪 + 上游分支 : + 复制提交指纹 + 跳转到提交 + SSH密钥 : + SSH密钥文件 + 开 始 + 贮藏(stash) + 包含未跟踪的文件 + 信息 : + 选填,此贮藏的描述信息 + 模式 : + 仅贮藏暂存区的变更 + 选中文件的所有变更均会被贮藏! + 贮藏本地变更 + 应用(apply) + 应用(apply)选中变更 + 检出新分支 + 复制描述信息 + 删除(drop) + 另存为补丁... + 丢弃贮藏确认 + 丢弃贮藏 : + 贮藏列表 + 查看变更 + 贮藏列表 + 提交统计 + 总览 + 本月 + 本周 + 贡献者人数: + 提交次数: + 子模块 + 添加子模块 + 跟踪分支 + 跟踪分支 + 相对路径 + 取消初始化 + 拉取子孙模块 + 变更历史 + 移动 + 打开仓库 + 相对仓库路径 : + 本地存放的相对路径。 + 删除 + 修改跟踪分支 + 修改远程地址 + 状态 + 未提交修改 + 未初始化 + SHA变更 + 未解决冲突 + 更新 + 仓库 + 子模块变更详情 + 查看变更详情 + 确 定 + 创建者 + 创建时间 + 检出标签... + 比较 2 个标签 + 与其他分支或标签比较... + 与当前 HEAD 比较 + 标签信息 + 标签名 + 创建者 + 复制标签名 + 自定义操作 + 删除 ${0}$... + 删除选中 {0} 个标签... + 合并 ${0}$ 到 ${1}$... + 推送 ${0}$... + 更新子模块 + 更新所有子模块 + 如未初始化子模块,先初始化 + 子模块 : + 递归更新子孙模块 + 更新到子模块自己的远程踪分支 + 仓库地址 : + 日志列表 + 清空日志 + 复制 + 删除 + 警告 + 起始页 + 新建分组 + 新建子分组 + 克隆远程仓库 + 删除 + 支持拖放目录添加。支持自定义分组。 + 编辑 + 调整分组 + 打开所有包含仓库 + 打开本地仓库 + 打开终端 + 重新扫描默认克隆路径下的仓库 + 快速查找仓库... + 本地更改 + 添加至 .gitignore 忽略列表 + 忽略所有 *{0} 文件 + 忽略同目录下所有 *{0} 文件 + 忽略该目录下的新文件 + 忽略本文件 + 忽略同目录下所有新文件 + 修补 + 现在您已可将其加入暂存区中 + 清空历史提交信息 + 您确定要清空所有的历史提交信息记录吗(执行操作后无法撤回)? + 提交 + 提交并推送 + 历史输入/模板 + 触发点击事件 + 提交(修改原始提交) + 自动暂存所有变更并提交 + 您正在向一个游离的 HEAD 提交变更,是否继续提交? + 当前有 {0} 个文件在暂存区中,但仅显示了 {1} 个文件({2} 个文件被过滤掉了),是否继续提交? + 检测到冲突 + 解决冲突 + 使用外部工具解决冲突 + 打开合并工具解决冲突 + 文件冲突已解决 + 使用 MINE + 使用 THEIRS + 查找变更... + 显示未跟踪文件 + 没有提交信息记录 + 没有可应用的提交信息模板 + 跳过GIT钩子 + 重置提交者 + 署名 + 已暂存 + 从暂存区移除选中 + 从暂存区移除所有 + 未暂存 + 暂存选中 + 暂存所有 + 查看忽略变更文件 + 模板:${0}$ + 工作区: + 配置工作区... + 本地工作树 + 連結分支 + 复制工作树路径 + 最新提交 + 锁定工作树 + 打开工作树 + 位置 + 移除工作树 + 解除工作树锁定 + 好的 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml new file mode 100644 index 000000000..b07c82996 --- /dev/null +++ b/src/Resources/Locales/zh_TW.axaml @@ -0,0 +1,1043 @@ + + + + + + 關於 + 關於 SourceGit + 發行日期: {0} + 版本說明 + 開源免費的 Git 客戶端 + 新增忽略檔案 + 比對模式: + 儲存路徑: + 新增工作樹 + 工作樹路徑: + 填寫該工作樹的路徑。支援相對路徑。 + 分支名稱: + 選填。預設使用目標資料夾名稱。 + 追蹤分支 + 設定遠端追蹤分支 + 簽出分支方式: + 建立新分支 + 已有分支 + AI 助理 + 模型 + 重新產生 + 使用 AI 產生提交訊息 + 套用 + 隱藏 SourceGit + 隱藏其他 + 顯示全部 + 嘗試三向合併 + 選擇修補檔 + 忽略空白符號 + 修補檔來源: + 檔案 + 剪貼簿 + 套用修補檔 + 空白字元處理: + 套用擱置變更 + 套用擱置變更後刪除 + 還原索引中已暫存的變更 (--index) + 已選擇擱置變更: + 封存 (archive)... + 封存檔案路徑: + 選擇封存檔案的儲存路徑 + 指定的提交: + 封存 + SourceGit Askpass + 輸入您的密碼: + 不追蹤變更的檔案 + 沒有不追蹤變更的檔案 + 載入本機圖片... + 重新載入 + 二進位檔案不支援該操作! + 二分搜尋 (bisect) + 中止 + 標記為錯誤 + 標記為良好 + 無法確認 + 二分搜尋進行中。請簽出另一個提交,然後標記該提交為「良好」或「錯誤」。 + 二分搜尋進行中。請標記目前的提交為「錯誤」,或簽出另一個提交並標記為「錯誤」。 + 二分搜尋進行中。請標記目前的提交為「良好」或「錯誤」,然後簽出另一個提交。 + 二分搜尋進行中。目前的提交是「良好」還是「錯誤」? + 逐行溯源 (blame) + 對上一個版本執行逐行溯源 + 忽略空白符號變化 + 所選擇的檔案不支援該操作! + 簽出 (checkout) ${0}$... + 比較所選的 2 個分支 + 與其他分支或標籤比較... + 與目前 HEAD 比較 + 與 ${0}$ 比較 + 複製分支名稱 + 建立拉取請求... + 為上游分支 ${0}$ 建立拉取請求... + 自訂動作 + 刪除 ${0}$... + 刪除所選的 {0} 個分支... + 編輯 ${0}$ 的描述... + 快轉 (fast-forward) 到 ${0}$ + 提取 (fetch) ${0}$ 到 ${1}$... + Git 工作流 - 完成 ${0}$... + 互動式重定基底 ${0}$ 至 ${1}$ + 合併 ${0}$ 到 ${1}$... + 合併 {0} 個分支到目前分支... + 拉取 (pull) ${0}$... + 拉取 (pull) ${0}$ 內容至 ${1}$... + 推送 (push) ${0}$... + 重定基底 (rebase) ${0}$ 分支至 ${1}$... + 重新命名 ${0}$... + 重設 ${0}$ 至 ${1}$... + 切換到 ${0}$ (工作樹) + 切換上游分支... + 領先 {0} 次提交 + 領先 {0} 次提交,落後 {1} 次提交 + 落後 {0} 次提交 + 無效 + 遠端 + 狀態 + 上游分支 + 遠端網址 + 工作樹 + 取 消 + 重設檔案到上一版本 + 重設檔案為此版本 + 產生提交訊息 + 解決衝突 (內建工具) + 解決衝突 (外部工具) + 重設檔案到 ${0}$ + 切換變更顯示模式 + 檔案名稱 + 路徑列表模式 + 全路徑列表模式 + 檔案目錄樹狀結構模式 + 修改子模組的遠端網址 + 子模組: + 遠端網址: + 簽出 (checkout) 分支 + 未提交變更: + 目標分支: + 目前的 HEAD 包含未連結至任何分支或標籤的提交! 您是否要繼續? + 以下子模組需要更新: {0},您要立即更新嗎? + 簽出分支並快轉 + 上游分支: + 從所選擱置簽出分支 + 新分支: + 所選擱置: + 簽出提交或標籤 + 目標: + 注意: 執行該操作後,目前 HEAD 會變為分離 (detached) 狀態! + 揀選提交 + 提交資訊中追加來源資訊 + 提交列表: + 提交變更 + 對比的父提交: + 通常您無法揀選 (cherry-pick) 合併提交,因為無法判斷哪個父提交應視為主線。此選項可指定作為主線的父提交。 + 捨棄擱置變更確認 + 您正在捨棄所有的擱置變更,一經操作便無法復原,是否繼續? + 複製 (clone) 遠端存放庫 + 額外參數: + 其他複製參數,選填。 + 書籤: + 群組: + 本機存放庫名稱: + 本機存放庫目錄的名稱,選填。 + 上層目錄: + 初始化並更新子模組 + 遠端存放庫: + 關閉 + 提交訊息編輯器 + 分支列表 + 分支 & 標籤 + 自訂動作 + 檔案列表 + 簽出 (checkout) 此提交... + 揀選 (cherry-pick) 此提交... + 揀選 (cherry-pick)... + 與目前 HEAD 比較 + 與本機工作樹比較 + 作者 + 修改時間 + 提交訊息 + 提交者 + 提交時間 + 提交編號 + 標題 + 自訂動作 + 互動式重定基底 (rebase -i) + 捨棄... + 編輯... + 修正至父提交... + 互動式重定基底 ${0}$ 至 ${1}$ + 編輯提交訊息... + 合併至父提交... + 合併 (merge)... + 推送 (push) ${0}$ 至 ${1}$... + 重定基底 (rebase) ${0}$ 至 ${1}$... + 重設 (reset) ${0}$ 至 ${1}$... + 復原此提交... + 另存為修補檔 (patch)... + 變更對比 + 個檔案已變更 + 搜尋變更... + 收合詳細面板 + 檔案列表 + LFS 檔案 + 搜尋檔案... + 子模組 + 基本資訊 + 作者 + 提交者 + 檢視包含此提交的分支或標籤 + 本提交包含於以下分支或標籤 + 複製電子郵件 + 複製名稱 + 複製名稱及電子郵件 + 僅顯示前 100 項變更。請前往 [變更對比] 頁面以瀏覽所有變更。 + 簽章金鑰: + 提交訊息 + 前次提交 + 相關參照 + 提交編號 + 簽署人: + 在瀏覽器中檢視 + + 請輸入提交訊息,標題與詳細描述之間請使用單行空白區隔。 + 標題 + 比較 + 差異檔案 + 差異提交 + 左側獨有提交 + 右側獨有提交 + 提示: 如果比較的另一方是 HEAD,您可以揀選 (cherry-pick) 已選擇的提交 (使用滑鼠右鍵) + 存放庫設定 + 提交訊息範本 + 內建參數: + + ${branch_name} 目前分支名稱 + ${files_num} 已變更檔案數 + ${files} 已變更檔案路徑清單 + ${files:N} 已變更檔案路徑清單 (僅列出前 N 個) + ${pure_files} 類似 ${files},不含資料夾的純檔案名稱 + ${pure_files:N} 類似 ${files:N},不含資料夾的純檔案名稱 + 範本內容: + 範本名稱: + 自訂動作 + 指令參數: + 內建參數: + + ${REPO} 存放庫路徑 + ${REMOTE} 所選的遠端存放庫或所選分支的遠端 + ${BRANCH} 所選的分支。遠端分支不包含遠端名稱 + ${BRANCH_FRIENDLY_NAME} 所選的分支。遠端分支會包含遠端名稱 + ${SHA} 所選的提交編號 + ${TAG} 所選的標籤 + ${FILE} 所選的檔案,相對於存放庫根目錄的路徑 + $1, $2 ... 輸入控制項中的值 + 可執行檔案路徑: + 輸入控制項: + 編輯 + 名稱: + 執行範圍: + 選取的分支 + 選取的提交 + 選取的檔案 + 遠端存放庫 + 存放庫 + 選取的標籤 + 等待自訂動作執行結束 + 電子郵件 + 電子郵件地址 + Git 設定 + 在自動更新子模組之前先詢問 + 啟用定時自動提取 (fetch) 遠端更新 + 分鐘 + 自訂約定式提交類型 + 預設遠端存放庫 + 自動更新子模組時啟用「--recursive」選項 + 預設合併模式 + Issue 追蹤 + 新增符合 Azure DevOps 規則 + 新增符合 Gerrit Change-Id 規則 + 新增符合 Gitee 議題規則 + 新增符合 Gitee 合併請求規則 + 新增符合 GitHub Issue 規則 + 新增符合 GitLab 議題規則 + 新增符合 GitLab 合併請求規則 + 新增符合 Jira 規則 + 新增自訂規則 + 符合 Issue 的正規表達式: + 規則名稱: + 寫入 .issuetracker 檔案以共用此規則 + 為 Issue 產生的網址連結: + 可在網址中使用 $1、$2 等變數填入正規表達式相符的內容 + AI + 偏好服務: + 設定 [偏好服務] 後,SourceGit 將於此存放庫中使用該服務,否則會顯示 AI 服務列表供使用者選擇。 + HTTP 代理 + HTTP 網路代理 + 使用者名稱 + 用於本存放庫的使用者名稱 + 編輯自訂動作輸入控制項 + 啟用時的指令參數: + 勾選核取方塊後,此值將用於指令參數中 + 描述: + 預設值: + 目標路徑是否為資料夾: + 名稱: + 選項列表: + 請使用英文「|」符號分隔選項 + 字串格式化: + 選填。用於格式化輸出字串。若輸入為空則會忽略此設定。請使用 `${VALUE}` 來表示輸入字串。 + 內建變數 ${REPO}、${REMOTE}、${BRANCH}、${BRANCH_FRIENDLY_NAME}、${SHA}、${FILE} 及 ${TAG} 在此處仍可使用 + 類型: + 輸出包含遠端的名稱: + 工作區 + 顏色 + 名稱 + 啟動時還原上次開啟的存放庫 + 確認繼續 + 未包含任何檔案變更! 您是否仍要提交 (--allow-empty)? + 暫存全部變更並提交 + 僅暫存所選變更並提交 + 未包含任何檔案變更! 您是否仍要提交 (--allow-empty) 或自動暫存變更並提交? + 系統提示 + 您需要重新啟動此應用程式才能套用變更! + 產生約定式提交訊息 + 破壞性變更: + 關閉的 Issue: + 詳細資訊: + 模組: + 簡述: + 類型: + 複製 + 複製全部內容 + 複製為修補檔 + 複製完整路徑 + 複製路徑 + 新增分支... + 新分支基於: + 完成後切換到新分支 + 未提交變更: + 新分支名稱: + 輸入分支名稱。 + 建立本機分支 + 允許覆寫現有分支 + 新增標籤... + 標籤位於: + 使用 GPG 簽章 + 標籤描述: + 選填。 + 標籤名稱: + 建議格式: v1.0.0-alpha + 推送到所有遠端存放庫 + 新增標籤 + 類型: + 附註標籤 + 輕量標籤 + 按住 Ctrl 鍵將直接以預設參數執行 + 剪下 + 捨棄變更 + 不做處理 + 擱置變更並自動復原 + 取消初始化子模組 + 強制取消,即使包含本機變更 + 子模組: + 刪除分支確認 + 您是否也想刪除以下遠端分支? + 分支名稱: + 強制刪除,即使包含未合併的提交 + 您正在刪除遠端上的分支,請務必小心! + 刪除多個分支 + 您正在嘗試一次性刪除多個分支,請務必仔細檢查後再刪除! + 刪除多個標籤 + 同時刪除遠端存放庫中的這些標籤 + 您正在嘗試一次性刪除多個標籤,請務必仔細檢查後再刪除! + 刪除遠端確認 + 遠端名稱: + 路徑: + 目標: + 所有子節點都會從清單中移除。 + 只會從清單中移除,而不會刪除磁碟中的檔案! + 刪除群組確認 + 刪除存放庫確認 + 刪除子模組確認 + 子模組路徑: + 刪除標籤確認 + 標籤名稱: + 同時刪除遠端存放庫中的此標籤 + 二進位檔案 + 空檔案 + 第一個差異 + 忽略空白符號變化 + 混合對比 + 差異對比 + 並排對比 + 滑桿對比 + 最後一個差異 + LFS 物件變更 + 變更後 + 下一個差異 + 沒有變更或僅有換行字元差異 + 變更前 + 上一個差異 + 另存為修補檔 (patch) + 顯示隱藏符號 + 並排對比 + 子模組 + 已刪除 + 新增 + + {0} 項未提交變更 + 交換比對雙方 + 語法上色 + 自動換行 + 使用外部合併工具檢視 + 顯示檔案的全部內容 + 減少可見的行數 + 增加可見的行數 + 請選擇需要對比的檔案 + 目錄內容變更歷史 + 未提交的本機變更 + 目前分支 HEAD 與上游不相符 + 已更新至最新 + 捨棄變更 + 所有本機未提交的變更。 + 變更: + 包括所有已忽略的檔案 + 包括已變更或刪除的檔案 + 包含未追蹤檔案 + 將捨棄總計 {0} 項已選取的變更 + 您無法復原此操作,請確認後再繼續! + 編輯分支的描述 + 目標: + 書籤: + 名稱: + 目標: + 編輯群組 + 編輯存放庫 + 目標: + 本存放庫 + 提取 (fetch) + 提取所有的遠端存放庫 + 強制覆寫本機 REFs + 不提取遠端標籤 + 遠端存放庫: + 提取遠端存放庫內容 + 不追蹤此檔案的變更 + 自訂動作 + 捨棄變更... + 捨棄已選的 {0} 個檔案變更... + 使用 ${0}$ + 另存為修補檔 (patch)... + 暫存 (add) + 暫存 (add) 已選的 {0} 個檔案 + 擱置變更 (stash)... + 擱置變更 (stash) 所選的 {0} 個檔案... + 取消暫存 + 從暫存中移除 {0} 個檔案 + 使用我方版本 (ours) + 使用對方版本 (theirs) + 檔案歷史 + 檔案變更 + 檔案內容 + 檔案類型變更: + 刪除檔案類型: + 目錄 + 可執行檔案 + 新增檔案類型: + 一般檔案 + 子模組 + 符號連結 + 未知 + Git 工作流 + 開發分支: + 功能分支: + 功能分支前置詞: + 完成 ${0}$ + 完成功能分支 + 完成修復分支 + 完成發行分支 + 目標分支: + 合併前重定基底 + 壓縮為單一提交後合併 + 修復分支: + 修復分支前置詞: + 初始化 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) 本機變更到遠端存放庫 + 強制重新載入存放庫 + 在歷史頁面中展開/收合詳細面板 + 顯示本機變更 + 顯示歷史記錄 + 顯示擱置變更列表 + 捨棄 + 暫存 + 取消暫存 + 初始化存放庫 + 您是否要在該路徑執行 `git init` 以初始化存放庫? + 無法在指定路徑開啟本機存放庫。原因: + 路徑: + 揀選 (cherry-pick) 操作進行中。 + 正在處理提交 + 合併操作進行中。 + 正在處理 + 重定基底 (rebase) 操作進行中。 + 目前停止於 + 復原提交操作進行中。 + 正在復原提交 + 互動式重定基底 + 自動擱置變更並復原本機變更 + 繞過 pre-rebase hook + 起始提交: + 拖曳以重新排序提交 + 目標分支: + 複製連結 + 在瀏覽器中開啟連結 + 命令列表 + 發生錯誤 + 系統提示 + 有新版本可用 + 開啟存放庫 + 頁面列表 + 工作區列表 + 合併分支 + 編輯合併訊息 + 目標分支: + 合併方式: + 合併來源: + 正在偵測合併衝突... + 合併操作不會產生衝突 + 偵測合併衝突時發生未知錯誤 + 合併操作將產生檔案衝突 + 先套用我方版本 (ours),再套用對方版本 (theirs) + 先套用對方版本 (theirs),再套用我方版本 (ours) + 套用兩者 + 所有衝突已經解決 + {0} 個衝突尚未解決 + 我方版本 (ours) + 下一個衝突 + 上一個衝突 + 合併結果 + 儲存並暫存 + 對方版本 (theirs) + 解決衝突 + 捨棄未儲存的變更? + 僅套用我方版本 (ours) + 僅套用對方版本 (theirs) + 復原變更 + 合併 (多個來源) + 提交變更 + 合併策略: + 目標列表: + 移動子模組 + 移動到: + 子模組: + 調整存放庫分組 + 請選擇目標分組: + 名稱: + + 尚未設定 Git。請開啟 [偏好設定] 以設定 Git 路徑。 + 開啟 + 系統預設編輯器 + 瀏覽程式資料目錄 + 開啟檔案 + 使用外部比對工具檢視 + 開啟本機存放庫 + 書籤: + 群組: + 存放庫位置: + 選填。 + 新增分頁 + 關閉分頁 + 關閉其他分頁 + 關閉右側分頁 + 複製存放庫路徑 + 編輯 + 移至工作區 + 重新整理 + 新分頁 + 貼上 + {0} 天前 + 1 小時前 + {0} 小時前 + 剛剛 + 上個月 + 一年前 + {0} 分鐘前 + {0} 個月前 + {0} 年前 + 昨天 + 偏好設定 + AI + 附加提示詞 (請使用 '-' 列出您的要求) + API 金鑰 + 模型 + 自動取得可用的模型列表 + 名稱 + 從環境變數中 (輸入環境變數名稱) 讀取 API 金鑰 + 伺服器 + 外觀設定 + 預設字型 + 編輯器 Tab 寬度 + 字型大小 + 預設 + 程式碼 + 等寬字型 + 佈景主題 + 自訂主題 + 允許自動隱藏捲軸 + 使用固定寬度的分頁標籤 + 使用系統原生預設視窗樣式 + 對比/合併工具 + 對比命令參數 + 可用參數: $LOCAL, $REMOTE + 合併命令參數 + 可用參數: $BASE, $LOCAL, $REMOTE, $MERGED + 安裝路徑 + 填寫可執行檔案所在路徑 + 工具 + 一般設定 + 啟動時檢查軟體更新 + 日期時間格式 + 在樹狀變更目錄中啟用密集資料夾模式 + 顯示語言 + 最大歷史提交數 + 預設顯示 [本機變更] 頁面 + 在提交詳細資訊頁面預設顯示 [變更對比] + 在提交路線圖中顯示相對時間 + 在路線圖中顯示標籤 + 提交標題字數偵測 + 24 小時制 + 在提交路線圖中使用簡潔的分支標籤 + 產生 GitHub 風格的預設頭貼 + Git 設定 + 自動換行轉換 + 預設複製 (clone) 路徑 + 電子郵件 + 預設 Git 使用者電子郵件 + 提取後清理遠端已刪除分支 + 對比檔案時,預設忽略行末的 CR 變更 + 本軟體要求 Git 最低版本為 2.25.1 + 安裝路徑 + 啟用 HTTP SSL 驗證 + 使用 git-credential-libsecret 取代 git-credential-manager + 使用者名稱 + 預設 Git 使用者名稱 + 在簽出或合併分支時,預設擱置變更並自動復原 + Git 版本 + GPG 簽章 + 啟用提交簽章 + GPG 簽章格式 + 可執行檔案路徑 + 填寫 gpg.exe 所在路徑 + 啟用標籤簽章 + 使用者簽章金鑰 + 填寫簽章提交所使用的金鑰 + 第三方工具整合 + 終端機/Shell + 啟動參數 + 請使用「.」表示目前工作目錄 + 安裝路徑 + 終端機/Shell + 清理遠端已刪除分支 + 目標: + 清理工作樹 + 清理在 `$GIT_COMMON_DIR/worktrees` 中的無效工作樹資訊 + 拉取 (pull) + 拉取分支: + 本機分支: + 未提交變更: + 遠端: + 拉取 (提取並合併) + 使用重定基底 (rebase) 合併分支 + 推送 (push) + 確保已推送子模組 + 啟用強制推送 + 本機分支: + 新增 + 遠端存放庫: + 修訂: + 推送修訂到遠端存放庫 + 推送到遠端存放庫 + 遠端分支: + 追蹤遠端分支 + 同時推送標籤 + 推送標籤到遠端存放庫 + 推送到所有遠端存放庫 + 遠端存放庫: + 標籤: + 推送到新的分支 + 輸入新的遠端分支名稱: + 結束 + 重定基底 (rebase) 操作 + 自動擱置變更並復原本機變更 + 繞過 pre-rebase hook + 目標提交: + 正在偵測重定基底衝突... + 重定基底操作不會產生衝突 + 偵測重定基底衝突時發生未知錯誤 + 重定基底操作將產生檔案衝突 + 新增遠端存放庫 + 編輯遠端存放庫 + 遠端名稱: + 唯一遠端名稱 + 存放庫網址: + 遠端存放庫的網址 + 複製遠端網址 + 自訂動作 + 刪除... + 編輯... + 啟用定時自動提取 + 提取 (fetch) 更新 + 在瀏覽器中開啟網址 + 清理遠端已刪除分支 + 刪除工作樹操作確認 + 啟用 [--force] 選項 + 目標工作樹: + 分支重新命名 + 新名稱: + 新的分支名稱不能與現有分支名稱相同 + 分支: + 中止 + 正在自動提取遠端變更... + 排序 + 依建立時間 + 依名稱遞增排序 + 清理本存放庫 (GC) + 本操作將執行 `git gc` 指令。 + 清空篩選規則 + 清空 + 設定本存放庫 + 下一步 + 自訂動作 + 沒有自訂的動作 + 首頁 + 捨棄所有變更 + 在檔案瀏覽器中開啟 + 快速搜尋分支/標籤/子模組 + 篩選以顯示或隱藏 + 摺疊篩選器 + 取消指定 + 在提交列表中隱藏 + 展開篩選器 + 以其篩選提交列表 + 個篩選規則已啟用 + 版面配置 + 橫向顯示 + 縱向顯示 + 提交顯示順序 + 依時間排序 + 依拓撲排序 + 本機分支 + 更多選項... + 回到 HEAD + 新增分支 + 清除所有通知 + 此存放庫 (資料夾) + 在 {0} 中開啟 + 使用外部工具開啟 + 遠端列表 + 新增遠端 + 解決衝突 + 搜尋提交 + 作者 + 變更內容 + 提交訊息 + 路徑 + 提交編號 + 僅搜尋目前分支 + 只顯示分支和標籤引用的提交 + 只顯示合併提交的第一父提交 + 顯示內容 + 顯示失去參照的提交 + 以樹狀結構顯示 + 以樹狀結構顯示 + 跳過此提交 + 提交統計 + 子模組列表 + 新增子模組 + 更新子模組 + 標籤列表 + 新增標籤 + 依建立時間 + 依名稱 + 排序 + 在終端機中開啟 + 檢視 Git 指令記錄 + 檢視遠端存放庫 '{0}' + 工作樹列表 + 新增工作樹 + 清理 + 遠端存放庫網址 + 重設目前分支到指定版本 + 重設模式: + 移至提交: + 目前分支: + 重設選取的分支 (非目前分支) + 重設位置: + 選取分支: + 在檔案瀏覽器中檢視 + 復原操作確認 + 目標提交: + 復原後提交變更 + 正在執行操作,請耐心等待... + 儲存 + 另存新檔... + 修補檔已成功儲存! + 掃描存放庫 + 頂層目錄: + 掃描其他自訂目錄 + 檢查更新... + 軟體有版本更新: + 目前版本: + 取得最新版本資訊失敗! + 下載 + 忽略此版本 + 新版本發行日期: + 軟體更新 + 目前已是最新版本。 + 設定子模組的追蹤分支 + 子模組: + 目前追蹤分支: + 變更為: + 選填。留空時設定為預設值。 + 切換上游分支 + 本機分支: + 取消設定上游分支 + 上游分支: + 複製提交編號 + 前往此提交 + SSH 金鑰: + SSH 金鑰檔案 + 開 始 + 擱置變更 (stash) + 包含未追蹤的檔案 + 擱置變更訊息: + 選填,用於描述此擱置變更 + 操作模式: + 僅擱置已暫存的變更 + 已選取的檔案中的變更均會被擱置! + 擱置本機變更 + 套用 (apply) + 套用 (apply) 所選變更 + 簽出分支 + 複製描述訊息 + 刪除 (drop) + 另存為修補檔 (patch)... + 捨棄擱置變更確認 + 捨棄擱置變更: + 擱置變更 + 檢視變更 + 擱置變更列表 + 提交統計 + 總覽 + 本月 + 本週 + 貢獻者人數: + 提交次數: + 子模組 + 新增子模組 + 追蹤分支 + 追蹤分支 + 相對路徑 + 取消初始化 + 提取子模組 + 變更歷史 + 移動 + 開啟存放庫 + 相對存放庫路徑: + 本機存放的相對路徑。 + 刪除 + 變更追蹤分支 + 變更遠端網址 + 狀態 + 未提交變更 + 未初始化 + SHA 變更 + 未解決的衝突 + 更新 + 存放庫 + 子模組變更詳細資訊 + 開啟變更詳細資訊 + 確 定 + 建立者 + 建立時間 + 簽出標籤... + 比較 2 個標籤 + 與其他分支或標籤比較... + 與目前 HEAD 比較 + 標籤訊息 + 標籤名稱 + 建立者 + 複製標籤名稱 + 自訂動作 + 刪除 ${0}$... + 刪除所選的 {0} 個標籤... + 合併 ${0}$ 到 ${1}$... + 推送 ${0}$... + 更新子模組 + 更新所有子模組 + 如果子模組尚未初始化,則將其初始化 + 子模組: + 遞迴更新子模組 + 更新至子模組的遠端追蹤分支 + 存放庫網址: + 記錄 + 清除所有記錄 + 複製 + 刪除 + 警告 + 起始頁 + 新增群組 + 新增子群組 + 複製 (clone) 遠端存放庫 + 刪除 + 支援拖放以新增目錄與自訂群組。 + 編輯 + 調整存放庫分組 + 開啟所有存放庫 + 開啟本機存放庫 + 開啟終端機 + 重新掃描預設複製 (clone) 目錄下的存放庫 + 快速搜尋存放庫... + 本機變更 + 加入至 .gitignore 忽略清單 + 忽略所有 *{0} 檔案 + 忽略同路徑下所有 *{0} 檔案 + 忽略本路徑下的新增檔案 + 忽略本檔案 + 忽略同路徑下所有新增檔案 + 修補 + 現在您已可將其加入暫存區中 + 清除提交訊息歷史 + 您確定要清除所有提交訊息記錄嗎 (執行後無法復原)? + 提 交 + 提交並推送 + 歷史輸入/範本 + 觸發點擊事件 + 提交 (修改原始提交) + 自動暫存全部變更並提交 + HEAD 目前處於分離狀態。您確定要繼續提交變更嗎? + 您已暫存 {0} 個檔案,但只顯示 {1} 個檔案 ({2} 個檔案被篩選器隱藏)。您確定要繼續提交嗎? + 偵測到衝突 + 解決衝突 + 使用外部工具解決衝突 + 使用外部合併工具開啟 + 檔案衝突已解決 + 使用我方版本 (ours) + 使用對方版本 (theirs) + 搜尋變更... + 顯示未追蹤檔案 + 沒有提交訊息記錄 + 沒有可套用的提交訊息範本 + 繞過 Hooks 檢查 + 重設作者 + 署名 + 已暫存 + 取消暫存選取的檔案 + 取消暫存所有檔案 + 未暫存 + 暫存選取的檔案 + 暫存所有檔案 + 檢視不追蹤變更的檔案 + 範本: ${0}$ + 工作區: + 設定工作區... + 本機工作樹 + 分支 + 複製工作樹路徑 + 最新提交 + 鎖定工作樹 + 開啟工作樹 + 位置 + 移除工作樹 + 解除鎖定工作樹 + + diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml index c5ed0ebf3..27878b5cc 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -1,20 +1,436 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vm="using:SourceGit.ViewModels" + xmlns:v="using:SourceGit.Views" + xmlns:c="using:SourceGit.Converters" + xmlns:ae="using:AvaloniaEdit" + xmlns:aee="using:AvaloniaEdit.Editing" + xmlns:aes="using:AvaloniaEdit.Search"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - + + - - - - - - - - - - - - + - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - + + - - - - - - - - - + + + + + + - + + + - - - - - - - - - + + - - + diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml index 88f38d8c5..414316380 100644 --- a/src/Resources/Themes.axaml +++ b/src/Resources/Themes.axaml @@ -2,94 +2,108 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - #FFFF6059 - #FFFFBE2F - #FF29c941 #FFF0F5F9 + #FF999999 #FFCFDEEA #FFF0F5F9 - #FFF8F8F8 + #FFF4F8FB + White #FFFAFAFA #FFB0CEE8 - #FF6F6F6F - #FFF8F8F8 + #FF1F1F1F #FF836C2E - #FFDFDFDF - #FFCFCFCF + #FFFFFFFF + #400078D7 + #40FF8C00 #FFCFCFCF #FF898989 #FFCFCFCF - #FFEFEFEF #FFF8F8F8 White - #FF4295FF - #FF529DFB + #FF898989 #FF1F1F1F #FF6F6F6F - #FFFFFFFF - #FF4295FF - #FF529DFB - #4C007ACC + #10000000 + #80BFE6C1 + #80FF9797 + #A7E1A7 + #F19B9D + DarkCyan + #0000EE + #FFE4E4E4 + Black + #FFF0F5F9 - #FFFF5E56 - #FFFCBB2D - #FF25C53C #FF252525 + #FF606060 #FF1F1F1F - #FF2C2C2C + #FF2F2F2F #FF2B2B2B - #FF181818 + #FF393939 + #FF1C1C1C #FF8F8F8F - #FF505050 - #FFF8F8F8 + #FFE0E0E0 #FFFAFAD2 - #FF323232 - #FF3F3F3F + #FF252525 + #400078D7 + #40FF8C00 #FF181818 #FF7C7C7C #FF404040 - #FF252525 #FF303030 #FF333333 - #FF3A3A3A - #FF404040 - #FFF1F1F1 - #40F1F1F1 - #FF252525 - #FF007ACC - #FF006BBE - #8C007ACC + #FF4F4F4F + #FFDFDFDF + #FF9F9F9F + #3C000000 + #C03A5C3F + #C0633F3E + #A0308D3C + #A09F4247 + DarkCyan + #4DAAFC + #FF383838 + #FFF0F0F0 + #FF272727 - - - + + - - + - - + + + - - - + - - - - + + + + + + + + + + + + + + fonts:Inter#Inter + fonts:SourceGit#JetBrains Mono NL diff --git a/src/SourceGit.csproj b/src/SourceGit.csproj index 95434cf83..fc8fe02d1 100644 --- a/src/SourceGit.csproj +++ b/src/SourceGit.csproj @@ -1,44 +1,83 @@ - - - WinExe - net8.0 - true - App.manifest - App.ico - 8.2 - false - true - true - - SourceGit - OpenSource GIT client - sourcegit-scm - Copyright © 2024 sourcegit-scm. - MIT - https://github.com/sourcegit-scm/sourcegit.git - https://github.com/sourcegit-scm/sourcegit.git - Public - - - - - - - - - - - - - - - - - - - - - - - - + + + WinExe + net10.0 + App.manifest + App.ico + $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)\\..\\VERSION")) + true + true + false + + 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 + + + + 13.0 + + + + $(DefineConstants);DISABLE_UPDATE_DETECTION + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SourceGit.sln b/src/SourceGit.sln deleted file mode 100644 index 13ea93f02..000000000 --- a/src/SourceGit.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34408.163 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGit", "SourceGit.csproj", "{89AD3F88-E72C-4399-AD61-6A87FC424E7B}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {89AD3F88-E72C-4399-AD61-6A87FC424E7B}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {DA70C1D9-A8D2-4C89-98F3-B263CCBC5F28} - EndGlobalSection -EndGlobal diff --git a/src/ViewModels/AIAssistant.cs b/src/ViewModels/AIAssistant.cs new file mode 100644 index 000000000..e9e4692fe --- /dev/null +++ b/src/ViewModels/AIAssistant.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class AIAssistant : ObservableObject + { + public List AvailableModels + { + get => _service.AvailableModels; + } + + public string CurrentModel + { + get => _service.Model; + set => _service.Model = value; + } + + public bool IsGenerating + { + get => _isGenerating; + private set => SetProperty(ref _isGenerating, value); + } + + public string Text + { + get => _text; + private set => SetProperty(ref _text, value); + } + + public string Response + { + get => _response; + private set => SetProperty(ref _response, value); + } + + public AIAssistant(Repository repo, AI.Service service, List changes) + { + _repo = repo; + _service = service; + _cancel = new CancellationTokenSource(); + + var builder = new StringBuilder(); + foreach (var c in changes) + SerializeChange(c, builder); + _changeList = builder.ToString(); + + if (changes.Count > 0 && changes[0].DataForAmend is { } amend) + _amendParent = amend.ParentSHA; + } + + public async Task GenAsync() + { + if (_cancel is { IsCancellationRequested: false }) + _cancel.Cancel(); + _cancel = new CancellationTokenSource(); + + var agent = new AI.Agent(_service); + var builder = new StringBuilder(); + builder.AppendLine("Asking AI to generate commit message...").AppendLine(); + + var responseBuilder = new StringBuilder(); + var foundResponse = false; + var currentBranchName = _repo.CurrentBranch?.Name ?? "main"; + + Text = builder.ToString(); + Response = string.Empty; + IsGenerating = true; + + try + { + await agent.GenerateCommitMessageAsync(_repo.FullPath, currentBranchName, _changeList, _amendParent, message => + { + builder.AppendLine(message); + + if (foundResponse) + { + if (message.Equals("# Token Usage", StringComparison.Ordinal)) + foundResponse = false; + else + responseBuilder.AppendLine(message); + } + else if (message.Equals("# Assistant", StringComparison.Ordinal)) + { + foundResponse = true; + } + + Dispatcher.UIThread.Post(() => Text = builder.ToString()); + }, _cancel.Token); + + Response = responseBuilder.ToString().Trim(); + } + catch (OperationCanceledException) + { + // Do nothing and leave `IsGenerating` to current (may already changed), so that the UI can update accordingly. + return; + } + catch (Exception e) + { + builder + .AppendLine() + .AppendLine("[ERROR]") + .Append(e.Message); + + Text = builder.ToString(); + Response = string.Empty; + } + + IsGenerating = false; + } + + public void Use(string text) + { + _repo.SetCommitMessage(text); + } + + public void Cancel() + { + _cancel?.Cancel(); + } + + private void SerializeChange(Models.Change c, StringBuilder builder) + { + var status = c.Index switch + { + Models.ChangeState.Added => "A", + Models.ChangeState.Modified => "M", + Models.ChangeState.Deleted => "D", + Models.ChangeState.TypeChanged => "T", + Models.ChangeState.Renamed => "R", + Models.ChangeState.Copied => "C", + _ => " ", + }; + + builder.Append(status).Append('\t'); + + if (c.Index == Models.ChangeState.Renamed || c.Index == Models.ChangeState.Copied) + builder.Append(c.OriginalPath).Append(" -> ").Append(c.Path).AppendLine(); + else + builder.Append(c.Path).AppendLine(); + } + + private readonly Repository _repo = null; + private readonly AI.Service _service = null; + private readonly string _changeList = null; + private readonly string _amendParent = null; + private CancellationTokenSource _cancel = null; + private bool _isGenerating = false; + private string _text = string.Empty; + private string _response = string.Empty; + } +} diff --git a/src/ViewModels/AddRemote.cs b/src/ViewModels/AddRemote.cs index f20422b70..f5182ef9f 100644 --- a/src/ViewModels/AddRemote.cs +++ b/src/ViewModels/AddRemote.cs @@ -1,83 +1,132 @@ using System.ComponentModel.DataAnnotations; +using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class AddRemote : Popup { +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 { + 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 { + public string Url + { get => _url; - set { - if (SetProperty(ref _url, value, true)) UseSSH = Models.Remote.IsSSH(value); + set + { + if (SetProperty(ref _url, value, true)) + UseSSH = Models.Remote.IsSSH(value); } } - public bool UseSSH { + public bool UseSSH + { get => _useSSH; - set => SetProperty(ref _useSSH, value); + 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 string SSHKey { + public bool FetchWithoutTags + { get; set; - } + } = false; - public AddRemote(Repository repo) { + public AddRemote(Repository repo) + { _repo = repo; - View = new Views.AddRemote() { DataContext = this }; } - public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) { - if (ctx.ObjectInstance is AddRemote add) { + 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!!!"); + 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!!!"); + 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!!!"); + if (exists != null) + return new ValidationResult("A remote with the same url already exists!!!"); } return ValidationResult.Success; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + 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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding remote ..."; - return Task.Run(() => { - var succ = new Commands.Remote(_repo.FullPath).Add(_name, _url); - if (succ) { - SetProgressDescription("Fetching from added remote ..."); - new Commands.Fetch(_repo.FullPath, _name, true, SetProgressDescription).Exec(); - - if (_useSSH) { - SetProgressDescription("Post processing ..."); - new Commands.Config(_repo.FullPath).Set($"remote.{_name}.sshkey", SSHKey); - } - } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var log = _repo.CreateLog("Add Remote"); + Use(log); + + var succ = await new Commands.Remote(_repo.FullPath) + .Use(log) + .AddAsync(_name, _url); + + if (succ) + { + await new Commands.Config(_repo.FullPath) + .Use(log) + .SetAsync($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); + + await new Commands.Fetch(_repo.FullPath, _name, FetchWithoutTags, false) + .Use(log) + .RunAsync(); + } + + log.Complete(); + + _repo.MarkFetched(); + _repo.MarkBranchesDirtyManually(); + return succ; } - private Repository _repo = null; + 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 index 2bfdde3cb..e9554ea34 100644 --- a/src/ViewModels/AddSubmodule.cs +++ b/src/ViewModels/AddSubmodule.cs @@ -1,62 +1,73 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class AddSubmodule : Popup { +namespace SourceGit.ViewModels +{ + public class AddSubmodule : Popup + { [Required(ErrorMessage = "Url is required!!!")] [CustomValidation(typeof(AddSubmodule), nameof(ValidateURL))] - public string Url { + public string Url + { get => _url; set => SetProperty(ref _url, value, true); } - [Required(ErrorMessage = "Reletive path is required!!!")] - [CustomValidation(typeof(AddSubmodule), nameof(ValidateRelativePath))] - public string RelativePath { + public string RelativePath + { get => _relativePath; - set => SetProperty(ref _relativePath, value, true); + set => SetProperty(ref _relativePath, value); } - public bool Recursive { + public bool Recursive + { get; set; } - public AddSubmodule(Repository repo) { + public AddSubmodule(Repository repo) + { _repo = repo; - View = new Views.AddSubmodule() { DataContext = this }; } - public static ValidationResult ValidateURL(string url, ValidationContext ctx) { - if (!Models.Remote.IsValidURL(url)) return new ValidationResult("Invalid repository URL format"); + public static ValidationResult ValidateURL(string url, ValidationContext ctx) + { + if (!Models.Remote.IsValidURL(url)) + return new ValidationResult("Invalid repository URL format"); + return ValidationResult.Success; } - public static ValidationResult ValidateRelativePath(string path, ValidationContext ctx) { - if (Path.Exists(path)) { - return new ValidationResult("Give path is exists already!"); - } + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Adding submodule..."; - if (Path.IsPathRooted(path)) { - return new ValidationResult("Path must be relative to this repository!"); - } + var log = _repo.CreateLog("Add Submodule"); + Use(log); - return ValidationResult.Success; - } + 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); + } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = "Adding submodule..."; + var succ = await new Commands.Submodule(_repo.FullPath) + .Use(log) + .AddAsync(_url, relativePath, Recursive); - return Task.Run(() => { - var succ = new Commands.Submodule(_repo.FullPath).Add(_url, _relativePath, Recursive, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + log.Complete(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; private string _url = string.Empty; private string _relativePath = string.Empty; } diff --git a/src/ViewModels/AddToIgnore.cs b/src/ViewModels/AddToIgnore.cs new file mode 100644 index 000000000..c88cf1e7f --- /dev/null +++ b/src/ViewModels/AddToIgnore.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class AddToIgnore : Popup + { + public List StorageFiles + { + get; + } + + [Required(ErrorMessage = "Ignore pattern is required!")] + public string Pattern + { + get => _pattern; + set => SetProperty(ref _pattern, value, true); + } + + [Required(ErrorMessage = "Storage file is required!!!")] + public Models.GitIgnoreFile SelectedStorageFile + { + get => _selectedStorageFile; + set + { + if (SetProperty(ref _selectedStorageFile, value, true)) + Pattern = value.Pattern; + } + } + + public AddToIgnore(Repository repo, string pattern) + { + _repo = repo; + _pattern = pattern; + + StorageFiles = Models.GitIgnoreFile.GetSupported(repo.FullPath, repo.GitDir, pattern); + SelectedStorageFile = StorageFiles[0]; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Adding Ignored File(s) ..."; + + var file = _selectedStorageFile.FullPath; + if (!File.Exists(file)) + { + await File.WriteAllLinesAsync(file!, [_pattern]); + } + else + { + var org = await File.ReadAllTextAsync(file); + if (!org.EndsWith('\n')) + await File.AppendAllLinesAsync(file, ["", _pattern]); + else + await File.AppendAllLinesAsync(file, [_pattern]); + } + + _repo.MarkWorkingCopyDirtyManually(); + return true; + } + + private readonly Repository _repo; + private string _pattern; + private Models.GitIgnoreFile _selectedStorageFile; + } +} diff --git a/src/ViewModels/AddWorktree.cs b/src/ViewModels/AddWorktree.cs new file mode 100644 index 000000000..8f8aea2c4 --- /dev/null +++ b/src/ViewModels/AddWorktree.cs @@ -0,0 +1,184 @@ +using System; +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 = null; + else + SelectedBranch = LocalBranches.Count > 0 ? LocalBranches[0] : null; + } + } + } + + public List LocalBranches + { + get; + private set; + } + + public Models.Branch SelectedBranch + { + get => _selectedBranch; + set => SetProperty(ref _selectedBranch, value); + } + + public string NewBranchName + { + get => _newBranchName; + set => SetProperty(ref _newBranchName, value); + } + + public bool SetTrackingBranch + { + get => _setTrackingBranch; + set + { + if (SetProperty(ref _setTrackingBranch, value)) + AutoSelectTrackingBranch(); + } + } + + public List RemoteBranches + { + get; + private set; + } + + public Models.Branch SelectedTrackingBranch + { + get => _selectedTrackingBranch; + set => SetProperty(ref _selectedTrackingBranch, value); + } + + public AddWorktree(Repository repo) + { + _repo = repo; + + LocalBranches = new List(); + RemoteBranches = new List(); + foreach (var branch in repo.Branches) + { + if (branch.IsLocal) + { + if (!branch.IsCurrent && !branch.HasWorktree) + LocalBranches.Add(branch); + } + else + { + RemoteBranches.Add(branch); + } + } + } + + public static ValidationResult ValidateWorktreePath(string path, ValidationContext ctx) + { + if (ctx.ObjectInstance is not AddWorktree creator) + 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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Adding worktree ..."; + + var branchName = GetBranchName(false); + var tracking = (_setTrackingBranch && _selectedTrackingBranch != null) ? _selectedTrackingBranch.FriendlyName : string.Empty; + var log = _repo.CreateLog("Add Worktree"); + + Use(log); + + var succ = await new Commands.Worktree(_repo.FullPath) + .Use(log) + .AddAsync(_path, branchName, _createNewBranch, tracking); + + log.Complete(); + return succ; + } + + private void AutoSelectTrackingBranch() + { + if (!_setTrackingBranch || RemoteBranches.Count == 0) + return; + + var name = GetBranchName(true); + if (!string.IsNullOrEmpty(name)) + { + var remoteBranch = RemoteBranches.Find(b => b.Name.Equals(name, StringComparison.Ordinal)); + remoteBranch ??= RemoteBranches.Find(b => b.Name.EndsWith("/" + name, StringComparison.Ordinal)); + if (remoteBranch != null) + { + SelectedTrackingBranch = remoteBranch; + return; + } + } + + SelectedTrackingBranch = RemoteBranches[0]; + } + + private string GetBranchName(bool fallback) + { + do + { + if (!_createNewBranch) + { + if (_selectedBranch != null) + return _selectedBranch.Name; + + break; + } + + if (!string.IsNullOrEmpty(_newBranchName)) + return _newBranchName; + } while (false); + + return fallback ? System.IO.Path.GetFileName(_path.TrimEnd('/', '\\')) : string.Empty; + } + + private Repository _repo = null; + private string _path = string.Empty; + private bool _createNewBranch = true; + private Models.Branch _selectedBranch = null; + private string _newBranchName = string.Empty; + private bool _setTrackingBranch = false; + private Models.Branch _selectedTrackingBranch = null; + } +} diff --git a/src/ViewModels/Apply.cs b/src/ViewModels/Apply.cs index 937ebe70e..14ecc0730 100644 --- a/src/ViewModels/Apply.cs +++ b/src/ViewModels/Apply.cs @@ -1,67 +1,118 @@ -using System.Collections.Generic; +using System; using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Apply : Popup { - [Required(ErrorMessage = "Patch file is required!!!")] +using Avalonia.Input.Platform; + +namespace SourceGit.ViewModels +{ + public class Apply : Popup + { [CustomValidation(typeof(Apply), nameof(ValidatePatchFile))] - public string PatchFile { + public string PatchFile + { get => _patchFile; set => SetProperty(ref _patchFile, value, true); } - public bool IgnoreWhiteSpace { + public bool IgnoreWhiteSpace + { get => _ignoreWhiteSpace; set => SetProperty(ref _ignoreWhiteSpace, value); } - public List WhiteSpaceModes { + public Models.ApplyWhiteSpaceMode SelectedWhiteSpaceMode + { get; - private set; + set; + } + + public bool FromClipboard + { + get => _fromClipboard; + set => SetProperty(ref _fromClipboard, value); } - public Models.ApplyWhiteSpaceMode SelectedWhiteSpaceMode { + public bool ThreeWayMerge + { get; set; } - public Apply(Repository repo) { - _repo = repo; - - WhiteSpaceModes = new List { - new Models.ApplyWhiteSpaceMode("Apply.NoWarn", "Apply.NoWarn.Desc", "nowarn"), - new Models.ApplyWhiteSpaceMode("Apply.Warn", "Apply.Warn.Desc", "warn"), - new Models.ApplyWhiteSpaceMode("Apply.Error", "Apply.Error.Desc", "error"), - new Models.ApplyWhiteSpaceMode("Apply.ErrorAll", "Apply.ErrorAll.Desc", "error-all") - }; - SelectedWhiteSpaceMode = WhiteSpaceModes[0]; + public IClipboard Clipboard + { + get; + set; + } = null; - View = new Views.Apply() { DataContext = this }; + public Apply(Repository repo) + { + _repo = repo; + SelectedWhiteSpaceMode = Models.ApplyWhiteSpaceMode.Supported[0]; } - public static ValidationResult ValidatePatchFile(string file, ValidationContext _) { - if (File.Exists(file)) { + public static ValidationResult ValidatePatchFile(string file, ValidationContext ctx) + { + if (ctx.ObjectInstance is not Apply apply) + return new ValidationResult("Invalid object instance!!!"); + + if (apply.FromClipboard) return ValidationResult.Success; - } - return new ValidationResult($"File '{file}' can NOT be found!!!"); + if (string.IsNullOrEmpty(file)) + return new ValidationResult("Please select a patch file!!!"); + + if (!File.Exists(file)) + return new ValidationResult($"File '{file}' can NOT be found!!!"); + + return ValidationResult.Success; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Apply patch..."; - return Task.Run(() => { - var succ = new Commands.Apply(_repo.FullPath, _patchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg, null).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var finalPatchFile = _patchFile; + if (_fromClipboard) + { + if (Clipboard == null) + { + _repo.SendNotification("Clipboard service is not available!!!", true); + return false; + } + + var content = await Clipboard.TryGetTextAsync(); + if (string.IsNullOrEmpty(content) || !content.StartsWith("diff --git ", StringComparison.Ordinal)) + { + _repo.SendNotification("There's no valid patch content in clipboard!!!", true); + return false; + } + + finalPatchFile = Path.GetTempFileName(); + File.WriteAllText(finalPatchFile, content); + } + + var log = _repo.CreateLog("Apply Patch"); + Use(log); + + var extra = ThreeWayMerge ? "--3way" : string.Empty; + var succ = await new Commands.Apply(_repo.FullPath, finalPatchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg, extra) + .Use(log) + .ExecAsync(); + + log.Complete(); + + if (_fromClipboard) + File.Delete(finalPatchFile); + + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; private string _patchFile = string.Empty; + private bool _fromClipboard = false; private bool _ignoreWhiteSpace = true; } } diff --git a/src/ViewModels/ApplyStash.cs b/src/ViewModels/ApplyStash.cs new file mode 100644 index 000000000..8fb9d4b72 --- /dev/null +++ b/src/ViewModels/ApplyStash.cs @@ -0,0 +1,63 @@ +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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Applying stash: {Stash.Name}"; + + var log = _repo.CreateLog("Apply Stash"); + Use(log); + + var succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .ApplyAsync(Stash.Name, RestoreIndex); + + if (succ) + { + _repo.MarkWorkingCopyDirtyManually(); + + if (DropAfterApply) + { + await new Commands.Stash(_repo.FullPath) + .Use(log) + .DropAsync(Stash.Name); + + _repo.MarkStashesDirtyManually(); + } + } + + log.Complete(); + return true; + } + + private readonly Repository _repo; + } +} diff --git a/src/ViewModels/Archive.cs b/src/ViewModels/Archive.cs index b9c172f69..7f5840eec 100644 --- a/src/ViewModels/Archive.cs +++ b/src/ViewModels/Archive.cs @@ -1,62 +1,70 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Archive : Popup { - +namespace SourceGit.ViewModels +{ + public class Archive : Popup + { [Required(ErrorMessage = "Output file name is required")] - public string SaveFile { + public string SaveFile + { get => _saveFile; set => SetProperty(ref _saveFile, value, true); } - public object BasedOn { + public object BasedOn + { get; private set; } - public Archive(Repository repo, Models.Branch branch) { + public Archive(Repository repo, Models.Branch branch) + { _repo = repo; _revision = branch.Head; - _saveFile = $"archive-{Path.GetFileNameWithoutExtension(branch.Name)}.zip"; + _saveFile = $"archive-{Path.GetFileName(branch.Name)}.zip"; BasedOn = branch; - View = new Views.Archive() { DataContext = this }; } - public Archive(Repository repo, Models.Commit commit) { + public Archive(Repository repo, Models.Commit commit) + { _repo = repo; _revision = commit.SHA; - _saveFile = $"archive-{commit.SHA.Substring(0,10)}.zip"; + _saveFile = $"archive-{commit.SHA.AsSpan(0, 10)}.zip"; BasedOn = commit; - View = new Views.Archive() { DataContext = this }; } - public Archive(Repository repo, Models.Tag tag) { + public Archive(Repository repo, Models.Tag tag) + { _repo = repo; _revision = tag.SHA; - _saveFile = $"archive-{tag.Name}.zip"; + _saveFile = $"archive-{Path.GetFileName(tag.Name)}.zip"; BasedOn = tag; - View = new Views.Archive() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Archiving ..."; - return Task.Run(() => { - var succ = new Commands.Archive(_repo.FullPath, _revision, _saveFile, SetProgressDescription).Exec(); - CallUIThread(() => { - _repo.SetWatcherEnabled(true); - if (succ) App.SendNotification(_repo.FullPath, $"Save archive to : {_saveFile}"); - }); + var log = _repo.CreateLog("Archive"); + Use(log); + + var succ = await new Commands.Archive(_repo.FullPath, _revision, _saveFile) + .Use(log) + .ExecAsync(); + + log.Complete(); - return succ; - }); + if (succ) + _repo.SendNotification($"Save archive to : {_saveFile}"); + return succ; } - private Repository _repo = null; - private string _saveFile = string.Empty; - private string _revision = string.Empty; + private readonly Repository _repo = null; + private string _saveFile; + private readonly string _revision; } } diff --git a/src/ViewModels/AssumeUnchangedManager.cs b/src/ViewModels/AssumeUnchangedManager.cs index f633de75c..8cd4a1663 100644 --- a/src/ViewModels/AssumeUnchangedManager.cs +++ b/src/ViewModels/AssumeUnchangedManager.cs @@ -1,30 +1,42 @@ -using Avalonia.Collections; +using System.Threading.Tasks; +using Avalonia.Collections; using Avalonia.Threading; -using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class AssumeUnchangedManager { +namespace SourceGit.ViewModels +{ + public class AssumeUnchangedManager + { public AvaloniaList Files { get; private set; } - public AssumeUnchangedManager(string repo) { + public AssumeUnchangedManager(Repository repo) + { _repo = repo; Files = new AvaloniaList(); - Task.Run(() => { - var collect = new Commands.AssumeUnchanged(_repo).View(); - Dispatcher.UIThread.Invoke(() => { - Files.AddRange(collect); - }); + Task.Run(async () => + { + var collect = await new Commands.QueryAssumeUnchangedFiles(_repo.FullPath) + .GetResultAsync() + .ConfigureAwait(false); + Dispatcher.UIThread.Post(() => Files.AddRange(collect)); }); } - public void Remove(object param) { - if (param is string file) { - new Commands.AssumeUnchanged(_repo).Remove(file); + public async Task RemoveAsync(string file) + { + if (!string.IsNullOrEmpty(file)) + { + var log = _repo.CreateLog("Remove Assume Unchanged File"); + + await new Commands.AssumeUnchanged(_repo.FullPath, file, false) + .Use(log) + .ExecAsync(); + + log.Complete(); Files.Remove(file); } } - private string _repo; + private readonly Repository _repo; } } diff --git a/src/ViewModels/Blame.cs b/src/ViewModels/Blame.cs index 3aa1e66e1..be05fccd4 100644 --- a/src/ViewModels/Blame.cs +++ b/src/ViewModels/Blame.cs @@ -1,48 +1,229 @@ -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Blame : ObservableObject + { + public string File + { + get => _file; + private set => SetProperty(ref _file, value); + } -namespace SourceGit.ViewModels { - public class Blame : ObservableObject { - public string Title { - get; - private set; + public bool IgnoreWhitespace + { + get => _ignoreWhitespace; + set + { + if (SetProperty(ref _ignoreWhitespace, value)) + SetBlameData(_navigationHistory[0]); + } } - public string SelectedSHA { - get => _selectedSHA; - private set => SetProperty(ref _selectedSHA, value); + public Models.Commit Revision + { + get => _revision; + private set => SetProperty(ref _revision, value); } - public bool IsBinary { - get => _data != null && _data.IsBinary; + public Models.Commit PrevRevision + { + get => _prevRevision; + private set => SetProperty(ref _prevRevision, value); } - public Models.BlameData Data { + public Models.BlameData Data + { get => _data; - private set => SetProperty(ref _data, value); + private set + { + if (SetProperty(ref _data, value)) + OnPropertyChanged(nameof(IsBinary)); + } + } + + public bool IsBinary + { + get => _data?.IsBinary ?? false; + } + + public bool CanBack + { + get => _navigationActiveIndex > 0; + } + + public bool CanForward + { + get => _navigationActiveIndex < _navigationHistory.Count - 1; } - public Blame(string repo, string file, string revision) { + public Blame(string repo, string file, Models.Commit commit) + { + var sha = commit.SHA.Substring(0, 10); _repo = repo; + _navigationHistory.Add(new RevisionInfo(file, sha)); + SetBlameData(_navigationHistory[0]); + } - Title = $"{file}@{revision.Substring(0, 10)}"; - Task.Run(() => { - var result = new Commands.Blame(repo, file, revision).Result(); - Dispatcher.UIThread.Invoke(() => { - Data = result; - OnPropertyChanged(nameof(IsBinary)); + public string GetCommitMessage(string sha) + { + if (_commitMessages.TryGetValue(sha, out var msg)) + return msg; + + msg = new Commands.QueryCommitFullMessage(_repo, sha).GetResult(); + _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 GotoPrevRevision() + { + if (_prevRevision != null) + NavigateToCommit(_file, _prevRevision.SHA.Substring(0, 10)); + } + + public void NavigateToCommit(string file, string sha) + { + if (App.GetLauncher() is { Pages: { } pages }) + { + foreach (var page in pages) + { + if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) + { + repo.NavigateToCommit(sha); + break; + } + } + } + + if (Revision.SHA.StartsWith(sha, StringComparison.Ordinal)) + return; + + var count = _navigationHistory.Count; + if (_navigationActiveIndex < count - 1) + _navigationHistory.RemoveRange(_navigationActiveIndex + 1, count - _navigationActiveIndex - 1); + + var rev = new RevisionInfo(file, sha); + _navigationHistory.Add(rev); + _navigationActiveIndex++; + OnPropertyChanged(nameof(CanBack)); + OnPropertyChanged(nameof(CanForward)); + SetBlameData(rev); + } + + private void NavigateToCommit(RevisionInfo rev) + { + if (App.GetLauncher() is { Pages: { } pages }) + { + foreach (var page in pages) + { + if (page.Data is Repository repo && repo.FullPath.Equals(_repo)) + { + repo.NavigateToCommit(rev.SHA); + break; + } + } + } + + if (!Revision.SHA.StartsWith(rev.SHA, StringComparison.Ordinal)) + SetBlameData(rev); + } + + private void SetBlameData(RevisionInfo rev) + { + if (_cancellationSource is { IsCancellationRequested: false }) + _cancellationSource.Cancel(); + + _cancellationSource = new CancellationTokenSource(); + var token = _cancellationSource.Token; + + File = rev.File; + + Task.Run(async () => + { + var argsBuilder = new StringBuilder(); + argsBuilder + .Append("--date-order -n 2 ") + .Append(rev.SHA) + .Append(" -- ") + .Append(rev.File.Quoted()); + + var commits = await new Commands.QueryCommits(_repo, argsBuilder.ToString(), false) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + if (!token.IsCancellationRequested) + { + Revision = commits.Count > 0 ? commits[0] : null; + PrevRevision = commits.Count > 1 ? commits[1] : null; + } }); }); + + Task.Run(async () => + { + var result = await new Commands.Blame(_repo, rev.File, rev.SHA, _ignoreWhitespace) + .ReadAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + if (!token.IsCancellationRequested) + Data = result; + }); + }, token); } - public void NavigateToCommit(string commitSHA) { - var repo = Preference.FindRepository(_repo); - if (repo != null) repo.NavigateToCommit(commitSHA); + private class RevisionInfo + { + public string File { get; set; } = string.Empty; + public string SHA { get; set; } = string.Empty; + + public RevisionInfo(string file, string sha) + { + File = file; + SHA = sha; + } } - private string _repo = string.Empty; - private string _selectedSHA = string.Empty; + private string _repo; + private string _file; + private bool _ignoreWhitespace = false; + private Models.Commit _revision; + private Models.Commit _prevRevision; + private CancellationTokenSource _cancellationSource = null; + private int _navigationActiveIndex = 0; + private List _navigationHistory = []; private Models.BlameData _data = null; + private Dictionary _commitMessages = new(); } } diff --git a/src/ViewModels/BlameCommandPalette.cs b/src/ViewModels/BlameCommandPalette.cs new file mode 100644 index 000000000..e73430872 --- /dev/null +++ b/src/ViewModels/BlameCommandPalette.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class BlameCommandPalette : ICommandPalette + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List VisibleFiles + { + get => _visibleFiles; + private set => SetProperty(ref _visibleFiles, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateVisible(); + } + } + + public string SelectedFile + { + get => _selectedFile; + set => SetProperty(ref _selectedFile, value); + } + + public BlameCommandPalette(string repo) + { + _repo = repo; + _isLoading = true; + + Task.Run(async () => + { + var files = await new Commands.QueryRevisionFileNames(_repo, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + var head = await new Commands.QuerySingleCommit(_repo, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + IsLoading = false; + _repoFiles = files; + _head = head; + UpdateVisible(); + }); + }); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public Blame Launch() + { + _repoFiles.Clear(); + _visibleFiles.Clear(); + Close(); + return !string.IsNullOrEmpty(_selectedFile) ? new Blame(_repo, _selectedFile, _head) : null; + } + + private void UpdateVisible() + { + if (_repoFiles is { Count: > 0 }) + { + if (string.IsNullOrEmpty(_filter)) + { + VisibleFiles = _repoFiles; + + if (string.IsNullOrEmpty(_selectedFile)) + SelectedFile = _repoFiles[0]; + } + else + { + var visible = new List(); + + foreach (var f in _repoFiles) + { + if (f.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(f); + } + + var autoSelected = _selectedFile; + if (visible.Count == 0) + autoSelected = null; + else if (string.IsNullOrEmpty(_selectedFile) || !visible.Contains(_selectedFile)) + autoSelected = visible[0]; + + VisibleFiles = visible; + SelectedFile = autoSelected; + } + } + } + + private string _repo = null; + private bool _isLoading = false; + private Models.Commit _head = null; + private List _repoFiles = null; + private string _filter = string.Empty; + private List _visibleFiles = []; + private string _selectedFile = null; + } +} diff --git a/src/ViewModels/BlockNavigation.cs b/src/ViewModels/BlockNavigation.cs new file mode 100644 index 000000000..0debfba0e --- /dev/null +++ b/src/ViewModels/BlockNavigation.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public enum BlockNavigationDirection + { + First = 0, + Prev, + Next, + Last + } + + public class BlockNavigation : ObservableObject + { + public record Block(int Start, int End) + { + public bool Contains(int line) + { + return line >= Start && line <= End; + } + } + + 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(List lines, int cur) + { + _blocks.Clear(); + + if (lines.Count == 0) + { + _current = -1; + return; + } + + var lineIdx = 0; + var blockStartIdx = 0; + var isReadingBlock = false; + var blocks = new List(); + + foreach (var line in lines) + { + lineIdx++; + if (line.Type is Models.TextDiffLineType.Added or Models.TextDiffLineType.Deleted or Models.TextDiffLineType.None) + { + if (!isReadingBlock) + { + isReadingBlock = true; + blockStartIdx = lineIdx; + } + } + else + { + if (isReadingBlock) + { + blocks.Add(new Block(blockStartIdx, lineIdx - 1)); + isReadingBlock = false; + } + } + } + + if (isReadingBlock) + blocks.Add(new Block(blockStartIdx, lines.Count)); + + _blocks.AddRange(blocks); + _current = Math.Min(_blocks.Count - 1, cur); + } + + public int GetCurrentBlockIndex() + { + return _current; + } + + public Block GetCurrentBlock() + { + if (_current >= 0 && _current < _blocks.Count) + return _blocks[_current]; + + return null; + } + + public Block Goto(BlockNavigationDirection direction) + { + if (_blocks.Count == 0) + return null; + + _current = direction switch + { + BlockNavigationDirection.First => 0, + BlockNavigationDirection.Prev => _current <= 0 ? 0 : _current - 1, + BlockNavigationDirection.Next => _current >= _blocks.Count - 1 ? _blocks.Count - 1 : _current + 1, + BlockNavigationDirection.Last => _blocks.Count - 1, + _ => _current + }; + + OnPropertyChanged(nameof(Indicator)); + return _blocks[_current]; + } + + public void UpdateByChunk(TextDiffSelectedChunk chunk) + { + _current = -1; + + var chunkStart = chunk.StartIdx + 1; + var chunkEnd = chunk.EndIdx + 1; + + for (var i = 0; i < _blocks.Count; i++) + { + var block = _blocks[i]; + if (chunkStart > block.End) + continue; + + if (chunkEnd < block.Start) + { + _current = i - 1; + break; + } + + _current = i; + } + } + + public void UpdateByCaretPosition(int caretLine) + { + if (_current >= 0 && _current < _blocks.Count) + { + var block = _blocks[_current]; + if (block.Contains(caretLine)) + return; + } + + _current = -1; + + for (var i = 0; i < _blocks.Count; i++) + { + var block = _blocks[i]; + if (block.Start > caretLine) + break; + + _current = i; + if (block.End >= caretLine) + break; + } + + OnPropertyChanged(nameof(Indicator)); + } + + private int _current; + private readonly List _blocks = []; + } +} diff --git a/src/ViewModels/BranchTreeNode.cs b/src/ViewModels/BranchTreeNode.cs new file mode 100644 index 000000000..53ac1df9b --- /dev/null +++ b/src/ViewModels/BranchTreeNode.cs @@ -0,0 +1,260 @@ +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; + } + + private Models.FilterMode _filterMode = Models.FilterMode.None; + private bool _isExpanded = false; + private CornerRadius _cornerRadius = new CornerRadius(4); + + public class Builder + { + public List Locals { get; } = []; + public List Remotes { get; } = []; + public List InvalidExpandedNodes { get; } = []; + + 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('/'); + if (sepIdx == -1 || branch.IsDetachedHead) + { + roots.Add(new BranchTreeNode() + { + Name = branch.Name, + Path = fullpath, + Backend = branch, + IsExpanded = false, + TimeToSort = time, + }); + return; + } + + BranchTreeNode lastFolder = null; + 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); + 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; + private readonly Models.BranchSortMode _remoteSortMode; + private readonly HashSet _expanded = new HashSet(); + } + } +} diff --git a/src/ViewModels/ChangeCollection.cs b/src/ViewModels/ChangeCollection.cs new file mode 100644 index 000000000..5de9f4fd1 --- /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/ChangeSubmoduleUrl.cs b/src/ViewModels/ChangeSubmoduleUrl.cs new file mode 100644 index 000000000..74deaddda --- /dev/null +++ b/src/ViewModels/ChangeSubmoduleUrl.cs @@ -0,0 +1,59 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ChangeSubmoduleUrl : Popup + { + public Models.Submodule Submodule + { + get; + } + + [Required(ErrorMessage = "Url is required!!!")] + [CustomValidation(typeof(ChangeSubmoduleUrl), nameof(ValidateUrl))] + public string Url + { + get => _url; + set => SetProperty(ref _url, value, true); + } + + public ChangeSubmoduleUrl(Repository repo, Models.Submodule submodule) + { + _repo = repo; + _url = submodule.URL; + Submodule = submodule; + } + + 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 async Task Sure() + { + if (_url.Equals(Submodule.URL, StringComparison.Ordinal)) + return true; + + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Change submodule's url..."; + + var log = _repo.CreateLog("Change Submodule's URL"); + Use(log); + + var succ = await new Commands.Submodule(_repo.FullPath) + .Use(log) + .SetURLAsync(Submodule.Path, _url); + + log.Complete(); + return succ; + } + + private readonly Repository _repo; + private string _url; + } +} diff --git a/src/ViewModels/ChangeTreeNode.cs b/src/ViewModels/ChangeTreeNode.cs new file mode 100644 index 000000000..c35f4fc1f --- /dev/null +++ b/src/ViewModels/ChangeTreeNode.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ChangeTreeNode : ObservableObject + { + public string FullPath { get; set; } + public string DisplayName { 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) + { + FullPath = c.Path; + DisplayName = Path.GetFileName(c.Path); + Change = c; + IsExpanded = false; + } + + public ChangeTreeNode(string path, bool isExpanded) + { + FullPath = path; + DisplayName = Path.GetFileName(path); + IsExpanded = isExpanded; + } + + public static List Build(IList changes, HashSet folded, bool compactFolders) + { + var nodes = new List(); + var folders = new Dictionary(); + + foreach (var c in changes) + { + var sepIdx = c.Path.IndexOf('/'); + if (sepIdx == -1) + { + nodes.Add(new ChangeTreeNode(c)); + } + else + { + ChangeTreeNode lastFolder = null; + + 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)); + folders.Add(folder, lastFolder); + InsertFolder(nodes, lastFolder); + } + else + { + var cur = new ChangeTreeNode(folder, !folded.Contains(folder)); + folders.Add(folder, cur); + InsertFolder(lastFolder.Children, cur); + lastFolder = cur; + } + + sepIdx = c.Path.IndexOf('/', sepIdx + 1); + } + + lastFolder?.Children.Add(new ChangeTreeNode(c)); + } + } + + if (compactFolders) + { + foreach (var node in nodes) + Compact(node); + } + + SortAndSetDepth(nodes, 0); + + 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 Compact(ChangeTreeNode node) + { + var childrenCount = node.Children.Count; + if (childrenCount == 0) + return; + + if (childrenCount > 1) + { + foreach (var c in node.Children) + Compact(c); + return; + } + + var child = node.Children[0]; + if (child.Change != null) + return; + + node.FullPath = $"{node.FullPath}/{child.DisplayName}"; + node.DisplayName = $"{node.DisplayName} / {child.DisplayName}"; + node.IsExpanded = child.IsExpanded; + node.Children = child.Children; + Compact(node); + } + + private static void SortAndSetDepth(List nodes, int depth) + { + foreach (var node in nodes) + { + node.Depth = depth; + if (node.IsFolder) + SortAndSetDepth(node.Children, depth + 1); + } + + nodes.Sort((l, r) => + { + if (l.IsFolder == r.IsFolder) + return Models.NumericSort.Compare(l.DisplayName, r.DisplayName); + return l.IsFolder ? -1 : 1; + }); + } + + private bool _isExpanded = true; + } +} diff --git a/src/ViewModels/Checkout.cs b/src/ViewModels/Checkout.cs index 9570b2657..3c47d47a3 100644 --- a/src/ViewModels/Checkout.cs +++ b/src/ViewModels/Checkout.cs @@ -1,29 +1,115 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Checkout : Popup { - public string Branch { +namespace SourceGit.ViewModels +{ + public class Checkout : Popup + { + public string BranchName + { + get => _branch.Name; + } + + public bool HasLocalChanges + { + get => _repo.LocalChangesCount > 0; + } + + public Models.DealWithLocalChanges DealWithLocalChanges + { get; - private set; + set; } - public Checkout(Repository repo, string branch) { + public Checkout(Repository repo, Models.Branch branch) + { _repo = repo; - Branch = branch; - View = new Views.Checkout() { DataContext = this }; + _branch = branch; + + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Checkout '{Branch}' ..."; + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + var branchName = BranchName; + ProgressDescription = $"Checkout '{branchName}' ..."; + + var log = _repo.CreateLog($"Checkout '{branchName}'"); + Use(log); + + if (_repo.CurrentBranch is { IsDetachedHead: true }) + { + var refs = await new Commands.QueryRefsContainsCommit(_repo.FullPath, _repo.CurrentBranch.Head).GetResultAsync(); + if (refs.Count == 0) + { + var msg = App.Text("Checkout.WarnLostCommits"); + var shouldContinue = await App.AskConfirmAsync(msg); + if (!shouldContinue) + return true; + } + } + + var succ = false; + var needPopStash = false; + + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(branchName, false); + } + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) + { + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); + if (changes > 0) + { + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync("CHECKOUT_AUTO_STASH", false); + if (!succ) + { + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return false; + } + + needPopStash = true; + } + + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(branchName, false); + } + else + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(branchName, true); + } + + if (succ) + { + await _repo.AutoUpdateSubmodulesAsync(log); + + if (needPopStash) + await new Commands.Stash(_repo.FullPath) + .Use(log) + .PopAsync("stash@{0}"); + + _repo.RefreshAfterCheckoutBranch(_branch); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } - return Task.Run(() => { - var succ = new Commands.Checkout(_repo.FullPath).Branch(Branch, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + log.Complete(); + return succ; } - private Repository _repo; + private readonly Repository _repo = null; + private readonly Models.Branch _branch = null; } } diff --git a/src/ViewModels/CheckoutAndFastForward.cs b/src/ViewModels/CheckoutAndFastForward.cs new file mode 100644 index 000000000..50dc99aa3 --- /dev/null +++ b/src/ViewModels/CheckoutAndFastForward.cs @@ -0,0 +1,123 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CheckoutAndFastForward : Popup + { + public Models.Branch LocalBranch + { + get; + } + + public Models.Branch RemoteBranch + { + get; + } + + public bool HasLocalChanges + { + get => _repo.LocalChangesCount > 0; + } + + public Models.DealWithLocalChanges DealWithLocalChanges + { + get; + set; + } + + public CheckoutAndFastForward(Repository repo, Models.Branch localBranch, Models.Branch remoteBranch) + { + _repo = repo; + LocalBranch = localBranch; + RemoteBranch = remoteBranch; + + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Checkout and Fast-Forward '{LocalBranch.Name}' ..."; + + var log = _repo.CreateLog($"Checkout and Fast-Forward '{LocalBranch.Name}' ..."); + Use(log); + + if (_repo.CurrentBranch is { IsDetachedHead: true }) + { + var refs = await new Commands.QueryRefsContainsCommit(_repo.FullPath, _repo.CurrentBranch.Head).GetResultAsync(); + if (refs.Count == 0) + { + var msg = App.Text("Checkout.WarnLostCommits"); + var shouldContinue = await App.AskConfirmAsync(msg); + if (!shouldContinue) + return true; + } + } + + var succ = false; + var needPopStash = false; + + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(LocalBranch.Name, RemoteBranch.Head, false, true); + } + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) + { + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); + if (changes > 0) + { + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync("CHECKOUT_AND_FASTFORWARD_AUTO_STASH", false); + if (!succ) + { + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return false; + } + + needPopStash = true; + } + + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(LocalBranch.Name, RemoteBranch.Head, false, true); + } + else + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(LocalBranch.Name, RemoteBranch.Head, true, true); + } + + if (succ) + { + await _repo.AutoUpdateSubmodulesAsync(log); + + if (needPopStash) + await new Commands.Stash(_repo.FullPath) + .Use(log) + .PopAsync("stash@{0}"); + + LocalBranch.Behind.Clear(); + LocalBranch.Head = RemoteBranch.Head; + LocalBranch.CommitterDate = RemoteBranch.CommitterDate; + + _repo.RefreshAfterCheckoutBranch(LocalBranch); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } + + log.Complete(); + return succ; + } + + private Repository _repo; + } +} diff --git a/src/ViewModels/CheckoutBranchFromStash.cs b/src/ViewModels/CheckoutBranchFromStash.cs new file mode 100644 index 000000000..d62f4f2df --- /dev/null +++ b/src/ViewModels/CheckoutBranchFromStash.cs @@ -0,0 +1,70 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CheckoutBranchFromStash : Popup + { + public Models.Stash Target + { + get; + } + + [Required(ErrorMessage = "Branch name is required!")] + [RegularExpression(@"^[\w\-/\.#\+]+$", ErrorMessage = "Bad branch name format!")] + [CustomValidation(typeof(CheckoutBranchFromStash), nameof(ValidateBranchName))] + public string BranchName + { + get => _branchName; + set => SetProperty(ref _branchName, value, true); + } + + public CheckoutBranchFromStash(Repository repo, Models.Stash stash) + { + _repo = repo; + Target = stash; + } + + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is CheckoutBranchFromStash caller) + { + foreach (var b in caller._repo.Branches) + { + if (b.FriendlyName.Equals(name, StringComparison.Ordinal)) + return new ValidationResult("A branch with same name already exists!"); + } + + return ValidationResult.Success; + } + + return new ValidationResult("Missing runtime context to create branch!"); + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Checkout branch from stash..."; + + var log = _repo.CreateLog($"Checkout Branch '{_branchName}'"); + Use(log); + + var succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .CheckoutBranchAsync(Target.Name, _branchName); + + if (succ) + { + _repo.MarkWorkingCopyDirtyManually(); + _repo.MarkStashesDirtyManually(); + } + + log.Complete(); + return true; + } + + private readonly Repository _repo; + private string _branchName = string.Empty; + } +} diff --git a/src/ViewModels/CheckoutCommandPalette.cs b/src/ViewModels/CheckoutCommandPalette.cs new file mode 100644 index 000000000..480556bc5 --- /dev/null +++ b/src/ViewModels/CheckoutCommandPalette.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CheckoutCommandPalette : ICommandPalette + { + public List Branches + { + get => _branches; + private set => SetProperty(ref _branches, value); + } + + public Models.Branch SelectedBranch + { + get => _selectedBranch; + set => SetProperty(ref _selectedBranch, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateBranches(); + } + } + + public CheckoutCommandPalette(Repository repo) + { + _repo = repo; + UpdateBranches(); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public async Task ExecAsync() + { + _branches.Clear(); + Close(); + + if (_selectedBranch != null) + await _repo.CheckoutBranchAsync(_selectedBranch); + } + + private void UpdateBranches() + { + var current = _repo.CurrentBranch; + if (current == null) + return; + + var branches = new List(); + foreach (var b in _repo.Branches) + { + if (b == current) + continue; + + if (string.IsNullOrEmpty(_filter) || b.FriendlyName.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + branches.Add(b); + } + + branches.Sort((l, r) => + { + if (l.IsLocal == r.IsLocal) + return Models.NumericSort.Compare(l.Name, r.Name); + + return l.IsLocal ? -1 : 1; + }); + + var autoSelected = _selectedBranch; + if (branches.Count == 0) + autoSelected = null; + else if (_selectedBranch == null || !branches.Contains(_selectedBranch)) + autoSelected = branches[0]; + + Branches = branches; + SelectedBranch = autoSelected; + } + + private Repository _repo; + private List _branches = []; + private Models.Branch _selectedBranch = null; + private string _filter; + } +} diff --git a/src/ViewModels/CheckoutDetached.cs b/src/ViewModels/CheckoutDetached.cs new file mode 100644 index 000000000..4e4d0def0 --- /dev/null +++ b/src/ViewModels/CheckoutDetached.cs @@ -0,0 +1,120 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class CheckoutDetached : Popup + { + public object Target + { + get; + } + + public bool HasLocalChanges + { + get => _repo.LocalChangesCount > 0; + } + + public Models.DealWithLocalChanges DealWithLocalChanges + { + get; + set; + } + + public CheckoutDetached(Repository repo, Models.Commit commit) + { + _repo = repo; + _revision = commit.SHA; + + Target = commit; + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + } + + public CheckoutDetached(Repository repo, Models.Tag tag) + { + _repo = repo; + _revision = tag.SHA; + + Target = tag; + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Checkout Commit '{_revision}' ..."; + + var log = _repo.CreateLog("Checkout Commit"); + Use(log); + + if (_repo.CurrentBranch is { IsDetachedHead: true }) + { + var refs = await new Commands.QueryRefsContainsCommit(_repo.FullPath, _repo.CurrentBranch.Head).GetResultAsync(); + if (refs.Count == 0) + { + var msg = App.Text("Checkout.WarnLostCommits"); + var shouldContinue = await App.AskConfirmAsync(msg); + if (!shouldContinue) + return true; + } + } + + var succ = false; + var needPop = false; + + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .CommitAsync(_revision, false); + } + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) + { + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); + if (changes > 0) + { + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync("CHECKOUT_AUTO_STASH", false); + if (!succ) + { + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return false; + } + + needPop = true; + } + + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .CommitAsync(_revision, false); + } + else + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .CommitAsync(_revision, true); + } + + if (succ) + { + await _repo.AutoUpdateSubmodulesAsync(log); + + if (needPop) + await new Commands.Stash(_repo.FullPath) + .Use(log) + .PopAsync("stash@{0}"); + } + + log.Complete(); + return succ; + } + + private readonly Repository _repo = null; + private readonly string _revision = string.Empty; + } +} diff --git a/src/ViewModels/CherryPick.cs b/src/ViewModels/CherryPick.cs index 2844effbd..55ec83c99 100644 --- a/src/ViewModels/CherryPick.cs +++ b/src/ViewModels/CherryPick.cs @@ -1,35 +1,119 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class CherryPick : Popup { - public Models.Commit Target { +namespace SourceGit.ViewModels +{ + public class CherryPick : Popup + { + public List Targets + { get; private set; } - public bool AutoCommit { + public bool IsMergeCommit + { + get; + private set; + } + + public List ParentsForMergeCommit + { + get; + private set; + } + + public int MainlineForMergeCommit + { + get; + set; + } + + public bool AppendSourceToMessage + { get; set; } - public CherryPick(Repository repo, Models.Commit target) { + 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; - Target = target; + Targets = [merge]; + IsMergeCommit = true; + ParentsForMergeCommit = parents; + MainlineForMergeCommit = 0; + AppendSourceToMessage = true; AutoCommit = true; - View = new Views.CherryPick() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Cherry-Pick commit '{Target.SHA}' ..."; + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + _repo.ClearCommitMessage(); + ProgressDescription = "Cherry-Pick commit(s) ..."; + + var log = _repo.CreateLog("Cherry-Pick"); + Use(log); + + if (IsMergeCommit) + { + await new Commands.CherryPick( + _repo.FullPath, + Targets[0].SHA, + !AutoCommit, + AppendSourceToMessage, + $"-m {MainlineForMergeCommit + 1}") + .Use(log) + .ExecAsync(); + } + else + { + await new Commands.CherryPick( + _repo.FullPath, + string.Join(' ', Targets.ConvertAll(c => c.SHA)), + !AutoCommit, + AppendSourceToMessage, + string.Empty) + .Use(log) + .ExecAsync(); + + if (!AutoCommit && Targets.Count > 1) + { + var builder = new StringBuilder(); + foreach (var t in Targets) + builder.Append(t.Subject).Append("\n"); + + builder.Append("\n"); + foreach (var t in Targets) + builder.Append("(cherry picked from commit ").Append(t.SHA).Append(")\n"); + + File.WriteAllText(Path.Combine(_repo.GitDir, "MERGE_MSG"), builder.ToString()); + } + } - return Task.Run(() => { - var succ = new Commands.CherryPick(_repo.FullPath, Target.SHA, !AutoCommit).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + log.Complete(); + return true; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/Cleanup.cs b/src/ViewModels/Cleanup.cs index a90b08f16..974f11cf3 100644 --- a/src/ViewModels/Cleanup.cs +++ b/src/ViewModels/Cleanup.cs @@ -1,30 +1,30 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Cleanup : Popup { - public Cleanup(Repository repo) { +namespace SourceGit.ViewModels +{ + public class Cleanup : Popup + { + public Cleanup(Repository repo) + { _repo = repo; - View = new Views.Cleanup() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Cleanup (GC & prune) ..."; - return Task.Run(() => { - new Commands.GC(_repo.FullPath, SetProgressDescription).Exec(); + var log = _repo.CreateLog("Cleanup (GC & prune)"); + Use(log); - var lfs = new Commands.LFS(_repo.FullPath); - if (lfs.IsEnabled()) { - SetProgressDescription("Run LFS prune ..."); - lfs.Prune(SetProgressDescription); - } + await new Commands.GC(_repo.FullPath) + .Use(log) + .ExecAsync(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + log.Complete(); + return true; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/ClearStashes.cs b/src/ViewModels/ClearStashes.cs index 40a360849..0c5092d12 100644 --- a/src/ViewModels/ClearStashes.cs +++ b/src/ViewModels/ClearStashes.cs @@ -1,23 +1,31 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class ClearStashes : Popup { - public ClearStashes(Repository repo) { +namespace SourceGit.ViewModels +{ + public class ClearStashes : Popup + { + public ClearStashes(Repository repo) + { _repo = repo; - View = new Views.ClearStashes() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Clear all stashes..."; - return Task.Run(() => { - new Commands.Stash(_repo.FullPath).Clear(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + var log = _repo.CreateLog("Clear Stashes"); + Use(log); + + await new Commands.Stash(_repo.FullPath) + .Use(log) + .ClearAsync(); + + log.Complete(); + _repo.MarkStashesDirtyManually(); + return true; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs index 7c06f7feb..e62937cbc 100644 --- a/src/ViewModels/Clone.cs +++ b/src/ViewModels/Clone.cs @@ -1,115 +1,203 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Clone : Popup { +namespace SourceGit.ViewModels +{ + public class Clone : Popup + { [Required(ErrorMessage = "Remote URL is required")] [CustomValidation(typeof(Clone), nameof(ValidateRemote))] - public string Remote { + public string Remote + { get => _remote; - set { - if (SetProperty(ref _remote, value, true)) UseSSH = Models.Remote.IsSSH(value); + set + { + if (SetProperty(ref _remote, value, true)) + UseSSH = Models.Remote.IsSSH(value); } } - public bool UseSSH { + public bool UseSSH + { get => _useSSH; set => SetProperty(ref _useSSH, value); } - public string SSHKey { + public string SSHKey + { get => _sshKey; set => SetProperty(ref _sshKey, value); } [Required(ErrorMessage = "Parent folder is required")] [CustomValidation(typeof(Clone), nameof(ValidateParentFolder))] - public string ParentFolder { + public string ParentFolder + { get => _parentFolder; set => SetProperty(ref _parentFolder, value, true); } - public string Local { + public string Local + { get => _local; set => SetProperty(ref _local, value); } - public string ExtraArgs { + public List Groups + { + get; + } + + public RepositoryNode SelectedGroup + { + get => _selectedGroup; + set => SetProperty(ref _selectedGroup, value); + } + + public int Bookmark + { + get => _bookmark; + set => SetProperty(ref _bookmark, value); + } + + public string ExtraArgs + { get => _extraArgs; set => SetProperty(ref _extraArgs, value); } - public Clone(Launcher launcher, LauncherPage page) { - _launcher = launcher; - _page = page; - - View = new Views.Clone() { DataContext = this }; + public bool InitAndUpdateSubmodules + { + get; + set; + } = true; + + public Clone(string pageId) + { + _pageId = pageId; + + Groups = new List(); + Groups.Add(new RepositoryNode { Name = "No Group (Uncategorized)", Id = string.Empty }); + SelectedGroup = Groups[0]; + CollectGroups(Groups, Preferences.Instance.RepositoryNodes); + + var activeWorkspace = Preferences.Instance.GetActiveWorkspace(); + _parentFolder = activeWorkspace?.DefaultCloneDir; + if (string.IsNullOrEmpty(ParentFolder)) + _parentFolder = Preferences.Instance.GitDefaultCloneDir; } - public static ValidationResult ValidateRemote(string remote, ValidationContext _) { - if (!Models.Remote.IsValidURL(remote)) return new ValidationResult("Invalid remote repository URL format"); + 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"); + 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() { + public override async Task Sure() + { ProgressDescription = "Clone ..."; - return Task.Run(() => { - var cmd = new Commands.Clone(HostPageId, _parentFolder, _remote, _local, _useSSH ? _sshKey : "", _extraArgs, SetProgressDescription); - 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")) name = name.Substring(0, name.Length - 4); - path = Path.GetFullPath(Path.Combine(path, name)); - } + var log = new CommandLog("Clone"); + Use(log); - if (!Directory.Exists(path)) { - CallUIThread(() => { - App.RaiseException(HostPageId, $"Folder '{path}' can NOT be found"); - }); - return false; - } + var succ = await new Commands.Clone(_pageId, _parentFolder, _remote, _local, _useSSH ? _sshKey : "", _extraArgs) + .Use(log) + .ExecAsync(); + if (!succ) + 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); + else if (name.EndsWith(".bundle", StringComparison.Ordinal)) + name = name.Substring(0, name.Length - 7); + + path = Path.GetFullPath(Path.Combine(path, name)); + } - if (_useSSH && !string.IsNullOrEmpty(_sshKey)) { - var config = new Commands.Config(path); - config.Set("remote.origin.sshkey", _sshKey); + if (!Directory.Exists(path)) + { + Models.Notification.Send(_pageId, $"Folder '{path}' can NOT be found", true); + return false; + } + + if (_useSSH && !string.IsNullOrEmpty(_sshKey)) + { + await new Commands.Config(path) + .Use(log) + .SetAsync("remote.origin.sshkey", _sshKey); + } + + if (InitAndUpdateSubmodules) + { + var submodules = await new Commands.QueryUpdatableSubmodules(path, true).GetResultAsync(); + if (submodules.Count > 0) + await new Commands.Submodule(path) + .Use(log) + .UpdateAsync(submodules, true, true, false); + } + + log.Complete(); + + var parent = _selectedGroup is { Id: not "" } ? _selectedGroup : null; + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(path, parent, true); + node.Bookmark = _bookmark; + await node.UpdateStatusAsync(false, null); + + var launcher = App.GetLauncher(); + LauncherPage page = null; + foreach (var one in launcher.Pages) + { + if (one.Node.Id == _pageId) + { + page = one; + break; } + } + + Welcome.Instance.Refresh(); + launcher.OpenRepositoryInTab(node, page); + return true; + } - CallUIThread(() => { - var repo = Preference.AddRepository(path, Path.Combine(path, ".git")); - var node = new RepositoryNode() { - Id = repo.FullPath, - Name = Path.GetFileName(repo.FullPath), - Bookmark = 0, - IsRepository = true, - }; - Preference.AddNode(node); - - _launcher.OpenRepositoryInTab(node, _page); - }); - - return true; - }); + private void CollectGroups(List outs, List collections) + { + foreach (var node in collections) + { + if (!node.IsRepository) + { + outs.Add(node); + CollectGroups(outs, node.SubNodes); + } + } } - private Launcher _launcher = null; - private LauncherPage _page = null; + private string _pageId = string.Empty; private string _remote = string.Empty; private bool _useSSH = false; private string _sshKey = string.Empty; - private string _parentFolder = Preference.Instance.GitDefaultCloneDir; + private string _parentFolder = string.Empty; private string _local = string.Empty; private string _extraArgs = string.Empty; + private RepositoryNode _selectedGroup = null; + private int _bookmark = 0; } } diff --git a/src/ViewModels/CommandLog.cs b/src/ViewModels/CommandLog.cs new file mode 100644 index 000000000..191f0f6c5 --- /dev/null +++ b/src/ViewModels/CommandLog.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +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; + } + + 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 Subscribe(Models.ICommandLogReceiver receiver) + { + _receivers.Add(receiver); + } + + public void Unsubscribe(Models.ICommandLogReceiver receiver) + { + _receivers.Remove(receiver); + } + + public void AppendLine(string line = null) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Invoke(() => AppendLine(line)); + } + else + { + var newline = line ?? string.Empty; + if (_builder != null) + _builder.AppendLine(newline); + else + _content = $"{_content}{newline}\n"; + + foreach (var receiver in _receivers) + receiver.OnReceiveCommandLog(newline); + } + } + + public void Complete() + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Invoke(Complete); + return; + } + + IsComplete = true; + EndTime = DateTime.Now; + + _content = _builder.ToString(); + _builder.Clear(); + _receivers.Clear(); + _builder = null; + + OnPropertyChanged(nameof(IsComplete)); + } + + private string _content = string.Empty; + private StringBuilder _builder = new StringBuilder(); + private List _receivers = new List(); + } +} diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 4bdcdfcab..b23465774 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -1,415 +1,754 @@ -using Avalonia.Controls; -using Avalonia.Platform.Storage; -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; using System; using System.Collections.Generic; using System.IO; +using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class CommitDetail : ObservableObject { - public DiffContext DiffContext { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); +using Avalonia; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class CommitDetailSharedData + { + public int ActiveTabIndex + { + get; + set; } - public int ActivePageIndex { - get => _activePageIndex; - set => SetProperty(ref _activePageIndex, value); + public CommitDetailSharedData() + { + ActiveTabIndex = Preferences.Instance.ShowChangesInCommitDetailByDefault ? 1 : 0; + } + } + + public partial class CommitDetail : ObservableObject + { + public Repository Repository + { + get => _repo; } - public Models.Commit Commit { + public int ActiveTabIndex + { + get => _sharedData.ActiveTabIndex; + set + { + if (value != _sharedData.ActiveTabIndex) + { + _sharedData.ActiveTabIndex = value; + OnPropertyChanged(nameof(ActiveTabIndex)); + + if (value == 1 && DiffContext == null && _selectedChanges is { Count: 1 }) + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, _selectedChanges[0])); + } + } + } + + public Models.Commit Commit + { get => _commit; - set { - if (SetProperty(ref _commit, value)) Refresh(); + set + { + if (_commit != null && value != null && _commit.SHA.Equals(value.SHA, StringComparison.Ordinal)) + return; + + if (SetProperty(ref _commit, value)) + Refresh(); } } - public List Changes { + 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 Changes + { get => _changes; set => SetProperty(ref _changes, value); } - public List VisibleChanges { + public List VisibleChanges + { get => _visibleChanges; set => SetProperty(ref _visibleChanges, value); } - public List ChangeTree { - get => _changeTree; - set => SetProperty(ref _changeTree, value); - } - - public Models.Change SelectedChange { - get => _selectedChange; - set { - if (SetProperty(ref _selectedChange, value)) { - if (value == null) { - SelectedChangeNode = null; + public List SelectedChanges + { + get => _selectedChanges; + set + { + if (SetProperty(ref _selectedChanges, value)) + { + if (ActiveTabIndex != 1 || value is not { Count: 1 }) DiffContext = null; - } else { - SelectedChangeNode = FileTreeNode.SelectByPath(_changeTree, value.Path); - DiffContext = new DiffContext(_repo, new Models.DiffOption(_commit, value)); - } + else + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, value[0]), _diffContext); } } } - public FileTreeNode SelectedChangeNode { - get => _selectedChangeNode; - set { - if (SetProperty(ref _selectedChangeNode, value)) { - if (value == null) { - SelectedChange = null; - } else { - SelectedChange = value.Backend as Models.Change; - } - } - } + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); } - public string SearchChangeFilter { + public string SearchChangeFilter + { get => _searchChangeFilter; - set { - if (SetProperty(ref _searchChangeFilter, value)) { + set + { + if (SetProperty(ref _searchChangeFilter, value)) RefreshVisibleChanges(); - } } } - public List RevisionFilesTree { - get => _revisionFilesTree; - set => SetProperty(ref _revisionFilesTree, value); + public string ViewRevisionFilePath + { + get => _viewRevisionFilePath; + private set => SetProperty(ref _viewRevisionFilePath, value); } - public FileTreeNode SelectedRevisionFileNode { - get => _selectedRevisionFileNode; - set { - if (SetProperty(ref _selectedRevisionFileNode, value) && value != null && !value.IsFolder) { - RefreshViewRevisionFile(value.Backend as Models.Object); - } else { - ViewRevisionFileContent = null; - } - } + public object ViewRevisionFileContent + { + get => _viewRevisionFileContent; + private set => SetProperty(ref _viewRevisionFileContent, value); } - public string SearchFileFilter { - get => _searchFileFilter; - set { - if (SetProperty(ref _searchFileFilter, value)) { - RefreshVisibleFiles(); - } + public string RevisionFileSearchFilter + { + get => _revisionFileSearchFilter; + set + { + if (SetProperty(ref _revisionFileSearchFilter, value)) + RefreshRevisionSearchSuggestion(); } } - public object ViewRevisionFileContent { - get => _viewRevisionFileContent; - set => SetProperty(ref _viewRevisionFileContent, value); + public List RevisionFileSearchSuggestion + { + get => _revisionFileSearchSuggestion; + private set => SetProperty(ref _revisionFileSearchSuggestion, value); + } + + public bool CanOpenRevisionFileWithDefaultEditor + { + get => _canOpenRevisionFileWithDefaultEditor; + private set => SetProperty(ref _canOpenRevisionFileWithDefaultEditor, value); + } + + public Vector ScrollOffset + { + get => _scrollOffset; + set => SetProperty(ref _scrollOffset, value); } - public CommitDetail(string repo) { + public CommitDetail(Repository repo, CommitDetailSharedData sharedData) + { _repo = repo; + _sharedData = sharedData ?? new CommitDetailSharedData(); + WebLinks = Models.CommitLink.Get(repo.Remotes); + } + + public CommitDetail Clone() + { + var cloned = new CommitDetail(_repo, null); + cloned.ActiveTabIndex = ActiveTabIndex; + cloned.Commit = _commit; + return cloned; } - public void Cleanup() { - _repo = null; - _commit = null; - if (_changes != null) _changes.Clear(); - if (_visibleChanges != null) _visibleChanges.Clear(); - if (_changeTree != null) _changeTree.Clear(); - _selectedChange = null; - _selectedChangeNode = null; - _searchChangeFilter = null; - _diffContext = null; - if (_revisionFiles != null) _revisionFiles.Clear(); - if (_revisionFilesTree != null) _revisionFilesTree.Clear(); - _selectedRevisionFileNode = null; - _searchFileFilter = null; - _viewRevisionFileContent = null; - _cancelToken = null; + public void NavigateTo(string commitSHA) + { + _repo?.NavigateToCommit(commitSHA); } - public void NavigateTo(string commitSHA) { - var repo = Preference.FindRepository(_repo); - if (repo != null) repo.NavigateToCommit(commitSHA); + public async Task> GetRefsContainsThisCommitAsync() + { + return await new Commands.QueryRefsContainsCommit(_repo.FullPath, _commit.SHA) + .GetResultAsync() + .ConfigureAwait(false); } - public void ClearSearchChangeFilter() { + public void ClearSearchChangeFilter() + { SearchChangeFilter = string.Empty; } - public void ClearSearchFileFilter() { - SearchFileFilter = string.Empty; - } - - public ContextMenu CreateChangeContextMenu(Models.Change change) { - var menu = new ContextMenu(); - - if (change.Index != Models.ChangeState.Deleted) { - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => { - var window = new Views.FileHistories() { DataContext = new FileHistories(_repo, change.Path) }; - window.Show(); - ev.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.Click += (o, ev) => { - var window = new Views.Blame() { DataContext = new Blame(_repo, change.Path, _commit.SHA) }; - window.Show(); - ev.Handled = true; - }; - - var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - explore.IsEnabled = File.Exists(full); - explore.Click += (_, ev) => { - Native.OS.OpenInFileManager(full, true); - ev.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(explore); + public void ClearRevisionFileSearchFilter() + { + RevisionFileSearchFilter = string.Empty; + } + + public void CancelRevisionFileSuggestions() + { + RevisionFileSearchSuggestion = null; + } + + public async Task GetCommitAsync(string sha) + { + return await new Commands.QuerySingleCommit(_repo.FullPath, sha) + .GetResultAsync() + .ConfigureAwait(false); + } + + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo.FullPath, path); + } + + public void OpenChangeInMergeTool(Models.Change c) + { + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(_commit, c)).Open(); + } + + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) + { + if (_commit == null) + return; + + var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync( + _repo.FullPath, + changes, + _commit.FirstParentToCompare, + _commit.SHA, + saveTo); + + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); + } + + public async Task ResetToThisRevisionAsync(string path) + { + var c = _changes?.Find(x => x.Path.Equals(path, StringComparison.Ordinal)); + if (c != null) + { + await ResetToThisRevisionAsync(c); + return; } - 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 log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); + await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(path, _commit.SHA); + log.Complete(); + } - menu.Items.Add(copyPath); - return menu; + public async Task ResetToThisRevisionAsync(Models.Change change) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); + + if (change.Index == Models.ChangeState.Deleted) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + await new Commands.Remove(_repo.FullPath, [change.Path]) + .Use(log) + .ExecAsync(); + } + else if (change.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, change.OriginalPath); + if (File.Exists(old)) + await new Commands.Remove(_repo.FullPath, [change.OriginalPath]) + .Use(log) + .ExecAsync(); + + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, _commit.SHA); + } + else + { + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, _commit.SHA); + } + + log.Complete(); } - public ContextMenu CreateRevisionFileContextMenu(Models.Object file) { - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => { - var window = new Views.FileHistories() { DataContext = new FileHistories(_repo, file.Path) }; - window.Show(); - ev.Handled = true; - }; + public async Task ResetToParentRevisionAsync(Models.Change change) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}~1'"); + + if (change.Index == Models.ChangeState.Added) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + await new Commands.Remove(_repo.FullPath, [change.Path]) + .Use(log) + .ExecAsync(); + } + else if (change.Index == Models.ChangeState.Renamed) + { + var renamed = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(renamed)) + await new Commands.Remove(_repo.FullPath, [change.Path]) + .Use(log) + .ExecAsync(); + + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.OriginalPath, $"{_commit.SHA}~1"); + } + else + { + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, $"{_commit.SHA}~1"); + } - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.Click += (o, ev) => { - var window = new Views.Blame() { DataContext = new Blame(_repo, file.Path, _commit.SHA) }; - window.Show(); - ev.Handled = true; - }; + log.Complete(); + } - var full = Path.GetFullPath(Path.Combine(_repo, file.Path)); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - explore.Click += (_, ev) => { - Native.OS.OpenInFileManager(full, file.Type == Models.ObjectType.Blob); - ev.Handled = true; - }; + public async Task ResetMultipleToThisRevisionAsync(List changes) + { + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Deleted) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, c.OriginalPath); + if (File.Exists(old)) + removes.Add(c.OriginalPath); - 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 topLevel = App.GetTopLevel(); - if (topLevel == null) return; - - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - var selected = await topLevel.StorageProvider.OpenFolderPickerAsync(options); - if (selected.Count == 1) { - var saveTo = Path.Combine(selected[0].Path.LocalPath, Path.GetFileName(file.Path)); - Commands.SaveRevisionFile.Run(_repo, _commit.SHA, file.Path, saveTo); + checkouts.Add(c.Path); } + else + { + checkouts.Add(c.Path); + } + } - ev.Handled = true; - }; + var log = _repo.CreateLog($"Reset Files to '{_commit.SHA}'"); - 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; - }; + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); - var menu = new ContextMenu(); - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(explore); - menu.Items.Add(saveAs); - menu.Items.Add(copyPath); - return menu; + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, _commit.SHA); + + log.Complete(); } - private void Refresh() { - _changes = null; - VisibleChanges = null; - SelectedChange = null; - RevisionFilesTree = null; - SelectedRevisionFileNode = null; - if (_commit == null) return; - if (_cancelToken != null) _cancelToken.Requested = true; + public async Task ResetMultipleToParentRevisionAsync(List changes) + { + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Added) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var renamed = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(renamed)) + removes.Add(c.Path); + + checkouts.Add(c.OriginalPath); + } + else + { + checkouts.Add(c.Path); + } + } + + var log = _repo.CreateLog($"Reset Files to '{_commit.SHA}~1'"); + + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); - _cancelToken = new Commands.Command.CancelToken(); - var cmdChanges = new Commands.QueryCommitChanges(_repo, _commit.SHA) { Cancel = _cancelToken }; - var cmdRevisionFiles = new Commands.QueryRevisionObjects(_repo, _commit.SHA) { Cancel = _cancelToken }; + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, $"{_commit.SHA}~1"); + + log.Complete(); + } - Task.Run(() => { - var changes = cmdChanges.Result(); - if (cmdChanges.Cancel.Requested) return; + public async Task> GetRevisionFilesUnderFolderAsync(string parentFolder) + { + return await new Commands.QueryRevisionObjects(_repo.FullPath, _commit.SHA, parentFolder) + .GetResultAsync() + .ConfigureAwait(false); + } + + public async Task ViewRevisionFileAsync(Models.Object file) + { + var obj = file ?? new Models.Object() { Path = string.Empty, Type = Models.ObjectType.None }; + ViewRevisionFilePath = obj.Path; + + switch (obj.Type) + { + case Models.ObjectType.Blob: + CanOpenRevisionFileWithDefaultEditor = true; + await SetViewingBlobAsync(obj); + break; + case Models.ObjectType.Commit: + CanOpenRevisionFileWithDefaultEditor = false; + await SetViewingCommitAsync(obj); + break; + default: + CanOpenRevisionFileWithDefaultEditor = false; + ViewRevisionFileContent = null; + break; + } + } + + public async Task OpenRevisionFileAsync(string file, Models.ExternalTool tool) + { + var fullPath = Native.OS.GetAbsPath(_repo.FullPath, file); + var fileName = Path.GetFileNameWithoutExtension(fullPath) ?? ""; + var fileExt = Path.GetExtension(fullPath) ?? ""; + var tmpFile = Path.Combine(Path.GetTempPath(), $"{fileName}~{_commit.SHA.AsSpan(0, 10)}{fileExt}"); + + await Commands.SaveRevisionFile + .RunAsync(_repo.FullPath, _commit.SHA, file, tmpFile) + .ConfigureAwait(false); + + if (tool == null) + Native.OS.OpenWithDefaultEditor(tmpFile); + else + tool.Launch(tmpFile.Quoted()); + } + + public async Task SaveRevisionFileAsync(Models.Object file, string saveTo) + { + await Commands.SaveRevisionFile + .RunAsync(_repo.FullPath, _commit.SHA, file.Path, saveTo) + .ConfigureAwait(false); + } + + private void Refresh() + { + _requestingRevisionFiles = false; + _revisionFiles = null; + + SignInfo = null; + ViewRevisionFileContent = null; + ViewRevisionFilePath = string.Empty; + CanOpenRevisionFileWithDefaultEditor = false; + RevisionFileSearchFilter = string.Empty; + RevisionFileSearchSuggestion = null; + ScrollOffset = Vector.Zero; + + if (_commit == null) + { + Changes = []; + VisibleChanges = []; + SelectedChanges = null; + return; + } + + if (_cancellationSource is { IsCancellationRequested: false }) + _cancellationSource.Cancel(); + + _cancellationSource = new CancellationTokenSource(); + var token = _cancellationSource.Token; + + Task.Run(async () => + { + var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA) + .GetResultAsync() + .ConfigureAwait(false); + var inlines = await ParseInlinesInMessageAsync(message).ConfigureAwait(false); + + if (!token.IsCancellationRequested) + Dispatcher.UIThread.Post(() => + { + FullMessage = new Models.CommitFullMessage + { + Message = message, + Inlines = inlines + }; + }); + }, token); + + Task.Run(async () => + { + var signInfo = await new Commands.QueryCommitSignInfo(_repo.FullPath, _commit.SHA, !_repo.HasAllowedSignersFile) + .GetResultAsync() + .ConfigureAwait(false); + + if (!token.IsCancellationRequested) + Dispatcher.UIThread.Post(() => SignInfo = signInfo); + }, token); + + Task.Run(async () => + { + var changes = await new Commands.CompareRevisions(_repo.FullPath, _commit.FirstParentToCompare, _commit.SHA) + .WithCancellation(token) + .ReadAsync() + .ConfigureAwait(false); var visible = changes; - if (!string.IsNullOrWhiteSpace(_searchChangeFilter)) { + if (!string.IsNullOrWhiteSpace(_searchChangeFilter)) + { visible = new List(); - foreach (var c in changes) { - if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) { + foreach (var c in changes) + { + if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) visible.Add(c); - } } } - var tree = FileTreeNode.Build(visible); - Dispatcher.UIThread.Invoke(() => { - Changes = changes; - VisibleChanges = visible; - ChangeTree = tree; - }); - }); - - Task.Run(() => { - var files = cmdRevisionFiles.Result(); - if (cmdRevisionFiles.Cancel.Requested) return; - - var visible = files; - if (!string.IsNullOrWhiteSpace(_searchFileFilter)) { - visible = new List(); - foreach (var f in files) { - if (f.Path.Contains(_searchFileFilter, StringComparison.OrdinalIgnoreCase)) { - visible.Add(f); - } - } + if (!token.IsCancellationRequested) + { + Dispatcher.UIThread.Post(() => + { + Changes = changes; + VisibleChanges = visible; + + if (visible.Count == 0) + SelectedChanges = null; + else + SelectedChanges = [VisibleChanges[0]]; + }); } - - var tree = FileTreeNode.Build(visible); - Dispatcher.UIThread.Invoke(() => { - _revisionFiles = files; - RevisionFilesTree = tree; - }); - }); + }, token); } - private void RefreshVisibleChanges() { - if (_changes == null) return; + private async Task ParseInlinesInMessageAsync(string message) + { + var inlines = new Models.InlineElementCollector(); + if (_repo.IssueTrackers is { Count: > 0 } rules) + { + foreach (var rule in rules) + rule.Matches(inlines, message); + } + + var urlMatches = REG_URL_FORMAT().Matches(message); + foreach (Match match in urlMatches) + { + 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)); + } - if (string.IsNullOrEmpty(_searchChangeFilter)) { + var shaMatches = REG_SHA_FORMAT().Matches(message); + foreach (Match match in shaMatches) + { + var start = match.Index; + var len = match.Length; + if (inlines.Intersect(start, len) != null) + continue; + + var sha = match.Groups[1].Value; + var isCommitSHA = await new Commands.IsCommitSHA(_repo.FullPath, sha).GetResultAsync().ConfigureAwait(false); + if (isCommitSHA) + inlines.Add(new Models.InlineElement(Models.InlineElementType.CommitSHA, start, len, sha)); + } + + var inlineCodeMatches = REG_INLINECODE_FORMAT().Matches(message); + foreach (Match match in inlineCodeMatches) + { + var start = match.Index; + var len = match.Length; + if (inlines.Intersect(start, len) != null) + continue; + + inlines.Add(new Models.InlineElement(Models.InlineElementType.Code, start + 1, len - 2, string.Empty)); + } + + inlines.Sort(); + return inlines; + } + + private void RefreshVisibleChanges() + { + if (string.IsNullOrEmpty(_searchChangeFilter)) + { VisibleChanges = _changes; - } else { + } + else + { var visible = new List(); - foreach (var c in _changes) { - if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) { + foreach (var c in _changes) + { + if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) visible.Add(c); - } } VisibleChanges = visible; } - - ChangeTree = FileTreeNode.Build(_visibleChanges); } - private void RefreshVisibleFiles() { - if (_revisionFiles == null) return; + private void RefreshRevisionSearchSuggestion() + { + if (!string.IsNullOrEmpty(_revisionFileSearchFilter)) + { + if (_revisionFiles == null) + { + if (_requestingRevisionFiles) + return; + + var sha = Commit.SHA; + _requestingRevisionFiles = true; - var visible = _revisionFiles; - if (!string.IsNullOrWhiteSpace(_searchFileFilter)) { - visible = new List(); - foreach (var f in _revisionFiles) { - if (f.Path.Contains(_searchFileFilter, StringComparison.OrdinalIgnoreCase)) { - visible.Add(f); - } + Task.Run(async () => + { + var files = await new Commands.QueryRevisionFileNames(_repo.FullPath, sha) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + 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; } - RevisionFilesTree = FileTreeNode.Build(visible); + RevisionFileSearchSuggestion = suggestion; } - private void RefreshViewRevisionFile(Models.Object file) { - switch (file.Type) { - case Models.ObjectType.Blob: - Task.Run(() => { - var isBinary = new Commands.IsBinary(_repo, _commit.SHA, file.Path).Result(); - if (isBinary) { - Dispatcher.UIThread.Invoke(() => { - ViewRevisionFileContent = new Models.RevisionBinaryFile(); - }); - return; - } + private async Task SetViewingBlobAsync(Models.Object file) + { + var isBinary = await new Commands.IsBinary(_repo.FullPath, _commit.SHA, file.Path).GetResultAsync(); + if (isBinary) + { + var imgDecoder = ImageSource.GetDecoder(file.Path); + if (imgDecoder != Models.ImageDecoder.None) + { + var source = await ImageSource.FromRevisionAsync(_repo.FullPath, _commit.SHA, file.Path, imgDecoder); + ViewRevisionFileContent = new Models.RevisionImageFile(file.Path, source.Bitmap, source.Size); + } + else + { + var size = await new Commands.QueryFileSize(_repo.FullPath, file.Path, _commit.SHA).GetResultAsync(); + ViewRevisionFileContent = new Models.RevisionBinaryFile() { Size = size }; + } - var content = new Commands.QueryFileContent(_repo, _commit.SHA, file.Path).Result(); - if (content.StartsWith("version https://git-lfs.github.com/spec/", StringComparison.OrdinalIgnoreCase)) { - var obj = new Models.RevisionLFSObject() { Object = new Models.LFSObject() }; - var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); - if (lines.Length == 3) { - foreach (var line in lines) { - if (line.StartsWith("oid sha256:")) { - obj.Object.Oid = line.Substring(11); - } else if (line.StartsWith("size ")) { - obj.Object.Size = long.Parse(line.Substring(5)); - } - } - Dispatcher.UIThread.Invoke(() => { - ViewRevisionFileContent = obj; - }); - return; - } - } + return; + } - Dispatcher.UIThread.Invoke(() => { - ViewRevisionFileContent = new Models.RevisionTextFile() { - FileName = file.Path, - Content = content - }; - }); - }); - break; - case Models.ObjectType.Commit: - ViewRevisionFileContent = new Models.RevisionSubmodule() { SHA = file.SHA }; - break; - default: - ViewRevisionFileContent = null; - break; + var contentStream = await Commands.QueryFileContent.RunAsync(_repo.FullPath, _commit.SHA, file.Path); + var content = await new StreamReader(contentStream).ReadToEndAsync(); + var lfs = Models.LFSObject.Parse(content); + if (lfs != null) + { + var imgDecoder = ImageSource.GetDecoder(file.Path); + if (imgDecoder != Models.ImageDecoder.None) + ViewRevisionFileContent = new RevisionLFSImage(_repo.FullPath, file.Path, lfs, imgDecoder); + else + ViewRevisionFileContent = new Models.RevisionLFSObject() { Object = lfs }; + } + else + { + ViewRevisionFileContent = new Models.RevisionTextFile() { FileName = file.Path, Content = content }; } } - private string _repo = string.Empty; - private int _activePageIndex = 0; + private async Task SetViewingCommitAsync(Models.Object file) + { + var submoduleRoot = Path.Combine(_repo.FullPath, file.Path).Replace('\\', '/').TrimEnd('/'); + var info = await new Commands.QuerySubmoduleRevision(submoduleRoot, file.SHA).GetResultAsync(); + ViewRevisionFileContent = info ?? new Models.RevisionSubmodule() + { + Commit = new Models.Commit() { SHA = file.SHA }, + FullMessage = new Models.CommitFullMessage() + }; + } + + [GeneratedRegex(@"\b(https?://|ftp://)[\w\d\._/\-~%@()+:?&=#!]*[\w\d/]")] + private static partial Regex REG_URL_FORMAT(); + + [GeneratedRegex(@"\b([0-9a-fA-F]{6,64})\b")] + private static partial Regex REG_SHA_FORMAT(); + + [GeneratedRegex(@"`.*?`")] + private static partial Regex REG_INLINECODE_FORMAT(); + + private Repository _repo = null; + private CommitDetailSharedData _sharedData = null; private Models.Commit _commit = null; - private List _changes = null; - private List _visibleChanges = null; - private List _changeTree = null; - private Models.Change _selectedChange = null; - private FileTreeNode _selectedChangeNode = null; + private Models.CommitFullMessage _fullMessage = null; + private Models.CommitSignInfo _signInfo = null; + private List _changes = []; + private List _visibleChanges = []; + private List _selectedChanges = null; private string _searchChangeFilter = string.Empty; private DiffContext _diffContext = null; - private List _revisionFiles = null; - private List _revisionFilesTree = null; - private FileTreeNode _selectedRevisionFileNode = null; - private string _searchFileFilter = string.Empty; + private string _viewRevisionFilePath = string.Empty; private object _viewRevisionFileContent = null; - private Commands.Command.CancelToken _cancelToken = null; + private CancellationTokenSource _cancellationSource = null; + private bool _requestingRevisionFiles = false; + private List _revisionFiles = null; + private string _revisionFileSearchFilter = string.Empty; + private List _revisionFileSearchSuggestion = null; + private bool _canOpenRevisionFileWithDefaultEditor = false; + private Vector _scrollOffset = Vector.Zero; } } diff --git a/src/ViewModels/Compare.cs b/src/ViewModels/Compare.cs new file mode 100644 index 000000000..9a3f4f490 --- /dev/null +++ b/src/ViewModels/Compare.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Compare : ObservableObject + { + public bool IsLoadingChanges + { + get => _isLoadingChanges; + private set => SetProperty(ref _isLoadingChanges, value); + } + + public bool IsLoadingPickableCommits + { + get => _isLoadingPickableCommits; + private set => SetProperty(ref _isLoadingPickableCommits, value); + } + + public bool IsViewChanges + { + get => _isViewChanges; + set => SetProperty(ref _isViewChanges, value); + } + + public bool CanResetFiles + { + get => _canResetFiles; + } + + public string BaseName + { + get => _baseName; + private set => SetProperty(ref _baseName, value); + } + + public string ToName + { + get => _toName; + private set => SetProperty(ref _toName, 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 int TotalChanges + { + get => _totalChanges; + private set => SetProperty(ref _totalChanges, 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 is { Count: 1 }) + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_based, _to, value[0]), _diffContext); + else + DiffContext = null; + } + } + } + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + RefreshVisibleChanges(); + } + } + + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public List LeftOnlyCommits + { + get => _leftOnlyCommits; + private set => SetProperty(ref _leftOnlyCommits, value); + } + + public List RightOnlyCommits + { + get => _rightOnlyCommits; + private set => SetProperty(ref _rightOnlyCommits, value); + } + + public Compare(Repository repo, object based, object to) + { + _repo = repo; + _canResetFiles = !repo.IsBare; + _based = GetSHA(based); + _to = GetSHA(to); + _baseName = GetName(based); + _toName = GetName(to); + + _baseHead = new Commands.QuerySingleCommit(_repo.FullPath, _based).GetResult(); + _toHead = new Commands.QuerySingleCommit(_repo.FullPath, _to).GetResult(); + + UpdatePickableCommits(); + UpdateChanges(); + } + + public void NavigateTo(string commitSHA) + { + _repo.NavigateToCommit(commitSHA); + } + + public void Swap() + { + (_based, _to) = (_to, _based); + (BaseName, ToName) = (_toName, _baseName); + (BaseHead, ToHead) = (_toHead, _baseHead); + (LeftOnlyCommits, RightOnlyCommits) = (_rightOnlyCommits, _leftOnlyCommits); + UpdateChanges(); + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; + } + + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo.FullPath, path); + } + + public void OpenInExternalDiffTool(Models.Change change) + { + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(_based, _to, change)).Open(); + } + + public async Task ResetToLeftAsync(Models.Change change) + { + if (!_canResetFiles) + return; + + if (change.Index == Models.ChangeState.Added) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + await new Commands.Remove(_repo.FullPath, [change.Path]).ExecAsync(); + } + else if (change.Index == Models.ChangeState.Renamed) + { + var renamed = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(renamed)) + await new Commands.Remove(_repo.FullPath, [change.Path]).ExecAsync(); + + await new Commands.Checkout(_repo.FullPath).FileWithRevisionAsync(change.OriginalPath, _baseHead.SHA); + } + else + { + await new Commands.Checkout(_repo.FullPath).FileWithRevisionAsync(change.Path, _baseHead.SHA); + } + } + + public async Task ResetToRightAsync(Models.Change change) + { + if (change.Index == Models.ChangeState.Deleted) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + await new Commands.Remove(_repo.FullPath, [change.Path]).ExecAsync(); + } + else if (change.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, change.OriginalPath); + if (File.Exists(old)) + await new Commands.Remove(_repo.FullPath, [change.OriginalPath]).ExecAsync(); + + await new Commands.Checkout(_repo.FullPath).FileWithRevisionAsync(change.Path, ToHead.SHA); + } + else + { + await new Commands.Checkout(_repo.FullPath).FileWithRevisionAsync(change.Path, ToHead.SHA); + } + } + + public async Task ResetMultipleToLeftAsync(List changes) + { + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Added) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(old)) + removes.Add(c.Path); + + checkouts.Add(c.OriginalPath); + } + else + { + checkouts.Add(c.Path); + } + } + + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes).ExecAsync(); + + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath).MultipleFilesWithRevisionAsync(checkouts, _baseHead.SHA); + } + + public async Task ResetMultipleToRightAsync(List changes) + { + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Deleted) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var renamed = Native.OS.GetAbsPath(_repo.FullPath, c.OriginalPath); + if (File.Exists(renamed)) + removes.Add(c.OriginalPath); + + checkouts.Add(c.Path); + } + else + { + checkouts.Add(c.Path); + } + } + + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes).ExecAsync(); + + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath).MultipleFilesWithRevisionAsync(checkouts, _toHead.SHA); + } + + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) + { + return await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, changes, _based, _to, saveTo); + } + + public void CherryPick(List commits) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CherryPick(_repo, commits)); + } + + private void UpdatePickableCommits() + { + IsLoadingPickableCommits = true; + + Task.Run(async () => + { + var rightOnly = await new Commands.QueryPickableCommits(_repo.FullPath, _baseHead.SHA, _toHead.SHA) + .GetResultAsync() + .ConfigureAwait(false); + + var leftOnly = await new Commands.QueryPickableCommits(_repo.FullPath, _toHead.SHA, _baseHead.SHA) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + LeftOnlyCommits = leftOnly; + RightOnlyCommits = rightOnly; + IsLoadingPickableCommits = false; + }); + }); + } + + private void UpdateChanges() + { + IsLoadingChanges = true; + VisibleChanges = []; + SelectedChanges = []; + + Task.Run(async () => + { + _changes = await new Commands.CompareRevisions(_repo.FullPath, _based, _to) + .ReadAsync() + .ConfigureAwait(false); + + 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.Post(() => + { + TotalChanges = _changes.Count; + VisibleChanges = visible; + IsLoadingChanges = false; + + if (VisibleChanges.Count > 0) + SelectedChanges = [VisibleChanges[0]]; + else + SelectedChanges = []; + }); + }); + } + + private void RefreshVisibleChanges() + { + 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 GetName(object obj) + { + return obj switch + { + Models.Branch b => b.FriendlyName, + Models.Tag t => t.Name, + Models.Commit c => c.SHA.Substring(0, 10), + _ => "HEAD", + }; + } + + private string GetSHA(object obj) + { + return obj switch + { + Models.Branch b => b.Head, + Models.Tag t => t.SHA, + Models.Commit c => c.SHA, + _ => "HEAD", + }; + } + + private Repository _repo; + private bool _isLoadingChanges = true; + private bool _isLoadingPickableCommits = true; + private bool _isViewChanges = true; + private bool _canResetFiles = false; + private string _based = string.Empty; + private string _to = string.Empty; + private string _baseName = string.Empty; + private string _toName = string.Empty; + private Models.Commit _baseHead = null; + private Models.Commit _toHead = null; + private int _totalChanges = 0; + private List _changes = null; + private List _visibleChanges = null; + private List _selectedChanges = null; + private List _leftOnlyCommits = []; + private List _rightOnlyCommits = []; + private string _searchFilter = string.Empty; + private DiffContext _diffContext = null; + } +} diff --git a/src/ViewModels/CompareCommandPalette.cs b/src/ViewModels/CompareCommandPalette.cs new file mode 100644 index 000000000..7eb49efd2 --- /dev/null +++ b/src/ViewModels/CompareCommandPalette.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.ViewModels +{ + public class CompareCommandPalette : ICommandPalette + { + public object BasedOn + { + get => _basedOn; + } + + public object CompareTo + { + get => _compareTo; + set => SetProperty(ref _compareTo, value); + } + + public List Refs + { + get => _refs; + private set => SetProperty(ref _refs, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateRefs(); + } + } + + public CompareCommandPalette(Repository repo, object basedOn) + { + _repo = repo; + _basedOn = basedOn ?? repo.CurrentBranch; + UpdateRefs(); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public Compare Launch() + { + _refs.Clear(); + Close(); + return _compareTo != null ? new Compare(_repo, _basedOn, _compareTo) : null; + } + + private void UpdateRefs() + { + var refs = new List(); + + foreach (var b in _repo.Branches) + { + if (b == _basedOn) + continue; + + if (string.IsNullOrEmpty(_filter) || b.FriendlyName.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + refs.Add(b); + } + + foreach (var t in _repo.Tags) + { + if (t == _basedOn) + continue; + + if (string.IsNullOrEmpty(_filter) || t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + refs.Add(t); + } + + refs.Sort((l, r) => + { + if (l is Models.Branch lb) + { + if (r is Models.Branch rb) + { + if (lb.IsLocal == rb.IsLocal) + return Models.NumericSort.Compare(lb.FriendlyName, rb.FriendlyName); + return lb.IsLocal ? -1 : 1; + } + + return -1; + } + + if (r is Models.Branch) + return 1; + + return Models.NumericSort.Compare((l as Models.Tag).Name, (r as Models.Tag).Name); + }); + + var autoSelected = _compareTo; + if (refs.Count == 0) + autoSelected = null; + else if (_compareTo == null || !refs.Contains(_compareTo)) + autoSelected = refs[0]; + + Refs = refs; + CompareTo = autoSelected; + } + + private Repository _repo; + private object _basedOn = null; + private object _compareTo = null; + private List _refs = []; + private string _filter; + } +} diff --git a/src/ViewModels/ConfigureCustomActionControls.cs b/src/ViewModels/ConfigureCustomActionControls.cs new file mode 100644 index 000000000..abe992eae --- /dev/null +++ b/src/ViewModels/ConfigureCustomActionControls.cs @@ -0,0 +1,67 @@ +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ConfigureCustomActionControls : ObservableObject + { + public AvaloniaList Controls + { + get; + } + + public Models.CustomActionControl Edit + { + get => _edit; + set => SetProperty(ref _edit, value); + } + + public ConfigureCustomActionControls(AvaloniaList controls) + { + Controls = controls; + } + + public void Add() + { + var added = new Models.CustomActionControl() + { + Label = "Unnamed", + Type = Models.CustomActionControlType.TextBox + }; + + Controls.Add(added); + Edit = added; + } + + public void Remove() + { + if (_edit == null) + return; + + Controls.Remove(_edit); + Edit = null; + } + + public void MoveUp() + { + if (_edit == null) + return; + + var idx = Controls.IndexOf(_edit); + if (idx > 0) + Controls.Move(idx - 1, idx); + } + + public void MoveDown() + { + if (_edit == null) + return; + + var idx = Controls.IndexOf(_edit); + if (idx < Controls.Count - 1) + Controls.Move(idx + 1, idx); + } + + private Models.CustomActionControl _edit; + } +} diff --git a/src/ViewModels/ConfigureWorkspace.cs b/src/ViewModels/ConfigureWorkspace.cs new file mode 100644 index 000000000..638caf5df --- /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 is { IsActive: false }; + } + } + + 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/Conflict.cs b/src/ViewModels/Conflict.cs new file mode 100644 index 000000000..d98187d35 --- /dev/null +++ b/src/ViewModels/Conflict.cs @@ -0,0 +1,101 @@ +using System.IO; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Conflict : ObservableObject + { + public string Marker + { + get => _change.ConflictMarker; + } + + public string Description + { + get => _change.ConflictDesc; + } + + public Models.ConflictFileState State + { + get => _state; + private set => SetProperty(ref _state, value); + } + + public object Theirs + { + get => _theirs; + private set => SetProperty(ref _theirs, value); + } + + public object Mine + { + get => _mine; + private set => SetProperty(ref _mine, value); + } + + public Conflict(Repository repo, WorkingCopy wc, Models.Change change) + { + _repo = repo; + _wc = wc; + _change = change; + + Task.Run(async () => + { + _head = new Commands.QuerySingleCommit(repo.FullPath, "HEAD").GetResult(); + + var (mine, theirs) = wc.InProgressContext switch + { + CherryPickInProgress cherryPick => (_head, cherryPick.Head), + RebaseInProgress rebase => (rebase.Onto, rebase.StoppedAt), + RevertInProgress revert => (_head, revert.Head), + MergeInProgress merge => (_head, merge.Source), + _ => (_head, (object)"Stash or Patch"), + }; + + var state = Models.ConflictFileState.Unknown; + if ((_change.ConflictReason is Models.ConflictReason.BothAdded or Models.ConflictReason.BothModified) && !Directory.Exists(Path.Combine(_repo.FullPath, _change.Path))) + state = await new Commands.QueryConflictFileState(repo.FullPath, change) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + State = state; + Mine = mine; + Theirs = theirs; + }); + }); + } + + public async Task UseTheirsAsync() + { + await _wc.UseTheirsAsync([_change]); + } + + public async Task UseMineAsync() + { + await _wc.UseMineAsync([_change]); + } + + public MergeConflictEditor CreateOpenMergeEditorRequest() + { + return _state == Models.ConflictFileState.UnmergedText ? new MergeConflictEditor(_repo, _head, _change.Path) : null; + } + + public async Task MergeExternalAsync() + { + if (_state == Models.ConflictFileState.UnmergedText) + await _wc.UseExternalMergeToolAsync(_change); + } + + private Repository _repo = null; + private WorkingCopy _wc = null; + private Models.Change _change = null; + private Models.Commit _head = null; + private Models.ConflictFileState _state = Models.ConflictFileState.Unknown; + private object _mine = null; + private object _theirs = null; + } +} diff --git a/src/ViewModels/ConventionalCommitMessageBuilder.cs b/src/ViewModels/ConventionalCommitMessageBuilder.cs new file mode 100644 index 000000000..99c57d819 --- /dev/null +++ b/src/ViewModels/ConventionalCommitMessageBuilder.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ConventionalCommitMessageBuilder : ObservableValidator + { + public List Types + { + get; + private set; + } = []; + + [Required(ErrorMessage = "Type of changes can not be null")] + public Models.ConventionalCommitType SelectedType + { + get => _selectedType; + set + { + if (SetProperty(ref _selectedType, value, true) && value is { PrefillShortDesc: { Length: > 0 } }) + Description = value.PrefillShortDesc; + } + } + + 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(string conventionalTypesOverride, Action onApply) + { + Types = Models.ConventionalCommitType.Load(conventionalTypesOverride); + SelectedType = Types.Count > 0 ? Types[0] : null; + _onApply = onApply; + } + + [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode")] + public bool Apply() + { + if (HasErrors) + return false; + + ValidateAllProperties(); + if (HasErrors) + return false; + + var builder = new StringBuilder(); + builder.Append(_selectedType.Type); + + if (!string.IsNullOrEmpty(_scope)) + { + builder.Append("("); + builder.Append(_scope); + builder.Append(")"); + } + + if (!string.IsNullOrEmpty(_breakingChanges)) + builder.Append("!"); + builder.Append(": "); + + builder.Append(_description); + builder.AppendLine().AppendLine(); + + if (!string.IsNullOrEmpty(_detail)) + { + builder.Append(_detail); + builder.AppendLine().AppendLine(); + } + + if (!string.IsNullOrEmpty(_breakingChanges)) + { + builder.Append("BREAKING CHANGE: "); + builder.Append(_breakingChanges); + builder.AppendLine().AppendLine(); + } + + if (!string.IsNullOrEmpty(_closedIssue)) + { + builder.Append("Closed "); + builder.Append(_closedIssue); + } + + _onApply?.Invoke(builder.ToString()); + return true; + } + + private Action _onApply = null; + private Models.ConventionalCommitType _selectedType = null; + 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 index 73d19ca43..f180c94e9 100644 --- a/src/ViewModels/CreateBranch.cs +++ b/src/ViewModels/CreateBranch.cs @@ -1,111 +1,255 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class CreateBranch : Popup { +namespace SourceGit.ViewModels +{ + public class CreateBranch : Popup + { [Required(ErrorMessage = "Branch name is required!")] - [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [RegularExpression(@"^[\w\-/\.#\+]+$", ErrorMessage = "Bad branch name format!")] [CustomValidation(typeof(CreateBranch), nameof(ValidateBranchName))] - public string Name { + public string Name + { get => _name; set => SetProperty(ref _name, value, true); } - public object BasedOn { + public object BasedOn + { get; - private set; } - public bool CheckoutAfterCreated { - get; - set; - } = true; + public bool HasLocalChanges + { + get => _repo.LocalChangesCount > 0; + } - public bool AutoStash { + public Models.DealWithLocalChanges DealWithLocalChanges + { get; set; - } = true; + } + + public bool CheckoutAfterCreated + { + get => _repo.UIStates.CheckoutBranchOnCreateBranch; + set + { + if (_repo.UIStates.CheckoutBranchOnCreateBranch != value) + { + _repo.UIStates.CheckoutBranchOnCreateBranch = value; + OnPropertyChanged(); + UpdateOverrideTip(); + } + } + } + + public bool IsBareRepository + { + get => _repo.IsBare; + } - public CreateBranch(Repository repo, Models.Branch branch) { + public string OverrideTip + { + get => _overrideTip; + private set => SetProperty(ref _overrideTip, value); + } + + public bool AllowOverwrite + { + get => _allowOverwrite; + set + { + if (SetProperty(ref _allowOverwrite, value)) + ValidateProperty(_name, nameof(Name)); + } + } + + public CreateBranch(Repository repo, Models.Branch branch) + { _repo = repo; _baseOnRevision = branch.Head; + _committerDate = branch.CommitterDate; + _head = branch.Head; + + if (!branch.IsLocal) + Name = branch.Name; BasedOn = branch; - View = new Views.CreateBranch() { DataContext = this }; + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + UpdateOverrideTip(); } - public CreateBranch(Repository repo, Models.Commit commit) { + public CreateBranch(Repository repo, Models.Commit commit) + { _repo = repo; _baseOnRevision = commit.SHA; + _committerDate = commit.CommitterTime; + _head = commit.SHA; BasedOn = commit; - View = new Views.CreateBranch() { DataContext = this }; + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + UpdateOverrideTip(); } - public CreateBranch(Repository repo, Models.Tag tag) { + public CreateBranch(Repository repo, Models.Tag tag) + { _repo = repo; _baseOnRevision = tag.SHA; + _committerDate = tag.CreatorDate; + _head = tag.SHA; BasedOn = tag; - View = new Views.CreateBranch() { DataContext = this }; + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + UpdateOverrideTip(); } - public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) { - var creator = ctx.ObjectInstance as CreateBranch; - if (creator == null) return new ValidationResult("Missing runtime context to create branch!"); + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is CreateBranch creator) + { + if (!creator._allowOverwrite) + { + foreach (var b in creator._repo.Branches) + { + if (b.FriendlyName.Equals(name, StringComparison.Ordinal)) + return new ValidationResult("A branch with same name already exists!"); + } + } - foreach (var b in creator._repo.Branches) { - var test = b.IsLocal ? b.Name : $"{b.Remote}/{b.Name}"; - if (test == name) return new ValidationResult("A branch with same name already exists!"); + return ValidationResult.Success; } - return ValidationResult.Success; + return new ValidationResult("Missing runtime context to create branch!"); } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - return Task.Run(() => { - if (CheckoutAfterCreated) { - bool needPopStash = false; - if (_repo.WorkingCopyChangesCount > 0) { - if (AutoStash) { - SetProgressDescription("Adding untracked changes..."); - var succ = new Commands.Add(_repo.FullPath).Exec(); - if (succ) { - SetProgressDescription("Stash local changes"); - succ = new Commands.Stash(_repo.FullPath).Push("CREATE_BRANCH_AUTO_STASH"); - } - - if (!succ) { - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return false; - } - - needPopStash = true; - } else { - SetProgressDescription("Discard local changes..."); - Commands.Discard.All(_repo.FullPath); - } + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog($"Create Branch '{_name}'"); + Use(log); + + if (CheckoutAfterCreated) + { + if (_repo.CurrentBranch is { IsDetachedHead: true } && !_repo.CurrentBranch.Head.Equals(_baseOnRevision, StringComparison.Ordinal)) + { + var refs = await new Commands.QueryRefsContainsCommit(_repo.FullPath, _repo.CurrentBranch.Head).GetResultAsync(); + if (refs.Count == 0) + { + var msg = App.Text("Checkout.WarnLostCommits"); + var shouldContinue = await App.AskConfirmAsync(msg); + if (!shouldContinue) + return true; } + } + } - SetProgressDescription($"Create new branch '{_name}'"); - new Commands.Checkout(_repo.FullPath).Branch(_name, _baseOnRevision, SetProgressDescription); + Models.Branch created = new() + { + Name = _name, + FullName = $"refs/heads/{_name}", + CommitterDate = _committerDate, + Head = _head, + IsLocal = true, + }; + + bool succ; + if (CheckoutAfterCreated && !_repo.IsBare) + { + var needPopStash = false; + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(_name, _baseOnRevision, false, _allowOverwrite); + } + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) + { + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); + if (changes > 0) + { + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync("CREATE_BRANCH_AUTO_STASH", false); + if (!succ) + { + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return false; + } - if (needPopStash) { - SetProgressDescription("Re-apply local changes..."); - new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); + needPopStash = true; } - } else { - Commands.Branch.Create(_repo.FullPath, _name, _baseOnRevision); + + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(_name, _baseOnRevision, false, _allowOverwrite); } + else + { + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(_name, _baseOnRevision, true, _allowOverwrite); + } + + if (succ) + { + await _repo.AutoUpdateSubmodulesAsync(log); + + if (needPopStash) + await new Commands.Stash(_repo.FullPath) + .Use(log) + .PopAsync("stash@{0}"); + } + } + else + { + succ = await new Commands.Branch(_repo.FullPath, _name) + .Use(log) + .CreateAsync(_baseOnRevision, _allowOverwrite); + } + + if (succ) + { + if (BasedOn is Models.Branch { IsLocal: false } basedOn && _name.Equals(basedOn.Name, StringComparison.Ordinal)) + { + await new Commands.Branch(_repo.FullPath, _name) + .Use(log) + .SetUpstreamAsync(basedOn); + + created.Upstream = basedOn.FullName; + } + + _repo.RefreshAfterCreateBranch(created, CheckoutAfterCreated); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } + + log.Complete(); + return true; + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + private void UpdateOverrideTip() + { + OverrideTip = CheckoutAfterCreated ? "-B in `git checkout`" : "-f in `git branch`"; } - private Repository _repo = null; + private readonly Repository _repo = null; + private readonly string _baseOnRevision = null; + private readonly ulong _committerDate = 0; + private readonly string _head = string.Empty; private string _name = null; - private string _baseOnRevision = null; + private string _overrideTip = "-B"; + private bool _allowOverwrite = false; } } diff --git a/src/ViewModels/CreateGroup.cs b/src/ViewModels/CreateGroup.cs index a398f6101..eb8ef2b5c 100644 --- a/src/ViewModels/CreateGroup.cs +++ b/src/ViewModels/CreateGroup.cs @@ -2,31 +2,37 @@ using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class CreateGroup : Popup { +namespace SourceGit.ViewModels +{ + public class CreateGroup : Popup + { [Required(ErrorMessage = "Group name is required!")] - public string Name { + public string Name + { get => _name; set => SetProperty(ref _name, value, true); } - public CreateGroup(RepositoryNode parent) { + public CreateGroup(RepositoryNode parent) + { _parent = parent; - View = new Views.CreateGroup() { DataContext = this }; } - public override Task Sure() { - Preference.AddNode(new RepositoryNode() { + public override Task Sure() + { + Preferences.Instance.AddNode(new RepositoryNode() + { Id = Guid.NewGuid().ToString(), Name = _name, IsRepository = false, IsExpanded = false, - }, _parent); + }, _parent, true); - return null; + Welcome.Instance.Refresh(); + return Task.FromResult(true); } - private RepositoryNode _parent = null; + private readonly RepositoryNode _parent = null; private string _name = string.Empty; } } diff --git a/src/ViewModels/CreateTag.cs b/src/ViewModels/CreateTag.cs index 494fc3fc7..ff729aab9 100644 --- a/src/ViewModels/CreateTag.cs +++ b/src/ViewModels/CreateTag.cs @@ -1,66 +1,118 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Text.RegularExpressions; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class CreateTag : Popup { +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!")] + [RegularExpression(@"^(?!\.)(?!/)(?!.*\.$)(?!.*/$)(?!.*\.\.)[\w\-\+\./]+$", ErrorMessage = "Bad tag name format!")] [CustomValidation(typeof(CreateTag), nameof(ValidateTagName))] - public string TagName { + public string TagName + { get => _tagName; set => SetProperty(ref _tagName, value, true); } - public string Message { + public string Message + { get; set; } - public object BasedOn { + public bool Annotated + { + get => _repo.UIStates.CreateAnnotatedTag; + set + { + if (_repo.UIStates.CreateAnnotatedTag != value) + { + _repo.UIStates.CreateAnnotatedTag = value; + OnPropertyChanged(); + } + } + } + + public bool SignTag + { get; - private set; + set; + } = false; + + public bool PushToRemotes + { + get => _repo.UIStates.PushToRemoteWhenCreateTag; + set => _repo.UIStates.PushToRemoteWhenCreateTag = value; } - public CreateTag(Repository repo, Models.Branch branch) { + public CreateTag(Repository repo, Models.Branch branch) + { _repo = repo; _basedOn = branch.Head; BasedOn = branch; - View = new Views.CreateTag() { DataContext = this }; + SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); } - public CreateTag(Repository repo, Models.Commit commit) { + public CreateTag(Repository repo, Models.Commit commit) + { _repo = repo; _basedOn = commit.SHA; BasedOn = commit; - View = new Views.CreateTag() { DataContext = this }; + SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); } - public static ValidationResult ValidateTagName(string name, ValidationContext ctx) { - var creator = ctx.ObjectInstance as CreateTag; - if (creator != null) { + 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!"); + if (found != null) + return new ValidationResult("A tag with same name already exists!"); } return ValidationResult.Success; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Create tag..."; - return Task.Run(() => { - Commands.Tag.Add(_repo.FullPath, TagName, _basedOn, Message); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + var remotes = PushToRemotes ? _repo.Remotes : null; + var log = _repo.CreateLog("Create Tag"); + Use(log); + + var cmd = new Commands.Tag(_repo.FullPath, _tagName) + .Use(log); + + bool succ; + if (_repo.UIStates.CreateAnnotatedTag) + succ = await cmd.AddAsync(_basedOn, Message, SignTag); + else + succ = await cmd.AddAsync(_basedOn); + + if (succ && remotes != null) + { + foreach (var remote in remotes) + await new Commands.Push(_repo.FullPath, remote.Name, $"refs/tags/{_tagName}", false) + .Use(log) + .RunAsync(); + } + + log.Complete(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; private string _tagName = string.Empty; - private string _basedOn = string.Empty; + private readonly string _basedOn; } } diff --git a/src/ViewModels/CustomActionContextMenuLabel.cs b/src/ViewModels/CustomActionContextMenuLabel.cs new file mode 100644 index 000000000..82804f09b --- /dev/null +++ b/src/ViewModels/CustomActionContextMenuLabel.cs @@ -0,0 +1,8 @@ +namespace SourceGit.ViewModels +{ + public class CustomActionContextMenuLabel(string name, bool isGlobal) + { + public string Name { get; set; } = name; + public bool IsGlobal { get; set; } = isGlobal; + } +} diff --git a/src/ViewModels/DeinitSubmodule.cs b/src/ViewModels/DeinitSubmodule.cs new file mode 100644 index 000000000..f9fff198d --- /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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "De-initialize Submodule"; + + var log = _repo.CreateLog("De-initialize Submodule"); + Use(log); + + var succ = await new Commands.Submodule(_repo.FullPath) + .Use(log) + .DeinitAsync(Submodule, false); + + log.Complete(); + _repo.MarkSubmodulesDirtyManually(); + return succ; + } + + private Repository _repo; + } +} diff --git a/src/ViewModels/DeleteBranch.cs b/src/ViewModels/DeleteBranch.cs index 924ee12b9..ed8e8ff0b 100644 --- a/src/ViewModels/DeleteBranch.cs +++ b/src/ViewModels/DeleteBranch.cs @@ -1,34 +1,97 @@ -using System.Threading.Tasks; +using System; +using System.Text; +using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class DeleteBranch : Popup { - public Models.Branch Target { +namespace SourceGit.ViewModels +{ + public class DeleteBranch : Popup + { + public Models.Branch Target + { get; - private set; } - public DeleteBranch(Repository repo, Models.Branch branch) { + public bool Force + { + get; + set; + } + + public DeleteBranch(Repository repo, Models.Branch branch) + { _repo = repo; Target = branch; - View = new Views.DeleteBranch() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting branch..."; - return Task.Run(() => { - if (Target.IsLocal) { - Commands.Branch.Delete(_repo.FullPath, Target.Name); - } else { - new Commands.Push(_repo.FullPath, Target.Remote, Target.Name).Exec(); + var log = _repo.CreateLog("Delete Branch"); + Use(log); + + var succ = false; + if (Target.IsLocal) + { + succ = await new Commands.Branch(_repo.FullPath, Target.Name) + .Use(log) + .DeleteLocalAsync(Force); + + if (succ) + { + _repo.UIStates.RemoveHistoryFilter(Target.FullName, Models.FilterType.LocalBranch); + + var upstream = Target.Upstream ?? string.Empty; + var tracking = _repo.Branches.Find(x => x.FullName.Equals(upstream, StringComparison.Ordinal)); + if (tracking != null && tracking.Name.Equals(Target.Name, StringComparison.Ordinal)) + { + var msgBuilder = new StringBuilder(); + msgBuilder + .AppendLine(App.Text("DeleteBranch.AskForRemote")) + .AppendLine() + .Append("• ").Append(tracking.FriendlyName); + + var deleteTracking = await App.AskConfirmAsync(msgBuilder.ToString(), Models.ConfirmButtonType.YesNo); + if (deleteTracking) + { + succ = await DeleteRemoteBranchAsync(tracking, log); + if (succ) + _repo.UIStates.RemoveHistoryFilter(tracking.FullName, Models.FilterType.RemoteBranch); + } + } } + } + else + { + succ = await DeleteRemoteBranchAsync(Target, log); + if (succ) + _repo.UIStates.RemoveHistoryFilter(Target.FullName, Models.FilterType.RemoteBranch); + } + + log.Complete(); + _repo.MarkBranchesDirtyManually(); + return succ; + } + + private async Task DeleteRemoteBranchAsync(Models.Branch branch, CommandLog log) + { + var exists = await new Commands.Remote(_repo.FullPath) + .HasBranchAsync(branch.Remote, branch.Name) + .ConfigureAwait(false); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + if (exists) + return await new Commands.Push(_repo.FullPath, branch.Remote, $"refs/heads/{branch.Name}", true) + .Use(log) + .RunAsync() + .ConfigureAwait(false); + else + return await new Commands.Branch(_repo.FullPath, branch.Name) + .Use(log) + .DeleteRemoteAsync(branch.Remote, Force) + .ConfigureAwait(false); } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/DeleteMultipleBranches.cs b/src/ViewModels/DeleteMultipleBranches.cs new file mode 100644 index 000000000..eae8192dc --- /dev/null +++ b/src/ViewModels/DeleteMultipleBranches.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteMultipleBranches : Popup + { + public List Targets + { + get; + } + + public bool Force + { + get; + set; + } = false; + + public DeleteMultipleBranches(Repository repo, List branches, bool isLocal) + { + _repo = repo; + _isLocal = isLocal; + Targets = branches; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Deleting multiple branches..."; + + var log = _repo.CreateLog("Delete Multiple Branches"); + Use(log); + + if (_isLocal) + { + foreach (var target in Targets) + await new Commands.Branch(_repo.FullPath, target.Name) + .Use(log) + .DeleteLocalAsync(Force); + } + else + { + foreach (var target in Targets) + { + var exists = await new Commands.Remote(_repo.FullPath).HasBranchAsync(target.Remote, target.Name); + if (exists) + await new Commands.Push(_repo.FullPath, target.Remote, $"refs/heads/{target.Name}", true) + .Use(log) + .RunAsync(); + else + await new Commands.Branch(_repo.FullPath, target.Name) + .Use(log) + .DeleteRemoteAsync(target.Remote, Force); + } + } + + log.Complete(); + _repo.MarkBranchesDirtyManually(); + return true; + } + + private Repository _repo = null; + private bool _isLocal = false; + } +} diff --git a/src/ViewModels/DeleteMultipleTags.cs b/src/ViewModels/DeleteMultipleTags.cs new file mode 100644 index 000000000..a11901c34 --- /dev/null +++ b/src/ViewModels/DeleteMultipleTags.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class DeleteMultipleTags : Popup + { + public List Tags + { + get; + } + + public bool DeleteFromRemote + { + get; + set; + } = false; + + public DeleteMultipleTags(Repository repo, List tags) + { + _repo = repo; + Tags = tags; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Deleting multiple tags..."; + + var log = _repo.CreateLog("Delete Multiple Tags"); + Use(log); + + foreach (var tag in Tags) + { + var succ = await new Commands.Tag(_repo.FullPath, tag.Name) + .Use(log) + .DeleteAsync(); + + if (succ && DeleteFromRemote) + { + foreach (var r in _repo.Remotes) + await new Commands.Push(_repo.FullPath, r.Name, $"refs/tags/{tag.Name}", true) + .Use(log) + .RunAsync(); + } + } + + log.Complete(); + _repo.MarkTagsDirtyManually(); + return true; + } + + private readonly Repository _repo; + } +} diff --git a/src/ViewModels/DeleteRemote.cs b/src/ViewModels/DeleteRemote.cs index 513865bab..ceef24588 100644 --- a/src/ViewModels/DeleteRemote.cs +++ b/src/ViewModels/DeleteRemote.cs @@ -1,29 +1,38 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class DeleteRemote : Popup { - public Models.Remote Remote { +namespace SourceGit.ViewModels +{ + public class DeleteRemote : Popup + { + public Models.Remote Remote + { get; private set; } - public DeleteRemote(Repository repo, Models.Remote remote) { + public DeleteRemote(Repository repo, Models.Remote remote) + { _repo = repo; Remote = remote; - View = new Views.DeleteRemote() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting remote ..."; - return Task.Run(() => { - var succ = new Commands.Remote(_repo.FullPath).Delete(Remote.Name); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var log = _repo.CreateLog("Delete Remote"); + Use(log); + + var succ = await new Commands.Remote(_repo.FullPath) + .Use(log) + .DeleteAsync(Remote.Name); + + log.Complete(); + _repo.MarkBranchesDirtyManually(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/DeleteRepositoryNode.cs b/src/ViewModels/DeleteRepositoryNode.cs index 03e4e9ef3..59ef60ea3 100644 --- a/src/ViewModels/DeleteRepositoryNode.cs +++ b/src/ViewModels/DeleteRepositoryNode.cs @@ -1,22 +1,24 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class DeleteRepositoryNode : Popup { - public RepositoryNode Node { - get => _node; - set => SetProperty(ref _node, value); +namespace SourceGit.ViewModels +{ + public class DeleteRepositoryNode : Popup + { + public RepositoryNode Node + { + get; } - public DeleteRepositoryNode(RepositoryNode node) { - _node = node; - View = new Views.DeleteRepositoryNode() { DataContext = this }; + public DeleteRepositoryNode(RepositoryNode node) + { + Node = node; } - public override Task Sure() { - Preference.RemoveNode(_node); - return null; + public override Task Sure() + { + Preferences.Instance.RemoveNode(Node, true); + Welcome.Instance.Refresh(); + return Task.FromResult(true); } - - private RepositoryNode _node = null; } } diff --git a/src/ViewModels/DeleteSubmodule.cs b/src/ViewModels/DeleteSubmodule.cs index 41b0fbca7..998777421 100644 --- a/src/ViewModels/DeleteSubmodule.cs +++ b/src/ViewModels/DeleteSubmodule.cs @@ -1,30 +1,37 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class DeleteSubmodule : Popup { - - public string Submodule { +namespace SourceGit.ViewModels +{ + public class DeleteSubmodule : Popup + { + public string Submodule + { get; private set; } - public DeleteSubmodule(Repository repo, string submodule) { + public DeleteSubmodule(Repository repo, string submodule) + { _repo = repo; Submodule = submodule; - View = new Views.DeleteSubmodule() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting submodule ..."; - return Task.Run(() => { - var succ = new Commands.Submodule(_repo.FullPath).Delete(Submodule); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var log = _repo.CreateLog("Delete Submodule"); + Use(log); + + var succ = await new Commands.Submodule(_repo.FullPath) + .Use(log) + .DeleteAsync(Submodule); + + log.Complete(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/DeleteTag.cs b/src/ViewModels/DeleteTag.cs index 9890a5358..6dc6ebb9c 100644 --- a/src/ViewModels/DeleteTag.cs +++ b/src/ViewModels/DeleteTag.cs @@ -1,36 +1,54 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class DeleteTag : Popup { - public Models.Tag Target { +namespace SourceGit.ViewModels +{ + public class DeleteTag : Popup + { + public Models.Tag Target + { get; private set; } - public bool ShouldPushToRemote { - get; - set; + public bool PushToRemotes + { + get => _repo.UIStates.PushToRemoteWhenDeleteTag; + set => _repo.UIStates.PushToRemoteWhenDeleteTag = value; } - public DeleteTag(Repository repo, Models.Tag tag) { + public DeleteTag(Repository repo, Models.Tag tag) + { _repo = repo; Target = tag; - ShouldPushToRemote = true; - View = new Views.DeleteTag() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Deleting tag '{Target.Name}' ..."; - return Task.Run(() => { - var remotes = ShouldPushToRemote ? _repo.Remotes : null; - var succ = Commands.Tag.Delete(_repo.FullPath, Target.Name, remotes); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var remotes = PushToRemotes ? _repo.Remotes : []; + var log = _repo.CreateLog("Delete Tag"); + Use(log); + + var succ = await new Commands.Tag(_repo.FullPath, Target.Name) + .Use(log) + .DeleteAsync(); + + if (succ) + { + foreach (var r in remotes) + await new Commands.Push(_repo.FullPath, r.Name, $"refs/tags/{Target.Name}", true) + .Use(log) + .RunAsync(); + } + + log.Complete(); + _repo.UIStates.RemoveHistoryFilter(Target.Name, Models.FilterType.Tag); + _repo.MarkTagsDirtyManually(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs index 470125aac..27eb93b8d 100644 --- a/src/ViewModels/DiffContext.cs +++ b/src/ViewModels/DiffContext.cs @@ -1,124 +1,361 @@ -using Avalonia; -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; +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 RepositoryPath { - get => _repo; +namespace SourceGit.ViewModels +{ + public class DiffContext : ObservableObject + { + public string Title + { + get; } - public Models.Change WorkingCopyChange { - get => _option.WorkingCopyChange; + public int OldMode + { + get => _oldMode; + private set => SetProperty(ref _oldMode, value); } - public bool IsUnstaged { - get => _option.IsUnstaged; + public int NewMode + { + get => _newMode; + private set => SetProperty(ref _newMode, value); } - public string FilePath { - get => _option.Path; + public bool IsTextDiff + { + get => _isTextDiff; + private set => SetProperty(ref _isTextDiff, value); } - public bool IsOrgFilePathVisible { - get => !string.IsNullOrWhiteSpace(_option.OrgPath) && _option.OrgPath != "/dev/null"; + public bool IsIgnoreWhitespaceVisible + { + get => _isIgnoreWhitespaceVisible; + private set => SetProperty(ref _isIgnoreWhitespaceVisible, value); } - public string OrgFilePath { - get => _option.OrgPath; + public object Content + { + get => _content; + private set => SetProperty(ref _content, value); } - public bool IsLoading { - get => _isLoading; - private set => SetProperty(ref _isLoading, value); + public int UnifiedLines + { + get => _unifiedLines; + private set => SetProperty(ref _unifiedLines, value); } - public bool IsNoChange { - get => _isNoChange; - private set => SetProperty(ref _isNoChange, value); + public DiffContext(string repo, Models.DiffOption option, DiffContext previous = null) + { + _repo = repo; + _option = option; + + if (previous != null) + { + _isTextDiff = previous._isTextDiff; + _isIgnoreWhitespaceVisible = previous._isIgnoreWhitespaceVisible; + _content = previous._content; + _oldMode = previous._oldMode; + _newMode = previous._newMode; + _unifiedLines = previous._unifiedLines; + _info = previous._info; + } + + if (string.IsNullOrEmpty(_option.OrgPath) || _option.OrgPath == "/dev/null") + Title = _option.Path; + else + Title = $"{_option.OrgPath} → {_option.Path}"; + + LoadContent(); } - public bool IsTextDiff { - get => _isTextDiff; - private set => SetProperty(ref _isTextDiff, value); + public void IncrUnified() + { + UnifiedLines = _unifiedLines + 1; + LoadContent(); } - public object Content { - get => _content; - private set => SetProperty(ref _content, value); + public void DecrUnified() + { + UnifiedLines = Math.Max(4, _unifiedLines - 1); + LoadContent(); } - public Vector SyncScrollOffset { - get => _syncScrollOffset; - set => SetProperty(ref _syncScrollOffset, value); + public void OpenExternalMergeTool() + { + new Commands.DiffTool(_repo, _option).Open(); } - public DiffContext(string repo, Models.DiffOption option) { - _repo = repo; - _option = option; + public void CheckSettings() + { + var pref = Preferences.Instance; + + if (Content is TextDiffContext ctx) + { + if ((pref.UseFullTextDiff && _info.UnifiedLines != _entireFileLines) || + (!pref.UseFullTextDiff && _info.UnifiedLines == _entireFileLines) || + (pref.IgnoreWhitespaceChangesInDiff != _info.IgnoreWhitespace)) + { + LoadContent(); + return; + } + + if (ctx.IsSideBySide() != pref.UseSideBySideDiff) + Content = ctx.SwitchMode(); + } + else if (Content is Models.NoOrEOLChange) + { + if (pref.IgnoreWhitespaceChangesInDiff != _info.IgnoreWhitespace) + LoadContent(); + } + } - OnPropertyChanged(nameof(FilePath)); - OnPropertyChanged(nameof(IsOrgFilePathVisible)); - OnPropertyChanged(nameof(OrgFilePath)); + private void LoadContent() + { + if (_option.Path.EndsWith('/')) + { + OldMode = 0; + NewMode = 160000; + IsTextDiff = false; + IsIgnoreWhitespaceVisible = false; + Content = null; + _info = null; + return; + } - Task.Run(() => { - var latest = new Commands.Diff(repo, option).Result(); - var binaryDiff = null as Models.BinaryDiff; + Task.Run(async () => + { + var pref = Preferences.Instance; + var numLines = pref.UseFullTextDiff ? _entireFileLines : _unifiedLines; + var ignoreWhitespace = pref.IgnoreWhitespaceChangesInDiff; + var ignoreCRAtEOL = pref.IgnoreCRAtEOLInDiff; - if (latest.IsBinary) { - binaryDiff = new Models.BinaryDiff(); + var latest = await new Commands.Diff(_repo, _option, numLines, ignoreWhitespace, ignoreCRAtEOL) + .ReadAsync() + .ConfigureAwait(false); - var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; - 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 { - binaryDiff.OldSize = new Commands.QueryFileSize(repo, oldPath, "HEAD").Result(); - binaryDiff.NewSize = new FileInfo(Path.Combine(repo, _option.Path)).Length; - } + var info = new Info(_option, numLines, ignoreWhitespace, latest); + if (_info != null && info.IsSame(_info)) + return; + + _info = info; + + object rs = null; + if (latest.TextDiff is { } textDiff) + { + rs = textDiff; + } + else if (latest.LFSDiff is { } lfs) + { + var imgDecoder = ImageSource.GetDecoder(_option.Path); + if (imgDecoder != Models.ImageDecoder.None) + rs = new LFSImageDiff(_repo, lfs, imgDecoder); + else + rs = lfs; + } + else if (latest.IsBinary) + { + var imgDecoder = ImageSource.GetDecoder(_option.Path); + if (imgDecoder != Models.ImageDecoder.None) + rs = await CreateImageDiffAsync(imgDecoder).ConfigureAwait(false); + else + rs = await CreateBinaryDiffAsync().ConfigureAwait(false); + } + else if (latest.IsSubmoduleChange) + { + rs = await CreateSubmoduleDiffAsync(latest.OldHash, latest.NewHash).ConfigureAwait(false); + } + else if (IsEmptyFileHash(latest.OldHash) || IsEmptyFileHash(latest.NewHash)) + { + rs = new Models.EmptyFile(); + } + else + { + rs = new Models.NoOrEOLChange(); } - Dispatcher.UIThread.InvokeAsync(() => { - if (latest.IsBinary) { - Content = binaryDiff; - } else if (latest.IsLFS) { - Content = latest.LFSDiff; - } else if (latest.TextDiff != null) { - latest.TextDiff.File = _option.Path; - Content = latest.TextDiff; + Dispatcher.UIThread.Post(() => + { + OldMode = latest.OldMode; + NewMode = latest.NewMode; + + if (rs is Models.TextDiff cur) + { IsTextDiff = true; - } else { + IsIgnoreWhitespaceVisible = true; + + if (Preferences.Instance.UseSideBySideDiff) + Content = new TwoSideTextDiff(_option, cur, _content as TextDiffContext); + else + Content = new CombinedTextDiff(_option, cur, _content as TextDiffContext); + } + else + { IsTextDiff = false; - IsNoChange = true; + IsIgnoreWhitespaceVisible = (rs is Models.NoOrEOLChange); + Content = rs; } - - IsLoading = false; }); }); } - public async void OpenExternalMergeTool() { - var type = Preference.Instance.ExternalMergeToolType; - var exec = Preference.Instance.ExternalMergeToolPath; + private async Task CreateImageDiffAsync(Models.ImageDecoder imgDecoder) + { + var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; + var imgDiff = new Models.ImageDiff(); + var fullPath = Path.Combine(_repo, _option.Path); - var tool = Models.ExternalMergeTools.Supported.Find(x => x.Type == type); - if (tool == null || !File.Exists(exec)) { - App.RaiseException(_repo, "Invalid merge tool in preference setting!"); - return; + if (_option.Revisions.Count == 2) + { + if (_option.Revisions[0].Equals("-R", StringComparison.Ordinal)) + { + var oldImage = await ImageSource.FromFileAsync(fullPath, imgDecoder).ConfigureAwait(false); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + } + else + { + var oldImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[0], oldPath, imgDecoder).ConfigureAwait(false); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + } + + var newImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[1], _option.Path, imgDecoder).ConfigureAwait(false); + imgDiff.New = newImage.Bitmap; + imgDiff.NewFileSize = newImage.Size; } + else + { + if (!oldPath.Equals("/dev/null", StringComparison.Ordinal)) + { + var oldImage = await ImageSource.FromRevisionAsync(_repo, "HEAD", oldPath, imgDecoder).ConfigureAwait(false); + imgDiff.Old = oldImage.Bitmap; + imgDiff.OldFileSize = oldImage.Size; + } + + var newImage = await ImageSource.FromFileAsync(fullPath, imgDecoder).ConfigureAwait(false); + imgDiff.New = newImage.Bitmap; + imgDiff.NewFileSize = newImage.Size; + } + + return imgDiff; + } + + private async Task CreateBinaryDiffAsync() + { + var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; + var binaryDiff = new Models.BinaryDiff(); + var fullPath = Path.Combine(_repo, _option.Path); + + if (_option.Revisions.Count == 2) + { + if (_option.Revisions[0].Equals("-R", StringComparison.Ordinal)) + { + binaryDiff.OldSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; + binaryDiff.NewSize = await new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).GetResultAsync().ConfigureAwait(false); + } + else + { + binaryDiff.OldSize = await new Commands.QueryFileSize(_repo, oldPath, _option.Revisions[0]).GetResultAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(_option.Revisions[1])) + binaryDiff.NewSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; + else + binaryDiff.NewSize = await new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).GetResultAsync().ConfigureAwait(false); + } + } + else + { + binaryDiff.OldSize = await new Commands.QueryFileSize(_repo, oldPath, "HEAD").GetResultAsync().ConfigureAwait(false); + binaryDiff.NewSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; + } + + return binaryDiff; + } - var args = tool.Type != 0 ? tool.DiffCmd : Preference.Instance.ExternalMergeToolDiffCmd; - await Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, exec, args, _option)); + private async Task CreateSubmoduleDiffAsync(string oldRevision, string newRevision) + { + var submoduleDiff = new Models.SubmoduleDiff(); + var submoduleRoot = $"{_repo}/{_option.Path}".Replace('\\', '/').TrimEnd('/'); + submoduleDiff.FullPath = submoduleRoot; + + if (IsValidSubmoduleHash(oldRevision)) + submoduleDiff.Old = await new Commands.QuerySubmoduleRevision(submoduleRoot, oldRevision) + .GetResultAsync() + .ConfigureAwait(false); + + if (IsValidSubmoduleHash(newRevision)) + submoduleDiff.New = await new Commands.QuerySubmoduleRevision(submoduleRoot, newRevision) + .GetResultAsync() + .ConfigureAwait(false); + + return submoduleDiff; + } + + private bool IsValidSubmoduleHash(string hash) + { + for (int i = 0; i < hash.Length; i++) + { + if (hash[i] != '0') + return true; + } + + return false; + } + + private bool IsEmptyFileHash(string hash) + { + if (string.IsNullOrEmpty(hash)) + return false; + + if (hash.Length == 40) + return hash.Equals(Models.EmptyFile.SHA1, StringComparison.Ordinal); + + if (hash.Length == 64) + return hash.Equals(Models.EmptyFile.SHA256, StringComparison.Ordinal); + + return false; + } + + 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 string _repo = string.Empty; - private Models.DiffOption _option = null; - private bool _isLoading = true; - private bool _isNoChange = false; + private readonly int _entireFileLines = 999999999; + private readonly string _repo; + private readonly Models.DiffOption _option = null; + private int _oldMode = 0; + private int _newMode = 0; + private int _unifiedLines = 4; private bool _isTextDiff = false; + private bool _isIgnoreWhitespaceVisible = true; private object _content = null; - private Vector _syncScrollOffset = Vector.Zero; + private Info _info = null; } } diff --git a/src/ViewModels/DirHistories.cs b/src/ViewModels/DirHistories.cs new file mode 100644 index 000000000..a1448009c --- /dev/null +++ b/src/ViewModels/DirHistories.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class DirHistories : ObservableObject + { + public string Title + { + get; + } + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List Commits + { + get => _commits; + private set => SetProperty(ref _commits, value); + } + + public Models.Commit SelectedCommit + { + get => _selectedCommit; + set + { + if (SetProperty(ref _selectedCommit, value)) + Detail.Commit = value; + } + } + + public CommitDetail Detail + { + get => _detail; + } + + public DirHistories(Repository repo, string dir, string revision = null) + { + if (!string.IsNullOrEmpty(revision)) + Title = $"{dir} @ {revision}"; + else + Title = dir; + + _repo = repo; + _detail = new CommitDetail(repo, null); + _detail.SearchChangeFilter = dir; + + Task.Run(async () => + { + var argsBuilder = new StringBuilder(); + argsBuilder + .Append("--date-order -n 10000 ") + .Append(revision ?? string.Empty) + .Append(" -- ") + .Append(dir.Quoted()); + + var commits = await new Commands.QueryCommits(_repo.FullPath, argsBuilder.ToString(), false) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + Commits = commits; + IsLoading = false; + + if (commits.Count > 0) + SelectedCommit = commits[0]; + }); + }); + } + + public DirHistories(string repoPath, string dir) + { + Title = dir; + + var gitDir = new Commands.QueryGitDir(repoPath).GetResult(); + _repo = new Repository(true, repoPath, gitDir); // Trait repository as a bare repository to disable some file operations. + _repo.RefreshBranches(); + + _detail = new CommitDetail(_repo, null); + _detail.SearchChangeFilter = dir; + + Task.Run(async () => + { + var argsBuilder = new StringBuilder(); + argsBuilder + .Append("--date-order -n 10000 -- ") + .Append(dir.Quoted()); + + var commits = await new Commands.QueryCommits(_repo.FullPath, argsBuilder.ToString(), false) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + Commits = commits; + IsLoading = false; + + if (commits.Count > 0) + SelectedCommit = commits[0]; + }); + }); + } + + public void NavigateToCommit(Models.Commit commit) + { + _repo.NavigateToCommit(commit.SHA); + } + + public string GetCommitFullMessage(Models.Commit commit) + { + var sha = commit.SHA; + if (_cachedCommitFullMessage.TryGetValue(sha, out var msg)) + return msg; + + msg = new Commands.QueryCommitFullMessage(_repo.FullPath, sha).GetResult(); + _cachedCommitFullMessage[sha] = msg; + return msg; + } + + private Repository _repo = null; + private bool _isLoading = true; + private List _commits = []; + private Models.Commit _selectedCommit = null; + private CommitDetail _detail = null; + private Dictionary _cachedCommitFullMessage = new(); + } +} diff --git a/src/ViewModels/Discard.cs b/src/ViewModels/Discard.cs index be2dedc0d..0a37b56ba 100644 --- a/src/ViewModels/Discard.cs +++ b/src/ViewModels/Discard.cs @@ -1,61 +1,97 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class DiscardModeAll { } - public class DiscardModeSingle { public string File { get; set; } } - public class DiscardModeMulti { public int Count { get; set; } } +namespace SourceGit.ViewModels +{ + public class DiscardAllMode + { + public bool IncludeModified + { + get; + set; + } = true; + + public bool IncludeUntracked + { + get; + set; + } = false; - public class Discard : Popup { + public bool IncludeIgnored + { + get; + set; + } = false; + } - public object Mode { + 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; - private set; } - public Discard(Repository repo) { + public Discard(Repository repo) + { _repo = repo; - - Mode = new DiscardModeAll(); - View = new Views.Discard { DataContext = this }; + Mode = new DiscardAllMode(); } - public Discard(Repository repo, List changes, bool isUnstaged) { + public Discard(Repository repo, List changes) + { _repo = repo; _changes = changes; - _isUnstaged = isUnstaged; - - if (_changes == null) { - Mode = new DiscardModeAll(); - } else if (_changes.Count == 1) { - Mode = new DiscardModeSingle() { File = _changes[0].Path }; - } else { - Mode = new DiscardModeMulti() { Count = _changes.Count }; - } - View = new Views.Discard() { DataContext = this }; + 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); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = _changes == null ? "Discard all local changes ..." : $"Discard total {_changes.Count} changes ..."; - return Task.Run(() => { - if (_changes == null) { - Commands.Discard.All(_repo.FullPath); - } else if (_isUnstaged) { - Commands.Discard.ChangesInWorkTree(_repo.FullPath, _changes); - } else { - Commands.Discard.ChangesInStaged(_repo.FullPath, _changes); - } - - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + var log = _repo.CreateLog("Discard Changes"); + Use(log); + + if (Mode is DiscardAllMode all) + { + await Commands.Discard.AllAsync(_repo.FullPath, all.IncludeModified, all.IncludeUntracked, all.IncludeIgnored, log); + _repo.ClearCommitMessage(); + } + else + { + await Commands.Discard.ChangesAsync(_repo.FullPath, _changes, log); + } + + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return true; } - private Repository _repo = null; - private List _changes = null; - private bool _isUnstaged = true; + private readonly Repository _repo = null; + private readonly List _changes = null; } } diff --git a/src/ViewModels/DropStash.cs b/src/ViewModels/DropStash.cs index 87926503e..7d4136ab9 100644 --- a/src/ViewModels/DropStash.cs +++ b/src/ViewModels/DropStash.cs @@ -1,24 +1,34 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class DropStash : Popup { - public Models.Stash Stash { get; private set; } +namespace SourceGit.ViewModels +{ + public class DropStash : Popup + { + public Models.Stash Stash { get; } - public DropStash(string repo, Models.Stash stash) { + public DropStash(Repository repo, Models.Stash stash) + { _repo = repo; Stash = stash; - View = new Views.DropStash() { DataContext = this }; } - public override Task Sure() { + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Dropping stash: {Stash.Name}"; - return Task.Run(() => { - new Commands.Stash(_repo).Drop(Stash.Name); - return true; - }); + var log = _repo.CreateLog("Drop Stash"); + Use(log); + + await new Commands.Stash(_repo.FullPath) + .Use(log) + .DropAsync(Stash.Name); + + log.Complete(); + _repo.MarkStashesDirtyManually(); + return true; } - private string _repo; + private readonly Repository _repo; } } diff --git a/src/ViewModels/EditBranchDescription.cs b/src/ViewModels/EditBranchDescription.cs new file mode 100644 index 000000000..d4b6634f9 --- /dev/null +++ b/src/ViewModels/EditBranchDescription.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class EditBranchDescription : Popup + { + public Models.Branch Target + { + get; + } + + public string Description + { + get => _description; + set => SetProperty(ref _description, value); + } + + public EditBranchDescription(Repository repo, Models.Branch target, string desc) + { + Target = target; + + _repo = repo; + _originalDescription = desc; + _description = desc; + } + + public override async Task Sure() + { + var trimmed = _description.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + if (string.IsNullOrEmpty(_originalDescription)) + return true; + } + else if (trimmed.Equals(_originalDescription, StringComparison.Ordinal)) + { + return true; + } + + var log = _repo.CreateLog("Edit Branch Description"); + Use(log); + + await new Commands.Config(_repo.FullPath) + .Use(log) + .SetAsync($"branch.{Target.Name}.description", trimmed); + + log.Complete(); + return true; + } + + private readonly Repository _repo; + private string _originalDescription = string.Empty; + private string _description = string.Empty; + } +} diff --git a/src/ViewModels/EditRemote.cs b/src/ViewModels/EditRemote.cs index ae735a80d..84b950e2a 100644 --- a/src/ViewModels/EditRemote.cs +++ b/src/ViewModels/EditRemote.cs @@ -1,100 +1,135 @@ using System.ComponentModel.DataAnnotations; +using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class EditRemote : Popup { +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 { + 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 { + public string Url + { get => _url; - set { - if (SetProperty(ref _url, value, true)) UseSSH = Models.Remote.IsSSH(value); + set + { + if (SetProperty(ref _url, value, true)) + UseSSH = Models.Remote.IsSSH(value); } } - public bool UseSSH { + public bool UseSSH + { get => _useSSH; - set => SetProperty(ref _useSSH, value); + set + { + if (SetProperty(ref _useSSH, value)) + ValidateProperty(_sshkey, nameof(SSHKey)); + } } - public string SSHKey { - get; - set; + [CustomValidation(typeof(EditRemote), nameof(ValidateSSHKey))] + public string SSHKey + { + get => _sshkey; + set => SetProperty(ref _sshkey, value, true); } - public EditRemote(Repository repo, Models.Remote remote) { + 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"); + 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!!!"); + } } - View = new Views.EditRemote() { DataContext = this }; + return ValidationResult.Success; } - 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) new ValidationResult("A remote with given name already exists!!!"); + 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 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) new ValidationResult("A remote with the same url already exists!!!"); - } + 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); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); 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.Name != _name) + { + var succ = await new Commands.Remote(_repo.FullPath).RenameAsync(_remote.Name, _name); + if (succ) + _remote.Name = _name; + } - if (_remote.URL != _url) { - var succ = new Commands.Remote(_repo.FullPath).SetURL(_name, _url); - if (succ) _remote.URL = _url; - } + if (_remote.URL != _url) + { + var succ = await new Commands.Remote(_repo.FullPath).SetURLAsync(_name, _url, false); + if (succ) + _remote.URL = _url; + } - if (_useSSH) { - SetProgressDescription("Post processing ..."); - new Commands.Config(_repo.FullPath).Set($"remote.{_name}.sshkey", SSHKey); - } + var pushURL = await new Commands.Remote(_repo.FullPath).GetURLAsync(_name, true); + if (pushURL != _url) + await new Commands.Remote(_repo.FullPath).SetURLAsync(_name, _url, true); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + await new Commands.Config(_repo.FullPath).SetAsync($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); + return true; } - private Repository _repo = null; - private Models.Remote _remote = null; - private string _name = string.Empty; - private string _url = string.Empty; + 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 index 6111b72e2..40402ef67 100644 --- a/src/ViewModels/EditRepositoryNode.cs +++ b/src/ViewModels/EditRepositoryNode.cs @@ -1,55 +1,60 @@ -using System; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class EditRepositoryNode : Popup { - public RepositoryNode Node { - get => _node; - set => SetProperty(ref _node, value); +namespace SourceGit.ViewModels +{ + public class EditRepositoryNode : Popup + { + public string Target + { + get; } - public string Id { - get => _id; - set => SetProperty(ref _id, value); + public bool IsRepository + { + get; } [Required(ErrorMessage = "Name is required!")] - public string Name { + public string Name + { get => _name; set => SetProperty(ref _name, value, true); } - public int Bookmark { + public int Bookmark + { get => _bookmark; set => SetProperty(ref _bookmark, value); } - public bool IsRepository { - get => _isRepository; - set => SetProperty(ref _isRepository, value); - } - - public EditRepositoryNode(RepositoryNode node) { + public EditRepositoryNode(RepositoryNode node) + { _node = node; - _id = node.Id; _name = node.Name; - _isRepository = node.IsRepository; _bookmark = node.Bookmark; - View = new Views.EditRepositoryNode() { DataContext = this }; + Target = node.IsRepository ? node.Id : node.Name; + IsRepository = node.IsRepository; } - public override Task Sure() { + public override Task Sure() + { + bool needSort = _node.Name != _name; _node.Name = _name; _node.Bookmark = _bookmark; - return null; + + if (needSort) + { + Preferences.Instance.SortByRenamedNode(_node); + Welcome.Instance.Refresh(); + } + + return Task.FromResult(true); } - private RepositoryNode _node = null; - private string _id = string.Empty; - private string _name = string.Empty; - private bool _isRepository = false; + private RepositoryNode _node = null; + private string _name = null; private int _bookmark = 0; } } diff --git a/src/ViewModels/ExecuteCustomAction.cs b/src/ViewModels/ExecuteCustomAction.cs new file mode 100644 index 000000000..1c01fb820 --- /dev/null +++ b/src/ViewModels/ExecuteCustomAction.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public interface ICustomActionControlParameter + { + string GetValue(); + } + + public class CustomActionControlTextBox : ICustomActionControlParameter + { + public string Label { get; set; } + public string Placeholder { get; set; } + public string Text { get; set; } + public string Formatter { get; set; } + + public CustomActionControlTextBox(string label, string placeholder, string defaultValue, string formatter) + { + Label = label + ":"; + Placeholder = placeholder; + Text = defaultValue; + Formatter = formatter; + } + + public string GetValue() + { + if (string.IsNullOrEmpty(Text)) + return string.Empty; + return string.IsNullOrEmpty(Formatter) ? Text : Formatter.Replace("${VALUE}", Text); + } + } + + public class CustomActionControlPathSelector : ObservableObject, ICustomActionControlParameter + { + public string Label { get; set; } + public string Placeholder { get; set; } + public bool IsFolder { get; set; } + + public string Path + { + get => _path; + set => SetProperty(ref _path, value); + } + + public CustomActionControlPathSelector(string label, string placeholder, bool isFolder, string defaultValue) + { + Label = label + ":"; + Placeholder = placeholder; + IsFolder = isFolder; + _path = defaultValue; + } + + public string GetValue() => _path; + + private string _path; + } + + public class CustomActionControlCheckBox : ICustomActionControlParameter + { + public string Label { get; set; } + public string ToolTip { get; set; } + public string CheckedValue { get; set; } + public bool IsChecked { get; set; } + + public CustomActionControlCheckBox(string label, string tooltip, string checkedValue, bool isChecked) + { + Label = label; + ToolTip = string.IsNullOrEmpty(tooltip) ? null : tooltip; + CheckedValue = checkedValue; + IsChecked = isChecked; + } + + public string GetValue() => IsChecked ? CheckedValue : string.Empty; + } + + public class CustomActionControlComboBox : ObservableObject, ICustomActionControlParameter + { + public string Label { get; set; } + public string Description { get; set; } + public List Options { get; set; } = []; + public string Value { get; set; } + + public CustomActionControlComboBox(string label, string description, string options) + { + Label = label; + Description = description; + + var parts = options.Split('|', StringSplitOptions.TrimEntries); + if (parts.Length > 0) + { + Options.AddRange(parts); + Value = parts[0]; + } + } + + public string GetValue() => Value; + } + + public class CustomActionControlBranchSelector : ObservableObject, ICustomActionControlParameter + { + public string Label { get; set; } + public string Description { get; set; } + public List Branches { get; set; } = []; + public Models.Branch SelectedBranch { get; set; } + + public CustomActionControlBranchSelector(string label, string description, Repository repo, bool isLocal, bool useFriendlyName, object target) + { + Label = label; + Description = description; + _useFriendlyName = useFriendlyName; + + foreach (var b in repo.Branches) + { + if (b.IsLocal == isLocal && !b.IsDetachedHead) + Branches.Add(b); + } + + if (Branches.Count == 0) + return; + + if (target is Models.Branch branch) + { + if (isLocal) + { + var prefer = Branches.Find(b => b.FullName.Equals(branch.FullName, StringComparison.Ordinal)); + if (prefer != null) + { + SelectedBranch = prefer; + return; + } + } + else if (!string.IsNullOrEmpty(branch.Upstream)) + { + var upstream = Branches.Find(b => b.FullName.Equals(branch.Upstream, StringComparison.Ordinal)); + if (upstream != null) + { + SelectedBranch = upstream; + return; + } + } + } + + SelectedBranch = Branches[0]; + } + + public string GetValue() + { + if (SelectedBranch == null) + return string.Empty; + + return _useFriendlyName ? SelectedBranch.FriendlyName : SelectedBranch.Name; + } + + private bool _useFriendlyName = false; + } + + public class ExecuteCustomAction : Popup + { + public Models.CustomAction CustomAction + { + get; + } + + public object Target + { + get; + } + + public List ControlParameters + { + get; + } = []; + + public ExecuteCustomAction(Repository repo, Models.CustomAction action, object scopeTarget) + { + _repo = repo; + CustomAction = action; + Target = scopeTarget ?? new Models.Null(); + + foreach (var ctl in CustomAction.Controls) + { + switch (ctl.Type) + { + case Models.CustomActionControlType.TextBox: + ControlParameters.Add(new CustomActionControlTextBox(ctl.Label, ctl.Description, PrepareStringByTarget(ctl.StringValue), ctl.StringFormatter)); + break; + case Models.CustomActionControlType.PathSelector: + ControlParameters.Add(new CustomActionControlPathSelector(ctl.Label, ctl.Description, ctl.BoolValue, PrepareStringByTarget(ctl.StringValue))); + break; + case Models.CustomActionControlType.CheckBox: + ControlParameters.Add(new CustomActionControlCheckBox(ctl.Label, ctl.Description, ctl.StringValue, ctl.BoolValue)); + break; + case Models.CustomActionControlType.ComboBox: + ControlParameters.Add(new CustomActionControlComboBox(ctl.Label, ctl.Description, PrepareStringByTarget(ctl.StringValue))); + break; + case Models.CustomActionControlType.LocalBranchSelector: + ControlParameters.Add(new CustomActionControlBranchSelector(ctl.Label, ctl.Description, _repo, true, false, scopeTarget ?? _repo.CurrentBranch)); + break; + case Models.CustomActionControlType.RemoteBranchSelector: + ControlParameters.Add(new CustomActionControlBranchSelector(ctl.Label, ctl.Description, _repo, false, ctl.BoolValue, scopeTarget ?? _repo.CurrentBranch)); + break; + } + } + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Run custom action ..."; + + var cmdline = PrepareStringByTarget(CustomAction.Arguments); + for (var i = ControlParameters.Count - 1; i >= 0; i--) + { + var param = ControlParameters[i]; + cmdline = cmdline.Replace($"${i + 1}", param.GetValue()); + } + + var log = _repo.CreateLog(CustomAction.Name); + Use(log); + + log.AppendLine($"$ {CustomAction.Executable} {cmdline}\n"); + + if (CustomAction.WaitForExit) + await RunAsync(cmdline, log); + else + _ = Task.Run(() => Run(cmdline)); + + log.Complete(); + return true; + } + + private string PrepareStringByTarget(string org) + { + var repoPath = OperatingSystem.IsWindows() ? _repo.FullPath.Replace("/", "\\") : _repo.FullPath; + org = org.Replace("${REPO}", repoPath); + + if (Target is Models.Branch { IsDetachedHead: false } targetBranch) + org = org.Replace("${BRANCH}", targetBranch.Name).Replace("${BRANCH_FRIENDLY_NAME}", targetBranch.FriendlyName).Replace("${REMOTE}", targetBranch.Remote); + else if (_repo.CurrentBranch is { IsDetachedHead: false } currentBranch) + org = org.Replace("${BRANCH}", currentBranch.Name).Replace("${BRANCH_FRIENDLY_NAME}", currentBranch.FriendlyName); + else + org = org.Replace("${BRANCH}", "HEAD").Replace("${BRANCH_FRIENDLY_NAME}", "HEAD"); + + return Target switch + { + Models.Branch b => org.Replace("${SHA}", b.Head), + Models.Commit c => org.Replace("${SHA}", c.SHA), + Models.Tag t => org.Replace("${SHA}", t.SHA).Replace("${TAG}", t.Name), + Models.Remote r => org.Replace("${REMOTE}", r.Name), + Models.CustomActionTargetFile f => org.Replace("${SHA}", f.Revision?.SHA ?? "HEAD").Replace("${FILE}", f.File), + _ => org + }; + } + + private void Run(string args) + { + var start = new ProcessStartInfo(); + start.FileName = CustomAction.Executable; + start.Arguments = args; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.WorkingDirectory = _repo.FullPath; + + try + { + Process.Start(start); + } + catch (Exception e) + { + _repo.SendNotification(e.Message, true); + } + } + + private async Task RunAsync(string args, Models.ICommandLog log) + { + var start = new ProcessStartInfo(); + start.FileName = CustomAction.Executable; + 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.FullPath; + + using var proc = new Process(); + proc.StartInfo = start; + + proc.OutputDataReceived += (_, e) => + { + if (e.Data != null) + log?.AppendLine(e.Data); + }; + + var builder = new StringBuilder(); + proc.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + { + log?.AppendLine(e.Data); + builder.AppendLine(e.Data); + } + }; + + try + { + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + await proc.WaitForExitAsync().ConfigureAwait(false); + + var exitCode = proc.ExitCode; + if (exitCode != 0) + { + var errMsg = builder.ToString().Trim(); + if (!string.IsNullOrEmpty(errMsg)) + _repo.SendNotification(errMsg, true); + } + } + catch (Exception e) + { + _repo.SendNotification(e.Message, true); + } + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/ExecuteCustomActionCommandPalette.cs b/src/ViewModels/ExecuteCustomActionCommandPalette.cs new file mode 100644 index 000000000..78290086a --- /dev/null +++ b/src/ViewModels/ExecuteCustomActionCommandPalette.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ExecuteCustomActionCommandPaletteCmd + { + public Models.CustomAction Action { get; set; } + public bool IsGlobal { get; set; } + public string Name { get => Action.Name; } + + public ExecuteCustomActionCommandPaletteCmd(Models.CustomAction action, bool isGlobal) + { + Action = action; + IsGlobal = isGlobal; + } + } + + public class ExecuteCustomActionCommandPalette : ICommandPalette + { + public List VisibleActions + { + get => _visibleActions; + private set => SetProperty(ref _visibleActions, value); + } + + public ExecuteCustomActionCommandPaletteCmd Selected + { + get => _selected; + set => SetProperty(ref _selected, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateVisibleActions(); + } + } + + public ExecuteCustomActionCommandPalette(Repository repo) + { + _repo = repo; + + var actions = repo.GetCustomActions(Models.CustomActionScope.Repository); + foreach (var (action, menu) in actions) + _actions.Add(new(action, menu.IsGlobal)); + + if (_actions.Count > 0) + { + _actions.Sort((l, r) => + { + if (l.IsGlobal != r.IsGlobal) + return l.IsGlobal ? -1 : 1; + + return l.Name.CompareTo(r.Name, StringComparison.OrdinalIgnoreCase); + }); + + _visibleActions = _actions; + _selected = _actions[0]; + } + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public async Task ExecAsync() + { + _actions.Clear(); + _visibleActions.Clear(); + Close(); + + if (_selected != null) + await _repo.ExecCustomActionAsync(_selected.Action, null); + } + + private void UpdateVisibleActions() + { + var filter = _filter?.Trim(); + if (string.IsNullOrEmpty(filter)) + { + VisibleActions = _actions; + return; + } + + var visible = new List(); + foreach (var act in _actions) + { + if (act.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(act); + } + + var autoSelected = _selected; + if (visible.Count == 0) + autoSelected = null; + else if (_selected == null || !visible.Contains(_selected)) + autoSelected = visible[0]; + + VisibleActions = visible; + Selected = autoSelected; + } + + private Repository _repo; + private List _actions = []; + private List _visibleActions = []; + private ExecuteCustomActionCommandPaletteCmd _selected = null; + private string _filter; + } +} diff --git a/src/ViewModels/FastForwardWithoutCheckout.cs b/src/ViewModels/FastForwardWithoutCheckout.cs deleted file mode 100644 index 5d2da8377..000000000 --- a/src/ViewModels/FastForwardWithoutCheckout.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.ViewModels { - public class FastForwardWithoutCheckout : Popup { - public Models.Branch Local { - get; - private set; - } - - public Models.Branch To { - get; - private set; - } - - public FastForwardWithoutCheckout(Repository repo, Models.Branch local, Models.Branch upstream) { - _repo = repo; - Local = local; - To = upstream; - View = new Views.FastForwardWithoutCheckout() { DataContext = this }; - } - - public override Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = "Fast-Forward ..."; - - return Task.Run(() => { - new Commands.Fetch(_repo.FullPath, To.Remote, Local.Name, To.Name, SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); - } - - private Repository _repo = null; - } -} diff --git a/src/ViewModels/Fetch.cs b/src/ViewModels/Fetch.cs index dc85c94dc..3236a6421 100644 --- a/src/ViewModels/Fetch.cs +++ b/src/ViewModels/Fetch.cs @@ -1,54 +1,113 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Fetch : Popup { - public List Remotes { +namespace SourceGit.ViewModels +{ + public class Fetch : Popup + { + public List Remotes + { get => _repo.Remotes; } - public bool FetchAllRemotes { + public bool IsFetchAllRemoteVisible + { + get; + } + + public bool FetchAllRemotes + { get => _fetchAllRemotes; - set => SetProperty(ref _fetchAllRemotes, value); + set + { + if (SetProperty(ref _fetchAllRemotes, value) && IsFetchAllRemoteVisible) + _repo.UIStates.FetchAllRemotes = value; + } } - public Models.Remote SelectedRemote { + public Models.Remote SelectedRemote + { get; set; } - public bool Prune { - get; - set; + public bool NoTags + { + get => _repo.UIStates.FetchWithoutTags; + set => _repo.UIStates.FetchWithoutTags = value; + } + + public bool Force + { + get => _repo.UIStates.EnableForceOnFetch; + set => _repo.UIStates.EnableForceOnFetch = value; } - public Fetch(Repository repo, Models.Remote preferedRemote = null) { + public Fetch(Repository repo, Models.Remote preferredRemote = null) + { _repo = repo; - _fetchAllRemotes = preferedRemote == null; - SelectedRemote = preferedRemote != null ? preferedRemote : _repo.Remotes[0]; - Prune = true; - View = new Views.Fetch() { DataContext = this }; - } - - public override Task Sure() { - _repo.SetWatcherEnabled(false); - return Task.Run(() => { - if (FetchAllRemotes) { - foreach (var remote in _repo.Remotes) { - SetProgressDescription($"Fetching remote: {remote.Name}"); - new Commands.Fetch(_repo.FullPath, remote.Name, Prune, SetProgressDescription).Exec(); - } - } else { - SetProgressDescription($"Fetching remote: {SelectedRemote.Name}"); - new Commands.Fetch(_repo.FullPath, SelectedRemote.Name, Prune, SetProgressDescription).Exec(); + IsFetchAllRemoteVisible = repo.Remotes.Count > 1 && preferredRemote == null; + _fetchAllRemotes = IsFetchAllRemoteVisible && _repo.UIStates.FetchAllRemotes; + + if (preferredRemote != null) + { + SelectedRemote = preferredRemote; + } + else if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + { + var def = _repo.Remotes.Find(r => r.Name == _repo.Settings.DefaultRemote); + SelectedRemote = def ?? _repo.Remotes[0]; + } + else + { + SelectedRemote = _repo.Remotes[0]; + } + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + + var navigateToUpstreamHEAD = _repo.IsHistoriesVisible && + _repo.Histories.SelectedCommits.Count == 1 && + _repo.Histories.SelectedCommits[0].IsCurrentHead; + + var notags = _repo.UIStates.FetchWithoutTags; + var force = _repo.UIStates.EnableForceOnFetch; + var log = _repo.CreateLog("Fetch"); + Use(log); + + if (FetchAllRemotes) + { + foreach (var remote in _repo.Remotes) + await new Commands.Fetch(_repo.FullPath, remote.Name, notags, force) + .Use(log) + .RunAsync(); + } + else + { + await new Commands.Fetch(_repo.FullPath, SelectedRemote.Name, notags, force) + .Use(log) + .RunAsync(); + } + + log.Complete(); + + if (navigateToUpstreamHEAD) + { + var upstream = _repo.CurrentBranch?.Upstream; + if (!string.IsNullOrEmpty(upstream)) + { + var upstreamHead = await new Commands.QueryRevisionByRefName(_repo.FullPath, upstream.Substring(13)).GetResultAsync(); + _repo.NavigateToCommit(upstreamHead, true); } + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + _repo.MarkFetched(); + return true; } - private Repository _repo = null; - private bool _fetchAllRemotes = true; + private readonly Repository _repo = null; + private bool _fetchAllRemotes = false; } } diff --git a/src/ViewModels/FetchInto.cs b/src/ViewModels/FetchInto.cs new file mode 100644 index 000000000..bcf461203 --- /dev/null +++ b/src/ViewModels/FetchInto.cs @@ -0,0 +1,49 @@ +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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Fast-Forward ..."; + + var log = _repo.CreateLog($"Fetch Into '{Local.FriendlyName}'"); + Use(log); + + await new Commands.Fetch(_repo.FullPath, Local, Upstream) + .Use(log) + .RunAsync(); + + log.Complete(); + + if (_repo.SelectedViewIndex == 0) + { + var newHead = await new Commands.QueryRevisionByRefName(_repo.FullPath, Local.Name).GetResultAsync(); + _repo.NavigateToCommit(newHead, true); + } + + return true; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/FileHistories.cs b/src/ViewModels/FileHistories.cs index 903564849..374396f1f 100644 --- a/src/ViewModels/FileHistories.cs +++ b/src/ViewModels/FileHistories.cs @@ -1,66 +1,345 @@ -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; +using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class FileHistoriesRevisionFile(string path, object content = null, bool canOpenWithDefaultEditor = false) + { + public string Path { get; set; } = path; + public object Content { get; set; } = content; + public bool CanOpenWithDefaultEditor { get; set; } = canOpenWithDefaultEditor; + } + + public class FileHistoriesSingleRevisionViewMode + { + public bool IsDiff + { + get; + set; + } = true; + } -namespace SourceGit.ViewModels { - public class FileHistories : ObservableObject { - public string File { - get => _file; + public class FileHistoriesSingleRevision : ObservableObject + { + public bool IsDiffMode + { + get => _viewMode.IsDiff; + set + { + if (_viewMode.IsDiff != value) + { + _viewMode.IsDiff = value; + RefreshViewContent(); + } + } } - public bool IsLoading { - get => _isLoading; - private set => SetProperty(ref _isLoading, value); + public object ViewContent + { + get => _viewContent; + set => SetProperty(ref _viewContent, value); } - public List Commits { - get => _commits; - set => SetProperty(ref _commits, value); + public FileHistoriesSingleRevision(string repo, Models.FileVersion revision, FileHistoriesSingleRevisionViewMode viewMode) + { + _repo = repo; + _file = revision.Path; + _revision = revision; + _viewMode = viewMode; + _viewContent = null; + + RefreshViewContent(); + } + + public void SetRevision(Models.FileVersion revision) + { + _file = revision.Path; + _revision = revision; + RefreshViewContent(); + } + + public async Task ResetToSelectedRevisionAsync() + { + return await new Commands.Checkout(_repo) + .FileWithRevisionAsync(_file, $"{_revision.SHA}") + .ConfigureAwait(false); + } + + public async Task OpenWithDefaultEditorAsync() + { + if (_viewContent is not FileHistoriesRevisionFile { CanOpenWithDefaultEditor: true }) + return; + + var fullPath = Native.OS.GetAbsPath(_repo, _file); + var fileName = Path.GetFileNameWithoutExtension(fullPath) ?? ""; + var fileExt = Path.GetExtension(fullPath) ?? ""; + var tmpFile = Path.Combine(Path.GetTempPath(), $"{fileName}~{_revision.SHA.AsSpan(0, 10)}{fileExt}"); + + await Commands.SaveRevisionFile + .RunAsync(_repo, _revision.SHA, _file, tmpFile) + .ConfigureAwait(false); + + Native.OS.OpenWithDefaultEditor(tmpFile); } - public Models.Commit SelectedCommit { - get => _selectedCommit; - set { - if (SetProperty(ref _selectedCommit, value)) { - if (value == null) { - DiffContext = null; - } else { - DiffContext = new DiffContext(_repo, new Models.DiffOption(value, _file)); + private void RefreshViewContent() + { + if (_viewMode.IsDiff) + { + ViewContent = new DiffContext(_repo, new(_revision), _viewContent as DiffContext); + return; + } + + Task.Run(async () => + { + var objs = await new Commands.QueryRevisionObjects(_repo, _revision.SHA, _file) + .GetResultAsync() + .ConfigureAwait(false); + + if (objs.Count == 0) + { + Dispatcher.UIThread.Post(() => ViewContent = new FileHistoriesRevisionFile(_file)); + return; + } + + var revisionContent = await GetRevisionFileContentAsync(objs[0]).ConfigureAwait(false); + Dispatcher.UIThread.Post(() => ViewContent = revisionContent); + }); + } + + private async Task GetRevisionFileContentAsync(Models.Object obj) + { + if (obj.Type == Models.ObjectType.Blob) + { + var isBinary = await new Commands.IsBinary(_repo, _revision.SHA, _file).GetResultAsync().ConfigureAwait(false); + if (isBinary) + { + var imgDecoder = ImageSource.GetDecoder(_file); + if (imgDecoder != Models.ImageDecoder.None) + { + var source = await ImageSource.FromRevisionAsync(_repo, _revision.SHA, _file, imgDecoder).ConfigureAwait(false); + var image = new Models.RevisionImageFile(_file, source.Bitmap, source.Size); + return new FileHistoriesRevisionFile(_file, image, true); + } + + var size = await new Commands.QueryFileSize(_repo, _file, _revision.SHA).GetResultAsync().ConfigureAwait(false); + var binaryFile = new Models.RevisionBinaryFile() { Size = size }; + return new FileHistoriesRevisionFile(_file, binaryFile, true); + } + + var contentStream = await Commands.QueryFileContent.RunAsync(_repo, _revision.SHA, _file).ConfigureAwait(false); + var content = await new StreamReader(contentStream).ReadToEndAsync(); + var lfs = Models.LFSObject.Parse(content); + if (lfs != null) + { + var imgDecoder = ImageSource.GetDecoder(_file); + if (imgDecoder != Models.ImageDecoder.None) + { + var combined = new RevisionLFSImage(_repo, _file, lfs, imgDecoder); + return new FileHistoriesRevisionFile(_file, combined, true); } + + var rlfs = new Models.RevisionLFSObject() { Object = lfs }; + return new FileHistoriesRevisionFile(_file, rlfs, true); + } + + var txt = new Models.RevisionTextFile() { FileName = obj.Path, Content = content }; + return new FileHistoriesRevisionFile(_file, txt, true); + } + + if (obj.Type == Models.ObjectType.Commit) + { + var submoduleRoot = Path.Combine(_repo, _file).Replace('\\', '/').TrimEnd('/'); + var module = await new Commands.QuerySubmoduleRevision(submoduleRoot, obj.SHA).GetResultAsync().ConfigureAwait(false); + if (module == null) + { + module = new Models.RevisionSubmodule() + { + Commit = new Models.Commit() { SHA = obj.SHA }, + FullMessage = new Models.CommitFullMessage { Message = null } + }; } + + return new FileHistoriesRevisionFile(_file, module); + } + + return new FileHistoriesRevisionFile(_file); + } + + private string _repo = null; + private string _file = null; + private Models.FileVersion _revision = null; + private FileHistoriesSingleRevisionViewMode _viewMode = null; + private object _viewContent = null; + } + + public class FileHistoriesCompareRevisions : ObservableObject + { + public Models.FileVersion StartPoint + { + get => _startPoint; + set => SetProperty(ref _startPoint, value); + } + + public Models.FileVersion EndPoint + { + get => _endPoint; + set => SetProperty(ref _endPoint, value); + } + + public DiffContext ViewContent + { + get => _viewContent; + set => SetProperty(ref _viewContent, value); + } + + public FileHistoriesCompareRevisions(string repo, Models.FileVersion start, Models.FileVersion end) + { + _repo = repo; + _startPoint = start; + _endPoint = end; + _viewContent = new(_repo, new(start, end)); + } + + public void Swap() + { + (StartPoint, EndPoint) = (_endPoint, _startPoint); + ViewContent = new(_repo, new(_startPoint, _endPoint), _viewContent); + } + + public async Task SaveAsPatch(string saveTo) + { + return await Commands.SaveChangesAsPatch + .ProcessRevisionCompareChangesAsync(_repo, _changes, _startPoint.SHA, _endPoint.SHA, saveTo) + .ConfigureAwait(false); + } + + private string _repo = null; + private Models.FileVersion _startPoint = null; + private Models.FileVersion _endPoint = null; + private List _changes = []; + private DiffContext _viewContent = null; + } + + public class FileHistories : ObservableObject + { + public string Title + { + get; + } + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List Revisions + { + get => _revisions; + set => SetProperty(ref _revisions, value); + } + + public List SelectedRevisions + { + get => _selectedRevisions; + set + { + if (SetProperty(ref _selectedRevisions, value)) + RefreshViewContent(); } } - public DiffContext DiffContext { - get => _diffContext; - set => SetProperty(ref _diffContext, value); + public object ViewContent + { + get => _viewContent; + private set => SetProperty(ref _viewContent, value); } - public FileHistories(string repo, string file) { + public FileHistories(string repo, string file, string commit = null) + { + if (!string.IsNullOrEmpty(commit)) + Title = $"{file} @ {commit}"; + else + Title = file; + _repo = repo; - _file = file; - Task.Run(() => { - var commits = new Commands.QueryCommits(_repo, $"-n 10000 -- \"{file}\"").Result(); - Dispatcher.UIThread.Invoke(() => { + Task.Run(async () => + { + var revisions = await new Commands.QueryFileHistory(_repo, file, commit) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { IsLoading = false; - Commits = commits; - if (commits.Count > 0) SelectedCommit = commits[0]; + Revisions = revisions; }); }); } - public void NavigateToCommit(string commitSHA) { - var repo = Preference.FindRepository(_repo); - if (repo != null) repo.NavigateToCommit(commitSHA); + public void NavigateToCommit(Models.FileVersion revision) + { + var launcher = App.GetLauncher(); + if (launcher != null) + { + foreach (var page in launcher.Pages) + { + if (page.Data is Repository repo && repo.FullPath.Equals(_repo, StringComparison.Ordinal)) + { + repo.NavigateToCommit(revision.SHA); + break; + } + } + } + } + + public string GetCommitFullMessage(Models.FileVersion revision) + { + var sha = revision.SHA; + if (_fullCommitMessages.TryGetValue(sha, out var msg)) + return msg; + + msg = new Commands.QueryCommitFullMessage(_repo, sha).GetResult(); + _fullCommitMessages[sha] = msg; + return msg; + } + + private void RefreshViewContent() + { + var count = _selectedRevisions?.Count ?? 0; + if (count == 0) + { + ViewContent = null; + } + else if (count == 1) + { + if (_viewContent is FileHistoriesSingleRevision single) + single.SetRevision(_selectedRevisions[0]); + else + ViewContent = new FileHistoriesSingleRevision(_repo, _selectedRevisions[0], _viewMode); + } + else if (count == 2) + { + ViewContent = new FileHistoriesCompareRevisions(_repo, _selectedRevisions[0], _selectedRevisions[1]); + } + else + { + ViewContent = _selectedRevisions.Count; + } } - private string _repo = string.Empty; - private string _file = string.Empty; + private readonly string _repo = null; private bool _isLoading = true; - private List _commits = null; - private Models.Commit _selectedCommit = null; - private DiffContext _diffContext = null; + private FileHistoriesSingleRevisionViewMode _viewMode = new(); + private List _revisions = null; + private List _selectedRevisions = []; + private Dictionary _fullCommitMessages = new(); + private object _viewContent = null; } } diff --git a/src/ViewModels/FileHistoryCommandPalette.cs b/src/ViewModels/FileHistoryCommandPalette.cs new file mode 100644 index 000000000..097e85669 --- /dev/null +++ b/src/ViewModels/FileHistoryCommandPalette.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class FileHistoryCommandPalette : ICommandPalette + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List VisibleFiles + { + get => _visibleFiles; + private set => SetProperty(ref _visibleFiles, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateVisible(); + } + } + + public string SelectedFile + { + get => _selectedFile; + set => SetProperty(ref _selectedFile, value); + } + + public FileHistoryCommandPalette(string repo) + { + _repo = repo; + _isLoading = true; + + Task.Run(async () => + { + var files = await new Commands.QueryRevisionFileNames(_repo, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + IsLoading = false; + _repoFiles = files; + UpdateVisible(); + }); + }); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public FileHistories Launch() + { + _repoFiles.Clear(); + _visibleFiles.Clear(); + Close(); + return !string.IsNullOrEmpty(_selectedFile) ? new FileHistories(_repo, _selectedFile) : null; + } + + private void UpdateVisible() + { + if (_repoFiles is { Count: > 0 }) + { + if (string.IsNullOrEmpty(_filter)) + { + VisibleFiles = _repoFiles; + + if (string.IsNullOrEmpty(_selectedFile)) + SelectedFile = _repoFiles[0]; + } + else + { + var visible = new List(); + + foreach (var f in _repoFiles) + { + if (f.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(f); + } + + var autoSelected = _selectedFile; + if (visible.Count == 0) + autoSelected = null; + else if (string.IsNullOrEmpty(_selectedFile) || !visible.Contains(_selectedFile)) + autoSelected = visible[0]; + + VisibleFiles = visible; + SelectedFile = autoSelected; + } + } + } + + private string _repo = null; + private bool _isLoading = false; + private List _repoFiles = null; + private string _filter = string.Empty; + private List _visibleFiles = []; + private string _selectedFile = null; + } +} diff --git a/src/ViewModels/FileTreeNode.cs b/src/ViewModels/FileTreeNode.cs deleted file mode 100644 index 94f305618..000000000 --- a/src/ViewModels/FileTreeNode.cs +++ /dev/null @@ -1,170 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using System.Collections.Generic; - -namespace SourceGit.ViewModels { - public class FileTreeNode : ObservableObject { - public string FullPath { get; set; } = string.Empty; - public bool IsFolder { get; set; } = false; - public object Backend { get; set; } = null; - public List Children { get; set; } = new List(); - - public bool IsExpanded { - get => _isExpanded; - set => SetProperty(ref _isExpanded, value); - } - - public static List Build(List changes) { - var nodes = new List(); - var folders = new Dictionary(); - var expanded = changes.Count <= 50; - - foreach (var c in changes) { - var sepIdx = c.Path.IndexOf('/'); - if (sepIdx == -1) { - nodes.Add(new FileTreeNode() { - FullPath = c.Path, - Backend = c, - IsFolder = false, - IsExpanded = false - }); - } else { - FileTreeNode 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 FileTreeNode() { - FullPath = folder, - Backend = null, - IsFolder = true, - IsExpanded = expanded - }; - nodes.Add(lastFolder); - folders.Add(folder, lastFolder); - } else { - var cur = new FileTreeNode() { - FullPath = folder, - Backend = null, - IsFolder = true, - IsExpanded = expanded - }; - folders.Add(folder, cur); - lastFolder.Children.Add(cur); - lastFolder = cur; - } - - start = sepIdx + 1; - sepIdx = c.Path.IndexOf('/', start); - } - - lastFolder.Children.Add(new FileTreeNode() { - FullPath = c.Path, - Backend = c, - IsFolder = false, - IsExpanded = false - }); - } - } - - folders.Clear(); - Sort(nodes); - return nodes; - } - - public static List Build(List files) { - var nodes = new List(); - var folders = new Dictionary(); - var expanded = files.Count <= 50; - - foreach (var f in files) { - var sepIdx = f.Path.IndexOf('/'); - if (sepIdx == -1) { - nodes.Add(new FileTreeNode() { - FullPath = f.Path, - Backend = f, - IsFolder = false, - IsExpanded = false - }); - } else { - FileTreeNode lastFolder = null; - var start = 0; - - while (sepIdx != -1) { - var folder = f.Path.Substring(0, sepIdx); - if (folders.ContainsKey(folder)) { - lastFolder = folders[folder]; - } else if (lastFolder == null) { - lastFolder = new FileTreeNode() { - FullPath = folder, - Backend = null, - IsFolder = true, - IsExpanded = expanded - }; - nodes.Add(lastFolder); - folders.Add(folder, lastFolder); - } else { - var cur = new FileTreeNode() { - FullPath = folder, - Backend = null, - IsFolder = true, - IsExpanded = expanded - }; - folders.Add(folder, cur); - lastFolder.Children.Add(cur); - lastFolder = cur; - } - - start = sepIdx + 1; - sepIdx = f.Path.IndexOf('/', start); - } - - lastFolder.Children.Add(new FileTreeNode() { - FullPath = f.Path, - Backend = f, - IsFolder = false, - IsExpanded = false - }); - } - } - - folders.Clear(); - Sort(nodes); - return nodes; - } - - public static FileTreeNode SelectByPath(List nodes, string path) { - foreach (var node in nodes) { - if (node.FullPath == path) return node; - - if (node.IsFolder && path.StartsWith(node.FullPath + "/")) { - var foundInChildren = SelectByPath(node.Children, path); - if (foundInChildren != null) { - node.IsExpanded = true; - } - return foundInChildren; - } - } - - return null; - } - - private static void Sort(List nodes) { - nodes.Sort((l, r) => { - if (l.IsFolder == r.IsFolder) { - return l.FullPath.CompareTo(r.FullPath); - } else { - return l.IsFolder ? -1 : 1; - } - }); - - foreach (var node in nodes) { - if (node.Children.Count > 1) Sort(node.Children); - } - } - - private bool _isExpanded = true; - } -} diff --git a/src/ViewModels/FilterModeInGraph.cs b/src/ViewModels/FilterModeInGraph.cs new file mode 100644 index 000000000..773f4bc5b --- /dev/null +++ b/src/ViewModels/FilterModeInGraph.cs @@ -0,0 +1,50 @@ +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 = _repo.UIStates.GetHistoryFilterMode(b.FullName); + else if (_target is Models.Tag t) + _mode = _repo.UIStates.GetHistoryFilterMode(t.Name); + } + + 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 index a50251ce0..373e5398b 100644 --- a/src/ViewModels/GitFlowFinish.cs +++ b/src/ViewModels/GitFlowFinish.cs @@ -1,48 +1,63 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class GitFlowFinish : Popup { - public Models.Branch Branch => _branch; - public bool IsFeature => _type == Models.GitFlowBranchType.Feature; - public bool IsRelease => _type == Models.GitFlowBranchType.Release; - public bool IsHotfix => _type == Models.GitFlowBranchType.Hotfix; - - public bool KeepBranch { +namespace SourceGit.ViewModels +{ + public class GitFlowFinish : Popup + { + public Models.Branch Branch + { + get; + } + + public Models.GitFlowBranchType Type + { + get; + private set; + } + + public bool RebaseBeforeMerging + { get; set; } = false; - public GitFlowFinish(Repository repo, Models.Branch branch, Models.GitFlowBranchType type) { + public bool Squash + { + 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; - View = new Views.GitFlowFinish() { DataContext = this }; + Branch = branch; + Type = type; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - return Task.Run(() => { - var branch = _branch.Name; - switch (_type) { - case Models.GitFlowBranchType.Feature: - branch = branch.Substring(_repo.GitFlow.Feature.Length); - break; - case Models.GitFlowBranchType.Release: - branch = branch.Substring(_repo.GitFlow.Release.Length); - break; - default: - branch = branch.Substring(_repo.GitFlow.Hotfix.Length); - break; - } - - var succ = new Commands.GitFlow(_repo.FullPath).Finish(_type, branch, KeepBranch); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + 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; + var succ = await new Commands.GitFlow(_repo.FullPath) + .Use(log) + .FinishAsync(Type, name, RebaseBeforeMerging, Squash, KeepBranch); + + log.Complete(); + return succ; } - private Repository _repo = null; - private Models.Branch _branch = null; - private Models.GitFlowBranchType _type = Models.GitFlowBranchType.None; + private readonly Repository _repo; } } diff --git a/src/ViewModels/GitFlowStart.cs b/src/ViewModels/GitFlowStart.cs index d1e4cf5c4..2fe9c0784 100644 --- a/src/ViewModels/GitFlowStart.cs +++ b/src/ViewModels/GitFlowStart.cs @@ -1,67 +1,101 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class GitFlowStart : Popup { +namespace SourceGit.ViewModels +{ + public class GitFlowStart : Popup + { + public Models.GitFlowBranchType Type + { + get; + private set; + } + + public List LocalBranches + { + get; + private set; + } + + public Models.Branch StartPoint + { + get => _startPoint; + set => SetProperty(ref _startPoint, value); + } + + public string Prefix + { + get; + private set; + } + [Required(ErrorMessage = "Name is required!!!")] - [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [RegularExpression(@"^[\w\-/\.#]+$", ErrorMessage = "Bad branch name format!")] [CustomValidation(typeof(GitFlowStart), nameof(ValidateBranchName))] - public string Name { + public string Name + { get => _name; set => SetProperty(ref _name, value, true); } - public string Prefix { - get => _prefix; - } + public GitFlowStart(Repository repo, Models.GitFlowBranchType type) + { + _repo = repo; - public bool IsFeature => _type == Models.GitFlowBranchType.Feature; - public bool IsRelease => _type == Models.GitFlowBranchType.Release; - public bool IsHotfix => _type == Models.GitFlowBranchType.Hotfix; + Type = type; + Prefix = _repo.GitFlow.GetPrefix(type); + LocalBranches = new List(); - public GitFlowStart(Repository repo, Models.GitFlowBranchType type) { - _repo = repo; - _type = type; - - switch (type) { - case Models.GitFlowBranchType.Feature: - _prefix = repo.GitFlow.Feature; - break; - case Models.GitFlowBranchType.Release: - _prefix = repo.GitFlow.Release; - break; - default: - _prefix = repo.GitFlow.Hotfix; - break; + foreach (var b in _repo.Branches) + { + if (b.IsLocal && !b.IsDetachedHead) + LocalBranches.Add(b); } - View = new Views.GitFlowStart() { DataContext = this }; + var defBranch = type switch + { + Models.GitFlowBranchType.Hotfix => _repo.GitFlow.ProductionBranch, + _ => _repo.GitFlow.DevelopmentBranch, + }; + + StartPoint = LocalBranches.Find(b => b.Name.Equals(defBranch, StringComparison.Ordinal)); } - 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) { - var test = b.IsLocal ? b.Name : $"{b.Remote}/{b.Name}"; - if (test == check) return new ValidationResult("A branch with same name already exists!"); + 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); - return Task.Run(() => { - var succ = new Commands.GitFlow(_repo.FullPath).Start(_type, _name); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Git Flow - Start {Prefix}{_name} ..."; + + var log = _repo.CreateLog("GitFlow - Start"); + Use(log); + + var succ = await new Commands.GitFlow(_repo.FullPath) + .Use(log) + .StartAsync(Type, _name, _startPoint); + + log.Complete(); + return succ; } - private Repository _repo = null; - private Models.GitFlowBranchType _type = Models.GitFlowBranchType.Feature; - private string _prefix = string.Empty; + private readonly Repository _repo; private string _name = null; + private Models.Branch _startPoint = null; } } diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 578d63001..15cf15748 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -1,477 +1,544 @@ -using Avalonia.Controls; -using Avalonia.Platform.Storage; -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using System.Collections; +using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class CountSelectedCommits { - public int Count { get; set; } - } +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; - public class Histories : ObservableObject { - public bool IsLoading { +namespace SourceGit.ViewModels +{ + public class Histories : ObservableObject + { + public bool IsLoading + { get => _isLoading; set => SetProperty(ref _isLoading, value); } - public double DataGridRowHeight { - get => _dataGridRowHeight; + public bool IsAuthorColumnVisible + { + get => _repo.UIStates.IsAuthorColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsAuthorColumnVisibleInHistory != value) + { + _repo.UIStates.IsAuthorColumnVisibleInHistory = value; + OnPropertyChanged(); + } + } } - public List Commits { - get => _commits; - set { - if (SetProperty(ref _commits, value)) { - Graph = null; - Task.Run(() => { - var graph = Models.CommitGraph.Parse(value, DataGridRowHeight, 8); - Dispatcher.UIThread.Invoke(() => { - Graph = graph; - }); - }); + public bool IsSHAColumnVisible + { + get => _repo.UIStates.IsSHAColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsSHAColumnVisibleInHistory != value) + { + _repo.UIStates.IsSHAColumnVisibleInHistory = value; + OnPropertyChanged(); + } + } + } + + public bool IsAuthorTimeColumnVisible + { + get => _repo.UIStates.IsAuthorTimeColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsAuthorTimeColumnVisibleInHistory != value) + { + _repo.UIStates.IsAuthorTimeColumnVisibleInHistory = value; + OnPropertyChanged(); + } + } + } + + public bool IsCommitTimeColumnVisible + { + get => _repo.UIStates.IsCommitTimeColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsCommitTimeColumnVisibleInHistory != value) + { + _repo.UIStates.IsCommitTimeColumnVisibleInHistory = value; + OnPropertyChanged(); } } } - public Models.CommitGraph Graph { + public List Commits + { + get => _commits; + set + { + GenerateGraph(value, true); + if (SetProperty(ref _commits, value)) + PostCommitsChanged(); + } + } + + public Models.CommitGraph Graph + { get => _graph; set => SetProperty(ref _graph, value); } - public Models.Commit AutoSelectedCommit { - get => _autoSelectedCommit; - private set => SetProperty(ref _autoSelectedCommit, value); + public Models.CommitGraphHighlighting GraphHighlighting + { + get => _repo.UIStates.GraphHighlighting; + set + { + if (_repo.UIStates.GraphHighlighting != value) + { + _repo.UIStates.GraphHighlighting = value; + GenerateGraph(_commits); + } + } + } + + public List SelectedCommits + { + get => _selectedCommits; + set + { + var oldCount = _selectedCommits.Count; + if (SetProperty(ref _selectedCommits, value) && oldCount + value.Count > 0) + PostSelectedCommitsChanged(); + } } - public object DetailContext { + public object DetailContext + { get => _detailContext; - private set => SetProperty(ref _detailContext, value); + set + { + if (SetProperty(ref _detailContext, value)) + OnPropertyChanged(nameof(IsOpenAsStandaloneVisible)); + } } - public Histories(Repository repo) { - _repo = repo; + public Models.Bisect Bisect + { + get => _bisect; + private set => SetProperty(ref _bisect, value); + } + + public Models.Branch CurrentBranch + { + get => _repo.CurrentBranch; } - public void Cleanup() { - Commits = new List(); + public AvaloniaList IssueTrackers + { + get => _repo.IssueTrackers; + } - _repo = null; - _graph = null; - _autoSelectedCommit = null; + public GridLength LeftArea + { + get => _leftArea; + set => SetProperty(ref _leftArea, value); + } + + public GridLength RightArea + { + get => _rightArea; + set => SetProperty(ref _rightArea, value); + } - if (_detailContext is CommitDetail cd) { - cd.Cleanup(); - } else if (_detailContext is RevisionCompare rc) { - rc.Cleanup(); + public GridLength TopArea + { + get => _topArea; + set => SetProperty(ref _topArea, value); + } + + public GridLength BottomArea + { + get => _isCollapseDetails ? new GridLength(28, GridUnitType.Pixel) : _bottomArea; + set + { + if (!Preferences.Instance.UseTwoColumnsLayoutInHistories && !_isCollapseDetails) + SetProperty(ref _bottomArea, value); } + } - _detailContext = null; + public double AuthorColumnWidth + { + get => _repo.UIStates.AuthorColumnWidth; + set => _repo.UIStates.AuthorColumnWidth = value; } - public void NavigateTo(string commitSHA) { - var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA)); - if (commit != null) { - AutoSelectedCommit = commit; + public bool IsOpenAsStandaloneVisible + { + get => DetailContext is CommitDetail or RevisionCompare; + } - if (_detailContext is CommitDetail detail) { - detail.Commit = commit; - } else { - var commitDetail = new CommitDetail(_repo.FullPath); - commitDetail.Commit = commit; - DetailContext = commitDetail; + public bool IsCollapseDetails + { + get => _isCollapseDetails; + set + { + if (!Preferences.Instance.UseTwoColumnsLayoutInHistories && SetProperty(ref _isCollapseDetails, value)) + { + OnPropertyChanged(nameof(TopArea)); + OnPropertyChanged(nameof(BottomArea)); } } } - public void Select(IList commits) { - if (commits.Count == 0) { - DetailContext = null; - } else if (commits.Count == 1) { - var commit = commits[0] as Models.Commit; - AutoSelectedCommit = commit; + public Histories(Repository repo) + { + _repo = repo; + _commitDetailSharedData = new CommitDetailSharedData(); + } - if (_detailContext is CommitDetail detail) { - detail.Commit = commit; - } else { - var commitDetail = new CommitDetail(_repo.FullPath); - commitDetail.Commit = commit; - DetailContext = commitDetail; + public void NotifyCurrentBranchChanged() + { + OnPropertyChanged(nameof(CurrentBranch)); + } + + public Models.BisectState UpdateBisectInfo() + { + var test = Path.Combine(_repo.GitDir, "BISECT_START"); + if (!File.Exists(test)) + { + Bisect = null; + return Models.BisectState.None; + } + + var head = new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResult(); + var info = new Models.Bisect(); + var markedHead = false; + var dir = Path.Combine(_repo.GitDir, "refs", "bisect"); + if (Directory.Exists(dir)) + { + var files = new DirectoryInfo(dir).GetFiles(); + foreach (var file in files) + { + var sha = File.ReadAllText(file.FullName).Trim(); + if (!markedHead) + markedHead = head.Equals(sha, StringComparison.Ordinal); + + if (file.Name.StartsWith("bad")) + info.Bads.Add(sha); + else if (file.Name.StartsWith("good")) + info.Goods.Add(sha); + else if (file.Name.StartsWith("skip")) + info.Skipped.Add(sha); } - } else if (commits.Count == 2) { - var end = commits[0] as Models.Commit; - var start = commits[1] as Models.Commit; - DetailContext = new RevisionCompare(_repo.FullPath, start, end); - } else { - DetailContext = new CountSelectedCommits() { Count = commits.Count }; } + + Bisect = info; + + if (info.Bads.Count == 0) + return Models.BisectState.WaitingForFirstBad; + + if (markedHead) + return Models.BisectState.WaitingForCheckoutAnother; + + if (info.Goods.Count == 0) + return Models.BisectState.WaitingForFirstGood; + + return Models.BisectState.WaitingForMark; } - public ContextMenu MakeContextMenu() { - var detail = _detailContext as CommitDetail; - if (detail == null) return null; - - var current = _repo.Branches.Find(x => x.IsCurrent); - if (current == null) return null; - - var commit = detail.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.Remote}/{x.Name}"); - 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); + public void NavigateTo(string commitSHA) + { + var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); + if (commit != null) + { + SelectedCommits = [commit]; + return; + } + + Task.Run(async () => + { + var c = await new Commands.QuerySingleCommit(_repo.FullPath, commitSHA) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + _ignoreSelectionChange = true; + SelectedCommits = []; + + if (_detailContext is CommitDetail detail) + { + detail.Commit = c; } - } + else + { + var commitDetail = new CommitDetail(_repo, _commitDetailSharedData); + commitDetail.Commit = c; + DetailContext = commitDetail; + } + + _ignoreSelectionChange = false; + }); + }); + } + + public async Task GetCommitAsync(string sha) + { + return await new Commands.QuerySingleCommit(_repo.FullPath, sha) + .GetResultAsync() + .ConfigureAwait(false); + } - if (menu.Items.Count > 0) menu.Items.Add(new MenuItem() { Header = "-" }); + public void CheckoutCommitDetached(Models.Commit c) + { + if (!c.IsCurrentHead && _repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutDetached(_repo, c)); + } + + public async Task CheckoutBranchByDecoratorAsync(Models.Decorator decorator) + { + if (decorator == null) + return false; + + if (decorator.Type == Models.DecoratorType.CurrentBranchHead || + decorator.Type == Models.DecoratorType.CurrentCommitHead) + return true; + + if (decorator.Type == Models.DecoratorType.LocalBranchHead) + { + var b = _repo.Branches.Find(x => x.Name == decorator.Name); + if (b == null) + return false; + + await _repo.CheckoutBranchAsync(b); + return true; } - if (tags.Count > 0) { - foreach (var tag in tags) FillTagMenu(menu, tag); - menu.Items.Add(new MenuItem() { Header = "-" }); + if (decorator.Type == Models.DecoratorType.RemoteBranchHead) + { + var rb = _repo.Branches.Find(x => x.FriendlyName == decorator.Name); + if (rb == null) + return false; + + var lb = _repo.Branches.Find(x => x.IsLocal && x.Upstream == rb.FullName); + if (lb == null || lb.Ahead.Count > 0) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CreateBranch(_repo, rb)); + } + else if (lb.Behind.Count > 0) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutAndFastForward(_repo, lb, rb)); + } + else if (!lb.IsCurrent) + { + await _repo.CheckoutBranchAsync(lb); + } + + return true; } - if (current.Head != commit.SHA) { - var reset = new MenuItem(); - reset.Header = new Views.NameHighlightedTextBlock("CommitCM.Reset", current.Name); - reset.Icon = App.CreateMenuIcon("Icons.Reset"); - reset.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Reset(_repo, current, commit)); - e.Handled = true; - }; - menu.Items.Add(reset); - } else { - var reword = new MenuItem(); - reword.Header = App.Text("CommitCM.Reword"); - reword.Icon = App.CreateMenuIcon("Icons.Edit"); - reword.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.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 += (o, e) => { - if (commit.Parents.Count == 1) { - var parent = _commits.Find(x => x.SHA == commit.Parents[0]); - if (parent != null && PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Squash(_repo, commit, parent)); + return false; + } + + public async Task CheckoutBranchByCommitAsync(Models.Commit commit) + { + if (commit.IsCurrentHead) + return; + + Models.Branch firstRemoteBranch = null; + foreach (var d in commit.Decorators) + { + if (d.Type == Models.DecoratorType.LocalBranchHead) + { + var b = _repo.Branches.Find(x => x.Name == d.Name); + if (b == null) + continue; + + await _repo.CheckoutBranchAsync(b); + return; + } + + if (d.Type == Models.DecoratorType.RemoteBranchHead) + { + var rb = _repo.Branches.Find(x => x.FriendlyName == d.Name); + if (rb == null) + continue; + + var lb = _repo.Branches.Find(x => x.IsLocal && x.Upstream == rb.FullName); + if (lb != null && lb.Behind.Count > 0 && lb.Ahead.Count == 0) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutAndFastForward(_repo, lb, rb)); + return; } - - e.Handled = true; - }; - menu.Items.Add(squash); + + firstRemoteBranch ??= rb; + } } - if (!commit.IsMerged) { - var rebase = new MenuItem(); - rebase.Header = new Views.NameHighlightedTextBlock("CommitCM.Rebase", current.Name); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Rebase(_repo, current, commit)); - e.Handled = true; - }; - menu.Items.Add(rebase); - - var cherryPick = new MenuItem(); - cherryPick.Header = App.Text("CommitCM.CherryPick"); - cherryPick.Icon = App.CreateMenuIcon("Icons.CherryPick"); - cherryPick.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CherryPick(_repo, commit)); - 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 += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Revert(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(revert); + if (_repo.CanCreatePopup()) + { + if (firstRemoteBranch != null) + _repo.ShowPopup(new CreateBranch(_repo, firstRemoteBranch)); + else if (!_repo.IsBare) + _repo.ShowPopup(new CheckoutDetached(_repo, commit)); } + } - menu.Items.Add(new MenuItem() { Header = "-" }); - - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.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 += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.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 topLevel = App.GetTopLevel(); - if (topLevel == null) return; - - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - var selected = await topLevel.StorageProvider.OpenFolderPickerAsync(options); - if (selected.Count == 1) { - var succ = new Commands.FormatPatch(_repo.FullPath, commit.SHA, selected[0].Path.LocalPath).Exec(); - if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + public async Task CherryPickAsync(Models.Commit commit) + { + 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.Equals(sha, StringComparison.Ordinal)); + if (parent == null) + parent = await new Commands.QuerySingleCommit(_repo.FullPath, sha).GetResultAsync(); + + if (parent != null) + parents.Add(parent); + } - e.Handled = true; - }; - menu.Items.Add(saveToPatch); - - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Archive(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(archive); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var copySHA = new MenuItem(); - copySHA.Header = App.Text("CommitCM.CopySHA"); - copySHA.Icon = App.CreateMenuIcon("Icons.Copy"); - copySHA.Click += (o, e) => { - App.CopyText(commit.SHA); - e.Handled = true; - }; - menu.Items.Add(copySHA); - return menu; - } - - private void FillCurrentBranchMenu(ContextMenu menu, Models.Branch current) { - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = current.Name; - - if (!string.IsNullOrEmpty(current.Upstream)) { - var upstream = current.Upstream.Substring(13); - - var fastForward = new MenuItem(); - fastForward.Header = new Views.NameHighlightedTextBlock("BranchCM.FastForward", upstream); - fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); - fastForward.IsEnabled = !string.IsNullOrEmpty(current.UpstreamTrackStatus) && current.UpstreamTrackStatus.IndexOf('↑') < 0; ; - fastForward.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Merge(_repo, upstream, current.Name)); - e.Handled = true; - }; - submenu.Items.Add(fastForward); - - var pull = new MenuItem(); - pull.Header = new Views.NameHighlightedTextBlock("BranchCM.Pull", upstream); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Pull(_repo, null)); - e.Handled = true; - }; - submenu.Items.Add(pull); + _repo.ShowPopup(new CherryPick(_repo, commit, parents)); + } } + } + + public async Task GetCommitFullMessageAsync(Models.Commit commit) + { + return await new Commands.QueryCommitFullMessage(_repo.FullPath, commit.SHA) + .GetResultAsync() + .ConfigureAwait(false); + } - var push = new MenuItem(); - push.Header = new Views.NameHighlightedTextBlock("BranchCM.Push", current.Name); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = _repo.Remotes.Count > 0; - push.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Push(_repo, current)); - e.Handled = true; - }; - submenu.Items.Add(push); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var type = _repo.GitFlow.GetBranchType(current.Name); - if (type != Models.GitFlowBranchType.None) { - var finish = new MenuItem(); - finish.Header = new Views.NameHighlightedTextBlock("BranchCM.Finish", current.Name); - finish.Icon = App.CreateMenuIcon("Icons.Flow"); - finish.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowFinish(_repo, current, type)); - e.Handled = true; - }; - submenu.Items.Add(finish); - submenu.Items.Add(new MenuItem() { Header = "-" }); + public async Task CompareWithHeadAsync(Models.Commit commit) + { + var head = _commits.Find(x => x.IsCurrentHead); + if (head == null) + { + _repo.SearchCommitContext.Selected = null; + head = await new Commands.QuerySingleCommit(_repo.FullPath, "HEAD").GetResultAsync(); + if (head != null) + DetailContext = new RevisionCompare(_repo, commit, head); + + return null; } - var rename = new MenuItem(); - rename.Header = new Views.NameHighlightedTextBlock("BranchCM.Rename", current.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new RenameBranch(_repo, current)); - e.Handled = true; - }; - submenu.Items.Add(rename); - - 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 checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", branch.Name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(_repo, branch.Name)); - e.Handled = true; - }; - submenu.Items.Add(checkout); - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", branch.Name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.IsEnabled = !merged; - merge.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(_repo, branch.Name, current.Name)); - e.Handled = true; - }; - submenu.Items.Add(merge); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var type = _repo.GitFlow.GetBranchType(branch.Name); - if (type != Models.GitFlowBranchType.None) { - var finish = new MenuItem(); - finish.Header = new Views.NameHighlightedTextBlock("BranchCM.Finish", branch.Name); - finish.Icon = App.CreateMenuIcon("Icons.Flow"); - finish.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowFinish(_repo, branch, type)); - e.Handled = true; - }; - submenu.Items.Add(finish); - submenu.Items.Add(new MenuItem() { Header = "-" }); + return head; + } + + public void CompareWithWorktree(Models.Commit commit) + { + DetailContext = new RevisionCompare(_repo, commit, null); + } + + private void PostCommitsChanged() + { + if (_selectedCommits.Count == 0) + return; + + if (_commits.Count == 0 || _selectedCommits.Count > 20) + { + SelectedCommits = []; + return; } - var rename = new MenuItem(); - rename.Header = new Views.NameHighlightedTextBlock("BranchCM.Rename", branch.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new RenameBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(rename); - - var delete = new MenuItem(); - delete.Header = new Views.NameHighlightedTextBlock("BranchCM.Delete", branch.Name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(delete); - - menu.Items.Add(submenu); - } - - private void FillRemoteBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) { - var name = $"{branch.Remote}/{branch.Name}"; - - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = name; - - var checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (o, e) => { - foreach (var b in _repo.Branches) { - if (b.IsLocal && b.Upstream == branch.FullName) { - if (b.IsCurrent) return; - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(_repo, b.Name)); - return; - } + var set = new HashSet(); + foreach (var c in _selectedCommits) + set.Add(c.SHA); + + var selected = new List(); + foreach (var c in _commits) + { + if (set.Contains(c.SHA)) + { + selected.Add(c); + set.Remove(c.SHA); + if (set.Count == 0) + break; } + } + + SelectedCommits = selected; + } + + private void PostSelectedCommitsChanged() + { + if (_ignoreSelectionChange) + return; + + if (_selectedCommits.Count == 0) + { + _repo.SearchCommitContext.Selected = null; + DetailContext = new Models.Null(); + } + else if (_selectedCommits.Count == 1) + { + var c = _selectedCommits[0]; + if (_repo.SearchCommitContext.Selected == null || !_repo.SearchCommitContext.Selected.SHA.Equals(c.SHA, StringComparison.Ordinal)) + _repo.SearchCommitContext.Selected = _repo.SearchCommitContext.Results?.Find(x => x.SHA.Equals(c.SHA, StringComparison.Ordinal)); + + if (_detailContext is CommitDetail detail) + detail.Commit = c; + else + DetailContext = new CommitDetail(_repo, _commitDetailSharedData) { Commit = c }; + } + else if (_selectedCommits.Count == 2) + { + _repo.SearchCommitContext.Selected = null; + + if (_detailContext is RevisionCompare compare) + compare.SetTargets(_selectedCommits[1], _selectedCommits[0]); + else + DetailContext = new RevisionCompare(_repo, _selectedCommits[1], _selectedCommits[0]); + } + else + { + _repo.SearchCommitContext.Selected = null; + DetailContext = new Models.Count(_selectedCommits.Count); + } + + if (_repo.UIStates.GraphHighlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) + GenerateGraph(_commits); + } + + private void GenerateGraph(List commits, bool commitsChanged = false) + { + var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); + var highlighting = _repo.UIStates.GraphHighlighting; + var extraHeads = new HashSet(); + + if (highlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) + { + foreach (var c in _selectedCommits) + extraHeads.Add(c.SHA); + } - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(checkout); - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.IsEnabled = !merged; - merge.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(_repo, name, current.Name)); - e.Handled = true; - }; - - submenu.Items.Add(merge); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var delete = new MenuItem(); - delete.Header = new Views.NameHighlightedTextBlock("BranchCM.Delete", name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(delete); - - menu.Items.Add(submenu); - } - - private void FillTagMenu(ContextMenu menu, Models.Tag tag) { - var submenu = new MenuItem(); - submenu.Header = tag.Name; - submenu.Icon = App.CreateMenuIcon("Icons.Tag"); - submenu.MinWidth = 200; - - var push = new MenuItem(); - push.Header = new Views.NameHighlightedTextBlock("TagCM.Push", tag.Name); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = _repo.Remotes.Count > 0; - push.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new PushTag(_repo, tag)); - e.Handled = true; - }; - submenu.Items.Add(push); - - var delete = new MenuItem(); - delete.Header = new Views.NameHighlightedTextBlock("TagCM.Delete", tag.Name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteTag(_repo, tag)); - e.Handled = true; - }; - submenu.Items.Add(delete); - - menu.Items.Add(submenu); + Graph = Models.CommitGraph.Generate(commits, commitsChanged, firstParentOnly, highlighting, extraHeads); } private Repository _repo = null; - private double _dataGridRowHeight = 28; + private CommitDetailSharedData _commitDetailSharedData = null; private bool _isLoading = true; - private List _commits = new List(); + private List _commits = []; private Models.CommitGraph _graph = null; - private Models.Commit _autoSelectedCommit = null; - private object _detailContext = null; + private List _selectedCommits = []; + private Models.Bisect _bisect = null; + private object _detailContext = new Models.Null(); + private bool _ignoreSelectionChange = false; + + private GridLength _leftArea = new(1, GridUnitType.Star); + private GridLength _rightArea = new(1, GridUnitType.Star); + private GridLength _topArea = new(1, GridUnitType.Star); + private GridLength _bottomArea = new(1, GridUnitType.Star); + private bool _isCollapseDetails = false; } } diff --git a/src/ViewModels/ICommandPalette.cs b/src/ViewModels/ICommandPalette.cs new file mode 100644 index 000000000..e46829122 --- /dev/null +++ b/src/ViewModels/ICommandPalette.cs @@ -0,0 +1,21 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class ICommandPalette : ObservableObject + { + public void Open() + { + var host = App.GetLauncher(); + if (host != null) + host.CommandPalette = this; + } + + public void Close() + { + var host = App.GetLauncher(); + if (host != null) + host.CommandPalette = null; + } + } +} diff --git a/src/ViewModels/ImageSource.cs b/src/ViewModels/ImageSource.cs new file mode 100644 index 000000000..392615ab3 --- /dev/null +++ b/src/ViewModels/ImageSource.cs @@ -0,0 +1,235 @@ +using System; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using BitMiracle.LibTiff.Classic; +using Pfim; +using StbImageSharp; + +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); + + return ext switch + { + ".ico" or ".bmp" or ".gif" or ".jpg" or ".jpeg" or ".png" or ".webp" => Models.ImageDecoder.Builtin, + ".tga" or ".dds" => Models.ImageDecoder.Pfim, + ".tif" or ".tiff" => Models.ImageDecoder.Tiff, + ".psd" => Models.ImageDecoder.StbImage, + _ => Models.ImageDecoder.None, + }; + } + + public static async Task FromFileAsync(string fullpath, Models.ImageDecoder decoder) + { + if (!File.Exists(fullpath)) + return new ImageSource(null, 0); + + await using var stream = File.OpenRead(fullpath); + return await Task.Run(() => LoadFromStream(stream, decoder)).ConfigureAwait(false); + } + + public static async Task FromRevisionAsync(string repo, string revision, string file, Models.ImageDecoder decoder) + { + // If revision is empty, it means we are reading file in worktree. + if (string.IsNullOrEmpty(revision)) + return await FromFileAsync(Path.Combine(repo, file), decoder).ConfigureAwait(false); + + var emptyTreeHash = Models.EmptyTreeHash.Guess(revision); + if (emptyTreeHash.Equals(revision, StringComparison.Ordinal)) + return new ImageSource(null, 0); + + await using var stream = await Commands.QueryFileContent.RunAsync(repo, revision, file).ConfigureAwait(false); + return await Task.Run(() => LoadFromStream(stream, decoder)).ConfigureAwait(false); + } + + public static async Task FromLFSObjectAsync(string repo, Models.LFSObject lfs, Models.ImageDecoder decoder) + { + if (string.IsNullOrEmpty(lfs.Oid) || lfs.Size == 0) + return new ImageSource(null, 0); + + var commonDir = await new Commands.QueryGitCommonDir(repo).GetResultAsync().ConfigureAwait(false); + var localFile = Path.Combine(commonDir, "lfs", "objects", lfs.Oid.Substring(0, 2), lfs.Oid.Substring(2, 2), lfs.Oid); + if (File.Exists(localFile)) + return await FromFileAsync(localFile, decoder).ConfigureAwait(false); + + await using var stream = await Commands.QueryFileContent.FromLFSAsync(repo, lfs.Oid, lfs.Size).ConfigureAwait(false); + return await Task.Run(() => LoadFromStream(stream, decoder)).ConfigureAwait(false); + } + + 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); + case Models.ImageDecoder.StbImage: + return DecodeWithStbImage(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.R16f: + pixelFormat = PixelFormats.Gray16; + break; + case ImageFormat.R32f: + pixelFormat = PixelFormats.Gray32Float; + break; + case ImageFormat.R5g5b5: + pixelFormat = PixelFormats.Bgr555; + break; + case ImageFormat.R5g5b5a1: + var pixels1 = pfiImage.DataLen / 2; + data = new byte[pixels1 * 4]; + stride = pfiImage.Width * 4; + for (var i = 0; i < pixels1; i++) + { + var src = BitConverter.ToUInt16(pfiImage.Data, i * 2); + data[i * 4 + 0] = (byte)Math.Round((src & 0x1F) / 31F * 255); // B + data[i * 4 + 1] = (byte)Math.Round(((src >> 5) & 0x1F) / 31F * 255); // G + data[i * 4 + 2] = (byte)Math.Round(((src >> 10) & 0x1F) / 31F * 255); // R + data[i * 4 + 3] = (byte)((src >> 15) * 255F); // A + } + + alphaFormat = AlphaFormat.Unpremul; + 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.Unpremul; + break; + case ImageFormat.Rgba32: + alphaFormat = AlphaFormat.Unpremul; + break; + default: + return new ImageSource(null, 0); + } + + var handle = GCHandle.Alloc(data, GCHandleType.Pinned); + try + { + 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); + } + catch + { + return new ImageSource(null, 0); + } + finally + { + handle.Free(); + } + } + } + + 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); + + // Currently only supports image when its `BITSPERSAMPLE` is one in [1,2,4,8,16] + var width = tiff.GetField(TiffTag.IMAGEWIDTH)[0].ToInt(); + var height = tiff.GetField(TiffTag.IMAGELENGTH)[0].ToInt(); + var pixels = new int[width * height]; + tiff.ReadRGBAImageOriented(width, height, pixels, Orientation.TOPLEFT); + + var pixelSize = new PixelSize(width, height); + var dpi = new Vector(96, 96); + var bitmap = new WriteableBitmap(pixelSize, dpi, PixelFormats.Rgba8888, AlphaFormat.Unpremul); + + using var frameBuffer = bitmap.Lock(); + Marshal.Copy(pixels, 0, frameBuffer.Address, pixels.Length); + return new ImageSource(bitmap, size); + } + } + + private static ImageSource DecodeWithStbImage(Stream stream, long size) + { + var image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha); + var data = image.Data; + var stride = image.Width * (int)image.Comp; + var pixelSize = new PixelSize(image.Width, image.Height); + var dpi = new Vector(96, 96); + var bitmap = new WriteableBitmap(pixelSize, dpi, PixelFormats.Rgba8888, AlphaFormat.Unpremul); + + using var frameBuffer = bitmap.Lock(); + Marshal.Copy(data, 0, frameBuffer.Address, data.Length); + return new ImageSource(bitmap, size); + } + } +} diff --git a/src/ViewModels/InProgressContexts.cs b/src/ViewModels/InProgressContexts.cs new file mode 100644 index 000000000..97699eeab --- /dev/null +++ b/src/ViewModels/InProgressContexts.cs @@ -0,0 +1,255 @@ +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public abstract class InProgressContext + { + public string Name + { + get; + protected set; + } + + public async Task ContinueAsync(CommandLog log) + { + if (_continueCmd != null) + await _continueCmd.Use(log).ExecAsync(); + } + + public async Task SkipAsync(CommandLog log) + { + if (_skipCmd != null) + await _skipCmd.Use(log).ExecAsync(); + } + + public async Task AbortAsync(CommandLog log) + { + if (_abortCmd != null) + await _abortCmd.Use(log).ExecAsync(); + + OnAborted(); + } + + protected virtual void OnAborted() + { + } + + protected Commands.Command _continueCmd = null; + protected Commands.Command _skipCmd = null; + protected Commands.Command _abortCmd = null; + } + + public class CherryPickInProgress : InProgressContext + { + public Models.Commit Head + { + get; + } + + public string HeadName + { + get; + } + + public CherryPickInProgress(Repository repo) + { + Name = "Cherry-Pick"; + + _continueCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Editor = Commands.Command.EditorType.None, + Args = "-c core.commentChar=\"^\" -c core.commentString=\"±\" cherry-pick --continue", + }; + + _skipCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "cherry-pick --skip", + }; + + _abortCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "cherry-pick --abort", + }; + + var headSHA = File.ReadAllText(Path.Combine(repo.GitDir, "CHERRY_PICK_HEAD")).Trim(); + Head = new Commands.QuerySingleCommit(repo.FullPath, headSHA).GetResult() ?? new Models.Commit() { SHA = headSHA }; + HeadName = Head.GetFriendlyName(); + } + } + + public class RebaseInProgress : InProgressContext + { + public string HeadName + { + get; + } + + public string BaseName + { + get; + } + + public Models.Commit StoppedAt + { + get; + } + + public Models.Commit Onto + { + get; + } + + public RebaseInProgress(Repository repo) + { + _gitDir = repo.GitDir; + Name = "Rebase"; + + _continueCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Editor = Commands.Command.EditorType.RebaseEditor, + Args = "-c core.commentChar=\"^\" -c core.commentString=\"±\" rebase --continue", + }; + + _skipCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "rebase --skip", + }; + + _abortCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "rebase --abort", + RaiseError = false, + }; + + 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 = File.Exists(stoppedSHAPath) + ? File.ReadAllText(stoppedSHAPath).Trim() + : new Commands.QueryRevisionByRefName(repo.FullPath, HeadName).GetResult(); + + if (!string.IsNullOrEmpty(stoppedSHA)) + StoppedAt = new Commands.QuerySingleCommit(repo.FullPath, stoppedSHA).GetResult() ?? new Models.Commit() { SHA = stoppedSHA }; + + var ontoSHA = File.ReadAllText(Path.Combine(repo.GitDir, "rebase-merge", "onto")).Trim(); + Onto = new Commands.QuerySingleCommit(repo.FullPath, ontoSHA).GetResult() ?? new Models.Commit() { SHA = ontoSHA }; + BaseName = Onto.GetFriendlyName(); + } + + protected override void OnAborted() + { + var rebaseMergeDir = Path.Combine(_gitDir, "rebase-merge"); + if (Directory.Exists(rebaseMergeDir)) + Directory.Delete(rebaseMergeDir, true); + + var rebaseApplyDir = Path.Combine(_gitDir, "rebase-apply"); + if (Directory.Exists(rebaseApplyDir)) + Directory.Delete(rebaseApplyDir, true); + + var jobFile = Path.Combine(_gitDir, "sourcegit.interactive_rebase"); + if (File.Exists(jobFile)) + File.Delete(jobFile); + } + + private readonly string _gitDir; + } + + public class RevertInProgress : InProgressContext + { + public Models.Commit Head + { + get; + } + + public RevertInProgress(Repository repo) + { + Name = "Revert"; + + _continueCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Editor = Commands.Command.EditorType.None, + Args = "-c core.commentChar=\"^\" -c core.commentString=\"±\" revert --continue", + }; + + _skipCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "revert --skip", + }; + + _abortCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "revert --abort", + }; + + var headSHA = File.ReadAllText(Path.Combine(repo.GitDir, "REVERT_HEAD")).Trim(); + Head = new Commands.QuerySingleCommit(repo.FullPath, headSHA).GetResult() ?? new Models.Commit() { SHA = headSHA }; + } + } + + public class MergeInProgress : InProgressContext + { + public string Current + { + get; + } + + public Models.Commit Source + { + get; + } + + public string SourceName + { + get; + } + + public MergeInProgress(Repository repo) + { + Name = "Merge"; + + _continueCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Editor = Commands.Command.EditorType.None, + Args = "-c core.commentChar=\"^\" -c core.commentString=\"±\" merge --continue", + }; + + _abortCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "merge --abort", + }; + + Current = new Commands.QueryCurrentBranch(repo.FullPath).GetResult(); + + var sourceSHA = File.ReadAllText(Path.Combine(repo.GitDir, "MERGE_HEAD")).Trim(); + Source = new Commands.QuerySingleCommit(repo.FullPath, sourceSHA).GetResult() ?? new Models.Commit() { SHA = sourceSHA }; + SourceName = Source.GetFriendlyName(); + } + } +} diff --git a/src/ViewModels/Init.cs b/src/ViewModels/Init.cs index 985453a07..882d3d647 100644 --- a/src/ViewModels/Init.cs +++ b/src/ViewModels/Init.cs @@ -1,42 +1,59 @@ -using System.IO; -using System.Threading.Tasks; +using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Init : Popup { - public string TargetPath { +namespace SourceGit.ViewModels +{ + public class Init : Popup + { + public string TargetPath + { get => _targetPath; set => SetProperty(ref _targetPath, value); } - public Init(string path) { - TargetPath = path; - View = new Views.Init() { DataContext = this }; + public string Reason + { + get; + private set; } - public override Task Sure() { + public Init(string pageId, string path, RepositoryNode parent, int bookmark, string reason) + { + _pageId = pageId; + _targetPath = path; + _parentNode = parent; + _bookmark = bookmark; + + Reason = string.IsNullOrEmpty(reason) ? "unknown error" : reason; + Reason = Reason.Trim(); + } + + public override async Task Sure() + { ProgressDescription = $"Initialize git repository at: '{_targetPath}'"; - return Task.Run(() => { - var succ = new Commands.Init(HostPageId, _targetPath).Exec(); - if (!succ) return false; - - var gitDir = Path.GetFullPath(Path.Combine(_targetPath, ".git")); - - CallUIThread(() => { - var repo = Preference.AddRepository(_targetPath, gitDir); - var node = new RepositoryNode() { - Id = repo.FullPath, - Name = Path.GetFileName(repo.FullPath), - Bookmark = 0, - IsRepository = true, - }; - Preference.AddNode(node); - }); - - return true; - }); + var log = new CommandLog("Initialize"); + Use(log); + + var succ = await new Commands.Init(_pageId, _targetPath) + .Use(log) + .ExecAsync(); + + log.Complete(); + + if (succ) + { + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(_targetPath, _parentNode, true); + node.Bookmark = _bookmark; + await node.UpdateStatusAsync(false, null); + + Welcome.Instance.Refresh(); + } + return succ; } - private string _targetPath; + private readonly string _pageId = null; + private string _targetPath = null; + private readonly RepositoryNode _parentNode = null; + private readonly int _bookmark = 0; } } diff --git a/src/ViewModels/InitGitFlow.cs b/src/ViewModels/InitGitFlow.cs index b63db27eb..f00ad01dd 100644 --- a/src/ViewModels/InitGitFlow.cs +++ b/src/ViewModels/InitGitFlow.cs @@ -1,94 +1,164 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class InitGitFlow : Popup { - private static readonly Regex TAG_PREFIX = new Regex(@"^[\w\-/\.]+$"); +namespace SourceGit.ViewModels +{ + public partial class InitGitFlow : Popup + { + [GeneratedRegex(@"^[\w\-/\.]+$")] + private static partial Regex REG_TAG_PREFIX(); - [Required(ErrorMessage = "Master branch name is required!!!")] + [Required(ErrorMessage = "Production 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); + public string Production + { + get => _production; + set => SetProperty(ref _production, value, true); } - [Required(ErrorMessage = "Develop branch name is required!!!")] + [Required(ErrorMessage = "Development branch name is required!!!")] [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] [CustomValidation(typeof(InitGitFlow), nameof(ValidateBaseBranch))] - public string Develop { + 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 FeturePrefix { + 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 { + 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 { + public string HotfixPrefix + { get => _hotfixPrefix; set => SetProperty(ref _hotfixPrefix, value, true); } [CustomValidation(typeof(InitGitFlow), nameof(ValidateTagPrefix))] - public string TagPrefix { + public string TagPrefix + { get => _tagPrefix; set => SetProperty(ref _tagPrefix, value, true); } - public InitGitFlow(Repository repo) { + public InitGitFlow(Repository repo) + { _repo = repo; - View = new Views.InitGitFlow() { DataContext = this }; + + var localBranches = new List(); + foreach (var branch in repo.Branches) + { + if (branch.IsLocal) + localBranches.Add(branch.Name); + } + + if (localBranches.Contains("master")) + _production = "master"; + else if (localBranches.Contains("main")) + _production = "main"; + else if (localBranches.Count > 0) + _production = localBranches[0]; + else + _production = "main"; } - 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!"); + public static ValidationResult ValidateBaseBranch(string _, ValidationContext ctx) + { + if (ctx.ObjectInstance is InitGitFlow initializer) + { + if (initializer._production == initializer._develop) + return new ValidationResult("Develop branch has the same name with production branch!"); } return ValidationResult.Success; } - public static ValidationResult ValidateTagPrefix(string tagPrefix, ValidationContext ctx) { - if (!string.IsNullOrWhiteSpace(tagPrefix) && !TAG_PREFIX.IsMatch(tagPrefix)) { + public static ValidationResult ValidateTagPrefix(string tagPrefix, ValidationContext ctx) + { + if (!string.IsNullOrWhiteSpace(tagPrefix) && !REG_TAG_PREFIX().IsMatch(tagPrefix)) return new ValidationResult("Bad tag prefix format!"); - } return ValidationResult.Success; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Init git-flow ..."; - return Task.Run(() => { - var succ = new Commands.GitFlow(_repo.FullPath).Init(_repo.Branches, _master, _develop, _featurePrefix, _releasePrefix, _hotfixPrefix, _tagPrefix); - if (succ) { - _repo.GitFlow.Feature = _featurePrefix; - _repo.GitFlow.Release = _releasePrefix; - _repo.GitFlow.Hotfix = _hotfixPrefix; + var log = _repo.CreateLog("Gitflow - Init"); + Use(log); + + bool succ; + var current = _repo.CurrentBranch; + + var productionBranch = _repo.Branches.Find(x => x.IsLocal && x.Name.Equals(_production, StringComparison.Ordinal)); + if (productionBranch == null) + { + succ = await new Commands.Branch(_repo.FullPath, _production) + .Use(log) + .CreateAsync(current.Head, true); + if (!succ) + { + log.Complete(); + return false; + } + } + + var developBranch = _repo.Branches.Find(x => x.IsLocal && x.Name.Equals(_develop, StringComparison.Ordinal)); + if (developBranch == null) + { + succ = await new Commands.Branch(_repo.FullPath, _develop) + .Use(log) + .CreateAsync(current.Head, true); + if (!succ) + { + log.Complete(); + return false; } + } + + succ = await new Commands.GitFlow(_repo.FullPath) + .Use(log) + .InitAsync(_production, _develop, _featurePrefix, _releasePrefix, _hotfixPrefix, _tagPrefix); + + log.Complete(); + + if (succ) + { + var gitflow = new Models.GitFlow(); + gitflow.ProductionBranch = _production; + gitflow.DevelopmentBranch = _develop; + gitflow.FeaturePrefix = _featurePrefix; + gitflow.ReleasePrefix = _releasePrefix; + gitflow.HotfixPrefix = _hotfixPrefix; + _repo.GitFlow = gitflow; + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + return succ; } - private Repository _repo = null; - private string _master = "master"; + private readonly Repository _repo; + private string _production; private string _develop = "develop"; private string _featurePrefix = "feature/"; private string _releasePrefix = "release/"; diff --git a/src/ViewModels/InteractiveRebase.cs b/src/ViewModels/InteractiveRebase.cs new file mode 100644 index 000000000..a8f013fe4 --- /dev/null +++ b/src/ViewModels/InteractiveRebase.cs @@ -0,0 +1,540 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public record InteractiveRebasePrefill(string SHA, Models.InteractiveRebaseAction Action); + public record InteractiveRebaseReorderItem(string Key, InteractiveRebaseItem Item); + + public class InteractiveRebaseItem : ObservableObject + { + public int OriginalOrder + { + get; + } + + public Models.Commit Commit + { + get; + } + + public Models.InteractiveRebaseAction Action + { + get => _action; + set => SetProperty(ref _action, value); + } + + public Models.InteractiveRebasePendingType PendingType + { + get => _pendingType; + set => SetProperty(ref _pendingType, 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 parts = normalized.Split("\n\n", 2); + Subject = parts[0].ReplaceLineEndings(" "); + } + } + } + + public string OriginalFullMessage + { + get; + set; + } + + public bool CanSquashOrFixup + { + get => _canSquashOrFixup; + set => SetProperty(ref _canSquashOrFixup, value); + } + + public bool ShowEditMessageButton + { + get => _showEditMessageButton; + set => SetProperty(ref _showEditMessageButton, value); + } + + public Thickness DropDirectionIndicator + { + get => _dropDirectionIndicator; + set => SetProperty(ref _dropDirectionIndicator, value); + } + + public bool IsMessageUserEdited + { + get; + set; + } = false; + + public InteractiveRebaseItem(int order, Models.Commit c, string message) + { + OriginalOrder = order; + Commit = c; + FullMessage = message; + OriginalFullMessage = message; + } + + private Models.InteractiveRebaseAction _action = Models.InteractiveRebaseAction.Pick; + private Models.InteractiveRebasePendingType _pendingType = Models.InteractiveRebasePendingType.None; + private string _subject; + private string _fullMessage; + private bool _canSquashOrFixup = true; + private bool _showEditMessageButton = false; + private Thickness _dropDirectionIndicator = new Thickness(0); + } + + public class InteractiveRebase : ObservableObject + { + public Models.Branch Current + { + get; + private set; + } + + public Models.Commit On + { + get; + } + + public bool AutoStash + { + get; + set; + } = true; + + public bool NoVerify + { + get; + set; + } + + public AvaloniaList IssueTrackers + { + get => _repo.IssueTrackers; + } + + public string ConventionalTypesOverride + { + get => _repo.Settings.ConventionalTypesOverride; + } + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public AvaloniaList Items + { + get; + } = []; + + public InteractiveRebaseItem PreSelected + { + get => _preSelected; + private set => SetProperty(ref _preSelected, value); + } + + public object Detail + { + get => _detail; + private set => SetProperty(ref _detail, value); + } + + public InteractiveRebase(Repository repo, Models.Commit on, InteractiveRebasePrefill prefill = null) + { + _repo = repo; + _commitDetail = new CommitDetail(repo, null); + Current = repo.CurrentBranch; + On = on; + IsLoading = true; + + Task.Run(async () => + { + var commits = await new Commands.QueryCommitsForInteractiveRebase(_repo.FullPath, on.SHA) + .GetResultAsync() + .ConfigureAwait(false); + + var list = new List(); + var needReorder = new List(); + var needReorderKeySet = new HashSet(); + + // First-pass to find out the commits that need to be reordered and leave the reset in original order. + for (var i = 0; i < commits.Count; i++) + { + var c = commits[i]; + var item = new InteractiveRebaseItem(commits.Count - i, c.Commit, c.Message); + var subject = c.Commit.Subject; + + if (item.OriginalOrder > 1) + { + if (subject.StartsWith("fixup! ", StringComparison.Ordinal)) + { + item.Action = Models.InteractiveRebaseAction.Fixup; + + var targetSubject = subject.Substring(7).Trim(); + needReorder.Add(new(targetSubject, item)); + needReorderKeySet.Add(targetSubject); + continue; + } + + if (subject.StartsWith("squash! ", StringComparison.Ordinal)) + { + item.Action = Models.InteractiveRebaseAction.Squash; + + var targetSubject = subject.Substring(8).Trim(); + needReorder.Add(new(targetSubject, item)); + needReorderKeySet.Add(targetSubject); + continue; + } + } + + list.Add(item); + } + + // Second-pass to find the place for reordered commits and insert them. + for (var i = list.Count - 1; i >= 0; i--) + { + var subject = list[i].Commit.Subject.Trim(); + if (!needReorderKeySet.Contains(subject)) + continue; + + for (var j = needReorder.Count - 1; j >= 0; j--) + { + var test = needReorder[j]; + if (subject.Equals(test.Key, StringComparison.Ordinal)) + { + list.Insert(i, test.Item); + needReorder.RemoveAt(j); + } + } + + needReorderKeySet.Remove(subject); + if (needReorderKeySet.Count == 0) + break; + } + + // Last-pass to insert the remaining commits that are marked as fixup!/squash! but their target commit is not found. + foreach (var v in needReorder) + { + // For safety, reset to pick if the target commit is not found + v.Item.Action = Models.InteractiveRebaseAction.Pick; + + // Find out the first picked (exclude reordered) commit that should be picked after this commit + var insertPos = 0; + for (var i = 0; i < list.Count; i++) + { + var item = list[i]; + if (item.Action == Models.InteractiveRebaseAction.Pick) + { + if (v.Item.OriginalOrder < item.OriginalOrder) + insertPos = i + 1; + else + break; + } + } + + list.Insert(insertPos, v.Item); + } + + var selected = list.Count > 0 ? list[0] : null; + if (prefill != null) + { + var item = list.Find(x => x.Commit.SHA.Equals(prefill.SHA, StringComparison.Ordinal)); + if (item != null) + { + item.Action = prefill.Action; + selected = item; + } + } + + Dispatcher.UIThread.Post(() => + { + Items.AddRange(list); + UpdateItems(); + PreSelected = selected; + IsLoading = false; + }); + }); + } + + public void SelectCommits(List items) + { + if (items.Count == 0) + { + Detail = null; + } + else if (items.Count == 1) + { + _commitDetail.Commit = items[0].Commit; + Detail = _commitDetail; + } + else + { + Detail = new Models.Count(items.Count); + } + } + + public void ChangeAction(List selected, Models.InteractiveRebaseAction action) + { + if (action == Models.InteractiveRebaseAction.Squash || action == Models.InteractiveRebaseAction.Fixup) + { + foreach (var item in selected) + { + if (item.CanSquashOrFixup) + item.Action = action; + } + } + else + { + foreach (var item in selected) + item.Action = action; + } + + UpdateItems(); + } + + public void Move(List commits, int index) + { + var hashes = new HashSet(); + foreach (var c in commits) + hashes.Add(c.Commit.SHA); + + var before = new List(); + var ordered = new List(); + var after = new List(); + + for (int i = 0; i < index; i++) + { + var item = Items[i]; + if (!hashes.Contains(item.Commit.SHA)) + before.Add(item); + else + ordered.Add(item); + } + + for (int i = index; i < Items.Count; i++) + { + var item = Items[i]; + if (!hashes.Contains(item.Commit.SHA)) + after.Add(item); + else + ordered.Add(item); + } + + Items.Clear(); + Items.AddRange(before); + Items.AddRange(ordered); + Items.AddRange(after); + UpdateItems(); + } + + public async Task Start() + { + using var lockWatcher = _repo.LockWatcher(); + + var saveFile = Path.Combine(_repo.GitDir, "sourcegit.interactive_rebase"); + var collection = new Models.InteractiveRebaseJobCollection(); + collection.OrigHead = _repo.CurrentBranch.Head; + collection.Onto = On.SHA; + + InteractiveRebaseItem pending = null; + for (int i = Items.Count - 1; i >= 0; i--) + { + var item = Items[i]; + var job = new Models.InteractiveRebaseJob() + { + SHA = item.Commit.SHA, + Action = item.Action, + }; + + if (pending != null && item.PendingType != Models.InteractiveRebasePendingType.Ignore) + job.Message = pending.FullMessage; + else + job.Message = item.FullMessage; + + collection.Jobs.Add(job); + + if (item.PendingType == Models.InteractiveRebasePendingType.Last) + pending = null; + else if (item.PendingType == Models.InteractiveRebasePendingType.Target) + pending = item; + } + + await using (var stream = File.Create(saveFile)) + { + await JsonSerializer.SerializeAsync(stream, collection, JsonCodeGen.Default.InteractiveRebaseJobCollection); + } + + var log = _repo.CreateLog("Interactive Rebase"); + var succ = await new Commands.InteractiveRebase(_repo.FullPath, On.SHA, AutoStash, NoVerify) + .Use(log) + .ExecAsync(); + + log.Complete(); + 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; + if (item.Action == Models.InteractiveRebaseAction.Squash || item.Action == Models.InteractiveRebaseAction.Fixup) + item.Action = Models.InteractiveRebaseAction.Pick; + + hasValidParent = item.Action != Models.InteractiveRebaseAction.Drop; + } + } + + var hasPending = false; + var pendingMessages = new List(); + for (var i = 0; i < Items.Count; i++) + { + var item = Items[i]; + + if (item.Action == Models.InteractiveRebaseAction.Drop) + { + item.ShowEditMessageButton = false; + item.PendingType = hasPending ? Models.InteractiveRebasePendingType.Ignore : Models.InteractiveRebasePendingType.None; + item.FullMessage = item.OriginalFullMessage; + item.IsMessageUserEdited = false; + continue; + } + + if (item.Action == Models.InteractiveRebaseAction.Fixup || + item.Action == Models.InteractiveRebaseAction.Squash) + { + item.ShowEditMessageButton = false; + item.PendingType = hasPending ? Models.InteractiveRebasePendingType.Pending : Models.InteractiveRebasePendingType.Last; + item.FullMessage = item.OriginalFullMessage; + item.IsMessageUserEdited = false; + + if (item.Action == Models.InteractiveRebaseAction.Squash) + { + if (item.OriginalFullMessage.StartsWith("squash! ", StringComparison.Ordinal)) + { + var firstLineEnd = item.OriginalFullMessage.IndexOf('\n'); + if (firstLineEnd > 0) + pendingMessages.Add(item.OriginalFullMessage.Substring(firstLineEnd + 1)); + } + else + { + pendingMessages.Add(item.OriginalFullMessage); + } + } + + hasPending = true; + continue; + } + + if (item.Action == Models.InteractiveRebaseAction.Reword || + item.Action == Models.InteractiveRebaseAction.Edit) + { + var oldPendingType = item.PendingType; + item.ShowEditMessageButton = true; + item.PendingType = hasPending ? Models.InteractiveRebasePendingType.Target : Models.InteractiveRebasePendingType.None; + + if (hasPending) + { + if (!item.IsMessageUserEdited) + { + var builder = new StringBuilder(); + builder.Append(item.OriginalFullMessage); + for (var j = pendingMessages.Count - 1; j >= 0; j--) + builder.Append("\n").Append(pendingMessages[j]); + + item.FullMessage = builder.ToString(); + } + + hasPending = false; + pendingMessages.Clear(); + } + else if (oldPendingType == Models.InteractiveRebasePendingType.Target) + { + if (!item.IsMessageUserEdited) + item.FullMessage = item.OriginalFullMessage; + } + + continue; + } + + if (item.Action == Models.InteractiveRebaseAction.Pick) + { + item.IsMessageUserEdited = false; + + if (hasPending) + { + if (pendingMessages.Count > 0) + { + var builder = new StringBuilder(); + builder.Append(item.OriginalFullMessage); + for (var j = pendingMessages.Count - 1; j >= 0; j--) + builder.Append("\n").Append(pendingMessages[j]); + + item.Action = Models.InteractiveRebaseAction.Reword; + item.PendingType = Models.InteractiveRebasePendingType.Target; + item.ShowEditMessageButton = true; + item.FullMessage = builder.ToString(); + } + else + { + item.PendingType = Models.InteractiveRebasePendingType.Target; + item.ShowEditMessageButton = false; + item.FullMessage = item.OriginalFullMessage; + } + + hasPending = false; + pendingMessages.Clear(); + } + else + { + item.PendingType = Models.InteractiveRebasePendingType.None; + item.ShowEditMessageButton = false; + item.FullMessage = item.OriginalFullMessage; + } + } + } + } + + private Repository _repo = null; + private bool _isLoading = false; + private InteractiveRebaseItem _preSelected = null; + private object _detail = null; + private CommitDetail _commitDetail = null; + } +} diff --git a/src/ViewModels/LFSFetch.cs b/src/ViewModels/LFSFetch.cs new file mode 100644 index 000000000..4a869abb4 --- /dev/null +++ b/src/ViewModels/LFSFetch.cs @@ -0,0 +1,40 @@ +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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Fetching LFS objects from remote ..."; + + var log = _repo.CreateLog("LFS Fetch"); + Use(log); + + await new Commands.LFS(_repo.FullPath) + .Use(log) + .FetchAsync(SelectedRemote.Name); + + log.Complete(); + return true; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSImageDiff.cs b/src/ViewModels/LFSImageDiff.cs new file mode 100644 index 000000000..c554f1788 --- /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(async () => + { + var oldImage = await ImageSource.FromLFSObjectAsync(repo, lfs.Old, decoder).ConfigureAwait(false); + var newImage = await ImageSource.FromLFSObjectAsync(repo, lfs.New, decoder).ConfigureAwait(false); + + var img = new Models.ImageDiff() + { + Old = oldImage.Bitmap, + OldFileSize = oldImage.Size, + New = newImage.Bitmap, + NewFileSize = newImage.Size + }; + + Dispatcher.UIThread.Post(() => Image = img); + }); + } + + private Models.ImageDiff _image; + } +} diff --git a/src/ViewModels/LFSLocks.cs b/src/ViewModels/LFSLocks.cs new file mode 100644 index 000000000..d048398eb --- /dev/null +++ b/src/ViewModels/LFSLocks.cs @@ -0,0 +1,133 @@ +using System; +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 => _hasValidUsername; + private set => SetProperty(ref _hasValidUsername, value); + } + + 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; + + Task.Run(async () => + { + _userName = await new Commands.Config(repo.FullPath).GetAsync("user.name").ConfigureAwait(false); + _cachedLocks = await new Commands.LFS(_repo.FullPath).GetLocksAsync(_remote).ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + UpdateVisibleLocks(); + IsLoading = false; + HasValidUserName = !string.IsNullOrEmpty(_userName); + }); + }); + } + + public async Task UnlockAsync(Models.LFSLock lfsLock, bool force) + { + if (_isLoading) + return; + + IsLoading = true; + + var succ = await _repo.UnlockLFSFileAsync(_remote, lfsLock.Path, force, false); + if (succ) + { + _cachedLocks.Remove(lfsLock); + UpdateVisibleLocks(); + } + + IsLoading = false; + } + + public async Task UnlockAllMyLocksAsync() + { + if (_isLoading || string.IsNullOrEmpty(_userName)) + return; + + var locks = new List(); + foreach (var lfsLock in _cachedLocks) + { + if (lfsLock.Owner.Name.Equals(_userName, StringComparison.Ordinal)) + locks.Add(lfsLock.Path); + } + + if (locks.Count == 0) + return; + + IsLoading = true; + + var log = _repo.CreateLog("Unlock LFS Locks"); + var succ = await new Commands.LFS(_repo.FullPath).Use(log).UnlockMultipleAsync(_remote, locks, true); + if (succ) + { + _cachedLocks.RemoveAll(lfsLock => lfsLock.Owner.Name.Equals(_userName, StringComparison.Ordinal)); + UpdateVisibleLocks(); + } + + log.Complete(); + IsLoading = false; + } + + private void UpdateVisibleLocks() + { + var visible = new List(); + + if (!_showOnlyMyLocks) + { + visible.AddRange(_cachedLocks); + } + else + { + foreach (var lfsLock in _cachedLocks) + { + if (lfsLock.Owner.Name.Equals(_userName, StringComparison.Ordinal)) + 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; + private bool _hasValidUsername; + } +} diff --git a/src/ViewModels/LFSPrune.cs b/src/ViewModels/LFSPrune.cs new file mode 100644 index 000000000..1353bc0d1 --- /dev/null +++ b/src/ViewModels/LFSPrune.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class LFSPrune : Popup + { + public LFSPrune(Repository repo) + { + _repo = repo; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "LFS prune ..."; + + var log = _repo.CreateLog("LFS Prune"); + Use(log); + + await new Commands.LFS(_repo.FullPath) + .Use(log) + .PruneAsync(); + + log.Complete(); + return true; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSPull.cs b/src/ViewModels/LFSPull.cs new file mode 100644 index 000000000..8b4b1081e --- /dev/null +++ b/src/ViewModels/LFSPull.cs @@ -0,0 +1,40 @@ +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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Pull LFS objects from remote ..."; + + var log = _repo.CreateLog("LFS Pull"); + Use(log); + + await new Commands.LFS(_repo.FullPath) + .Use(log) + .PullAsync(SelectedRemote.Name); + + log.Complete(); + return true; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSPush.cs b/src/ViewModels/LFSPush.cs new file mode 100644 index 000000000..e5c28783f --- /dev/null +++ b/src/ViewModels/LFSPush.cs @@ -0,0 +1,40 @@ +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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Push LFS objects to remote ..."; + + var log = _repo.CreateLog("LFS Push"); + Use(log); + + await new Commands.LFS(_repo.FullPath) + .Use(log) + .PushAsync(SelectedRemote.Name); + + log.Complete(); + return true; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/LFSTrackCustomPattern.cs b/src/ViewModels/LFSTrackCustomPattern.cs new file mode 100644 index 000000000..d9a98a24d --- /dev/null +++ b/src/ViewModels/LFSTrackCustomPattern.cs @@ -0,0 +1,45 @@ +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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Adding custom LFS tracking pattern ..."; + + var log = _repo.CreateLog("LFS Add Custom Pattern"); + Use(log); + + var succ = await new Commands.LFS(_repo.FullPath) + .Use(log) + .TrackAsync(_pattern, IsFilename); + + log.Complete(); + return succ; + } + + private readonly Repository _repo = null; + private string _pattern = string.Empty; + } +} diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs index 97877a6d8..7b17290a7 100644 --- a/src/ViewModels/Launcher.cs +++ b/src/ViewModels/Launcher.cs @@ -1,184 +1,478 @@ -using Avalonia.Collections; -using CommunityToolkit.Mvvm.ComponentModel; using System; using System.IO; +using System.Text; + +using Avalonia.Collections; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; -namespace SourceGit.ViewModels { - public class Launcher : ObservableObject { - public AvaloniaList Pages { +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 LauncherPage ActivePage { + public Workspace ActiveWorkspace + { + get => _activeWorkspace; + private set => SetProperty(ref _activeWorkspace, value); + } + + public LauncherPage ActivePage + { get => _activePage; - set { - if (SetProperty(ref _activePage, value)) { - PopupHost.Active = value; - } + set + { + if (SetProperty(ref _activePage, value)) + PostActivePageChanged(); } } - public Launcher() { + public ICommandPalette CommandPalette + { + get => _commandPalette; + set => SetProperty(ref _commandPalette, value); + } + + public Models.Version NewVersion + { + get => _newVersion; + set => SetProperty(ref _newVersion, value); + } + + public Launcher(string startupRepo) + { + Models.Notification.Raised += DispatchNotification; + _ignoreIndexChange = true; + + ActiveWorkspace = Preferences.Instance.GetActiveWorkspace(); Pages = new AvaloniaList(); AddNewTab(); - if (Preference.Instance.RestoreTabs) { - foreach (var id in Preference.Instance.OpenedTabs) { - var node = Preference.FindNode(id); - if (node == null) continue; + var repos = ActiveWorkspace.Repositories.ToArray(); + foreach (var repo in repos) + OpenRepositoryInTab(repo, null); - OpenRepositoryInTab(node, null); - } + _ignoreIndexChange = false; + + if (!TryOpenRepositoryFromPath(startupRepo)) + { + var activeIdx = ActiveWorkspace.ActiveIdx; + if (activeIdx > 0 && activeIdx < Pages.Count) + ActivePage = Pages[activeIdx]; + else + ActivePage = Pages[0]; } + + PostActivePageChanged(); } - public void Quit() { - Preference.Instance.OpenedTabs.Clear(); + public bool TryOpenRepositoryFromPath(string repo) + { + if (!string.IsNullOrEmpty(repo) && Directory.Exists(repo)) + { + var isBare = new Commands.IsBareRepository(repo).GetResult(); + if (isBare) + { + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(repo, null, false); + Welcome.Instance.Refresh(); + OpenRepositoryInTab(node, null); + return true; + } - if (Preference.Instance.RestoreTabs) { - foreach (var page in Pages) { - if (page.Node.IsRepository) Preference.Instance.OpenedTabs.Add(page.Node.Id); + var test = new Commands.QueryRepositoryRootPath(repo).GetResult(); + if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) + { + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(test.StdOut.Trim(), null, false); + Welcome.Instance.Refresh(); + OpenRepositoryInTab(node, null); + return true; + } + else + { + if (ActivePage is not { Data: Welcome { }, Popup: null }) + AddNewTab(); + + ActivePage.Popup = new Init(ActivePage.Node.Id, repo, null, 0, test.StdErr ?? "Unknown error occurred while opening the repository."); + return true; } } - Preference.Save(); + return false; + } + + public void CloseAll() + { + _ignoreIndexChange = true; + + foreach (var one in Pages) + CloseRepositoryInTab(one, false); + + _ignoreIndexChange = false; } - public void AddNewTab() { + public void SwitchWorkspace(Workspace to) + { + if (to == null || to.IsActive) + 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) + OpenRepositoryInTab(repo, null); + + var activeIdx = to.ActiveIdx; + if (activeIdx >= 0 && activeIdx < Pages.Count) + ActivePage = Pages[activeIdx]; + else + ActivePage = Pages[0]; + + _ignoreIndexChange = false; + PostActivePageChanged(); + Preferences.Instance.Save(); + GC.Collect(); + } + + public void AddNewTab() + { var page = new LauncherPage(); Pages.Add(page); ActivePage = page; } - public void MoveTab(LauncherPage from, LauncherPage to) { + public void MoveTab(LauncherPage from, LauncherPage to) + { + _ignoreIndexChange = true; + var fromIdx = Pages.IndexOf(from); var toIdx = Pages.IndexOf(to); Pages.Move(fromIdx, toIdx); + + _activeWorkspace.Repositories.Clear(); + foreach (var p in Pages) + { + if (p.Data is Repository r) + _activeWorkspace.Repositories.Add(r.FullPath); + } + + _ignoreIndexChange = false; ActivePage = from; } - public void GotoNextTab() { - if (Pages.Count == 1) return; + public void GotoNextTab() + { + if (Pages.Count == 1) + return; var activeIdx = Pages.IndexOf(_activePage); var nextIdx = (activeIdx + 1) % Pages.Count; ActivePage = Pages[nextIdx]; } - public void CloseTab(object param) { - if (Pages.Count == 1) { - App.Quit(); + public void GotoPrevTab() + { + if (Pages.Count == 1) return; - } - - LauncherPage page = param as LauncherPage; - if (page == null) page = _activePage; - var removeIdx = Pages.IndexOf(page); var activeIdx = Pages.IndexOf(_activePage); - if (removeIdx == activeIdx) { - if (removeIdx == Pages.Count - 1) { - ActivePage = Pages[removeIdx - 1]; - } else { - ActivePage = Pages[removeIdx + 1]; + 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; + + if (last.Node.IsUnmanaged) + last.Node.SaveMinimalInfo(repo.GitDir); + repo.Close(); + + Welcome.Instance.ClearSearchFilter(); + last.Node = new RepositoryNode() { Id = Guid.NewGuid().ToString() }; + last.Data = Welcome.Instance; + last.Popup?.Cleanup(); + last.Popup = null; + + PostActivePageChanged(); + GC.Collect(); + } + else + { + App.Quit(0); } - CloseRepositoryInTab(page); - Pages.RemoveAt(removeIdx); - OnPropertyChanged(nameof(Pages)); - } else if (removeIdx + 1 == activeIdx) { - CloseRepositoryInTab(page); - Pages.RemoveAt(removeIdx); - OnPropertyChanged(nameof(Pages)); - } else { - CloseRepositoryInTab(page); - Pages.RemoveAt(removeIdx); + return; } + 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(object param) { - if (Pages.Count == 1) return; - - var page = param as LauncherPage; - if (page == null) page = _activePage; + public void CloseOtherTabs() + { + if (Pages.Count == 1) + return; - ActivePage = page; + _ignoreIndexChange = true; - foreach (var one in Pages) { - if (one.Node.Id != page.Node.Id) CloseRepositoryInTab(one); + var id = ActivePage.Node.Id; + foreach (var one in Pages) + { + if (one.Node.Id != id) + CloseRepositoryInTab(one); } - Pages = new AvaloniaList { page }; + Pages = new AvaloniaList { ActivePage }; OnPropertyChanged(nameof(Pages)); + _activeWorkspace.ActiveIdx = 0; + _ignoreIndexChange = false; GC.Collect(); } - public void CloseRightTabs(object param) { - LauncherPage page = param as LauncherPage; - if (page == null) page = _activePage; + public void CloseRightTabs() + { + _ignoreIndexChange = true; - var endIdx = Pages.IndexOf(page); - var activeIdx = Pages.IndexOf(_activePage); - if (endIdx < activeIdx) { - ActivePage = page; - } - - for (var i = Pages.Count - 1; i > endIdx; i--) { + 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) { + public void OpenRepositoryInTab(string repo, LauncherPage page) + { + var normalizedPath = repo.Replace('\\', '/').TrimEnd('/'); + var node = Preferences.Instance.FindNode(normalizedPath) ?? new RepositoryNode + { + Id = normalizedPath, + Name = Path.GetFileName(normalizedPath), + Bookmark = 0, + IsRepository = true, + IsUnmanaged = true + }; + + OpenRepositoryInTab(node, null); + } + + public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) + { + foreach (var one in Pages) + { + if (one.Node.Id == node.Id) + { ActivePage = one; return; } } - var repo = Preference.FindRepository(node.Id); - if (repo == null || !Path.Exists(repo.FullPath)) { - var ctx = page == null ? ActivePage.Node.Id : page.Node.Id; - App.RaiseException(ctx, "Repository does NOT exists any more. Please remove it."); + if (!Directory.Exists(node.Id)) + { + ActivePage.Notifications.Add(new Models.Notification + { + Group = node.Id, + Message = "Repository does NOT exist any more. Please remove it.", + IsError = true, + }); + return; + } + + var isBare = new Commands.IsBareRepository(node.Id).GetResult(); + var gitDir = isBare ? node.Id : GetRepositoryGitDir(node.Id); + if (string.IsNullOrEmpty(gitDir)) + { + ActivePage.Notifications.Add(new Models.Notification + { + Group = node.Id, + Message = "Given path is not a valid git repository!", + IsError = true, + }); return; } + if (node.IsUnmanaged) + node.LoadMinimalInfo(gitDir); + + var repo = new Repository(isBare, node.Id, gitDir); repo.Open(); - Commands.AutoFetch.AddRepository(repo.FullPath); - if (page == null) { - if (ActivePage == null || ActivePage.Node.IsRepository) { + if (page == null) + { + if (_activePage == null || _activePage.Node.IsRepository) + { page = new LauncherPage(node, repo); Pages.Add(page); - } else { - page = ActivePage; + } + else + { + page = _activePage; page.Node = node; page.Data = repo; } - } else { + } + else + { page.Node = node; page.Data = repo; } - ActivePage = page; + _activeWorkspace.Repositories.Clear(); + foreach (var p in Pages) + { + if (p.Data is Repository r) + _activeWorkspace.Repositories.Add(r.FullPath); + } + + if (_activePage == page) + PostActivePageChanged(); + else + ActivePage = page; + } + + private void DispatchNotification(Models.Notification notification) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Invoke(() => DispatchNotification(notification)); + return; + } + + if (string.IsNullOrEmpty(notification.Group)) + { + _activePage?.Notifications.Add(notification); + return; + } + + foreach (var page in Pages) + { + var id = page.Node.Id.Replace('\\', '/').TrimEnd('/'); + if (id.Equals(notification.Group, StringComparison.OrdinalIgnoreCase)) + { + page.Notifications.Add(notification); + return; + } + } + + _activePage?.Notifications.Add(notification); + } + + 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).GetResult(); } - private void CloseRepositoryInTab(LauncherPage page) { - if (page.Data is Repository repo) { - Commands.AutoFetch.RemoveRepository(repo.FullPath); + private void CloseRepositoryInTab(LauncherPage page, bool removeFromWorkspace = true) + { + if (page.Data is Repository repo) + { + if (removeFromWorkspace) + _activeWorkspace.Repositories.Remove(repo.FullPath); + + if (page.Node.IsUnmanaged) + page.Node.SaveMinimalInfo(repo.GitDir); + repo.Close(); } + page.Popup?.Cleanup(); + page.Popup = null; page.Data = null; } - private LauncherPage _activePage = null; + private void PostActivePageChanged() + { + if (_ignoreIndexChange) + return; + + if (_activePage is { Data: Repository repo }) + _activeWorkspace.ActiveIdx = _activeWorkspace.Repositories.IndexOf(repo.FullPath); + + var builder = new StringBuilder(512); + builder.Append(string.IsNullOrEmpty(_activePage.Node.Name) ? "Repositories" : _activePage.Node.Name); + + var workspaces = Preferences.Instance.Workspaces; + if (workspaces.Count == 0 || workspaces.Count > 1 || workspaces[0] != _activeWorkspace) + builder.Append(" - ").Append(_activeWorkspace.Name); + + Title = builder.ToString(); + CommandPalette = null; + } + + private Workspace _activeWorkspace; + private LauncherPage _activePage; + private bool _ignoreIndexChange; + private string _title = string.Empty; + private ICommandPalette _commandPalette; + private Models.Version _newVersion = null; } } diff --git a/src/ViewModels/LauncherPage.cs b/src/ViewModels/LauncherPage.cs index 4be607f53..db5e0241b 100644 --- a/src/ViewModels/LauncherPage.cs +++ b/src/ViewModels/LauncherPage.cs @@ -1,53 +1,122 @@ -using Avalonia.Collections; -using System; +using System; +using System.Threading.Tasks; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; -namespace SourceGit.ViewModels { - public class LauncherPage : PopupHost { - public RepositoryNode Node { +namespace SourceGit.ViewModels +{ + public class LauncherPage : ObservableObject + { + public RepositoryNode Node + { get => _node; set => SetProperty(ref _node, value); } - public object Data { + public object Data + { get => _data; set => SetProperty(ref _data, value); } - public AvaloniaList Notifications { + public Models.DirtyState DirtyState + { + get => _dirtyState; + private set => SetProperty(ref _dirtyState, 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(), - Name = "WelcomePage", - Bookmark = 0, - IsRepository = false, - }; - _data = new Welcome(); + 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) { + public LauncherPage(RepositoryNode node, Repository repo) + { _node = node; _data = repo; } - public override string GetId() { - return _node.Id; + public void ClearNotifications() + { + Notifications.Clear(); + } + + public void ChangeDirtyState(Models.DirtyState flag, bool remove) + { + var state = _dirtyState; + if (remove) + { + if (state.HasFlag(flag)) + state -= flag; + } + else + { + state |= flag; + } + + DirtyState = state; } - public void CopyPath() { - if (_node.IsRepository) App.CopyText(_node.Id); + public bool CanCreatePopup() + { + return _popup is not { InProgress: true }; } - public void DismissNotification(object param) { - if (param is Models.Notification notice) { - Notifications.Remove(notice); + public async Task ProcessPopupAsync() + { + if (_popup is { InProgress: false } dump) + { + if (!dump.Check()) + return; + + dump.InProgress = true; + + try + { + var finished = await dump.Sure(); + if (finished) + { + dump.Cleanup(); + Popup = null; + } + } + catch (Exception e) + { + Native.OS.LogException(e); + } + + dump.InProgress = false; } } + public void CancelPopup() + { + if (_popup == null || _popup.InProgress) + return; + + _popup?.Cleanup(); + Popup = null; + } + private RepositoryNode _node = null; private object _data = null; + private Models.DirtyState _dirtyState = Models.DirtyState.None; + private Popup _popup = null; } } diff --git a/src/ViewModels/LauncherPagesCommandPalette.cs b/src/ViewModels/LauncherPagesCommandPalette.cs new file mode 100644 index 000000000..265aa0704 --- /dev/null +++ b/src/ViewModels/LauncherPagesCommandPalette.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.ViewModels +{ + public class LauncherPagesCommandPalette : ICommandPalette + { + public List VisiblePages + { + get => _visiblePages; + private set => SetProperty(ref _visiblePages, value); + } + + public List VisibleRepos + { + get => _visibleRepos; + private set => SetProperty(ref _visibleRepos, value); + } + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + UpdateVisible(); + } + } + + public LauncherPage SelectedPage + { + get => _selectedPage; + set + { + if (SetProperty(ref _selectedPage, value) && value != null) + SelectedRepo = null; + } + } + + public RepositoryNode SelectedRepo + { + get => _selectedRepo; + set + { + if (SetProperty(ref _selectedRepo, value) && value != null) + SelectedPage = null; + } + } + + public LauncherPagesCommandPalette(Launcher launcher) + { + _launcher = launcher; + + foreach (var page in _launcher.Pages) + { + if (page.Node.IsRepository) + _opened.Add(page.Node.Id); + } + + UpdateVisible(); + } + + public void ClearFilter() + { + SearchFilter = string.Empty; + } + + public void OpenOrSwitchTo() + { + _opened.Clear(); + _visiblePages.Clear(); + _visibleRepos.Clear(); + Close(); + + if (_selectedPage != null) + _launcher.ActivePage = _selectedPage; + else if (_selectedRepo != null) + _launcher.OpenRepositoryInTab(_selectedRepo, null); + } + + private void UpdateVisible() + { + var pages = new List(); + CollectVisiblePages(pages); + + var repos = new List(); + CollectVisibleRepository(repos, Preferences.Instance.RepositoryNodes); + + var autoSelectPage = _selectedPage; + var autoSelectRepo = _selectedRepo; + + if (_selectedPage != null) + { + if (pages.Contains(_selectedPage)) + { + // Keep selection + } + else if (pages.Count > 0) + { + autoSelectPage = pages[0]; + } + else if (repos.Count > 0) + { + autoSelectPage = null; + autoSelectRepo = repos[0]; + } + else + { + autoSelectPage = null; + } + } + else if (_selectedRepo != null) + { + if (repos.Contains(_selectedRepo)) + { + // Keep selection + } + else if (repos.Count > 0) + { + autoSelectRepo = repos[0]; + } + else if (pages.Count > 0) + { + autoSelectPage = pages[0]; + autoSelectRepo = null; + } + else + { + autoSelectRepo = null; + } + } + else if (pages.Count > 0) + { + autoSelectPage = pages[0]; + autoSelectRepo = null; + } + else if (repos.Count > 0) + { + autoSelectPage = null; + autoSelectRepo = repos[0]; + } + else + { + autoSelectPage = null; + autoSelectRepo = null; + } + + VisiblePages = pages; + VisibleRepos = repos; + SelectedPage = autoSelectPage; + SelectedRepo = autoSelectRepo; + } + + private void CollectVisiblePages(List pages) + { + foreach (var page in _launcher.Pages) + { + if (page == _launcher.ActivePage) + continue; + + if (string.IsNullOrEmpty(_searchFilter) || + page.Node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase) || + (page.Node.IsRepository && page.Node.Id.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase))) + pages.Add(page); + } + } + + private void CollectVisibleRepository(List outs, List nodes) + { + foreach (var node in nodes) + { + if (!node.IsRepository) + { + CollectVisibleRepository(outs, node.SubNodes); + continue; + } + + if (_opened.Contains(node.Id)) + continue; + + if (string.IsNullOrEmpty(_searchFilter) || + node.Id.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase) || + node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + outs.Add(node); + } + } + + private Launcher _launcher = null; + private HashSet _opened = new HashSet(); + private List _visiblePages = []; + private List _visibleRepos = []; + private string _searchFilter = string.Empty; + private LauncherPage _selectedPage = null; + private RepositoryNode _selectedRepo = null; + } +} diff --git a/src/ViewModels/LayoutInfo.cs b/src/ViewModels/LayoutInfo.cs new file mode 100644 index 000000000..f993e242e --- /dev/null +++ b/src/ViewModels/LayoutInfo.cs @@ -0,0 +1,74 @@ +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 int LauncherPositionX + { + get; + set; + } = int.MinValue; + + public int LauncherPositionY + { + get; + set; + } = int.MinValue; + + public WindowState LauncherWindowState + { + get; + set; + } = WindowState.Normal; + + public GridLength RepositorySidebarWidth + { + get => _repositorySidebarWidth; + set => SetProperty(ref _repositorySidebarWidth, 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 _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 index 3f68796a4..35f9567f6 100644 --- a/src/ViewModels/Merge.cs +++ b/src/ViewModels/Merge.cs @@ -1,65 +1,179 @@ -using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; +using Avalonia.Threading; -namespace SourceGit.ViewModels { - public class MergeMode { - 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; - } +namespace SourceGit.ViewModels +{ + public enum MergeTestingState + { + Disabled = 0, + Testing, + WillCauseConflicts, + UnknownError, + NoConflicts, } - public class Merge : Popup { - public string Source { + public class Merge : Popup + { + public object Source + { get; - private set; } - public string Into { + public string Into + { get; - private set; } - public List Modes { - get; - private set; + public Models.MergeMode Mode + { + get => _mode; + set + { + if (SetProperty(ref _mode, value)) + CanEditMessage = _mode == Models.MergeMode.Default || _mode == Models.MergeMode.NoFastForward; + } + } + + public bool CanEditMessage + { + get => _canEditMessage; + set => SetProperty(ref _canEditMessage, value); } - public MergeMode SelectedMode { + public bool Edit + { get; set; + } = false; + + public MergeTestingState TestingState + { + get => _testingState; + private set => SetProperty(ref _testingState, value); + } + + public Merge(Repository repo, Models.Branch source, string into, bool forceFastForward) + { + _repo = repo; + _sourceName = source.FriendlyName; + + Source = source; + Into = into; + Mode = forceFastForward ? Models.MergeMode.FastForward : AutoSelectMergeMode(); + + if (!forceFastForward) + Test(); + } + + public Merge(Repository repo, Models.Commit source, string into) + { + _repo = repo; + _sourceName = source.SHA; + + Source = source; + Into = into; + Mode = AutoSelectMergeMode(); + + Test(); } - public Merge(Repository repo, string source, string into) { + public Merge(Repository repo, Models.Tag source, string into) + { _repo = repo; + _sourceName = source.Name; + Source = source; Into = into; - Modes = new List() { - new MergeMode("Default", "Fast-forward if possible", ""), - new MergeMode("No Fast-forward", "Always create a merge commit", "--no-ff"), - new MergeMode("Squash", "Use '--squash'", "--squash"), - new MergeMode("Don't commit", "Merge without commit", "--no-commit"), + Mode = AutoSelectMergeMode(); + + Test(); + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + _repo.ClearCommitMessage(); + ProgressDescription = $"Merging '{_sourceName}' into '{Into}' ..."; + + var log = _repo.CreateLog($"Merging '{_sourceName}' into '{Into}'"); + Use(log); + + var succ = await new Commands.Merge(_repo.FullPath, _sourceName, Mode.Arg, _canEditMessage && Edit) + .Use(log) + .ExecAsync(); + + if (succ) + { + var squashMsgFile = Path.Combine(_repo.GitDir, "SQUASH_MSG"); + if (Mode == Models.MergeMode.Squash && File.Exists(squashMsgFile)) + { + var msg = await File.ReadAllTextAsync(squashMsgFile); + _repo.SetCommitMessage(msg); + } + + await _repo.AutoUpdateSubmodulesAsync(log); + } + + log.Complete(); + + if (succ && _repo.SelectedViewIndex == 0) + { + var head = await new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResultAsync(); + _repo.NavigateToCommit(head, true); + } + + return true; + } + + private Models.MergeMode AutoSelectMergeMode() + { + var config = new Commands.Config(_repo.FullPath).Get($"branch.{Into}.mergeoptions"); + var mode = config switch + { + "--ff-only" => Models.MergeMode.FastForward, + "--no-ff" => Models.MergeMode.NoFastForward, + "--squash" => Models.MergeMode.Squash, + "--no-commit" or "--no-ff --no-commit" => Models.MergeMode.DontCommit, + _ => null, }; - SelectedMode = Modes[0]; - View = new Views.Merge() { DataContext = this }; + + if (mode != null) + return mode; + + var preferredMergeModeIdx = _repo.Settings.PreferredMergeMode; + if (preferredMergeModeIdx < 0 || preferredMergeModeIdx > Models.MergeMode.Supported.Length) + return Models.MergeMode.Default; + + return Models.MergeMode.Supported[preferredMergeModeIdx]; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Merging '{Source}' into '{Into}' ..."; + private void Test() + { + if (Native.OS.GitVersion < Models.GitVersions.TESTING_MERGE) + return; + + TestingState = MergeTestingState.Testing; + + Task.Run(async () => + { + var exitCode = await new Commands.MergeTree(_repo.FullPath, _sourceName, Into) + .GetExitCodeAsync() + .ConfigureAwait(false); - return Task.Run(() => { - var succ = new Commands.Merge(_repo.FullPath, Source, SelectedMode.Arg, SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; + Dispatcher.UIThread.Post(() => TestingState = exitCode switch + { + 0 => MergeTestingState.NoConflicts, + 1 => MergeTestingState.WillCauseConflicts, + _ => MergeTestingState.UnknownError, + }); }); } - private Repository _repo = null; + private readonly Repository _repo = null; + private readonly string _sourceName; + private Models.MergeMode _mode = Models.MergeMode.Default; + private bool _canEditMessage = true; + private MergeTestingState _testingState = MergeTestingState.Disabled; } } diff --git a/src/ViewModels/MergeCommandPalette.cs b/src/ViewModels/MergeCommandPalette.cs new file mode 100644 index 000000000..745bcbc07 --- /dev/null +++ b/src/ViewModels/MergeCommandPalette.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.ViewModels +{ + public class MergeCommandPalette : ICommandPalette + { + public List Branches + { + get => _branches; + private set => SetProperty(ref _branches, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateBranches(); + } + } + + public Models.Branch SelectedBranch + { + get => _selectedBranch; + set => SetProperty(ref _selectedBranch, value); + } + + public MergeCommandPalette(Repository repo) + { + _repo = repo; + UpdateBranches(); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public void Launch() + { + _branches.Clear(); + Close(); + + if (_repo.CanCreatePopup() && _selectedBranch != null) + _repo.ShowPopup(new Merge(_repo, _selectedBranch, _repo.CurrentBranch.Name, false)); + } + + private void UpdateBranches() + { + var current = _repo.CurrentBranch; + if (current == null) + return; + + var branches = new List(); + foreach (var b in _repo.Branches) + { + if (b == current) + continue; + + if (string.IsNullOrEmpty(_filter) || b.FriendlyName.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + branches.Add(b); + } + + branches.Sort((l, r) => + { + if (l.IsLocal == r.IsLocal) + return Models.NumericSort.Compare(l.Name, r.Name); + + return l.IsLocal ? -1 : 1; + }); + + var autoSelected = _selectedBranch; + if (branches.Count == 0) + autoSelected = null; + else if (_selectedBranch == null || !branches.Contains(_selectedBranch)) + autoSelected = branches[0]; + + Branches = branches; + SelectedBranch = autoSelected; + } + + private Repository _repo = null; + private List _branches = new List(); + private string _filter = string.Empty; + private Models.Branch _selectedBranch = null; + } +} diff --git a/src/ViewModels/MergeConflictEditor.cs b/src/ViewModels/MergeConflictEditor.cs new file mode 100644 index 000000000..fc9d0f632 --- /dev/null +++ b/src/ViewModels/MergeConflictEditor.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class MergeConflictEditor : ObservableObject + { + public string FilePath + { + get => _filePath; + } + + public object Mine + { + get; + } + + public object Theirs + { + get; + } + + public string Error + { + get => _error; + private set => SetProperty(ref _error, value); + } + + public List OursLines + { + get => _oursLines; + private set => SetProperty(ref _oursLines, value); + } + + public List TheirsLines + { + get => _theirsLines; + private set => SetProperty(ref _theirsLines, value); + } + + public List ResultLines + { + get => _resultLines; + private set => SetProperty(ref _resultLines, value); + } + + public int MaxLineNumber + { + get => _maxLineNumber; + private set => SetProperty(ref _maxLineNumber, value); + } + + public int UnsolvedCount + { + get => _unsolvedCount; + private set => SetProperty(ref _unsolvedCount, value); + } + + public Vector ScrollOffset + { + get => _scrollOffset; + set => SetProperty(ref _scrollOffset, value); + } + + public Models.ConflictSelectedChunk SelectedChunk + { + get => _selectedChunk; + set => SetProperty(ref _selectedChunk, value); + } + + public IReadOnlyList ConflictRegions + { + get => _conflictRegions; + } + + public MergeConflictEditor(Repository repo, Models.Commit head, string filePath) + { + _repo = repo; + _filePath = filePath; + + (Mine, Theirs) = repo.InProgressContext switch + { + CherryPickInProgress cherryPick => (head, cherryPick.Head), + RebaseInProgress rebase => (rebase.Onto, rebase.StoppedAt), + RevertInProgress revert => (head, revert.Head), + MergeInProgress merge => (head, merge.Source), + _ => (head, (object)"Stash or Patch"), + }; + + var workingCopyPath = Path.Combine(_repo.FullPath, _filePath); + var workingCopyContent = string.Empty; + if (File.Exists(workingCopyPath)) + workingCopyContent = File.ReadAllText(workingCopyPath); + + if (workingCopyContent.IndexOf('\0', StringComparison.Ordinal) >= 0) + { + _error = "Binary file is not supported."; + return; + } + + ParseOriginalContent(workingCopyContent); + RefreshDisplayData(); + } + + public Models.ConflictLineState GetLineState(int line) + { + if (line >= 0 && line < _lineStates.Count) + return _lineStates[line]; + return Models.ConflictLineState.Normal; + } + + public void Resolve(object param) + { + if (_selectedChunk == null) + return; + + var region = _conflictRegions[_selectedChunk.ConflictIndex]; + if (param is not Models.ConflictResolution resolution) + return; + + // Try to resolve a resolved region. + if (resolution != Models.ConflictResolution.None && region.IsResolved) + return; + + // Try to undo an unresolved region. + if (resolution == Models.ConflictResolution.None && !region.IsResolved) + return; + + region.IsResolved = resolution != Models.ConflictResolution.None; + region.ResolutionType = resolution; + RefreshDisplayData(); + } + + public async Task SaveAndStageAsync() + { + if (_conflictRegions.Count == 0) + return true; + + if (_unsolvedCount > 0) + { + Error = "Cannot save: there are still unresolved conflicts."; + return false; + } + + var lines = _originalContent.Split('\n', StringSplitOptions.None); + var builder = new StringBuilder(); + var lastLineIdx = 0; + + foreach (var r in _conflictRegions) + { + for (var i = lastLineIdx; i < r.StartLineInOriginal; i++) + builder.Append(lines[i]).Append('\n'); + + if (r.ResolutionType == Models.ConflictResolution.UseOurs) + { + foreach (var l in r.OursContent) + builder.Append(l).Append('\n'); + } + else if (r.ResolutionType == Models.ConflictResolution.UseTheirs) + { + foreach (var l in r.TheirsContent) + builder.Append(l).Append('\n'); + } + else if (r.ResolutionType == Models.ConflictResolution.UseBothMineFirst) + { + foreach (var l in r.OursContent) + builder.Append(l).Append('\n'); + + foreach (var l in r.TheirsContent) + builder.Append(l).Append('\n'); + } + else if (r.ResolutionType == Models.ConflictResolution.UseBothTheirsFirst) + { + foreach (var l in r.TheirsContent) + builder.Append(l).Append('\n'); + + foreach (var l in r.OursContent) + builder.Append(l).Append('\n'); + } + + lastLineIdx = r.EndLineInOriginal + 1; + } + + for (var j = lastLineIdx; j < lines.Length; j++) + builder.Append(lines[j]).Append('\n'); + + if (builder.Length > 1) + builder.Length--; // Remove extra '\n' + + try + { + // Write merged content to file + var fullPath = Path.Combine(_repo.FullPath, _filePath); + await File.WriteAllTextAsync(fullPath, builder.ToString()); + + // Stage the file + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllTextAsync(pathSpecFile, _filePath); + await new Commands.Add(_repo.FullPath, pathSpecFile).ExecAsync(); + File.Delete(pathSpecFile); + + _repo.MarkWorkingCopyDirtyManually(); + return true; + } + catch (Exception ex) + { + Error = $"Failed to save and stage: {ex.Message}"; + return false; + } + } + + public void ClearErrorMessage() + { + Error = string.Empty; + } + + private void ParseOriginalContent(string content) + { + _originalContent = content; + _conflictRegions.Clear(); + + if (string.IsNullOrEmpty(content)) + return; + + var lines = content.Split('\n', StringSplitOptions.None); + var oursLines = new List(); + var theirsLines = new List(); + int oursLineNumber = 1; + int theirsLineNumber = 1; + int i = 0; + + while (i < lines.Length) + { + var line = lines[i]; + + if (line.StartsWith("<<<<<<<", StringComparison.Ordinal)) + { + var region = new Models.ConflictRegion + { + StartLineInOriginal = i, + StartMarker = line, + }; + + oursLines.Add(new()); + theirsLines.Add(new()); + i++; + + // Collect ours content + while (i < lines.Length && + !lines[i].StartsWith("|||||||", StringComparison.Ordinal) && + !lines[i].StartsWith("=======", StringComparison.Ordinal)) + { + line = lines[i]; + region.OursContent.Add(line); + oursLines.Add(new(Models.ConflictLineType.Ours, line, oursLineNumber++)); + theirsLines.Add(new()); + i++; + } + + // Skip diff3 base section if present + if (i < lines.Length && lines[i].StartsWith("|||||||", StringComparison.Ordinal)) + { + i++; + while (i < lines.Length && !lines[i].StartsWith("=======", StringComparison.Ordinal)) + i++; + } + + // Capture separator marker + if (i < lines.Length && lines[i].StartsWith("=======", StringComparison.Ordinal)) + { + oursLines.Add(new()); + theirsLines.Add(new()); + region.SeparatorMarker = lines[i]; + i++; + } + + // Collect theirs content + while (i < lines.Length && !lines[i].StartsWith(">>>>>>>", StringComparison.Ordinal)) + { + line = lines[i]; + region.TheirsContent.Add(line); + oursLines.Add(new()); + theirsLines.Add(new(Models.ConflictLineType.Theirs, line, theirsLineNumber++)); + i++; + } + + // Capture end marker (e.g., ">>>>>>> feature-branch") + if (i < lines.Length && lines[i].StartsWith(">>>>>>>", StringComparison.Ordinal)) + { + oursLines.Add(new()); + theirsLines.Add(new()); + + region.EndMarker = lines[i]; + region.EndLineInOriginal = i; + i++; + } + + _conflictRegions.Add(region); + } + else + { + oursLines.Add(new(Models.ConflictLineType.Common, line, oursLineNumber)); + theirsLines.Add(new(Models.ConflictLineType.Common, line, theirsLineNumber)); + i++; + oursLineNumber++; + theirsLineNumber++; + } + } + + MaxLineNumber = Math.Max(oursLineNumber, theirsLineNumber); + OursLines = oursLines; + TheirsLines = theirsLines; + } + + private void RefreshDisplayData() + { + var resultLines = new List(); + _lineStates.Clear(); + + if (_oursLines == null || _oursLines.Count == 0) + { + ResultLines = resultLines; + return; + } + + int resultLineNumber = 1; + int currentLine = 0; + int conflictIdx = 0; + + while (currentLine < _oursLines.Count) + { + // Check if we're at a conflict region + Models.ConflictRegion currentRegion = null; + if (conflictIdx < _conflictRegions.Count) + { + var region = _conflictRegions[conflictIdx]; + if (region.StartLineInOriginal == currentLine) + currentRegion = region; + } + + if (currentRegion != null) + { + int regionLines = currentRegion.EndLineInOriginal - currentRegion.StartLineInOriginal + 1; + if (currentRegion.IsResolved) + { + var oldLineCount = resultLines.Count; + var resolveType = currentRegion.ResolutionType; + + // Resolved - show resolved content with color based on resolution type + if (resolveType == Models.ConflictResolution.UseBothMineFirst) + { + int mineCount = currentRegion.OursContent.Count; + for (int i = 0; i < mineCount; i++) + { + resultLines.Add(new(Models.ConflictLineType.Ours, currentRegion.OursContent[i], resultLineNumber)); + resultLineNumber++; + } + + int theirsCount = currentRegion.TheirsContent.Count; + for (int i = 0; i < theirsCount; i++) + { + resultLines.Add(new(Models.ConflictLineType.Theirs, currentRegion.TheirsContent[i], resultLineNumber)); + resultLineNumber++; + } + } + else if (resolveType == Models.ConflictResolution.UseBothTheirsFirst) + { + int theirsCount = currentRegion.TheirsContent.Count; + for (int i = 0; i < theirsCount; i++) + { + resultLines.Add(new(Models.ConflictLineType.Theirs, currentRegion.TheirsContent[i], resultLineNumber)); + resultLineNumber++; + } + + int mineCount = currentRegion.OursContent.Count; + for (int i = 0; i < mineCount; i++) + { + resultLines.Add(new(Models.ConflictLineType.Ours, currentRegion.OursContent[i], resultLineNumber)); + resultLineNumber++; + } + } + else if (resolveType == Models.ConflictResolution.UseOurs) + { + int mineCount = currentRegion.OursContent.Count; + for (int i = 0; i < mineCount; i++) + { + resultLines.Add(new(Models.ConflictLineType.Ours, currentRegion.OursContent[i], resultLineNumber)); + resultLineNumber++; + } + } + else if (resolveType == Models.ConflictResolution.UseTheirs) + { + int theirsCount = currentRegion.TheirsContent.Count; + for (int i = 0; i < theirsCount; i++) + { + resultLines.Add(new(Models.ConflictLineType.Theirs, currentRegion.TheirsContent[i], resultLineNumber)); + resultLineNumber++; + } + } + + // Pad with empty lines to match Mine/Theirs panel height + int added = resultLines.Count - oldLineCount; + int padding = regionLines - added; + for (int p = 0; p < padding; p++) + resultLines.Add(new()); + + int blockSize = resultLines.Count - oldLineCount - 2; + _lineStates.Add(Models.ConflictLineState.ResolvedBlockStart); + for (var i = 0; i < blockSize; i++) + _lineStates.Add(Models.ConflictLineState.ResolvedBlock); + _lineStates.Add(Models.ConflictLineState.ResolvedBlockEnd); + } + else + { + resultLines.Add(new(Models.ConflictLineType.Marker, currentRegion.StartMarker)); + _lineStates.Add(Models.ConflictLineState.ConflictBlockStart); + + foreach (var line in currentRegion.OursContent) + { + resultLines.Add(new(Models.ConflictLineType.Ours, line, resultLineNumber++)); + _lineStates.Add(Models.ConflictLineState.ConflictBlock); + } + + resultLines.Add(new(Models.ConflictLineType.Marker, currentRegion.SeparatorMarker)); + _lineStates.Add(Models.ConflictLineState.ConflictBlock); + + foreach (var line in currentRegion.TheirsContent) + { + resultLines.Add(new(Models.ConflictLineType.Theirs, line, resultLineNumber++)); + _lineStates.Add(Models.ConflictLineState.ConflictBlock); + } + + resultLines.Add(new(Models.ConflictLineType.Marker, currentRegion.EndMarker)); + _lineStates.Add(Models.ConflictLineState.ConflictBlockEnd); + } + + currentLine = currentRegion.EndLineInOriginal + 1; + conflictIdx++; + } + else + { + var oursLine = _oursLines[currentLine]; + resultLines.Add(new(oursLine.Type, oursLine.Content, resultLineNumber)); + _lineStates.Add(Models.ConflictLineState.Normal); + resultLineNumber++; + currentLine++; + } + } + + SelectedChunk = null; + ResultLines = resultLines; + + var unsolved = new List(); + for (var i = 0; i < _conflictRegions.Count; i++) + { + var r = _conflictRegions[i]; + if (!r.IsResolved) + unsolved.Add(i); + } + + UnsolvedCount = unsolved.Count; + } + + private readonly Repository _repo; + private readonly string _filePath; + private string _originalContent = string.Empty; + private int _unsolvedCount = 0; + private int _maxLineNumber = 0; + private List _oursLines = []; + private List _theirsLines = []; + private List _resultLines = []; + private List _conflictRegions = []; + private List _lineStates = []; + private Vector _scrollOffset = Vector.Zero; + private Models.ConflictSelectedChunk _selectedChunk; + private string _error = string.Empty; + } +} diff --git a/src/ViewModels/MergeMultiple.cs b/src/ViewModels/MergeMultiple.cs new file mode 100644 index 000000000..781cadce1 --- /dev/null +++ b/src/ViewModels/MergeMultiple.cs @@ -0,0 +1,90 @@ +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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + _repo.ClearCommitMessage(); + ProgressDescription = "Merge head(s) ..."; + + var log = _repo.CreateLog("Merge Multiple Heads"); + Use(log); + + await new Commands.Merge( + _repo.FullPath, + ConvertTargetToMergeSources(), + AutoCommit, + Strategy.Arg) + .Use(log) + .ExecAsync(); + + log.Complete(); + 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 000000000..716274295 --- /dev/null +++ b/src/ViewModels/MoveRepositoryNode.cs @@ -0,0 +1,70 @@ +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); + Selected = Rows[0]; + } + + public override Task Sure() + { + if (_selected != null) + { + var node = Preferences.Instance.FindNode(_selected.Id); + Preferences.Instance.MoveNode(Target, node, true); + Welcome.Instance.Refresh(); + } + + return Task.FromResult(true); + } + + 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/MoveSubmodule.cs b/src/ViewModels/MoveSubmodule.cs new file mode 100644 index 000000000..b17f21128 --- /dev/null +++ b/src/ViewModels/MoveSubmodule.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class MoveSubmodule : Popup + { + public Models.Submodule Submodule + { + get; + } + + [Required(ErrorMessage = "Path is required!!!")] + public string MoveTo + { + get => _moveTo; + set => SetProperty(ref _moveTo, value, true); + } + + public MoveSubmodule(Repository repo, Models.Submodule submodule) + { + _repo = repo; + _moveTo = submodule.Path; + Submodule = submodule; + } + + public override async Task Sure() + { + ProgressDescription = "Moving submodule ..."; + + var oldPath = Native.OS.GetAbsPath(_repo.FullPath, Submodule.Path); + var newPath = Native.OS.GetAbsPath(_repo.FullPath, _moveTo); + if (oldPath.Equals(newPath, StringComparison.Ordinal)) + return true; + + using var lockWatcher = _repo.LockWatcher(); + var log = _repo.CreateLog("Move Submodule"); + Use(log); + + var succ = await new Commands.Move(_repo.FullPath, oldPath, newPath, false) + .Use(log) + .ExecAsync(); + + log.Complete(); + return succ; + } + + private Repository _repo; + private string _moveTo; + } +} diff --git a/src/ViewModels/OpenFileCommandPalette.cs b/src/ViewModels/OpenFileCommandPalette.cs new file mode 100644 index 000000000..78a702e0d --- /dev/null +++ b/src/ViewModels/OpenFileCommandPalette.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class OpenFileCommandPalette : ICommandPalette + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public List VisibleFiles + { + get => _visibleFiles; + private set => SetProperty(ref _visibleFiles, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateVisible(); + } + } + + public string SelectedFile + { + get => _selectedFile; + set => SetProperty(ref _selectedFile, value); + } + + public OpenFileCommandPalette(string repo) + { + _repo = repo; + _isLoading = true; + + Task.Run(async () => + { + var files = await new Commands.QueryRevisionFileNames(_repo, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + IsLoading = false; + _repoFiles = files; + UpdateVisible(); + }); + }); + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public void Launch() + { + _repoFiles.Clear(); + _visibleFiles.Clear(); + Close(); + + if (!string.IsNullOrEmpty(_selectedFile)) + Native.OS.OpenWithDefaultEditor(Native.OS.GetAbsPath(_repo, _selectedFile)); + } + + private void UpdateVisible() + { + if (_repoFiles is { Count: > 0 }) + { + if (string.IsNullOrEmpty(_filter)) + { + VisibleFiles = _repoFiles; + + if (string.IsNullOrEmpty(_selectedFile)) + SelectedFile = _repoFiles[0]; + } + else + { + var visible = new List(); + + foreach (var f in _repoFiles) + { + if (f.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(f); + } + + var autoSelected = _selectedFile; + if (visible.Count == 0) + autoSelected = null; + else if (string.IsNullOrEmpty(_selectedFile) || !visible.Contains(_selectedFile)) + autoSelected = visible[0]; + + VisibleFiles = visible; + SelectedFile = autoSelected; + } + } + } + + private string _repo = null; + private bool _isLoading = false; + private List _repoFiles = null; + private string _filter = string.Empty; + private List _visibleFiles = []; + private string _selectedFile = null; + } +} diff --git a/src/ViewModels/OpenLocalRepository.cs b/src/ViewModels/OpenLocalRepository.cs new file mode 100644 index 000000000..877818d92 --- /dev/null +++ b/src/ViewModels/OpenLocalRepository.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class OpenLocalRepository : Popup + { + [Required(ErrorMessage = "Repository folder is required")] + [CustomValidation(typeof(OpenLocalRepository), nameof(ValidateRepoPath))] + public string RepoPath + { + get => _repoPath; + set => SetProperty(ref _repoPath, value, true); + } + + public List Groups + { + get; + } + + public RepositoryNode Group + { + get => _group; + set => SetProperty(ref _group, value); + } + + public int Bookmark + { + get => _bookmark; + set => SetProperty(ref _bookmark, value); + } + + public OpenLocalRepository(string pageId, RepositoryNode group) + { + _pageId = pageId; + + Groups = new List(); + Groups.Add(new RepositoryNode { Name = "No Group (Uncategorized)", Id = string.Empty }); + Group = group ?? Groups[0]; + CollectGroups(Groups, Preferences.Instance.RepositoryNodes); + } + + public static ValidationResult ValidateRepoPath(string folder, ValidationContext _) + { + if (!Directory.Exists(folder)) + return new ValidationResult("Given path can NOT be found"); + return ValidationResult.Success; + } + + public override async Task Sure() + { + var isBare = await new Commands.IsBareRepository(_repoPath).GetResultAsync(); + var parent = _group is { Id: not "" } ? _group : null; + var repoRoot = _repoPath; + if (!isBare) + { + var test = await new Commands.QueryRepositoryRootPath(_repoPath).GetResultAsync(); + if (test.IsSuccess && !string.IsNullOrWhiteSpace(test.StdOut)) + { + repoRoot = test.StdOut.Trim(); + } + else + { + var launcher = App.GetLauncher(); + foreach (var page in launcher.Pages) + { + if (page.Node.Id.Equals(_pageId, StringComparison.Ordinal)) + { + page.Popup = new Init(page.Node.Id, _repoPath, parent, _bookmark, test.StdErr); + break; + } + } + + return false; + } + } + + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(repoRoot, parent, true); + node.Bookmark = _bookmark; + await node.UpdateStatusAsync(false, null); + Welcome.Instance.Refresh(); + node.Open(); + return true; + } + + private void CollectGroups(List outs, List collections) + { + foreach (var node in collections) + { + if (!node.IsRepository) + { + outs.Add(node); + CollectGroups(outs, node.SubNodes); + } + } + } + + private string _pageId = string.Empty; + private string _repoPath = string.Empty; + private RepositoryNode _group = null; + private int _bookmark = 0; + } +} diff --git a/src/ViewModels/Popup.cs b/src/ViewModels/Popup.cs index 52e99c84c..9d800c50c 100644 --- a/src/ViewModels/Popup.cs +++ b/src/ViewModels/Popup.cs @@ -1,51 +1,62 @@ -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using System; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; -namespace SourceGit.ViewModels { - public class Popup : ObservableValidator { - public string HostPageId { - get; - set; - } - - public object View { - get; - set; - } - - public bool InProgress { +namespace SourceGit.ViewModels +{ + public class Popup : ObservableValidator, Models.ICommandLogReceiver + { + public bool InProgress + { get => _inProgress; set => SetProperty(ref _inProgress, value); } - public string ProgressDescription { + public string ProgressDescription + { get => _progressDescription; set => SetProperty(ref _progressDescription, value); } [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode")] - public bool Check() { - if (HasErrors) return false; + public bool Check() + { + if (HasErrors) + return false; ValidateAllProperties(); return !HasErrors; } - public virtual Task Sure() { - return null; + public void OnReceiveCommandLog(string data) + { + var desc = data.Trim(); + if (!string.IsNullOrEmpty(desc)) + ProgressDescription = desc; + } + + public void Cleanup() + { + _log?.Unsubscribe(this); } - protected void CallUIThread(Action action) { - Dispatcher.UIThread.Invoke(action); + public virtual bool CanStartDirectly() + { + return true; + } + + public virtual Task Sure() + { + return null; } - protected void SetProgressDescription(string description) { - CallUIThread(() => ProgressDescription = description); + protected void Use(CommandLog log) + { + _log = log; + _log.Subscribe(this); } private bool _inProgress = false; private string _progressDescription = string.Empty; + private CommandLog _log = null; } } diff --git a/src/ViewModels/PopupHost.cs b/src/ViewModels/PopupHost.cs deleted file mode 100644 index 1d1cec029..000000000 --- a/src/ViewModels/PopupHost.cs +++ /dev/null @@ -1,75 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.ViewModels { - public class PopupHost : ObservableObject { - public static PopupHost Active { - get; - set; - } = null; - - public Popup Popup { - get => _popup; - set => SetProperty(ref _popup, value); - } - - public static bool CanCreatePopup() { - return Active != null && (Active._popup == null || !Active._popup.InProgress); - } - - public static void ShowPopup(Popup popup) { - popup.HostPageId = Active.GetId(); - Active.Popup = popup; - } - - public static async void ShowAndStartPopup(Popup popup) { - popup.HostPageId = Active.GetId(); - Active.Popup = popup; - - if (!popup.Check()) return; - - popup.InProgress = true; - var task = popup.Sure(); - if (task != null) { - var finished = await task; - if (finished) { - Active.Popup = null; - } else { - popup.InProgress = false; - } - } else { - Active.Popup = null; - } - } - - public virtual string GetId() { - return string.Empty; - } - - public async void ProcessPopup() { - if (_popup != null) { - if (!_popup.Check()) return; - - _popup.InProgress = true; - var task = _popup.Sure(); - if (task != null) { - var finished = await task; - if (finished) { - Popup = null; - } else { - _popup.InProgress = false; - } - } else { - Popup = null; - } - } - } - - public void CancelPopup() { - if (_popup == null) return; - if (_popup.InProgress) return; - Popup = null; - } - - private Popup _popup = null; - } -} diff --git a/src/ViewModels/Preference.cs b/src/ViewModels/Preference.cs deleted file mode 100644 index d711622eb..000000000 --- a/src/ViewModels/Preference.cs +++ /dev/null @@ -1,304 +0,0 @@ -using Avalonia.Collections; -using CommunityToolkit.Mvvm.ComponentModel; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace SourceGit.ViewModels { - public class Preference : ObservableObject { - [JsonIgnore] - public static Preference Instance { - get { - if (_instance == null) { - if (!File.Exists(_savePath)) { - _instance = new Preference(); - } else { - try { - _instance = JsonSerializer.Deserialize(File.ReadAllText(_savePath), JsonSerializationCodeGen.Default.Preference); - } catch { - _instance = new Preference(); - } - } - } - - _instance.Repositories.RemoveAll(x => !Directory.Exists(x.FullPath)); - - if (!_instance.IsGitConfigured) { - _instance.GitInstallPath = Native.OS.FindGitExecutable(); - } - - return _instance; - } - } - - public string Locale { - get => _locale; - set { - if (SetProperty(ref _locale, value)) { - App.SetLocale(value); - } - } - } - - public string Theme { - get => _theme; - set { - if (SetProperty(ref _theme, value)) { - App.SetTheme(value); - } - } - } - - public string AvatarServer { - get => Models.AvatarManager.SelectedServer; - set { - if (Models.AvatarManager.SelectedServer != value) { - Models.AvatarManager.SelectedServer = value; - OnPropertyChanged(nameof(AvatarServer)); - } - } - } - - public int MaxHistoryCommits { - get => _maxHistoryCommits; - set => SetProperty(ref _maxHistoryCommits, value); - } - - public bool RestoreTabs { - get => _restoreTabs; - set => SetProperty(ref _restoreTabs, value); - } - - public bool UseFixedTabWidth { - get => _useFixedTabWidth; - set => SetProperty(ref _useFixedTabWidth, value); - } - - public bool UseMacOSStyle { - get => _useMacOSStyle; - set => SetProperty(ref _useMacOSStyle, value); - } - - public bool UseTwoColumnsLayoutInHistories { - get => _useTwoColumnsLayoutInHistories; - set => SetProperty(ref _useTwoColumnsLayoutInHistories, value); - } - - public bool UseCombinedTextDiff { - get => _useCombinedTextDiff; - set => SetProperty(ref _useCombinedTextDiff, 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); - } - - [JsonIgnore] - public bool IsGitConfigured { - get => !string.IsNullOrEmpty(GitInstallPath) && File.Exists(GitInstallPath); - } - - public string GitInstallPath { - get => Native.OS.GitInstallPath; - set { - if (Native.OS.GitInstallPath != value) { - Native.OS.GitInstallPath = value; - OnPropertyChanged(nameof(GitInstallPath)); - } - } - } - - public string GitDefaultCloneDir { - get => _gitDefaultCloneDir; - set => SetProperty(ref _gitDefaultCloneDir, value); - } - - public bool GitAutoFetch { - get => Commands.AutoFetch.IsEnabled; - set { - if (Commands.AutoFetch.IsEnabled != value) { - Commands.AutoFetch.IsEnabled = value; - OnPropertyChanged(nameof(GitAutoFetch)); - } - } - } - - public int ExternalMergeToolType { - get => _externalMergeToolType; - set { - var changed = SetProperty(ref _externalMergeToolType, value); - if (changed && !OperatingSystem.IsWindows() && value > 0 && value < Models.ExternalMergeTools.Supported.Count) { - var tool = Models.ExternalMergeTools.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 string ExternalMergeToolCmd { - get => _externalMergeToolCmd; - set => SetProperty(ref _externalMergeToolCmd, value); - } - - public string ExternalMergeToolDiffCmd { - get => _externalMergeToolDiffCmd; - set => SetProperty(ref _externalMergeToolDiffCmd, value); - } - - public List Repositories { - get; - set; - } = new List(); - - public AvaloniaList RepositoryNodes { - get => _repositoryNodes; - set => SetProperty(ref _repositoryNodes, value); - } - - public List OpenedTabs { - get; - set; - } = new List(); - - public static void AddNode(RepositoryNode node, RepositoryNode to = null) { - var collection = to == null ? _instance._repositoryNodes : to.SubNodes; - var list = new List(); - list.AddRange(collection); - list.Add(node); - list.Sort((l, r) => { - if (l.IsRepository != r.IsRepository) { - return l.IsRepository ? 1 : -1; - } else { - return l.Name.CompareTo(r.Name); - } - }); - - collection.Clear(); - foreach (var one in list) { - collection.Add(one); - } - } - - public static RepositoryNode FindNode(string id) { - return FindNodeRecursive(id, _instance.RepositoryNodes); - } - - public static void MoveNode(RepositoryNode node, RepositoryNode to = null) { - if (to == null && _instance._repositoryNodes.Contains(node)) return; - if (to != null && to.SubNodes.Contains(node)) return; - - RemoveNode(node); - AddNode(node, to); - } - - public static void RemoveNode(RepositoryNode node) { - RemoveNodeRecursive(node, _instance._repositoryNodes); - } - - public static Repository FindRepository(string path) { - foreach (var repo in _instance.Repositories) { - if (repo.FullPath == path) return repo; - } - return null; - } - - public static Repository AddRepository(string rootDir, string gitDir) { - var normalized = rootDir.Replace('\\', '/'); - var repo = FindRepository(normalized); - if (repo != null) { - repo.GitDir = gitDir; - return repo; - } - - repo = new Repository() { - FullPath = normalized, - GitDir = gitDir - }; - - _instance.Repositories.Add(repo); - return repo; - } - - public static void Save() { - var dir = Path.GetDirectoryName(_savePath); - if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); - - var data = JsonSerializer.Serialize(_instance, JsonSerializationCodeGen.Default.Preference); - File.WriteAllText(_savePath, data); - } - - private static RepositoryNode FindNodeRecursive(string id, AvaloniaList 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 static bool RemoveNodeRecursive(RepositoryNode node, AvaloniaList collection) { - if (collection.Contains(node)) { - collection.Remove(node); - return true; - } - - foreach (RepositoryNode one in collection) { - if (RemoveNodeRecursive(node, one.SubNodes)) return true; - } - - return false; - } - - private static Preference _instance = null; - private static readonly string _savePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SourceGit", - "preference.json"); - - private string _locale = "en_US"; - private string _theme = "Default"; - private int _maxHistoryCommits = 20000; - private bool _restoreTabs = false; - private bool _useFixedTabWidth = true; - private bool _useMacOSStyle = OperatingSystem.IsMacOS(); - private bool _useTwoColumnsLayoutInHistories = false; - private bool _useCombinedTextDiff = true; - - 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 _externalMergeToolType = 0; - private string _externalMergeToolPath = string.Empty; - private string _externalMergeToolCmd = string.Empty; - private string _externalMergeToolDiffCmd = string.Empty; - - private AvaloniaList _repositoryNodes = new AvaloniaList(); - } - - [JsonSourceGenerationOptions(WriteIndented = true, IgnoreReadOnlyFields = true, IgnoreReadOnlyProperties = true)] - [JsonSerializable(typeof(Preference))] - internal partial class JsonSerializationCodeGen : JsonSerializerContext { } -} \ No newline at end of file diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs new file mode 100644 index 000000000..25bbb431f --- /dev/null +++ b/src/ViewModels/Preferences.cs @@ -0,0 +1,837 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +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.PrepareExternalDiffMergeTool(); + _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); + } + } + + public string MonospaceFontFamily + { + get => _monospaceFontFamily; + set + { + if (SetProperty(ref _monospaceFontFamily, value) && !_isLoading) + App.SetFonts(_defaultFontFamily, value); + } + } + + 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 double Zoom + { + get => _zoom; + set => SetProperty(ref _zoom, value); + } + + public LayoutInfo Layout + { + get => _layout; + set => SetProperty(ref _layout, value); + } + + public bool ShowLocalChangesByDefault + { + get; + set; + } = false; + + public bool ShowChangesInCommitDetailByDefault + { + get; + set; + } = false; + + 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 Use24Hours + { + get => Models.DateTimeFormat.Use24Hours; + set + { + if (value != Models.DateTimeFormat.Use24Hours) + { + Models.DateTimeFormat.Use24Hours = value; + OnPropertyChanged(); + } + } + } + + public bool UseFixedTabWidth + { + get => _useFixedTabWidth; + set => SetProperty(ref _useFixedTabWidth, value); + } + + public bool UseAutoHideScrollBars + { + get => _useAutoHideScrollBars; + set => SetProperty(ref _useAutoHideScrollBars, value); + } + + public bool UseGitHubStyleAvatar + { + get => _useGitHubStyleAvatar; + set => SetProperty(ref _useGitHubStyleAvatar, value); + } + + public bool Check4UpdatesOnStartup + { + get => _check4UpdatesOnStartup; + set => SetProperty(ref _check4UpdatesOnStartup, value); + } + + public string IgnoreUpdateTag + { + get => _ignoreUpdateTag; + set => SetProperty(ref _ignoreUpdateTag, value); + } + + public bool ShowTagsInGraph + { + get => _showTagsInGraph; + set => SetProperty(ref _showTagsInGraph, value); + } + + public bool UseCompactBranchNamesInGraph + { + get => _useCompactBranchNamesInGraph; + set => SetProperty(ref _useCompactBranchNamesInGraph, value); + } + + 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 => _ignoreCRAtEOLInDiff; + set => SetProperty(ref _ignoreCRAtEOLInDiff, value); + } + + public bool UseStashAndReapplyByDefault + { + get; + set; + } = false; + + public bool EnableAutoFetch + { + get; + set; + } = false; + + public int AutoFetchInterval + { + get; + set; + } = 10; + + 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 int LFSImageActiveIdx + { + get => _lfsImageActiveIdx; + set => SetProperty(ref _lfsImageActiveIdx, value); + } + + public int ImageDiffActiveIdx + { + get => _imageDiffActiveIdx; + set => SetProperty(ref _imageDiffActiveIdx, value); + } + + public bool EnableCompactFoldersInChangesTree + { + get => _enableCompactFoldersInChangesTree; + set => SetProperty(ref _enableCompactFoldersInChangesTree, 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 Models.ChangeViewMode StashChangeViewMode + { + get => _stashChangeViewMode; + set => SetProperty(ref _stashChangeViewMode, 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 bool UseLibsecretInsteadOfGCM + { + get => Native.OS.CredentialHelper.Equals("libsecret", StringComparison.Ordinal); + set + { + var helper = value ? "libsecret" : "manager"; + if (OperatingSystem.IsLinux() && !Native.OS.CredentialHelper.Equals(helper, StringComparison.Ordinal)) + { + Native.OS.CredentialHelper = helper; + OnPropertyChanged(); + } + } + } + + public int ShellOrTerminalType + { + get => _shellOrTerminalType; + set + { + if (SetProperty(ref _shellOrTerminalType, value) && !_isLoading) + { + if (value >= 0 && value < Models.ShellOrTerminal.Supported.Count) + Native.OS.SetShellOrTerminal(Models.ShellOrTerminal.Supported[value]); + else + Native.OS.SetShellOrTerminal(null); + + OnPropertyChanged(nameof(ShellOrTerminalPath)); + OnPropertyChanged(nameof(ShellOrTerminalArgs)); + } + } + } + + public string ShellOrTerminalPath + { + get => Native.OS.ShellOrTerminal; + set + { + if (value != Native.OS.ShellOrTerminal) + { + Native.OS.ShellOrTerminal = value; + OnPropertyChanged(); + } + } + } + + public string ShellOrTerminalArgs + { + get => Native.OS.ShellOrTerminalArgs; + set + { + if (value != Native.OS.ShellOrTerminalArgs) + { + Native.OS.ShellOrTerminalArgs = value; + OnPropertyChanged(); + } + } + } + + public int ExternalMergeToolType + { + get => Native.OS.ExternalMergerType; + set + { + if (Native.OS.ExternalMergerType != value) + { + Native.OS.ExternalMergerType = value; + OnPropertyChanged(); + + if (!_isLoading) + { + Native.OS.AutoSelectExternalMergeToolExecFile(); + OnPropertyChanged(nameof(ExternalMergeToolPath)); + OnPropertyChanged(nameof(ExternalMergeToolDiffArgs)); + OnPropertyChanged(nameof(ExternalMergeToolMergeArgs)); + } + } + } + } + + public string ExternalMergeToolPath + { + get => Native.OS.ExternalMergerExecFile; + set + { + if (!Native.OS.ExternalMergerExecFile.Equals(value, StringComparison.Ordinal)) + { + Native.OS.ExternalMergerExecFile = value; + OnPropertyChanged(); + } + } + } + + public string ExternalMergeToolDiffArgs + { + get => Native.OS.ExternalDiffArgs; + set + { + if (!Native.OS.ExternalDiffArgs.Equals(value, StringComparison.Ordinal)) + { + Native.OS.ExternalDiffArgs = value; + OnPropertyChanged(); + } + } + } + + public string ExternalMergeToolMergeArgs + { + get => Native.OS.ExternalMergeArgs; + set + { + if (!Native.OS.ExternalMergeArgs.Equals(value, StringComparison.Ordinal)) + { + Native.OS.ExternalMergeArgs = value; + OnPropertyChanged(); + } + } + } + + 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 UpdateAvailableAIModels() + { + Task.Run(() => + { + foreach (var service in OpenAIServices) + { + try + { + service.FetchAvailableModels(); + } + catch + { + // Ignore errors. + } + } + }); + } + + public void Save() + { + if (_isLoading || _isReadonly) + return; + + var tmpfile = Path.Combine(Native.OS.DataDir, "preference_tmp.json"); + var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.Preferences); + File.WriteAllText(tmpfile, content); + + var finalFile = Path.Combine(Native.OS.DataDir, "preference.json"); + File.Move(tmpfile, finalFile, true); + } + + private static Preferences Load() + { + var path = Path.Combine(Native.OS.DataDir, "preference.json"); + if (!File.Exists(path)) + return new Preferences(); + + try + { + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize(stream, 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 (_shellOrTerminalType >= 0) + return; + + for (int i = 0; i < Models.ShellOrTerminal.Supported.Count; i++) + { + var shell = Models.ShellOrTerminal.Supported[i]; + if (Native.OS.TestShellOrTerminal(shell)) + { + ShellOrTerminalType = i; + break; + } + } + } + + private void PrepareExternalDiffMergeTool() + { + var mergerType = Native.OS.ExternalMergerType; + if (mergerType > 0 && mergerType < Models.ExternalMerger.Supported.Count) + { + var merger = Models.ExternalMerger.Supported[mergerType]; + if (string.IsNullOrEmpty(Native.OS.ExternalDiffArgs)) + Native.OS.ExternalDiffArgs = merger.DiffCmd; + if (string.IsNullOrEmpty(Native.OS.ExternalMergeArgs)) + Native.OS.ExternalMergeArgs = merger.MergeCmd; + } + } + + 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 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 double _defaultFontSize = 13; + private double _editorFontSize = 13; + private int _editorTabWidth = 4; + private double _zoom = 1.0; + private LayoutInfo _layout = new(); + + private int _maxHistoryCommits = 20000; + private int _subjectGuideLength = 50; + private bool _useFixedTabWidth = true; + private bool _useAutoHideScrollBars = true; + private bool _useGitHubStyleAvatar = true; + private bool _useCompactBranchNamesInGraph = true; + + 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 _ignoreCRAtEOLInDiff = true; + private bool _useSyntaxHighlighting = false; + private bool _enableDiffViewWordWrap = false; + private bool _showHiddenSymbolsInDiffView = false; + private bool _useFullTextDiff = false; + private int _lfsImageActiveIdx = 0; + private int _imageDiffActiveIdx = 0; + private bool _enableCompactFoldersInChangesTree = false; + + private Models.ChangeViewMode _unstagedChangeViewMode = Models.ChangeViewMode.List; + private Models.ChangeViewMode _stagedChangeViewMode = Models.ChangeViewMode.List; + private Models.ChangeViewMode _commitChangeViewMode = Models.ChangeViewMode.List; + private Models.ChangeViewMode _stashChangeViewMode = Models.ChangeViewMode.List; + + private string _gitDefaultCloneDir = string.Empty; + private int _shellOrTerminalType = -1; + } +} diff --git a/src/ViewModels/PruneRemote.cs b/src/ViewModels/PruneRemote.cs index c256281b5..cba2213cc 100644 --- a/src/ViewModels/PruneRemote.cs +++ b/src/ViewModels/PruneRemote.cs @@ -1,29 +1,36 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class PruneRemote : Popup { - public Models.Remote Remote { +namespace SourceGit.ViewModels +{ + public class PruneRemote : Popup + { + public Models.Remote Remote + { get; - private set; } - public PruneRemote(Repository repo, Models.Remote remote) { + public PruneRemote(Repository repo, Models.Remote remote) + { _repo = repo; Remote = remote; - View = new Views.PruneRemote() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Run `prune` on remote ..."; - return Task.Run(() => { - var succ = new Commands.Remote(_repo.FullPath).Prune(Remote.Name); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var log = _repo.CreateLog($"Prune Remote '{Remote.Name}'"); + Use(log); + + var succ = await new Commands.Remote(_repo.FullPath) + .Use(log) + .PruneAsync(Remote.Name); + + log.Complete(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/PruneWorktrees.cs b/src/ViewModels/PruneWorktrees.cs new file mode 100644 index 000000000..561168362 --- /dev/null +++ b/src/ViewModels/PruneWorktrees.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class PruneWorktrees : Popup + { + public PruneWorktrees(Repository repo) + { + _repo = repo; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Prune worktrees ..."; + + var log = _repo.CreateLog("Prune Worktrees"); + Use(log); + + await new Commands.Worktree(_repo.FullPath) + .Use(log) + .PruneAsync(); + + log.Complete(); + return true; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/Pull.cs b/src/ViewModels/Pull.cs index fab1afb32..367bca40d 100644 --- a/src/ViewModels/Pull.cs +++ b/src/ViewModels/Pull.cs @@ -2,126 +2,215 @@ using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Pull : Popup { +namespace SourceGit.ViewModels +{ + public class Pull : Popup + { public List Remotes => _repo.Remotes; - public Models.Branch Current => _current; + public Models.Branch Current { get; } - public bool HasSpecifiedRemoteBranch { + public bool HasSpecifiedRemoteBranch + { get; private set; } - public Models.Remote SelectedRemote { + public Models.Remote SelectedRemote + { get => _selectedRemote; - set { - if (SetProperty(ref _selectedRemote, value)) { - var branches = new List(); - foreach (var branch in _repo.Branches) { - if (branch.Remote == value.Name) branches.Add(branch); - } - RemoteBranches = branches; - SelectedBranch = branches.Count > 0 ? branches[0] : null; - } + set + { + if (SetProperty(ref _selectedRemote, value)) + PostRemoteSelected(); } } - public List RemoteBranches { + public List RemoteBranches + { get => _remoteBranches; private set => SetProperty(ref _remoteBranches, value); } [Required(ErrorMessage = "Remote branch to pull is required!!!")] - public Models.Branch SelectedBranch { + public Models.Branch SelectedBranch + { get => _selectedBranch; - set => SetProperty(ref _selectedBranch, value); + set => SetProperty(ref _selectedBranch, value, true); } - public bool UseRebase { - get; - set; + public bool HasLocalChanges + { + get => _repo.LocalChangesCount > 0; } - public bool AutoStash { + public Models.DealWithLocalChanges DealWithLocalChanges + { get; set; } - public Pull(Repository repo, Models.Branch specifiedRemoteBranch) { + public bool UseRebase + { + get => _repo.UIStates.PreferRebaseInsteadOfMerge; + set => _repo.UIStates.PreferRebaseInsteadOfMerge = value; + } + + public Pull(Repository repo, Models.Branch specifiedRemoteBranch) + { _repo = repo; - _current = repo.Branches.Find(x => x.IsCurrent); - - if (specifiedRemoteBranch != null) { + Current = repo.CurrentBranch; + + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; + + 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 { - if (!string.IsNullOrEmpty(_current.Upstream)) { - foreach (var branch in repo.Branches) { - if (!branch.IsLocal && _current.Upstream == branch.FullName) { - _selectedRemote = repo.Remotes.Find(x => x.Name == branch.Remote); - _selectedBranch = branch; - break; - } + } + else + { + Models.Remote autoSelectedRemote = null; + 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) + { + Models.Remote remote = null; + 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; } + } - // Make sure remote is exists. - if (_selectedRemote == null) { - _selectedRemote = repo.Remotes[0]; - _selectedBranch = null; - HasSpecifiedRemoteBranch = false; + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog("Pull"); + Use(log); + + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); + var needPopStash = false; + if (changes > 0) + { + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) + { + // Do nothing, just let the pull command fail and show the error to user + } + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) + { + var succ = await new Commands.Stash(_repo.FullPath).Use(log).PushAsync("PULL_AUTO_STASH", false); + if (!succ) + { + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return false; + } + + needPopStash = true; + } + else + { + await Commands.Discard.AllAsync(_repo.FullPath, true, false, false, log); + } } - _remoteBranches = new List(); - foreach (var branch in _repo.Branches) { - if (branch.Remote == _selectedRemote.Name) _remoteBranches.Add(branch); + bool rs = await new Commands.Pull( + _repo.FullPath, + _selectedRemote.Name, + !string.IsNullOrEmpty(Current.Upstream) && Current.Upstream.Equals(_selectedBranch.FullName) ? string.Empty : _selectedBranch.Name, + UseRebase).Use(log).RunAsync(); + if (rs) + { + await _repo.AutoUpdateSubmodulesAsync(log); + + if (needPopStash) + await new Commands.Stash(_repo.FullPath).Use(log).PopAsync("stash@{0}"); } - if (_selectedBranch == null && _remoteBranches.Count > 0) { - _selectedBranch = _remoteBranches[0]; + log.Complete(); + + if (_repo.SelectedViewIndex == 0) + { + var head = await new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResultAsync(); + _repo.NavigateToCommit(head, true); } - View = new Views.Pull() { DataContext = this }; + return rs; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - return Task.Run(() => { - var needPopStash = false; - if (AutoStash && _repo.WorkingCopyChangesCount > 0) { - SetProgressDescription("Adding untracked changes..."); - var succ = new Commands.Add(_repo.FullPath).Exec(); - if (succ) { - SetProgressDescription("Stash local changes..."); - succ = new Commands.Stash(_repo.FullPath).Push("PULL_AUTO_STASH"); - } - - if (!succ) { - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return false; - } + private void PostRemoteSelected() + { + var remoteName = _selectedRemote.Name; + var branches = new List(); + foreach (var branch in _repo.Branches) + { + if (branch.Remote == remoteName) + branches.Add(branch); + } - needPopStash = true; + 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; + } } + } - SetProgressDescription($"Pull {_selectedRemote.Name}/{_selectedBranch.Name}..."); - var rs = new Commands.Pull(_repo.FullPath, _selectedRemote.Name, _selectedBranch.Name, UseRebase, SetProgressDescription).Exec(); - if (rs && needPopStash) { - SetProgressDescription("Re-apply local changes..."); - rs = new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); + if (!autoSelectedBranch) + { + foreach (var branch in branches) + { + if (Current.Name == branch.Name) + { + SelectedBranch = branch; + autoSelectedBranch = true; + break; + } } + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return rs; - }); + if (!autoSelectedBranch) + SelectedBranch = null; } - private Repository _repo = null; - private Models.Branch _current = null; + private readonly Repository _repo = 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 index 73f8c42ac..b4d9029a9 100644 --- a/src/ViewModels/Push.cs +++ b/src/ViewModels/Push.cs @@ -1,146 +1,235 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Push : Popup { - public bool HasSpecifiedLocalBranch { +namespace SourceGit.ViewModels +{ + public class Push : Popup + { + public bool HasSpecifiedLocalBranch + { get; private set; } [Required(ErrorMessage = "Local branch is required!!!")] - public Models.Branch SelectedLocalBranch { + public Models.Branch SelectedLocalBranch + { get => _selectedLocalBranch; - set { - if (SetProperty(ref _selectedLocalBranch, value)) { - // If selected local branch has upstream branch. Try to find it's remote. - if (!string.IsNullOrEmpty(value.Upstream)) { - var branch = _repo.Branches.Find(x => x.FullName == value.Upstream); - if (branch != null) { - var remote = _repo.Remotes.Find(x => x.Name == branch.Remote); - if (remote != null && remote != _selectedRemote) { - SelectedRemote = remote; - return; - } - } - } - - // Re-generate remote branches and auto-select remote branches. + set + { + if (SetProperty(ref _selectedLocalBranch, value, true)) AutoSelectBranchByRemote(); - } } } - public List LocalBranches { + public List LocalBranches + { get; - private set; } - public List Remotes { + public List Remotes + { get => _repo.Remotes; } [Required(ErrorMessage = "Remote is required!!!")] - public Models.Remote SelectedRemote { + public Models.Remote SelectedRemote + { get => _selectedRemote; - set { - if (SetProperty(ref _selectedRemote, value)) AutoSelectBranchByRemote(); + set + { + if (SetProperty(ref _selectedRemote, value, true)) + AutoSelectBranchByRemote(); } } - public List RemoteBranches { + public List RemoteBranches + { get => _remoteBranches; private set => SetProperty(ref _remoteBranches, value); } [Required(ErrorMessage = "Remote branch is required!!!")] - public Models.Branch SelectedRemoteBranch { + public Models.Branch SelectedRemoteBranch + { get => _selectedRemoteBranch; - set => SetProperty(ref _selectedRemoteBranch, value); + set + { + if (SetProperty(ref _selectedRemoteBranch, value, true)) + IsSetTrackOptionVisible = value != null && (value.Head == null || _selectedLocalBranch.Upstream != value.FullName); + } + } + + public bool IsSetTrackOptionVisible + { + get => _isSetTrackOptionVisible; + private set => SetProperty(ref _isSetTrackOptionVisible, value); } - public bool PushAllTags { + public bool Tracking + { + get => _tracking; + set => SetProperty(ref _tracking, value); + } + + public bool IsCheckSubmodulesVisible + { + get => _repo.Submodules.Count > 0; + } + + public bool CheckSubmodules + { get; set; + } = true; + + public bool PushAllTags + { + get => _repo.UIStates.PushAllTags; + set => _repo.UIStates.PushAllTags = value; } - public bool ForcePush { + public bool ForcePush + { get; set; } - public Push(Repository repo, Models.Branch localBranch) { + 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; + Models.Branch current = null; + 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 (localBranch != null) + { + if (LocalBranches.Count == 0) + LocalBranches.Add(localBranch); + _selectedLocalBranch = localBranch; HasSpecifiedLocalBranch = true; - } else { + } + 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) { + if (!string.IsNullOrEmpty(_selectedLocalBranch?.Upstream) && !_selectedLocalBranch.IsUpstreamGone) + { + _tracking = false; + + foreach (var branch in repo.Branches) + { + if (!branch.IsLocal && _selectedLocalBranch.Upstream == branch.FullName) + { _selectedRemote = repo.Remotes.Find(x => x.Name == branch.Remote); break; } } } + else + { + _tracking = true; + } + + // Set default remote to the first if it has not been set. + if (_selectedRemote == null) + { + Models.Remote remote = null; + if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + remote = repo.Remotes.Find(x => x.Name == _repo.Settings.DefaultRemote); - // Set default remote to the first if haven't been set. - if (_selectedRemote == null) _selectedRemote = repo.Remotes[0]; + _selectedRemote = remote ?? repo.Remotes[0]; + } // Auto select preferred remote branch. AutoSelectBranchByRemote(); + } + + public void PushToNewBranch(string name) + { + var exist = _remoteBranches.Find(x => x.Name.Equals(name, StringComparison.Ordinal)); + if (exist != null) + { + SelectedRemoteBranch = exist; + return; + } + + var fake = new Models.Branch() + { + Name = name, + Remote = _selectedRemote.Name, + }; + var collection = new List(); + collection.AddRange(_remoteBranches); + collection.Add(fake); + RemoteBranches = collection; + SelectedRemoteBranch = fake; + } - View = new Views.Push() { DataContext = this }; + public override bool CanStartDirectly() + { + return !string.IsNullOrEmpty(_selectedRemoteBranch?.Head); } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); - var remoteBranchName = _selectedRemoteBranch.Name.Replace(" (new)", ""); + var remoteBranchName = _selectedRemoteBranch.Name; ProgressDescription = $"Push {_selectedLocalBranch.Name} -> {_selectedRemote.Name}/{remoteBranchName} ..."; - return Task.Run(() => { - var succ = new Commands.Push( - _repo.FullPath, - _selectedLocalBranch.Name, - _selectedRemote.Name, - remoteBranchName, - PushAllTags, - ForcePush, - string.IsNullOrEmpty(_selectedLocalBranch.Upstream), - SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); - } - - private void AutoSelectBranchByRemote() { + var log = _repo.CreateLog("Push"); + Use(log); + + var succ = await new Commands.Push( + _repo.FullPath, + _selectedLocalBranch.Name, + _selectedRemote.Name, + remoteBranchName, + PushAllTags, + _repo.Submodules.Count > 0 && CheckSubmodules, + _isSetTrackOptionVisible && _tracking, + ForcePush).Use(log).RunAsync(); + + log.Complete(); + return succ; + } + + private void AutoSelectBranchByRemote() + { + if (_selectedRemote == null || _selectedLocalBranch == null) + return; + // Gather branches. var branches = new List(); - foreach (var branch in _repo.Branches) { - if (branch.Remote == _selectedRemote.Name) branches.Add(branch); + foreach (var branch in _repo.Branches) + { + if (!branch.IsLocal && _selectedRemote.Name.Equals(branch.Remote, StringComparison.Ordinal)) + branches.Add(branch); } - // If selected local branch has upstream branch. Try to find it in current remote branches. - if (!string.IsNullOrEmpty(_selectedLocalBranch.Upstream)) { - foreach (var branch in branches) { - if (_selectedLocalBranch.Upstream == branch.FullName) { + // 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.Equals(branch.FullName, StringComparison.Ordinal)) + { RemoteBranches = branches; SelectedRemoteBranch = branch; return; @@ -148,9 +237,11 @@ private void AutoSelectBranchByRemote() { } } - // Find best remote branch by name. - foreach (var branch in branches) { - if (_selectedLocalBranch.Name == branch.Name) { + // Try to find a remote branch with the same name of selected local branch. + foreach (var branch in branches) + { + if (_selectedLocalBranch.Name.Equals(branch.Name, StringComparison.Ordinal)) + { RemoteBranches = branches; SelectedRemoteBranch = branch; return; @@ -158,8 +249,9 @@ private void AutoSelectBranchByRemote() { } // Add a fake new branch. - var fake = new Models.Branch() { - Name = $"{_selectedLocalBranch.Name} (new)", + var fake = new Models.Branch() + { + Name = _selectedLocalBranch.Name, Remote = _selectedRemote.Name, }; branches.Add(fake); @@ -167,10 +259,12 @@ private void AutoSelectBranchByRemote() { SelectedRemoteBranch = fake; } - private Repository _repo = null; + private readonly Repository _repo = null; private Models.Branch _selectedLocalBranch = null; private Models.Remote _selectedRemote = null; - private List _remoteBranches = new List(); + private List _remoteBranches = []; private Models.Branch _selectedRemoteBranch = null; + private bool _isSetTrackOptionVisible = false; + private bool _tracking = true; } } diff --git a/src/ViewModels/PushRevision.cs b/src/ViewModels/PushRevision.cs new file mode 100644 index 000000000..4d49b5272 --- /dev/null +++ b/src/ViewModels/PushRevision.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class PushRevision : Popup + { + public Models.Commit Revision + { + get; + } + + public Models.Branch RemoteBranch + { + get; + } + + public bool Force + { + get; + set; + } + + public PushRevision(Repository repo, Models.Commit revision, Models.Branch remoteBranch) + { + _repo = repo; + Revision = revision; + RemoteBranch = remoteBranch; + Force = false; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Push {Revision.SHA.AsSpan(0, 10)} -> {RemoteBranch.FriendlyName} ..."; + + var log = _repo.CreateLog("Push Revision"); + Use(log); + + var succ = await new Commands.Push( + _repo.FullPath, + Revision.SHA, + RemoteBranch.Remote, + RemoteBranch.Name, + false, + false, + false, + Force).Use(log).RunAsync(); + + log.Complete(); + return succ; + } + + private readonly Repository _repo; + } +} diff --git a/src/ViewModels/PushTag.cs b/src/ViewModels/PushTag.cs index c3a8172e3..d59548254 100644 --- a/src/ViewModels/PushTag.cs +++ b/src/ViewModels/PushTag.cs @@ -1,40 +1,72 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class PushTag : Popup { - public Models.Tag Target { +namespace SourceGit.ViewModels +{ + public class PushTag : Popup + { + public Models.Tag Target + { get; - private set; } - public List Remotes { + public List Remotes + { get => _repo.Remotes; } - public Models.Remote SelectedRemote { + public Models.Remote SelectedRemote + { get; set; } - public PushTag(Repository repo, Models.Tag target) { + 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]; - View = new Views.PushTag() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Pushing tag '{Target.Name}' to remote '{SelectedRemote.Name}' ..."; + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Pushing tag ..."; + + var log = _repo.CreateLog("Push Tag"); + Use(log); + + var succ = true; + var tag = $"refs/tags/{Target.Name}"; + if (_pushAllRemotes) + { + foreach (var remote in _repo.Remotes) + { + succ = await new Commands.Push(_repo.FullPath, remote.Name, tag, false) + .Use(log) + .RunAsync(); + if (!succ) + break; + } + } + else + { + succ = await new Commands.Push(_repo.FullPath, SelectedRemote.Name, tag, false) + .Use(log) + .RunAsync(); + } - return Task.Run(() => { - var succ = new Commands.Push(_repo.FullPath, SelectedRemote.Name, Target.Name, false).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + log.Complete(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; + private bool _pushAllRemotes = false; } } diff --git a/src/ViewModels/Rebase.cs b/src/ViewModels/Rebase.cs index 07c579c98..2213a55af 100644 --- a/src/ViewModels/Rebase.cs +++ b/src/ViewModels/Rebase.cs @@ -1,52 +1,128 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; +using Avalonia.Threading; -namespace SourceGit.ViewModels { - public class Rebase : Popup { - public Models.Branch Current { +namespace SourceGit.ViewModels +{ + public enum RebaseTestingState + { + Disabled = 0, + Testing, + WillCauseConflicts, + UnknownError, + NoConflicts, + } + + public class Rebase : Popup + { + public Models.Branch Current + { get; private set; } - public object On { + public object On + { get; private set; } - public bool AutoStash { + public bool AutoStash + { get; set; } - public Rebase(Repository repo, Models.Branch current, Models.Branch on) { + public bool NoVerify + { + get; + set; + } + + public RebaseTestingState TestingState + { + get => _testingState; + private set => SetProperty(ref _testingState, value); + } + + public Rebase(Repository repo, Models.Branch current, Models.Branch on) + { _repo = repo; _revision = on.Head; Current = current; On = on; AutoStash = true; - View = new Views.Rebase() { DataContext = this }; + + Test(); } - public Rebase(Repository repo, Models.Branch current, Models.Commit on) { + public Rebase(Repository repo, Models.Branch current, Models.Commit on) + { _repo = repo; _revision = on.SHA; Current = current; On = on; AutoStash = true; - View = new Views.Rebase() { DataContext = this }; + + Test(); } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + _repo.ClearCommitMessage(); ProgressDescription = "Rebasing ..."; - return Task.Run(() => { - var succ = new Commands.Rebase(_repo.FullPath, _revision, AutoStash).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; + var log = _repo.CreateLog("Rebase"); + Use(log); + + await new Commands.Rebase(_repo.FullPath, _revision, AutoStash, NoVerify) + .Use(log) + .ExecAsync(); + + log.Complete(); + return true; + } + + private void Test() + { + if (Native.OS.GitVersion < Models.GitVersions.REPLAY) + return; + + var head = Current.Head; + TestingState = RebaseTestingState.Testing; + Task.Run(async () => + { + var mergeBase = await new Commands.MergeBase(_repo.FullPath, head, _revision) + .GetResultAsync() + .ConfigureAwait(false); + + if (string.IsNullOrEmpty(mergeBase)) + { + Dispatcher.UIThread.Post(() => TestingState = RebaseTestingState.UnknownError); + return; + } + else if (head.Equals(mergeBase, StringComparison.Ordinal)) + { + Dispatcher.UIThread.Post(() => TestingState = RebaseTestingState.NoConflicts); + return; + } + + var exitCode = await new Commands.Replay(_repo.FullPath, _revision, $"{mergeBase}..{head}") + .GetExitCodeAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => TestingState = exitCode switch + { + 0 => RebaseTestingState.NoConflicts, + 1 => RebaseTestingState.WillCauseConflicts, + _ => RebaseTestingState.UnknownError, + }); }); } - private Repository _repo = null; - private string _revision = string.Empty; + private readonly Repository _repo; + private readonly string _revision; + private RebaseTestingState _testingState = RebaseTestingState.Disabled; } } diff --git a/src/ViewModels/RemoveWorktree.cs b/src/ViewModels/RemoveWorktree.cs new file mode 100644 index 000000000..40c3b87b3 --- /dev/null +++ b/src/ViewModels/RemoveWorktree.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class RemoveWorktree : Popup + { + public Worktree Target + { + get; + } + + public bool Force + { + get; + set; + } = false; + + public RemoveWorktree(Repository repo, Worktree target) + { + _repo = repo; + Target = target; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Remove worktree ..."; + + var log = _repo.CreateLog("Remove worktree"); + Use(log); + + var succ = await new Commands.Worktree(_repo.FullPath) + .Use(log) + .RemoveAsync(Target.FullPath, Force); + + log.Complete(); + return succ; + } + + private readonly Repository _repo = null; + } +} diff --git a/src/ViewModels/RenameBranch.cs b/src/ViewModels/RenameBranch.cs index b3056853d..dbca651e7 100644 --- a/src/ViewModels/RenameBranch.cs +++ b/src/ViewModels/RenameBranch.cs @@ -1,54 +1,72 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class RenameBranch : Popup { - public Models.Branch Target { +namespace SourceGit.ViewModels +{ + public class RenameBranch : Popup + { + public Models.Branch Target + { get; - private set; } [Required(ErrorMessage = "Branch name is required!!!")] - [RegularExpression(@"^[\w\-/\.]+$", ErrorMessage = "Bad branch name format!")] + [RegularExpression(@"^[\w\-/\.#\+]+$", ErrorMessage = "Bad branch name format!")] [CustomValidation(typeof(RenameBranch), nameof(ValidateBranchName))] - public string Name { + public string Name + { get => _name; set => SetProperty(ref _name, value, true); } - public RenameBranch(Repository repo, Models.Branch target) { + public RenameBranch(Repository repo, Models.Branch target) + { _repo = repo; _name = target.Name; Target = target; - View = new Views.RenameBranch() { DataContext = this }; } - public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) { - if (ctx.ObjectInstance is RenameBranch rename) { - foreach (var b in rename._repo.Branches) { - if (b != rename.Target && b.Name == name) { + public static ValidationResult ValidateBranchName(string name, ValidationContext ctx) + { + if (ctx.ObjectInstance is RenameBranch rename) + { + foreach (var b in rename._repo.Branches) + { + if (b.IsLocal && b != rename.Target && b.Name.Equals(name, StringComparison.Ordinal)) return new ValidationResult("A branch with same name already exists!!!"); - } } } return ValidationResult.Success; } - public override Task Sure() { - if (_name == Target.Name) return null; + public override async Task Sure() + { + if (Target.Name.Equals(_name, StringComparison.Ordinal)) + return true; - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Rename '{Target.Name}'"; - return Task.Run(() => { - var succ = Commands.Branch.Rename(_repo.FullPath, Target.Name, _name); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var log = _repo.CreateLog($"Rename Branch '{Target.Name}'"); + Use(log); + + var isCurrent = Target.IsCurrent; + var oldName = Target.FullName; + + var succ = await new Commands.Branch(_repo.FullPath, Target.Name) + .Use(log) + .RenameAsync(_name); + + if (succ) + _repo.RefreshAfterRenameBranch(Target, _name); + + log.Complete(); + return succ; } - private Repository _repo = null; - private string _name = string.Empty; + private readonly Repository _repo; + private string _name; } } diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index c19d0a253..99a3a47e2 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -1,1082 +1,1973 @@ -using Avalonia.Collections; -using Avalonia.Controls; -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using System; +using System; using System.Collections.Generic; using System.IO; -using System.Text.Json.Serialization; +using System.Text; +using System.Threading; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Repository : ObservableObject, Models.IRepository { - public string FullPath { - get => _fullpath; - set { - if (value != null) { - var normalized = value.Replace('\\', '/'); - SetProperty(ref _fullpath, normalized); - } else { - SetProperty(ref _fullpath, null); - } - } +using Avalonia.Collections; +using Avalonia.Threading; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Repository : ObservableObject, Models.IRepository + { + public bool IsBare + { + get; } - public string GitDir { - get => _gitDir; - set => SetProperty(ref _gitDir, value); + public string FullPath + { + get; } - public AvaloniaList Filters { + public string GitDir + { get; - set; - } = new AvaloniaList(); + } + + public Models.RepositorySettings Settings + { + get => _settings; + } + + public Models.RepositoryUIStates UIStates + { + get => _uiStates; + } - public AvaloniaList CommitMessages { + public Models.GitFlow GitFlow + { get; set; - } = new AvaloniaList(); + } = new(); - [JsonIgnore] - public bool IsVSCodeFound { - get => !string.IsNullOrEmpty(Native.OS.VSCodeExecutableFile); + public Models.FilterMode HistoryFilterMode + { + get => _historyFilterMode; + private set => SetProperty(ref _historyFilterMode, value); + } + + public bool IsHistoryFiltersCollapsed + { + get => _uiStates.IsHistoryFiltersCollapsed; + set + { + if (value != _uiStates.IsHistoryFiltersCollapsed) + { + _uiStates.IsHistoryFiltersCollapsed = value; + OnPropertyChanged(); + } + } } - [JsonIgnore] - public Models.GitFlow GitFlow { - get => _gitflow; - set => SetProperty(ref _gitflow, value); + public bool HasAllowedSignersFile + { + get => _hasAllowedSignersFile; } - [JsonIgnore] - public int SelectedViewIndex { + 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; - } + set + { + if (SetProperty(ref _selectedViewIndex, value)) + { + OnPropertyChanged(nameof(IsHistoriesVisible)); + OnPropertyChanged(nameof(IsWorkingCopyVisible)); + OnPropertyChanged(nameof(IsStashesVisible)); + } + } + } + + public Histories Histories + { + get => _histories; + } + + public WorkingCopy WorkingCopy + { + get => _workingCopy; + } + + public StashesPage StashesPage + { + get => _stashesPage; + } + + public bool IsHistoriesVisible + { + get => SelectedViewIndex == 0; + } + + public bool IsWorkingCopyVisible + { + get => SelectedViewIndex == 1; + } + + public bool IsStashesVisible + { + get => SelectedViewIndex == 2; + } + + public bool EnableTopoOrderInHistory + { + get => _uiStates.EnableTopoOrderInHistory; + set + { + if (value != _uiStates.EnableTopoOrderInHistory) + { + _uiStates.EnableTopoOrderInHistory = value; + RefreshCommits(); } } } - [JsonIgnore] - public object SelectedView { - get => _selectedView; - set => SetProperty(ref _selectedView, value); + public Models.HistoryShowFlags HistoryShowFlags + { + get => _uiStates.HistoryShowFlags; + private set + { + if (value != _uiStates.HistoryShowFlags) + { + _uiStates.HistoryShowFlags = value; + RefreshCommits(); + } + } + } + + 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(); + } + } } - [JsonIgnore] - public List Remotes { + public List Remotes + { get => _remotes; private set => SetProperty(ref _remotes, value); } - [JsonIgnore] - public List Branches { + public List Branches + { get => _branches; private set => SetProperty(ref _branches, value); } - [JsonIgnore] - public List LocalBranchTrees { + public Models.Branch CurrentBranch + { + get => _currentBranch; + private set + { + var oldHead = _currentBranch?.Head; + if (SetProperty(ref _currentBranch, value)) + { + _histories?.NotifyCurrentBranchChanged(); + if (value != null && !value.Head.Equals(oldHead, StringComparison.Ordinal) && _workingCopy is { UseAmend: true }) + _workingCopy.UseAmend = false; + } + } + } + + public List LocalBranchTrees + { get => _localBranchTrees; private set => SetProperty(ref _localBranchTrees, value); } - [JsonIgnore] - public List RemoteBranchTrees { + public List RemoteBranchTrees + { get => _remoteBranchTrees; private set => SetProperty(ref _remoteBranchTrees, value); } - [JsonIgnore] - public List Tags { + public List Worktrees + { + get => _worktrees; + private set => SetProperty(ref _worktrees, value); + } + + public List Tags + { get => _tags; private set => SetProperty(ref _tags, value); } - [JsonIgnore] - public List Submodules { + public bool ShowTagsAsTree + { + get => _uiStates.ShowTagsAsTree; + set + { + if (value != _uiStates.ShowTagsAsTree) + { + _uiStates.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); } - [JsonIgnore] - public int WorkingCopyChangesCount { - get => _workingCopy == null ? 0 : _workingCopy.Count; + public bool ShowSubmodulesAsTree + { + get => _uiStates.ShowSubmodulesAsTree; + set + { + if (value != _uiStates.ShowSubmodulesAsTree) + { + _uiStates.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); } - [JsonIgnore] - public int StashesCount { - get => _stashesPage == null ? 0 : _stashesPage.Count; + public int LocalBranchesCount + { + get => _localBranchesCount; + private set => SetProperty(ref _localBranchesCount, value); } - [JsonIgnore] - public bool IsConflictBarVisible { - get => _isConflictBarVisible; - private set => SetProperty(ref _isConflictBarVisible, value); + public bool IncludeUntracked + { + get => _uiStates.IncludeUntrackedInLocalChanges; + set + { + if (value != _uiStates.IncludeUntrackedInLocalChanges) + { + _uiStates.IncludeUntrackedInLocalChanges = value; + OnPropertyChanged(); + RefreshWorkingCopyChanges(); + } + } + } + + public bool IsSearchingCommits + { + get => _isSearchingCommits; + set + { + if (SetProperty(ref _isSearchingCommits, value)) + { + if (value) + SelectedViewIndex = 0; + else + _searchCommitContext.EndSearch(); + } + } + } + + public SearchCommitContext SearchCommitContext + { + get => _searchCommitContext; + } + + public bool IsLocalBranchGroupExpanded + { + get => _uiStates.IsLocalBranchesExpandedInSideBar; + set + { + if (value != _uiStates.IsLocalBranchesExpandedInSideBar) + { + _uiStates.IsLocalBranchesExpandedInSideBar = value; + OnPropertyChanged(); + } + } } - [JsonIgnore] - public bool HasUnsolvedConflict { - get => _hasUnsolvedConflict; - private set => SetProperty(ref _hasUnsolvedConflict, value); + public bool IsRemoteGroupExpanded + { + get => _uiStates.IsRemotesExpandedInSideBar; + set + { + if (value != _uiStates.IsRemotesExpandedInSideBar) + { + _uiStates.IsRemotesExpandedInSideBar = value; + OnPropertyChanged(); + } + } } - [JsonIgnore] - public bool CanCommitWithPush { - get => _canCommitWithPush; - private set => SetProperty(ref _canCommitWithPush, value); + public bool IsTagGroupExpanded + { + get => _uiStates.IsTagsExpandedInSideBar; + set + { + if (value != _uiStates.IsTagsExpandedInSideBar) + { + _uiStates.IsTagsExpandedInSideBar = value; + OnPropertyChanged(); + } + } } - [JsonIgnore] - public bool IncludeUntracked { - get => _includeUntracked; - set { - if (SetProperty(ref _includeUntracked, value)) { - Task.Run(RefreshWorkingCopyChanges); + public bool IsSubmoduleGroupExpanded + { + get => _uiStates.IsSubmodulesExpandedInSideBar; + set + { + if (value != _uiStates.IsSubmodulesExpandedInSideBar) + { + _uiStates.IsSubmodulesExpandedInSideBar = value; + OnPropertyChanged(); } } } - [JsonIgnore] - public bool IsSearching { - get => _isSearching; - set { - if (SetProperty(ref _isSearching, value)) { - SearchedCommits = new List(); - SearchCommitFilter = string.Empty; - if (value) SelectedViewIndex = 0; + public bool IsWorktreeGroupExpanded + { + get => _uiStates.IsWorktreeExpandedInSideBar; + set + { + if (value != _uiStates.IsWorktreeExpandedInSideBar) + { + _uiStates.IsWorktreeExpandedInSideBar = value; + OnPropertyChanged(); } } } - [JsonIgnore] - public string SearchCommitFilter { - get => _searchCommitFilter; - set => SetProperty(ref _searchCommitFilter, value); + public bool IsSortingLocalBranchByName + { + get => _uiStates.LocalBranchSortMode == Models.BranchSortMode.Name; + set + { + _uiStates.LocalBranchSortMode = value ? Models.BranchSortMode.Name : Models.BranchSortMode.CommitterDate; + OnPropertyChanged(); + + var builder = BuildBranchTree(_branches, _remotes); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + } + } + + public bool IsSortingRemoteBranchByName + { + get => _uiStates.RemoteBranchSortMode == Models.BranchSortMode.Name; + set + { + _uiStates.RemoteBranchSortMode = value ? Models.BranchSortMode.Name : Models.BranchSortMode.CommitterDate; + OnPropertyChanged(); + + var builder = BuildBranchTree(_branches, _remotes); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + } + } + + public bool IsSortingTagsByName + { + get => _uiStates.TagSortMode == Models.TagSortMode.Name; + set + { + _uiStates.TagSortMode = value ? Models.TagSortMode.Name : Models.TagSortMode.CreatorDate; + OnPropertyChanged(); + VisibleTags = BuildVisibleTags(); + } } - [JsonIgnore] - public List SearchedCommits { - get => _searchedCommits; - set => SetProperty(ref _searchedCommits, value); + public InProgressContext InProgressContext + { + get => _workingCopy?.InProgressContext; } - public void Open() { - _watcher = new Models.Watcher(this); + 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 AvaloniaList IssueTrackers + { + get; + } = []; + + public AvaloniaList Logs + { + get; + } = []; + + public Repository(bool isBare, string path, string gitDir) + { + IsBare = isBare; + FullPath = path.Replace('\\', '/').TrimEnd('/'); + GitDir = gitDir.Replace('\\', '/').TrimEnd('/'); + + var commonDirFile = Path.Combine(GitDir, "commondir"); + var isWorktree = GitDir.IndexOf("/worktrees/", StringComparison.Ordinal) > 0 && + File.Exists(commonDirFile); + + if (isWorktree) + { + var commonDir = File.ReadAllText(commonDirFile).Trim(); + if (Path.IsPathRooted(commonDir)) + commonDir = new DirectoryInfo(commonDir).FullName; + else + commonDir = new DirectoryInfo(Path.Combine(GitDir, commonDir)).FullName; + + _gitCommonDir = commonDir.Replace('\\', '/').TrimEnd('/'); + } + else + { + _gitCommonDir = GitDir; + } + + _settings = Models.RepositorySettings.Get(_gitCommonDir); + _uiStates = Models.RepositoryUIStates.Load(GitDir); + } + + public void Open() + { + try + { + _watcher = new Models.Watcher(this, FullPath, _gitCommonDir); + } + catch (Exception ex) + { + SendNotification($"Failed to start watcher for repository: '{FullPath}'. You may need to press 'F5' to refresh repository manually!\n\nReason: {ex.Message}", true); + } + + _historyFilterMode = _uiStates.GetHistoryFilterMode(); _histories = new Histories(this); - _workingCopy = new WorkingCopy(this); + _workingCopy = new WorkingCopy(this) { CommitMessage = _uiStates.LastCommitMessage }; _stashesPage = new StashesPage(this); - _selectedView = _histories; - _selectedViewIndex = 0; - _isConflictBarVisible = false; - _hasUnsolvedConflict = false; - - Task.Run(() => { - RefreshBranches(); - RefreshTags(); - RefreshCommits(); - }); + _searchCommitContext = new SearchCommitContext(this); + _selectedViewIndex = Preferences.Instance.ShowLocalChangesByDefault ? 1 : 0; + _lastFetchTime = DateTime.Now; + _autoFetchTimer = new Timer(AutoFetchByTimer, null, 5000, 5000); + RefreshAll(); + } + + public void Close() + { + var commitMessage = _workingCopy.CommitMessage; + if (!string.IsNullOrEmpty(commitMessage) && _workingCopy.InProgressContext != null) + File.WriteAllText(Path.Combine(GitDir, "MERGE_MSG"), commitMessage); + + _uiStates.LastCommitMessage = commitMessage; + _uiStates.Save(); + + if (_cancellationRefreshBranches is { IsCancellationRequested: false }) + _cancellationRefreshBranches.Cancel(); + if (_cancellationRefreshTags is { IsCancellationRequested: false }) + _cancellationRefreshTags.Cancel(); + if (_cancellationRefreshWorkingCopyChanges is { IsCancellationRequested: false }) + _cancellationRefreshWorkingCopyChanges.Cancel(); + if (_cancellationRefreshCommits is { IsCancellationRequested: false }) + _cancellationRefreshCommits.Cancel(); + if (_cancellationRefreshStashes is { IsCancellationRequested: false }) + _cancellationRefreshStashes.Cancel(); + + _watcher?.Dispose(); + _autoFetchTimer.Dispose(); + } + + public void SendNotification(string message, bool isError = false) + { + Models.Notification.Send(FullPath, message, isError); + } + + 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 async Task ShowAndStartPopupAsync(Popup popup) + { + var page = GetOwnerPage(); + page.Popup = popup; + + if (popup.CanStartDirectly()) + await page.ProcessPopupAsync(); + } + + public bool IsGitFlowEnabled() + { + return GitFlow is { IsValid: true } && + _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.ProductionBranch, StringComparison.Ordinal)) != null && + _branches.Find(x => x.IsLocal && x.Name.Equals(GitFlow.DevelopmentBranch, 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; + } - Task.Run(RefreshSubmodules); - Task.Run(RefreshWorkingCopyChanges); - Task.Run(RefreshStashes); - Task.Run(RefreshGitFlow); + public bool IsLFSEnabled() + { + var path = Path.Combine(FullPath, ".git", "hooks", "pre-push"); + if (!File.Exists(path)) + return false; + + try + { + var content = File.ReadAllText(path); + return content.Contains("git lfs pre-push"); + } + catch + { + return false; + } } - public void Close() { - SelectedView = 0.0; // Do NOT modify. Used to remove exists widgets for GC.Collect + public async Task InstallLFSAsync() + { + var log = CreateLog("Install LFS"); + var succ = await new Commands.LFS(FullPath).Use(log).InstallAsync(); + if (succ) + SendNotification("LFS enabled successfully!"); - _watcher.Dispose(); - _histories.Cleanup(); - _workingCopy.Cleanup(); - _stashesPage.Cleanup(); + log.Complete(); + } - _watcher = null; - _histories = null; - _workingCopy = null; - _stashesPage = null; - _isSearching = false; - _searchCommitFilter = string.Empty; + public async Task TrackLFSFileAsync(string pattern, bool isFilenameMode) + { + var log = CreateLog("Track LFS"); + var succ = await new Commands.LFS(FullPath) + .Use(log) + .TrackAsync(pattern, isFilenameMode); - _remotes.Clear(); - _branches.Clear(); - _localBranchTrees.Clear(); - _remoteBranchTrees.Clear(); - _tags.Clear(); - _submodules.Clear(); - _searchedCommits.Clear(); + if (succ) + SendNotification($"Tracking successfully! Pattern: {pattern}"); + + log.Complete(); + return succ; } - public void OpenInFileManager() { - Native.OS.OpenInFileManager(_fullpath); + public async Task LockLFSFileAsync(string remote, string path) + { + var log = CreateLog("Lock LFS File"); + var succ = await new Commands.LFS(FullPath) + .Use(log) + .LockAsync(remote, path); + + if (succ) + SendNotification($"Lock file successfully! File: {path}"); + + log.Complete(); + return succ; } - public void OpenInVSCode() { - Native.OS.OpenInVSCode(_fullpath); + public async Task UnlockLFSFileAsync(string remote, string path, bool force, bool notify) + { + var log = CreateLog("Unlock LFS File"); + var succ = await new Commands.LFS(FullPath) + .Use(log) + .UnlockAsync(remote, path, force); + + if (succ && notify) + SendNotification($"Unlock file successfully! File: {path}"); + + log.Complete(); + return succ; + } + + public CommandLog CreateLog(string name) + { + var log = new CommandLog(name); + Logs.Insert(0, log); + return log; } - public void OpenInTerminal() { - Native.OS.OpenTerminal(_fullpath); + public void RefreshAll() + { + RefreshCommits(); + RefreshBranches(); + RefreshTags(); + RefreshSubmodules(); + RefreshWorktrees(); + RefreshWorkingCopyChanges(); + RefreshStashes(); + + Task.Run(async () => + { + var issuetrackers = new List(); + await new Commands.IssueTracker(FullPath, true).ReadAllAsync(issuetrackers, true).ConfigureAwait(false); + await new Commands.IssueTracker(FullPath, false).ReadAllAsync(issuetrackers, false).ConfigureAwait(false); + Dispatcher.UIThread.Post(() => + { + IssueTrackers.Clear(); + IssueTrackers.AddRange(issuetrackers); + }); + + var config = await new Commands.Config(FullPath).ReadAllAsync().ConfigureAwait(false); + _hasAllowedSignersFile = config.TryGetValue("gpg.ssh.allowedsignersfile", out var allowedSignersFile) && !string.IsNullOrEmpty(allowedSignersFile); + GitFlow.Parse(config); + }); } - public void Fetch() { - if (!PopupHost.CanCreatePopup()) return; + public async Task FetchAsync(bool autoStart) + { + if (!CanCreatePopup()) + return; - if (Remotes.Count == 0) { - App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + if (_remotes.Count == 0) + { + SendNotification("No remotes added to this repository!!!", true); return; } - PopupHost.ShowPopup(new Fetch(this)); + if (autoStart) + await ShowAndStartPopupAsync(new Fetch(this)); + else + ShowPopup(new Fetch(this)); } - public void Pull() { - if (!PopupHost.CanCreatePopup()) return; + public async Task PullAsync(bool autoStart) + { + if (IsBare || !CanCreatePopup()) + return; + + if (_remotes.Count == 0) + { + SendNotification("No remotes added to this repository!!!", true); + return; + } - if (Remotes.Count == 0) { - App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + if (_currentBranch == null) + { + SendNotification("Can NOT find current branch!!!", true); return; } - PopupHost.ShowPopup(new Pull(this, null)); + var pull = new Pull(this, null); + if (autoStart && pull.SelectedBranch != null) + await ShowAndStartPopupAsync(pull); + else + ShowPopup(pull); } - public void Push() { - if (!PopupHost.CanCreatePopup()) return; + public async Task PushAsync(bool autoStart) + { + if (!CanCreatePopup()) + return; - if (Remotes.Count == 0) { - App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + if (_remotes.Count == 0) + { + SendNotification("No remotes added to this repository!!!", true); return; } - if (Branches.Find(x => x.IsCurrent) == null) App.RaiseException(_fullpath, "Can NOT found current branch!!!"); - PopupHost.ShowPopup(new Push(this, null)); + if (_currentBranch == null) + { + SendNotification("Can NOT find current branch!!!", true); + return; + } + + if (autoStart) + await ShowAndStartPopupAsync(new Push(this, null)); + else + ShowPopup(new Push(this, null)); + } + + public void ApplyPatch() + { + if (CanCreatePopup()) + ShowPopup(new Apply(this)); } - public void ApplyPatch() { - if (!PopupHost.CanCreatePopup()) return; - PopupHost.ShowPopup(new Apply(this)); + public async Task ExecCustomActionAsync(Models.CustomAction action, object scopeTarget) + { + if (!CanCreatePopup()) + return; + + var popup = new ExecuteCustomAction(this, action, scopeTarget); + if (action.Controls.Count == 0) + await ShowAndStartPopupAsync(popup); + else + ShowPopup(popup); } - public void Cleanup() { - if (!PopupHost.CanCreatePopup()) return; - PopupHost.ShowAndStartPopup(new Cleanup(this)); + public async Task CleanupAsync() + { + if (CanCreatePopup()) + await ShowAndStartPopupAsync(new Cleanup(this)); } - public void OpenConfigure() { - if (!PopupHost.CanCreatePopup()) return; - PopupHost.ShowPopup(new RepositoryConfigure(this)); + public void ClearFilter() + { + Filter = string.Empty; } - public void ClearSearchCommitFilter() { - SearchCommitFilter = string.Empty; + public IDisposable LockWatcher() + { + return _watcher?.Lock(); } - public void StartSearchCommits() { - if (_histories == null) return; + public void RefreshAfterCreateBranch(Models.Branch created, bool checkout) + { + _watcher?.MarkBranchUpdated(); + _watcher?.MarkWorkingCopyUpdated(); + + _branches.RemoveAll(b => b.IsLocal && b.Name.Equals(created.Name, StringComparison.Ordinal)); + _branches.Add(created); - var visible = new List(); - foreach (var c in _histories.Commits) { - if (c.SHA.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) - || c.Subject.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) - || c.Message.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) - || c.Author.Name.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) - || c.Committer.Name.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase)) { - visible.Add(c); + if (checkout) + { + if (_currentBranch.IsDetachedHead) + { + _branches.Remove(_currentBranch); } + else + { + _currentBranch.IsCurrent = false; + _currentBranch.WorktreePath = null; + } + + created.IsCurrent = true; + created.WorktreePath = FullPath; + + var folderEndIdx = created.FullName.LastIndexOf('/'); + if (folderEndIdx > 10) + _uiStates.ExpandedBranchNodesInSideBar.Add(created.FullName.Substring(0, folderEndIdx)); + + if (_historyFilterMode == Models.FilterMode.Included) + SetBranchFilterMode(created, Models.FilterMode.Included, false, false); + + CurrentBranch = created; } - SearchedCommits = visible; - } + var locals = new List(); + var count = 0; + foreach (var b in _branches) + { + if (b.IsLocal) + { + locals.Add(b); + if (!b.IsDetachedHead) + count++; + } + } + + var builder = BuildBranchTree(locals, [], false); + LocalBranchTrees = builder.Locals; + LocalBranchesCount = count; - public void SetWatcherEnabled(bool enabled) { - if (_watcher != null) _watcher.SetEnabled(enabled); + RefreshCommits(); + RefreshWorkingCopyChanges(); + RefreshWorktrees(); } - public void NavigateToCommit(string sha) { - if (_histories != null) { - SelectedViewIndex = 0; - _histories.NavigateTo(sha); + public void RefreshAfterCheckoutBranch(Models.Branch checkouted) + { + _watcher?.MarkBranchUpdated(); + _watcher?.MarkWorkingCopyUpdated(); + + if (_currentBranch.IsDetachedHead) + { + _branches.Remove(_currentBranch); + } + else + { + _currentBranch.IsCurrent = false; + _currentBranch.WorktreePath = null; + } + + checkouted.IsCurrent = true; + checkouted.WorktreePath = FullPath; + if (_historyFilterMode == Models.FilterMode.Included) + SetBranchFilterMode(checkouted, Models.FilterMode.Included, false, false); + + List locals = []; + foreach (var b in _branches) + { + if (b.IsLocal) + locals.Add(b); } + + var builder = BuildBranchTree(locals, [], false); + LocalBranchTrees = builder.Locals; + CurrentBranch = checkouted; + + RefreshCommits(); + RefreshWorkingCopyChanges(); + RefreshWorktrees(); } - public void UpdateFilter(string filter, bool toggle) { - var changed = false; - if (toggle) { - if (!Filters.Contains(filter)) { - Filters.Add(filter); - changed = true; - } - } else { - changed = Filters.Remove(filter); + public void RefreshAfterRenameBranch(Models.Branch b, string newName) + { + _watcher?.MarkBranchUpdated(); + + var newFullName = $"refs/heads/{newName}"; + _uiStates.RenameBranchFilter(b.FullName, newFullName); + + var renamed = new Models.Branch + { + Name = newName, + FullName = newFullName, + CommitterDate = b.CommitterDate, + Head = b.Head, + IsLocal = b.IsLocal, + IsCurrent = b.IsCurrent, + IsDetachedHead = b.IsDetachedHead, + Upstream = b.Upstream, + Ahead = b.Ahead, + Behind = b.Behind, + Remote = b.Remote, + IsUpstreamGone = b.IsUpstreamGone, + WorktreePath = b.WorktreePath, + }; + + _branches.Remove(b); + _branches.Add(renamed); + + if (b.IsCurrent) + CurrentBranch = renamed; + + List locals = []; + foreach (var branch in _branches) + { + if (branch.IsLocal) + locals.Add(branch); } - if (changed) Task.Run(RefreshCommits); + var builder = BuildBranchTree(locals, [], false); + LocalBranchTrees = builder.Locals; + + RefreshCommits(); + RefreshWorktrees(); } - public void StashAll() { - if (PopupHost.CanCreatePopup()) { - var changes = new List(); - changes.AddRange(_workingCopy.Unstaged); - changes.AddRange(_workingCopy.Staged); - PopupHost.ShowPopup(new StashChanges(this, changes, true)); + public void MarkBranchesDirtyManually() + { + _watcher?.MarkBranchUpdated(); + RefreshBranches(); + RefreshCommits(); + RefreshWorkingCopyChanges(); + RefreshWorktrees(); + } + + public void MarkTagsDirtyManually() + { + _watcher?.MarkTagUpdated(); + RefreshTags(); + RefreshCommits(); + } + + public void MarkWorkingCopyDirtyManually() + { + _watcher?.MarkWorkingCopyUpdated(); + RefreshWorkingCopyChanges(); + } + + public void MarkStashesDirtyManually() + { + _watcher?.MarkStashUpdated(); + RefreshStashes(); + } + + public void MarkSubmodulesDirtyManually() + { + _watcher?.MarkSubmodulesUpdated(); + RefreshSubmodules(); + } + + public void MarkFetched() + { + _lastFetchTime = DateTime.Now; + } + + public void NavigateToCommit(string sha, bool isDelayMode = false) + { + if (isDelayMode) + { + _navigateToCommitDelayed = sha; + } + else + { + SelectedViewIndex = 0; + _histories?.NavigateTo(sha); } } - public void GotoResolve() { - if (_workingCopy != null) SelectedViewIndex = 1; + public void SetCommitMessage(string message) + { + if (_workingCopy is not null) + _workingCopy.CommitMessage = message; } - public async void ContinueMerge() { - var cherryPickMerge = Path.Combine(_gitDir, "CHERRY_PICK_HEAD"); - var rebaseMerge = Path.Combine(_gitDir, "REBASE_HEAD"); - var rebaseMergeFolder = Path.Combine(_gitDir, "rebase-merge"); - var revertMerge = Path.Combine(_gitDir, "REVERT_HEAD"); - var otherMerge = Path.Combine(_gitDir, "MERGE_HEAD"); + public void ClearCommitMessage() + { + if (_workingCopy is not null) + _workingCopy.CommitMessage = string.Empty; + } - var mode = ""; - if (File.Exists(cherryPickMerge)) { - mode = "cherry-pick"; - } else if (File.Exists(rebaseMerge) && Directory.Exists(rebaseMergeFolder)) { - mode = "rebase"; - } else if (File.Exists(revertMerge)) { - mode = "revert"; - } else if (File.Exists(otherMerge)) { - mode = "merge"; - } else { - await Task.Run(RefreshWorkingCopyChanges); + public Models.Commit GetSelectedCommitInHistory() + { + return (_histories?.DetailContext as CommitDetail)?.Commit; + } + + public void ClearHistoryFilters() + { + _uiStates.HistoryFilters.Clear(); + HistoryFilterMode = Models.FilterMode.None; + + ResetBranchTreeFilterMode(LocalBranchTrees); + ResetBranchTreeFilterMode(RemoteBranchTrees); + ResetTagFilterMode(); + RefreshCommits(); + } + + public void RemoveHistoryFilter(Models.HistoryFilter filter) + { + if (_uiStates.HistoryFilters.Remove(filter)) + { + HistoryFilterMode = _uiStates.GetHistoryFilterMode(); + RefreshHistoryFilters(true); + } + } + + public void UpdateBranchNodeIsExpanded(BranchTreeNode node) + { + if (_uiStates == null || !string.IsNullOrWhiteSpace(_filter)) return; + + if (node.IsExpanded) + { + if (!_uiStates.ExpandedBranchNodesInSideBar.Contains(node.Path)) + _uiStates.ExpandedBranchNodesInSideBar.Add(node.Path); + } + else + { + _uiStates.ExpandedBranchNodesInSideBar.Remove(node.Path); } + } + + public void SetTagFilterMode(Models.Tag tag, Models.FilterMode mode) + { + var changed = _uiStates.UpdateHistoryFilters(tag.Name, Models.FilterType.Tag, mode); + if (changed) + RefreshHistoryFilters(true); + } - var cmd = new Commands.Command(); - cmd.WorkingDirectory = _fullpath; - cmd.Context = _fullpath; - cmd.Args = $"-c core.editor=true {mode} --continue"; + 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; - SetWatcherEnabled(false); - var succ = await Task.Run(cmd.Exec); - SetWatcherEnabled(true); + if (clearExists) + { + _uiStates.HistoryFilters.Clear(); + HistoryFilterMode = Models.FilterMode.None; + } - if (succ) { - if (_workingCopy != null) _workingCopy.CommitMessage = string.Empty; + if (node.Backend is Models.Branch branch) + { + var type = isLocal ? Models.FilterType.LocalBranch : Models.FilterType.RemoteBranch; + var changed = _uiStates.UpdateHistoryFilters(node.Path, type, mode); + if (!changed) + return; - if (mode == "rebase") { - if (File.Exists(rebaseMerge)) File.Delete(rebaseMerge); - if (Directory.Exists(rebaseMergeFolder)) Directory.Delete(rebaseMergeFolder); - } + if (isLocal && !string.IsNullOrEmpty(branch.Upstream) && !branch.IsUpstreamGone) + _uiStates.UpdateHistoryFilters(branch.Upstream, Models.FilterType.RemoteBranch, mode); } - } + else + { + var type = isLocal ? Models.FilterType.LocalBranchFolder : Models.FilterType.RemoteBranchFolder; + var changed = _uiStates.UpdateHistoryFilters(node.Path, type, mode); + if (!changed) + return; + + _uiStates.RemoveBranchFiltersByPrefix(node.Path); + } + + var parentType = isLocal ? Models.FilterType.LocalBranchFolder : Models.FilterType.RemoteBranchFolder; + var cur = node; + do + { + var lastSepIdx = cur.Path.LastIndexOf('/'); + if (lastSepIdx <= 0) + break; - public async void AbortMerge() { - var cmd = new Commands.Command(); - cmd.WorkingDirectory = _fullpath; - cmd.Context = _fullpath; + var parentPath = cur.Path.Substring(0, lastSepIdx); + var parent = FindBranchNode(tree, parentPath); + if (parent == null) + break; - if (File.Exists(Path.Combine(_gitDir, "CHERRY_PICK_HEAD"))) { - cmd.Args = "cherry-pick --abort"; - } else if (File.Exists(Path.Combine(_gitDir, "REBASE_HEAD"))) { - cmd.Args = "rebase --abort"; - } else if (File.Exists(Path.Combine(_gitDir, "REVERT_HEAD"))) { - cmd.Args = "revert --abort"; - } else if (File.Exists(Path.Combine(_gitDir, "MERGE_HEAD"))) { - cmd.Args = "merge --abort"; - } else { - await Task.Run(RefreshWorkingCopyChanges); + _uiStates.UpdateHistoryFilters(parent.Path, parentType, Models.FilterMode.None); + cur = parent; + } while (true); + + RefreshHistoryFilters(refresh); + } + + public async Task StashAllAsync(bool autoStart) + { + if (!CanCreatePopup()) return; + + var popup = new StashChanges(this, null); + if (autoStart) + await ShowAndStartPopupAsync(popup); + else + ShowPopup(popup); + } + + public async Task SkipMergeAsync() + { + if (_workingCopy != null) + await _workingCopy.SkipMergeAsync(); + } + + public async Task AbortMergeAsync() + { + if (_workingCopy != null) + await _workingCopy.AbortMergeAsync(); + } + + public List<(Models.CustomAction, CustomActionContextMenuLabel)> GetCustomActions(Models.CustomActionScope scope) + { + var actions = new List<(Models.CustomAction, CustomActionContextMenuLabel)>(); + + foreach (var act in Preferences.Instance.CustomActions) + { + if (act.Scope == scope) + actions.Add((act, new CustomActionContextMenuLabel(act.Name, true))); + } + + foreach (var act in _settings.CustomActions) + { + if (act.Scope == scope) + actions.Add((act, new CustomActionContextMenuLabel(act.Name, false))); } - SetWatcherEnabled(false); - await Task.Run(cmd.Exec); - SetWatcherEnabled(true); + return actions; } - public void RefreshBranches() { - var branches = new Commands.QueryBranches(FullPath).Result(); - var remotes = new Commands.QueryRemotes(FullPath).Result(); + public async Task ExecBisectCommandAsync(string subcmd) + { + using var lockWatcher = _watcher?.Lock(); + IsBisectCommandRunning = true; - var builder = new Models.BranchTreeNode.Builder(); - builder.SetFilters(Filters); - builder.CollectExpandedNodes(_localBranchTrees, true); - builder.CollectExpandedNodes(_remoteBranchTrees, false); - builder.Run(branches, remotes); + var log = CreateLog($"Bisect({subcmd})"); - Dispatcher.UIThread.Invoke(() => { - Remotes = remotes; - Branches = branches; - LocalBranchTrees = builder.Locals; - RemoteBranchTrees = builder.Remotes; + var succ = await new Commands.Bisect(FullPath, subcmd).Use(log).ExecAsync(); + log.Complete(); - var cur = Branches.Find(x => x.IsCurrent); - CanCommitWithPush = cur != null && !string.IsNullOrEmpty(cur.Upstream); - }); + var head = await new Commands.QueryRevisionByRefName(FullPath, "HEAD").GetResultAsync(); + if (!succ) + SendNotification(log.Content.Substring(log.Content.IndexOf('\n')).Trim(), true); + else if (log.Content.Contains("is the first bad commit")) + SendNotification(log.Content.Substring(log.Content.IndexOf('\n')).Trim()); + + MarkBranchesDirtyManually(); + NavigateToCommit(head, 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 RefreshTags() { - var tags = new Commands.QueryTags(FullPath).Result(); - foreach (var tag in tags) tag.IsFiltered = Filters.Contains(tag.Name); - Dispatcher.UIThread.Invoke(() => { - Tags = tags; + public void RefreshBranches() + { + if (_cancellationRefreshBranches is { IsCancellationRequested: false }) + _cancellationRefreshBranches.Cancel(); + + _cancellationRefreshBranches = new CancellationTokenSource(); + var token = _cancellationRefreshBranches.Token; + + Task.Run(async () => + { + var branches = await new Commands.QueryBranches(FullPath).GetResultAsync().ConfigureAwait(false); + var remotes = await new Commands.QueryRemotes(FullPath).GetResultAsync().ConfigureAwait(false); + var builder = BuildBranchTree(branches, remotes); + + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; + + Remotes = remotes; + Branches = branches; + CurrentBranch = branches.Find(x => x.IsCurrent); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + + var localBranchesCount = 0; + foreach (var b in branches) + { + if (b.IsLocal && !b.IsDetachedHead) + localBranchesCount++; + } + LocalBranchesCount = localBranchesCount; + + if (_workingCopy != null) + _workingCopy.HasRemotes = remotes.Count > 0; + + var hasPendingPullOrPush = CurrentBranch?.IsTrackStatusVisible ?? false; + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasPendingPullOrPush, !hasPendingPullOrPush); + }); + }, token); + } + + public void RefreshWorktrees() + { + Task.Run(async () => + { + var worktrees = await new Commands.Worktree(FullPath).ReadAllAsync().ConfigureAwait(false); + var cleaned = Worktree.Build(FullPath, worktrees); + Dispatcher.UIThread.Invoke(() => Worktrees = cleaned); }); } - public void RefreshCommits() { - Dispatcher.UIThread.Invoke(() => _histories.IsLoading = true); + public void RefreshTags() + { + if (_cancellationRefreshTags is { IsCancellationRequested: false }) + _cancellationRefreshTags.Cancel(); + + _cancellationRefreshTags = new CancellationTokenSource(); + var token = _cancellationRefreshTags.Token; + + Task.Run(async () => + { + var tags = await new Commands.QueryTags(FullPath).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; + + Tags = tags; + VisibleTags = BuildVisibleTags(); + }); + }, token); + } + + public void RefreshCommits() + { + if (_cancellationRefreshCommits is { IsCancellationRequested: false }) + _cancellationRefreshCommits.Cancel(); + + _cancellationRefreshCommits = new CancellationTokenSource(); + var token = _cancellationRefreshCommits.Token; + + Task.Run(async () => + { + await Dispatcher.UIThread.InvokeAsync(() => _histories.IsLoading = true); + + var builder = new StringBuilder(); + builder + .Append('-').Append(Preferences.Instance.MaxHistoryCommits).Append(' ') + .Append(_uiStates.BuildHistoryParams(GitDir)); + + var commits = await new Commands.QueryCommits(FullPath, builder.ToString()) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; + + if (_histories != null) + { + _histories.IsLoading = false; + _histories.Commits = commits; + BisectState = _histories.UpdateBisectInfo(); + + if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) + NavigateToCommit(_navigateToCommitDelayed); + } + + _navigateToCommitDelayed = string.Empty; + }); + }, token); + } - var limits = $"-{Preference.Instance.MaxHistoryCommits} "; - var validFilters = new List(); - foreach (var filter in Filters) { - if (filter.StartsWith("refs/")) { - if (_branches.FindIndex(x => x.FullName == filter) >= 0) validFilters.Add(filter); - } else { - if (_tags.FindIndex(t => t.Name == filter) >= 0) validFilters.Add(filter); + public void RefreshSubmodules() + { + if (!MayHaveSubmodules()) + { + if (_submodules.Count > 0) + { + Dispatcher.UIThread.Invoke(() => + { + Submodules = []; + VisibleSubmodules = BuildVisibleSubmodules(); + }); } + + return; } - if (validFilters.Count > 0) { - limits += string.Join(" ", validFilters); - } else { - limits += "--branches --remotes --tags"; - } - var commits = new Commands.QueryCommits(FullPath, limits).Result(); - Dispatcher.UIThread.Invoke(() => { - if (_histories != null) { - _histories.IsLoading = false; - _histories.Commits = commits; - } + Task.Run(async () => + { + var submodules = await new Commands.QuerySubmodules(FullPath).GetResultAsync().ConfigureAwait(false); + + 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.Branch.Equals(module.Branch, StringComparison.Ordinal) || + !exist.URL.Equals(module.URL, StringComparison.Ordinal) || + exist.Status != module.Status; + + if (hasChanged) + break; + } + } + + if (hasChanged) + { + Submodules = submodules; + VisibleSubmodules = BuildVisibleSubmodules(); + } + }); }); } - public void RefreshSubmodules() { - var submodules = new Commands.QuerySubmodules(FullPath).Result(); - Dispatcher.UIThread.Invoke(() => { - Submodules = submodules; - }); + public void RefreshWorkingCopyChanges() + { + if (IsBare) + return; + + if (_cancellationRefreshWorkingCopyChanges is { IsCancellationRequested: false }) + _cancellationRefreshWorkingCopyChanges.Cancel(); + + _cancellationRefreshWorkingCopyChanges = new CancellationTokenSource(); + var token = _cancellationRefreshWorkingCopyChanges.Token; + var noOptionalLocks = Interlocked.Add(ref _queryLocalChangesTimes, 1) > 1; + + Task.Run(async () => + { + var changes = await new Commands.QueryLocalChanges(FullPath, _uiStates.IncludeUntrackedInLocalChanges, noOptionalLocks) + .GetResultAsync() + .ConfigureAwait(false); + + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; + + _workingCopy.SetData(changes); + LocalChangesCount = changes.Count; + OnPropertyChanged(nameof(InProgressContext)); + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasLocalChanges, changes.Count == 0); + }); + }, token); } - public void RefreshWorkingCopyChanges() { - var changes = new Commands.QueryLocalChanges(FullPath, _includeUntracked).Result(); - var hasUnsolvedConflict = _workingCopy.SetData(changes); + public void RefreshStashes() + { + if (IsBare) + return; - var cherryPickMerge = Path.Combine(_gitDir, "CHERRY_PICK_HEAD"); - var rebaseMerge = Path.Combine(_gitDir, "REBASE_HEAD"); - var rebaseMergeFolder = Path.Combine(_gitDir, "rebase-merge"); - var revertMerge = Path.Combine(_gitDir, "REVERT_HEAD"); - var otherMerge = Path.Combine(_gitDir, "MERGE_HEAD"); - var runningMerge = (File.Exists(cherryPickMerge) || - (File.Exists(rebaseMerge) && Directory.Exists(rebaseMergeFolder)) || - File.Exists(revertMerge) || - File.Exists(otherMerge)); + if (_cancellationRefreshStashes is { IsCancellationRequested: false }) + _cancellationRefreshStashes.Cancel(); - if (!runningMerge) { - if (Directory.Exists(rebaseMergeFolder)) Directory.Delete(rebaseMergeFolder, true); - var applyFolder = Path.Combine(_gitDir, "rebase-apply"); - if (Directory.Exists(applyFolder)) Directory.Delete(applyFolder, true); - } + _cancellationRefreshStashes = new CancellationTokenSource(); + var token = _cancellationRefreshStashes.Token; - Dispatcher.UIThread.Invoke(() => { - IsConflictBarVisible = runningMerge; - HasUnsolvedConflict = hasUnsolvedConflict; - OnPropertyChanged(nameof(WorkingCopyChangesCount)); - }); + Task.Run(async () => + { + var stashes = await new Commands.QueryStashes(FullPath).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Invoke(() => + { + if (token.IsCancellationRequested) + return; + + if (_stashesPage != null) + _stashesPage.Stashes = stashes; - _watcher.MarkWorkingCopyRefreshed(); + StashesCount = stashes.Count; + }); + }, token); } - public void RefreshStashes() { - var stashes = new Commands.QueryStashes(FullPath).Result(); - Dispatcher.UIThread.Invoke(() => { - if (_stashesPage != null) _stashesPage.Stashes = stashes; - OnPropertyChanged(nameof(StashesCount)); - }); + public void ToggleHistoryShowFlag(Models.HistoryShowFlags flag) + { + if (_uiStates.HistoryShowFlags.HasFlag(flag)) + HistoryShowFlags -= flag; + else + HistoryShowFlags |= flag; } - public void RefreshGitFlow() { - var config = new Commands.Config(_fullpath).ListAll(); - var gitFlow = new Models.GitFlow(); - if (config.ContainsKey("gitflow.prefix.feature")) gitFlow.Feature = config["gitflow.prefix.feature"]; - if (config.ContainsKey("gitflow.prefix.release")) gitFlow.Release = config["gitflow.prefix.release"]; - if (config.ContainsKey("gitflow.prefix.hotfix")) gitFlow.Hotfix = config["gitflow.prefix.hotfix"]; - Dispatcher.UIThread.Invoke(() => { - GitFlow = gitFlow; - }); + public void CreateNewBranch() + { + if (_currentBranch == null) + { + SendNotification("Git cannot create a branch before your first commit.", true); + return; + } + + if (CanCreatePopup()) + ShowPopup(new CreateBranch(this, _currentBranch)); } - public void CreateNewBranch() { - var current = Branches.Find(x => x.IsCurrent); - if (current == null) { - App.RaiseException(_fullpath, "Git do not hold any branch until you do first commit."); + public async Task CheckoutBranchAsync(Models.Branch branch) + { + if (branch.IsLocal) + { + var worktree = _worktrees.Find(x => x.IsAttachedTo(branch)); + if (worktree != null) + { + OpenWorktree(worktree); + return; + } + } + + if (IsBare) return; + + if (!CanCreatePopup()) + return; + + if (branch.IsLocal) + { + if (_workingCopy is { CanSwitchBranchDirectly: true }) + await ShowAndStartPopupAsync(new Checkout(this, branch)); + else + ShowPopup(new Checkout(this, branch)); + } + else + { + foreach (var b in _branches) + { + if (b.IsLocal && + b.Upstream.Equals(branch.FullName, StringComparison.Ordinal) && + b.Ahead.Count == 0) + { + if (b.Behind.Count > 0) + ShowPopup(new CheckoutAndFastForward(this, b, branch)); + else if (!b.IsCurrent) + await CheckoutBranchAsync(b); + + return; + } + } + + ShowPopup(new CreateBranch(this, branch)); } + } + + public async Task CheckoutTagAsync(Models.Tag tag) + { + var c = await new Commands.QuerySingleCommit(FullPath, tag.SHA).GetResultAsync(); + if (c != null && _histories != null) + await _histories.CheckoutBranchByCommitAsync(c); + } + + 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)); + } - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, current)); + public void MergeMultipleBranches(List branches) + { + if (CanCreatePopup()) + ShowPopup(new MergeMultiple(this, branches)); } - public void CreateNewTag() { - var current = Branches.Find(x => x.IsCurrent); - if (current == null) { - App.RaiseException(_fullpath, "Git do not hold any branch until you do first commit."); + public void CreateNewTag() + { + if (_currentBranch == null) + { + SendNotification("Git cannot create a tag before your first commit.", true); return; } - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateTag(this, current)); + if (CanCreatePopup()) + ShowPopup(new CreateTag(this, _currentBranch)); } - public void AddRemote() { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new AddRemote(this)); + public void DeleteTag(Models.Tag tag) + { + if (CanCreatePopup()) + ShowPopup(new DeleteTag(this, tag)); } - public void AddSubmodule() { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new AddSubmodule(this)); + public void AddRemote() + { + if (CanCreatePopup()) + ShowPopup(new AddRemote(this)); } - public ContextMenu CreateContextMenuForGitFlow() { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + public void DeleteRemote(Models.Remote remote) + { + if (CanCreatePopup()) + ShowPopup(new DeleteRemote(this, remote)); + } + + public async Task ToggleAutoFetchOnRemoteAsync(Models.Remote remote) + { + var val = remote.DisableAutoFetch ? "false" : "true"; + var succ = await new Commands.Config(FullPath).SetAsync($"remote.{remote.Name.Quoted()}.disableautofetch", val); + if (succ) + remote.DisableAutoFetch = !remote.DisableAutoFetch; + } - if (GitFlow.IsEnabled) { - var startFeature = new MenuItem(); - startFeature.Header = App.Text("GitFlow.StartFeature"); - startFeature.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Feature)); - e.Handled = true; - }; + public void AddSubmodule() + { + if (CanCreatePopup()) + ShowPopup(new AddSubmodule(this)); + } - var startRelease = new MenuItem(); - startRelease.Header = App.Text("GitFlow.StartRelease"); - startRelease.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Release)); - e.Handled = true; - }; + public void UpdateSubmodules() + { + if (CanCreatePopup()) + ShowPopup(new UpdateSubmodules(this, null)); + } - var startHotfix = new MenuItem(); - startHotfix.Header = App.Text("GitFlow.StartHotfix"); - startHotfix.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new GitFlowStart(this, Models.GitFlowBranchType.Hotfix)); - e.Handled = true; - }; + public async Task AutoUpdateSubmodulesAsync(Models.ICommandLog log) + { + var submodules = await new Commands.QueryUpdatableSubmodules(FullPath, false).GetResultAsync(); + if (submodules.Count == 0) + return; - menu.Items.Add(startFeature); - menu.Items.Add(startRelease); - menu.Items.Add(startHotfix); - } else { - var init = new MenuItem(); - init.Header = App.Text("GitFlow.Init"); - init.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new InitGitFlow(this)); - e.Handled = true; - }; - menu.Items.Add(init); + do + { + if (_settings.AskBeforeAutoUpdatingSubmodules) + { + var builder = new StringBuilder(); + builder.Append("\n\n"); + foreach (var s in submodules) + builder.Append("- ").Append(s).Append('\n'); + builder.Append("\n"); + + var msg = App.Text("Checkout.WarnUpdatingSubmodules", builder.ToString()); + var shouldContinue = await App.AskConfirmAsync(msg, Models.ConfirmButtonType.YesNo); + if (!shouldContinue) + break; + } + + await new Commands.Submodule(FullPath) + .Use(log) + .UpdateAsync(submodules, false, _settings.EnableRecursiveWhenAutoUpdatingSubmodules, false); + } while (false); + } + + 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('/'); + App.GetLauncher().OpenRepositoryInTab(normalizedPath, null); + } + + public void AddWorktree() + { + if (CanCreatePopup()) + ShowPopup(new AddWorktree(this)); + } + + public async Task PruneWorktreesAsync() + { + if (CanCreatePopup()) + await ShowAndStartPopupAsync(new PruneWorktrees(this)); + } + + public void OpenWorktree(Worktree worktree) + { + if (worktree.IsCurrent) + return; + + var normalizedPath = worktree.FullPath.Replace('\\', '/').TrimEnd('/'); + App.GetLauncher().OpenRepositoryInTab(normalizedPath, null); + } + + public async Task LockWorktreeAsync(Worktree worktree) + { + using var lockWatcher = _watcher?.Lock(); + var log = CreateLog("Lock Worktree"); + var succ = await new Commands.Worktree(FullPath).Use(log).LockAsync(worktree.FullPath); + if (succ) + worktree.IsLocked = true; + log.Complete(); + } + + public async Task UnlockWorktreeAsync(Worktree worktree) + { + using var lockWatcher = _watcher?.Lock(); + var log = CreateLog("Unlock Worktree"); + var succ = await new Commands.Worktree(FullPath).Use(log).UnlockAsync(worktree.FullPath); + if (succ) + worktree.IsLocked = false; + log.Complete(); + } + + 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 menu; + + return all; } - public ContextMenu CreateContextMenuForLocalBranch(Models.Branch branch) { - var menu = new ContextMenu(); + public void DiscardAllChanges() + { + if (CanCreatePopup()) + ShowPopup(new Discard(this)); + } - var push = new MenuItem(); - push.Header = new Views.NameHighlightedTextBlock("BranchCM.Push", branch.Name); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = Remotes.Count > 0; - push.Click += (_, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Push(this, branch)); - e.Handled = true; - }; + public void ClearStashes() + { + if (CanCreatePopup()) + ShowPopup(new ClearStashes(this)); + } - if (branch.IsCurrent) { - var discard = new MenuItem(); - discard.Header = App.Text("BranchCM.DiscardAll"); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.IsEnabled = _workingCopy.Count > 0; - discard.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Discard(this)); - e.Handled = true; - }; - - menu.Items.Add(discard); - menu.Items.Add(new MenuItem() { Header = "-" }); - - if (!string.IsNullOrEmpty(branch.Upstream)) { - var upstream = branch.Upstream.Substring(13); - var fastForward = new MenuItem(); - fastForward.Header = new Views.NameHighlightedTextBlock("BranchCM.FastForward", upstream); - fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); - fastForward.IsEnabled = !string.IsNullOrEmpty(branch.UpstreamTrackStatus) && branch.UpstreamTrackStatus.IndexOf('↑') < 0; - fastForward.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Merge(this, upstream, branch.Name)); - e.Handled = true; - }; - - var pull = new MenuItem(); - pull.Header = new Views.NameHighlightedTextBlock("BranchCM.Pull", upstream); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Pull(this, null)); - e.Handled = true; - }; - - menu.Items.Add(fastForward); - menu.Items.Add(pull); + public async Task SaveCommitAsPatchAsync(Models.Commit commit, string folder, int index = 0) + { + var ignoredChars = 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 (!ignoredChars.Contains(c)) + { + if (c == ' ' || c == '\t') + builder.Append('-'); + else + builder.Append(c); + + len++; + + if (len >= 48) + break; } + } + builder.Append(".patch"); - menu.Items.Add(push); - } else { - var current = Branches.Find(x => x.IsCurrent); - - var checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", branch.Name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(this, branch.Name)); - e.Handled = true; - }; - menu.Items.Add(checkout); - - var upstream = Branches.Find(x => x.FullName == branch.Upstream); - if (upstream != null) { - var fastForward = new MenuItem(); - fastForward.Header = new Views.NameHighlightedTextBlock("BranchCM.FastForward", $"{upstream.Remote}/{upstream.Name}"); - fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); - fastForward.IsEnabled = !string.IsNullOrEmpty(branch.UpstreamTrackStatus) && branch.UpstreamTrackStatus.IndexOf('↑') < 0; - fastForward.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new FastForwardWithoutCheckout(this, branch, upstream)); - e.Handled = true; - }; - - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(fastForward); - } + var saveTo = Path.Combine(folder, builder.ToString()); + var log = CreateLog("Save Commit as Patch"); + var succ = await new Commands.FormatPatch(FullPath, commit.SHA, saveTo).Use(log).ExecAsync(); + log.Complete(); + return succ; + } - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(push); - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", branch.Name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(this, branch.Name, current.Name)); - e.Handled = true; - }; - - var rebase = new MenuItem(); - rebase.Header = new Views.NameHighlightedTextBlock("BranchCM.Rebase", current.Name, branch.Name); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Rebase(this, current, branch)); - e.Handled = true; - }; - - menu.Items.Add(merge); - menu.Items.Add(rebase); - } - - var type = GitFlow.GetBranchType(branch.Name); - if (type != Models.GitFlowBranchType.None) { - var finish = new MenuItem(); - finish.Header = new Views.NameHighlightedTextBlock("BranchCM.Finish", branch.Name); - finish.Icon = App.CreateMenuIcon("Icons.Flow"); - finish.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.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 = new Views.NameHighlightedTextBlock("BranchCM.Rename", branch.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new RenameBranch(this, branch)); - e.Handled = true; - }; + private LauncherPage GetOwnerPage() + { + var launcher = App.GetLauncher(); + if (launcher == null) + return null; - var delete = new MenuItem(); - delete.Header = new Views.NameHighlightedTextBlock("BranchCM.Delete", branch.Name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.IsEnabled = !branch.IsCurrent; - delete.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(this, branch)); - e.Handled = true; - }; + foreach (var page in launcher.Pages) + { + if (page.Node.Id.Equals(FullPath)) + return page; + } - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, branch)); - e.Handled = true; - }; + return null; + } - var createTag = new MenuItem(); - createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); - createTag.Header = App.Text("CreateTag"); - createTag.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateTag(this, branch)); - e.Handled = true; - }; + private BranchTreeNode.Builder BuildBranchTree(List branches, List remotes, bool validateExpandedNodes = true) + { + var builder = new BranchTreeNode.Builder(_uiStates.LocalBranchSortMode, _uiStates.RemoteBranchSortMode); + if (string.IsNullOrEmpty(_filter)) + { + builder.SetExpandedNodes(_uiStates.ExpandedBranchNodesInSideBar); + builder.Run(branches, remotes, false); + + if (validateExpandedNodes) + { + foreach (var invalid in builder.InvalidExpandedNodes) + _uiStates.ExpandedBranchNodesInSideBar.Remove(invalid); + } + } + else + { + var visibles = new List(); + foreach (var b in branches) + { + if (b.FullName.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visibles.Add(b); + } - 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 = "-" }); - - 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.Branch"); - - foreach (var b in remoteBranches) { - var upstream = b.FullName.Replace("refs/remotes/", ""); - var target = new MenuItem(); - target.Header = upstream; - if (branch.Upstream == b.FullName) target.Icon = App.CreateMenuIcon("Icons.Check"); - - target.Click += (o, e) => { - if (Commands.Branch.SetUpstream(_fullpath, branch.Name, upstream)) { - Task.Run(RefreshBranches); - } - e.Handled = true; - }; + builder.Run(visibles, remotes, true); + } - tracking.Items.Add(target); - } + var filterMap = _uiStates.GetHistoryFiltersMap(); + UpdateBranchTreeFilterMode(builder.Locals, filterMap); + UpdateBranchTreeFilterMode(builder.Remotes, filterMap); + return builder; + } - var unsetUpstream = new MenuItem(); - unsetUpstream.Header = App.Text("BranchCM.UnsetUpstream"); - unsetUpstream.Click += (_, e) => { - if (Commands.Branch.SetUpstream(_fullpath, branch.Name, string.Empty)) { - Task.Run(RefreshBranches); - } - e.Handled = true; - }; - tracking.Items.Add(new MenuItem() { Header = "-" }); - tracking.Items.Add(unsetUpstream); + private object BuildVisibleTags() + { + switch (_uiStates.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; + } - menu.Items.Add(tracking); + 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 archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.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 += (o, e) => { - App.CopyText(branch.Name); - e.Handled = true; - }; - menu.Items.Add(copy); + var filterMap = _uiStates.GetHistoryFiltersMap(); + UpdateTagFilterMode(filterMap); - return menu; + if (_uiStates.ShowTagsAsTree) + { + var tree = TagCollectionAsTree.Build(visible, _visibleTags as TagCollectionAsTree); + foreach (var node in tree.Tree) + node.UpdateFilterMode(filterMap); + return tree; + } + else + { + var list = new TagCollectionAsList(visible); + foreach (var item in list.TagItems) + item.FilterMode = filterMap.GetValueOrDefault(item.Tag.Name, Models.FilterMode.None); + return list; + } } - public ContextMenu CreateContextMenuForRemote(Models.Remote remote) { - var menu = new ContextMenu(); + 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); + } + } - var fetch = new MenuItem(); - fetch.Header = App.Text("RemoteCM.Fetch"); - fetch.Icon = App.CreateMenuIcon("Icons.Fetch"); - fetch.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Fetch(this, remote)); - e.Handled = true; - }; + if (_uiStates.ShowSubmodulesAsTree) + return SubmoduleCollectionAsTree.Build(visible, _visibleSubmodules as SubmoduleCollectionAsTree); + else + return new SubmoduleCollectionAsList() { Submodules = visible }; + } - var prune = new MenuItem(); - prune.Header = App.Text("RemoteCM.Prune"); - prune.Icon = App.CreateMenuIcon("Icons.Clear2"); - prune.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new PruneRemote(this, remote)); - e.Handled = true; - }; + private void RefreshHistoryFilters(bool refresh) + { + HistoryFilterMode = _uiStates.GetHistoryFilterMode(); + if (!refresh) + return; - var edit = new MenuItem(); - edit.Header = App.Text("RemoteCM.Edit"); - edit.Icon = App.CreateMenuIcon("Icons.Edit"); - edit.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new EditRemote(this, remote)); - e.Handled = true; - }; + var map = _uiStates.GetHistoryFiltersMap(); + UpdateBranchTreeFilterMode(LocalBranchTrees, map); + UpdateBranchTreeFilterMode(RemoteBranchTrees, map); + UpdateTagFilterMode(map); + RefreshCommits(); + } - var delete = new MenuItem(); - delete.Header = App.Text("RemoteCM.Delete"); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteRemote(this, remote)); - e.Handled = true; - }; + private void UpdateBranchTreeFilterMode(List nodes, Dictionary map) + { + foreach (var node in nodes) + { + node.FilterMode = map.GetValueOrDefault(node.Path, Models.FilterMode.None); - var copy = new MenuItem(); - copy.Header = App.Text("RemoteCM.CopyURL"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (o, e) => { - App.CopyText(remote.URL); - e.Handled = true; - }; + if (!node.IsBranch) + UpdateBranchTreeFilterMode(node.Children, map); + } + } - 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 current = Branches.Find(x => x.IsCurrent); - - var checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", $"{branch.Remote}/{branch.Name}"); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (o, e) => { - foreach (var b in Branches) { - if (b.IsLocal && b.Upstream == branch.FullName) { - if (b.IsCurrent) return; - if (PopupHost.CanCreatePopup()) PopupHost.ShowAndStartPopup(new Checkout(this, b.Name)); - return; - } - } + private void UpdateTagFilterMode(Dictionary map) + { + if (VisibleTags is TagCollectionAsTree tree) + { + foreach (var node in tree.Tree) + node.UpdateFilterMode(map); + } + else if (VisibleTags is TagCollectionAsList list) + { + foreach (var item in list.TagItems) + item.FilterMode = map.GetValueOrDefault(item.Tag.Name, Models.FilterMode.None); + } + } - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, branch)); - e.Handled = true; - }; - menu.Items.Add(checkout); - menu.Items.Add(new MenuItem() { Header = "-" }); - - if (current != null) { - var pull = new MenuItem(); - pull.Header = new Views.NameHighlightedTextBlock("BranchCM.PullInto", $"{branch.Remote}/{branch.Name}", current.Name); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Pull(this, branch)); - e.Handled = true; - }; - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", $"{branch.Remote}/{branch.Name}", current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Merge(this, $"{branch.Remote}/{branch.Name}", current.Name)); - e.Handled = true; - }; - - var rebase = new MenuItem(); - rebase.Header = new Views.NameHighlightedTextBlock("BranchCM.Rebase", current.Name, $"{branch.Remote}/{branch.Name}"); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Rebase(this, current, branch)); - e.Handled = true; - }; - - menu.Items.Add(pull); - menu.Items.Add(merge); - menu.Items.Add(rebase); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var delete = new MenuItem(); - delete.Header = new Views.NameHighlightedTextBlock("BranchCM.Delete", $"{branch.Remote}/{branch.Name}"); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteBranch(this, branch)); - e.Handled = true; - }; + private void ResetBranchTreeFilterMode(List nodes) + { + foreach (var node in nodes) + { + node.FilterMode = Models.FilterMode.None; + if (!node.IsBranch) + ResetBranchTreeFilterMode(node.Children); + } + } - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, branch)); - e.Handled = true; - }; + private void ResetTagFilterMode() + { + if (VisibleTags is TagCollectionAsTree tree) + { + var filters = new Dictionary(); + foreach (var node in tree.Tree) + node.UpdateFilterMode(filters); + } + else if (VisibleTags is TagCollectionAsList list) + { + foreach (var item in list.TagItems) + item.FilterMode = Models.FilterMode.None; + } + } - var createTag = new MenuItem(); - createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); - createTag.Header = App.Text("CreateTag"); - createTag.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateTag(this, branch)); - e.Handled = true; - }; + private BranchTreeNode FindBranchNode(List nodes, string path) + { + if (string.IsNullOrEmpty(path)) + return null; + + 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; + } + } - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (o, e) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Archive(this, branch)); - e.Handled = true; - }; + return null; + } - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (o, e) => { - App.CopyText(branch.Remote + "/" + branch.Name); - e.Handled = true; - }; + private void AutoFetchByTimer(object sender) + { + try + { + Dispatcher.UIThread.Invoke(AutoFetchOnUIThread); + } + catch + { + // Ignore exception. + } + } - 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 = "-" }); - 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 += (o, ev) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateBranch(this, tag)); - ev.Handled = true; - }; + private async Task AutoFetchOnUIThread() + { + if (IsAutoFetching) + return; - var pushTag = new MenuItem(); - pushTag.Header = new Views.NameHighlightedTextBlock("TagCM.Push", tag.Name); - pushTag.Icon = App.CreateMenuIcon("Icons.Push"); - pushTag.IsEnabled = Remotes.Count > 0; - pushTag.Click += (o, ev) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new PushTag(this, tag)); - ev.Handled = true; - }; + CommandLog log = null; - var deleteTag = new MenuItem(); - deleteTag.Header = new Views.NameHighlightedTextBlock("TagCM.Delete", tag.Name); - deleteTag.Icon = App.CreateMenuIcon("Icons.Clear"); - deleteTag.Click += (o, ev) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteTag(this, tag)); - ev.Handled = true; - }; + try + { + if (!Preferences.Instance.EnableAutoFetch || !CanCreatePopup()) + { + _lastFetchTime = DateTime.Now; + return; + } - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (o, ev) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new Archive(this, tag)); - ev.Handled = true; - }; + var lockFile = Path.Combine(GitDir, "index.lock"); + if (File.Exists(lockFile)) + return; - var copy = new MenuItem(); - copy.Header = App.Text("TagCM.Copy"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (o, ev) => { - App.CopyText(tag.Name); - ev.Handled = true; - }; + var now = DateTime.Now; + var desire = _lastFetchTime.AddMinutes(Preferences.Instance.AutoFetchInterval); + if (desire > now) + return; - 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); - return menu; - } - - public ContextMenu CreateContextMenuForSubmodule(string submodule) { - var open = new MenuItem(); - open.Header = App.Text("Submodule.Open"); - open.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - open.Click += (o, ev) => { - var root = Path.GetFullPath(Path.Combine(_fullpath, submodule)); - var gitDir = new Commands.QueryGitDir(root).Result(); - var repo = Preference.AddRepository(root, gitDir); - var node = new RepositoryNode() { - Id = repo.FullPath, - Name = Path.GetFileName(repo.FullPath), - Bookmark = 0, - IsRepository = true, - }; - - var launcher = App.GetTopLevel().DataContext as Launcher; - if (launcher != null) { - launcher.OpenRepositoryInTab(node, null); + var remotes = new List(); + foreach (var r in _remotes) + { + if (!r.DisableAutoFetch) + remotes.Add(r.Name); } - ev.Handled = true; - }; + if (remotes.Count == 0) + return; - var copy = new MenuItem(); - copy.Header = App.Text("Submodule.CopyPath"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (o, ev) => { - App.CopyText(submodule); - ev.Handled = true; - }; + IsAutoFetching = true; + log = CreateLog("Auto-Fetch"); - var rm = new MenuItem(); - rm.Header = App.Text("Submodule.Remove"); - rm.Icon = App.CreateMenuIcon("Icons.Clear"); - rm.Click += (o, ev) => { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteSubmodule(this, submodule)); - ev.Handled = true; - }; + foreach (var remote in remotes) + await new Commands.Fetch(FullPath, remote).Use(log).RunAsync(); + + _lastFetchTime = DateTime.Now; + } + catch + { + // Ignore all exceptions. + } - var menu = new ContextMenu(); - menu.Items.Add(open); - menu.Items.Add(copy); - menu.Items.Add(rm); - return menu; + IsAutoFetching = false; + log?.Complete(); } - private string _fullpath = string.Empty; - private string _gitDir = string.Empty; - private Models.GitFlow _gitflow = new Models.GitFlow(); + private readonly string _gitCommonDir = null; + private Models.RepositorySettings _settings = null; + private Models.RepositoryUIStates _uiStates = null; + private Models.FilterMode _historyFilterMode = Models.FilterMode.None; + private bool _hasAllowedSignersFile = false; + private ulong _queryLocalChangesTimes = 0; 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 bool _isSearching = false; - private string _searchCommitFilter = string.Empty; - private List _searchedCommits = new List(); - - private List _remotes = new List(); - private List _branches = new List(); - private List _localBranchTrees = new List(); - private List _remoteBranchTrees = new List(); - private List _tags = new List(); - private List _submodules = new List(); - private bool _isConflictBarVisible = false; - private bool _hasUnsolvedConflict = false; - private bool _canCommitWithPush = false; - private bool _includeUntracked = true; + + private int _localBranchesCount = 0; + private int _localChangesCount = 0; + private int _stashesCount = 0; + + private bool _isSearchingCommits = false; + private SearchCommitContext _searchCommitContext = null; + + private string _filter = string.Empty; + private List _remotes = []; + private List _branches = []; + private Models.Branch _currentBranch = null; + private List _localBranchTrees = []; + private List _remoteBranchTrees = []; + private List _worktrees = []; + private List _tags = []; + private object _visibleTags = null; + private List _submodules = []; + private object _visibleSubmodules = null; + private string _navigateToCommitDelayed = string.Empty; + + 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 CancellationTokenSource _cancellationRefreshBranches = null; + private CancellationTokenSource _cancellationRefreshTags = null; + private CancellationTokenSource _cancellationRefreshWorkingCopyChanges = null; + private CancellationTokenSource _cancellationRefreshCommits = null; + private CancellationTokenSource _cancellationRefreshStashes = null; } } diff --git a/src/ViewModels/RepositoryCommandPalette.cs b/src/ViewModels/RepositoryCommandPalette.cs new file mode 100644 index 000000000..b607488a6 --- /dev/null +++ b/src/ViewModels/RepositoryCommandPalette.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.ViewModels +{ + public class RepositoryCommandPaletteCmd + { + public string Label { get; set; } + public string Keyword { get; set; } + public string Icon { get; set; } + public bool CloseBeforeExec { get; set; } + public Action Action { get; set; } + + public RepositoryCommandPaletteCmd(string labelKey, string keyword, string icon, Action action) + { + Label = $"{App.Text(labelKey)}..."; + Keyword = keyword; + Icon = icon; + CloseBeforeExec = true; + Action = action; + } + + public RepositoryCommandPaletteCmd(string labelKey, string keyword, string icon, ICommandPalette child) + { + Label = $"{App.Text(labelKey)}..."; + Keyword = keyword; + Icon = icon; + CloseBeforeExec = false; + Action = () => child.Open(); + } + } + + public class RepositoryCommandPalette : ICommandPalette + { + public List VisibleCmds + { + get => _visibleCmds; + private set => SetProperty(ref _visibleCmds, value); + } + + public RepositoryCommandPaletteCmd SelectedCmd + { + get => _selectedCmd; + set => SetProperty(ref _selectedCmd, value); + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateVisible(); + } + } + + public RepositoryCommandPalette(Repository repo) + { + // Sub-CommandPalettes + _cmds.Add(new("Blame", "blame", "Blame", new BlameCommandPalette(repo.FullPath))); + _cmds.Add(new("Checkout", "checkout", "Check", new CheckoutCommandPalette(repo))); + _cmds.Add(new("Compare", "compare", "Compare", new CompareCommandPalette(repo, null))); + _cmds.Add(new("FileHistory", "history", "Histories", new FileHistoryCommandPalette(repo.FullPath))); + _cmds.Add(new("Merge", "merge", "Merge", new MergeCommandPalette(repo))); + _cmds.Add(new("OpenFile", "open", "OpenWith", new OpenFileCommandPalette(repo.FullPath))); + _cmds.Add(new("Repository.CustomActions", "custom actions", "Action", new ExecuteCustomActionCommandPalette(repo))); + + // Raw-Actions + _cmds.Add(new("Repository.NewBranch", "create branch", "Branch.Add", () => repo.CreateNewBranch())); + _cmds.Add(new("CreateTag.Title", "create tag", "Tag.Add", () => repo.CreateNewTag())); + _cmds.Add(new("Fetch", "fetch", "Fetch", async () => await repo.FetchAsync(false))); + _cmds.Add(new("Pull.Title", "pull", "Pull", async () => await repo.PullAsync(false))); + _cmds.Add(new("Push", "push", "Push", async () => await repo.PushAsync(false))); + _cmds.Add(new("Stash.Title", "stash", "Stashes.Add", async () => await repo.StashAllAsync(false))); + _cmds.Add(new("Apply.Title", "apply", "ApplyPatch", () => repo.ApplyPatch())); + + _cmds.Sort((l, r) => l.Label.CompareTo(r.Label)); + _visibleCmds = _cmds; + _selectedCmd = _cmds[0]; + } + + public void ClearFilter() + { + Filter = string.Empty; + } + + public void Exec() + { + _cmds.Clear(); + _visibleCmds.Clear(); + + if (_selectedCmd != null) + { + if (_selectedCmd.CloseBeforeExec) + Close(); + + _selectedCmd.Action?.Invoke(); + } + } + + private void UpdateVisible() + { + if (string.IsNullOrEmpty(_filter)) + { + VisibleCmds = _cmds; + } + else + { + var visible = new List(); + + foreach (var cmd in _cmds) + { + if (cmd.Label.Contains(_filter, StringComparison.OrdinalIgnoreCase) || + cmd.Keyword.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(cmd); + } + + var autoSelected = _selectedCmd; + if (!visible.Contains(_selectedCmd)) + autoSelected = visible.Count > 0 ? visible[0] : null; + + VisibleCmds = visible; + SelectedCmd = autoSelected; + } + } + + private List _cmds = []; + private List _visibleCmds = []; + private RepositoryCommandPaletteCmd _selectedCmd = null; + private string _filter; + } +} diff --git a/src/ViewModels/RepositoryConfigure.cs b/src/ViewModels/RepositoryConfigure.cs index 928d58fd7..a76194716 100644 --- a/src/ViewModels/RepositoryConfigure.cs +++ b/src/ViewModels/RepositoryConfigure.cs @@ -1,69 +1,357 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class RepositoryConfigure : Popup { - public string UserName { +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 string UserEmail { + 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 GPGSigningEnabled { + public bool GPGTagSigningEnabled + { get; set; } - public string GPGUserSigningKey { + public string GPGUserSigningKey + { get; set; } - public string HttpProxy { + public string HttpProxy + { + get => _httpProxy; + set => SetProperty(ref _httpProxy, value); + } + + public string ConventionalTypesOverride + { + get => _repo.Settings.ConventionalTypesOverride; + set + { + if (_repo.Settings.ConventionalTypesOverride != value) + { + _repo.Settings.ConventionalTypesOverride = value; + OnPropertyChanged(); + } + } + } + + public bool EnablePruneOnFetch + { get; set; } - public RepositoryConfigure(Repository repo) { + public bool AskBeforeAutoUpdatingSubmodules + { + get => _repo.Settings.AskBeforeAutoUpdatingSubmodules; + set => _repo.Settings.AskBeforeAutoUpdatingSubmodules = value; + } + + public bool EnableRecursiveWhenAutoUpdatingSubmodules + { + get => _repo.Settings.EnableRecursiveWhenAutoUpdatingSubmodules; + set => _repo.Settings.EnableRecursiveWhenAutoUpdatingSubmodules = value; + } + + public AvaloniaList CommitTemplates + { + get => _repo.Settings.CommitTemplates; + } + + public Models.CommitTemplate SelectedCommitTemplate + { + get => _selectedCommitTemplate; + set => SetProperty(ref _selectedCommitTemplate, value); + } + + public AvaloniaList IssueTrackers + { + get; + } = []; + + public Models.IssueTracker SelectedIssueTracker + { + get => _selectedIssueTracker; + set => SetProperty(ref _selectedIssueTracker, 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; - _cached = new Commands.Config(repo.FullPath).ListAll(); - if (_cached.ContainsKey("user.name")) UserName = _cached["user.name"]; - if (_cached.ContainsKey("user.email")) UserEmail = _cached["user.email"]; - if (_cached.ContainsKey("commit.gpgsign")) GPGSigningEnabled = _cached["commit.gpgsign"] == "true"; - if (_cached.ContainsKey("user.signingkey")) GPGUserSigningKey = _cached["user.signingkey"]; - if (_cached.ContainsKey("http.proxy")) HttpProxy = _cached["user.signingkey"]; + 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).ReadAll(); + 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"); + + foreach (var rule in _repo.IssueTrackers) + { + IssueTrackers.Add(new() + { + IsShared = rule.IsShared, + Name = rule.Name, + RegexString = rule.RegexString, + URLTemplate = rule.URLTemplate, + }); + } + } + + 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 List GetRemoteVisitUrls() + { + var outs = new List(); + foreach (var remote in _repo.Remotes) + { + if (remote.TryGetVisitURL(out var url)) + outs.Add(url); + } + return outs; + } + + public void AddIssueTracker(string name, string regex, string url) + { + var rule = new Models.IssueTracker() + { + IsShared = false, + Name = name, + RegexString = regex, + URLTemplate = url, + }; + + IssueTrackers.Add(rule); + SelectedIssueTracker = rule; + } + + public void RemoveIssueTracker() + { + if (_selectedIssueTracker is { } rule) + IssueTrackers.Remove(rule); + + SelectedIssueTracker = null; + } + + public void AddNewCustomAction() + { + SelectedCustomAction = _repo.Settings.AddNewCustomAction(); + } + + public void RemoveSelectedCustomAction() + { + _repo.Settings.RemoveCustomAction(_selectedCustomAction); + SelectedCustomAction = null; + } - View = new Views.RepositoryConfigure() { DataContext = this }; + public void MoveSelectedCustomActionUp() + { + if (_selectedCustomAction != null) + _repo.Settings.MoveCustomActionUp(_selectedCustomAction); } - public override Task Sure() { - SetIfChanged("user.name", UserName); - SetIfChanged("user.email", UserEmail); - SetIfChanged("commit.gpgsign", GPGSigningEnabled ? "true" : "false"); - SetIfChanged("user.signingkey", GPGUserSigningKey); - SetIfChanged("http.proxy", HttpProxy); - return null; + public void MoveSelectedCustomActionDown() + { + if (_selectedCustomAction != null) + _repo.Settings.MoveCustomActionDown(_selectedCustomAction); } - private void SetIfChanged(string key, string value) { - bool changed = false; - if (_cached.ContainsKey(key)) { - changed = value != _cached[key]; - } else if (!string.IsNullOrEmpty(value)) { + public async Task SaveAsync() + { + _repo.Settings.Save(); + + await SetIfChangedAsync("user.name", UserName, ""); + await SetIfChangedAsync("user.email", UserEmail, ""); + await SetIfChangedAsync("commit.gpgsign", GPGCommitSigningEnabled ? "true" : "false", "false"); + await SetIfChangedAsync("tag.gpgsign", GPGTagSigningEnabled ? "true" : "false", "false"); + await SetIfChangedAsync("user.signingkey", GPGUserSigningKey, ""); + await SetIfChangedAsync("http.proxy", HttpProxy, ""); + await SetIfChangedAsync("fetch.prune", EnablePruneOnFetch ? "true" : "false", "false"); + + await ApplyIssueTrackerChangesAsync(); + } + + private async Task SetIfChangedAsync(string key, string value, string defValue) + { + if (value != _cached.GetValueOrDefault(key, defValue)) + await new Commands.Config(_repo.FullPath).SetAsync(key, value); + } + + private async Task ApplyIssueTrackerChangesAsync() + { + var changed = false; + var oldRules = new Dictionary(); + foreach (var rule in _repo.IssueTrackers) + oldRules.Add(rule.Name, rule); + + foreach (var rule in IssueTrackers) + { + if (oldRules.TryGetValue(rule.Name, out var old)) + { + if (old.IsShared != rule.IsShared) + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, old.IsShared).RemoveAsync(old.Name); + await new Commands.IssueTracker(_repo.FullPath, rule.IsShared).AddAsync(rule); + } + else + { + if (!old.RegexString.Equals(rule.RegexString, StringComparison.Ordinal)) + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, old.IsShared).UpdateRegexAsync(rule); + } + + if (!old.URLTemplate.Equals(rule.URLTemplate, StringComparison.Ordinal)) + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, old.IsShared).UpdateURLTemplateAsync(rule); + } + } + + oldRules.Remove(rule.Name); + } + else + { + changed = true; + await new Commands.IssueTracker(_repo.FullPath, rule.IsShared).AddAsync(rule); + } + } + + if (oldRules.Count > 0) + { changed = true; + + foreach (var kv in oldRules) + await new Commands.IssueTracker(_repo.FullPath, kv.Value.IsShared).RemoveAsync(kv.Key); } - if (changed) { - new Commands.Config(_repo.FullPath).Set(key, value); + if (changed) + { + _repo.IssueTrackers.Clear(); + _repo.IssueTrackers.AddRange(IssueTrackers); } } - private Repository _repo = null; - private Dictionary _cached = null; + private readonly Repository _repo; + private readonly Dictionary _cached; + private string _httpProxy; + private Models.CommitTemplate _selectedCommitTemplate = null; + private Models.IssueTracker _selectedIssueTracker = null; + private Models.CustomAction _selectedCustomAction = null; } } diff --git a/src/ViewModels/RepositoryNode.cs b/src/ViewModels/RepositoryNode.cs index 0955f0285..9d7ab9deb 100644 --- a/src/ViewModels/RepositoryNode.cs +++ b/src/ViewModels/RepositoryNode.cs @@ -1,68 +1,227 @@ -using Avalonia.Collections; -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RepositoryNodeMinimalInfo + { + public string FriendlyName { get; set; } = string.Empty; + public int Bookmark { get; set; } = 0; + } -namespace SourceGit.ViewModels { - public class RepositoryNode : ObservableObject { - public string Id { + public class RepositoryNode : ObservableObject + { + public string Id + { get => _id; - set { - var normalized = value.Replace('\\', '/'); + set + { + var normalized = value.Replace('\\', '/').TrimEnd('/'); SetProperty(ref _id, normalized); } } - public string Name { + public string Name + { get => _name; set => SetProperty(ref _name, value); } - public int Bookmark { + public int Bookmark + { get => _bookmark; set => SetProperty(ref _bookmark, value); } - public bool IsRepository { + public bool IsRepository + { get => _isRepository; set => SetProperty(ref _isRepository, value); } - public bool IsExpanded { + public bool IsExpanded + { get => _isExpanded; set => SetProperty(ref _isExpanded, value); } [JsonIgnore] - public bool IsVisible { + public bool IsVisible + { get => _isVisible; set => SetProperty(ref _isVisible, value); } - - public AvaloniaList SubNodes { - get => _subNodes; - set => SetProperty(ref _subNodes, value); + + [JsonIgnore] + public bool IsInvalid + { + get => _isRepository && !Directory.Exists(_id); + } + + [JsonIgnore] + public bool IsUnmanaged + { + get; + set; + } = false; + + [JsonIgnore] + public int Depth + { + get; + set; + } = 0; + + public Models.RepositoryStatus Status + { + get => _status; + set => SetProperty(ref _status, value); + } + + public List SubNodes + { + get; + set; + } = []; + + public void Open() + { + if (!_isRepository) + { + foreach (var subNode in SubNodes) + subNode.Open(); + } + else if (Directory.Exists(_id)) + { + App.GetLauncher().OpenRepositoryInTab(this, null); + } + } + + 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 Move() + { + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new MoveRepositoryNode(this); } - public void Edit() { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new EditRepositoryNode(this)); + public void OpenInFileManager() + { + if (_isRepository && Directory.Exists(_id)) + Native.OS.OpenInFileManager(_id); } - public void AddSubFolder() { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateGroup(this)); + public void OpenTerminal() + { + if (_isRepository && Directory.Exists(_id)) + Native.OS.OpenTerminal(_id); } - public void OpenInFileManager() { - if (!IsRepository) return; - Native.OS.OpenInFileManager(_id); + public void Delete() + { + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new DeleteRepositoryNode(this); } - public void OpenTerminal() { - if (!IsRepository) return; - Native.OS.OpenTerminal(_id); + public async Task UpdateStatusAsync(bool force, CancellationToken? token) + { + if (token is { IsCancellationRequested: true }) + return; + + if (!_isRepository) + { + Status = null; + + if (SubNodes.Count > 0) + { + // avoid collection was modified while enumerating. + var nodes = new List(); + nodes.AddRange(SubNodes); + + foreach (var node in nodes) + await node.UpdateStatusAsync(force, token); + } + + return; + } + + if (!Directory.Exists(_id)) + { + _lastUpdateStatus = DateTime.Now; + Status = null; + return; + } + + if (!force) + { + var passed = DateTime.Now - _lastUpdateStatus; + if (passed.TotalSeconds < 10.0) + return; + } + + _lastUpdateStatus = DateTime.Now; + Status = await new Commands.QueryRepositoryStatus(_id).GetResultAsync(); + } + + public void LoadMinimalInfo(string gitDir) + { + var savedTo = Path.Combine(gitDir, "sourcegit.node"); + if (!File.Exists(savedTo)) + return; + + try + { + var minimalInfo = JsonSerializer.Deserialize(File.ReadAllText(savedTo), JsonCodeGen.Default.RepositoryNodeMinimalInfo); + if (!string.IsNullOrEmpty(minimalInfo.FriendlyName)) + Name = minimalInfo.FriendlyName; + Bookmark = minimalInfo.Bookmark; + } + catch + { + // Ignore any error and just use default values. + } } - public void Delete() { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new DeleteRepositoryNode(this)); + public void SaveMinimalInfo(string gitDir) + { + if (!Directory.Exists(gitDir)) + return; + + var savedTo = Path.Combine(gitDir, "sourcegit.node"); + var minimalInfo = new RepositoryNodeMinimalInfo + { + FriendlyName = Name, + Bookmark = Bookmark + }; + + try + { + File.WriteAllText(savedTo, JsonSerializer.Serialize(minimalInfo, JsonCodeGen.Default.RepositoryNodeMinimalInfo)); + } + catch + { + // Ignore any error (e.g. the repository directory was removed while the tab was open). + } } private string _id = string.Empty; @@ -71,6 +230,7 @@ public void Delete() { private int _bookmark = 0; private bool _isExpanded = false; private bool _isVisible = true; - private AvaloniaList _subNodes = new AvaloniaList(); + private Models.RepositoryStatus _status = null; + private DateTime _lastUpdateStatus = DateTime.UnixEpoch.ToLocalTime(); } } diff --git a/src/ViewModels/Reset.cs b/src/ViewModels/Reset.cs index 12ba31ed6..11adf1ea7 100644 --- a/src/ViewModels/Reset.cs +++ b/src/ViewModels/Reset.cs @@ -1,67 +1,51 @@ -using Avalonia.Media; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace SourceGit.ViewModels { - public class ResetMode { - public string Name { get; set; } - public string Desc { get; set; } - public string Arg { get; set; } - public IBrush Color { get; set; } - - public ResetMode(string n, string d, string a, IBrush b) { - Name = n; - Desc = d; - Arg = a; - Color = b; - } - } - - public class Reset : Popup { - public Models.Branch Current { +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class Reset : Popup + { + public Models.Branch Current + { get; - private set; } - public Models.Commit To { + public Models.Commit To + { get; - private set; } - public List Modes { - get; - private set; - } - - public ResetMode SelectedMode { + public Models.ResetMode SelectedMode + { get; set; } - public Reset(Repository repo, Models.Branch current, Models.Commit to) { + public Reset(Repository repo, Models.Branch current, Models.Commit to) + { _repo = repo; Current = current; To = to; - Modes = 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), - }; - SelectedMode = Modes[0]; - View = new Views.Reset() { DataContext = this }; + SelectedMode = Models.ResetMode.Supported[1]; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Reset current branch to {To.SHA} ..."; - return Task.Run(() => { - var succ = new Commands.Reset(_repo.FullPath, To.SHA, SelectedMode.Arg).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var log = _repo.CreateLog($"Reset HEAD to '{To.SHA}'"); + Use(log); + + var succ = await new Commands.Reset(_repo.FullPath, To.SHA, SelectedMode.Arg) + .Use(log) + .ExecAsync(); + + await _repo.AutoUpdateSubmodulesAsync(log); + + log.Complete(); + return succ; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/ResetWithoutCheckout.cs b/src/ViewModels/ResetWithoutCheckout.cs new file mode 100644 index 000000000..fb32265b3 --- /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 async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Reset {Target.Name} to {_revision} ..."; + + var log = _repo.CreateLog($"Reset '{Target.Name}' to '{_revision}'"); + Use(log); + + var succ = await new Commands.Branch(_repo.FullPath, Target.Name) + .Use(log) + .CreateAsync(_revision, true); + + log.Complete(); + _repo.MarkBranchesDirtyManually(); + return succ; + } + + private readonly Repository _repo = null; + private readonly string _revision; + } +} diff --git a/src/ViewModels/Revert.cs b/src/ViewModels/Revert.cs index ab3dd9279..ea6f86d09 100644 --- a/src/ViewModels/Revert.cs +++ b/src/ViewModels/Revert.cs @@ -1,35 +1,44 @@ using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class Revert : Popup { - public Models.Commit Target { +namespace SourceGit.ViewModels +{ + public class Revert : Popup + { + public Models.Commit Target + { get; - private set; } - public bool AutoCommit { + public bool AutoCommit + { get; set; } - public Revert(Repository repo, Models.Commit target) { + public Revert(Repository repo, Models.Commit target) + { _repo = repo; Target = target; AutoCommit = true; - View = new Views.Revert() { DataContext = this }; } - public override Task Sure() { - _repo.SetWatcherEnabled(false); + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + _repo.ClearCommitMessage(); ProgressDescription = $"Revert commit '{Target.SHA}' ..."; - return Task.Run(() => { - var succ = new Commands.Revert(_repo.FullPath, Target.SHA, AutoCommit).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + var log = _repo.CreateLog($"Revert '{Target.SHA}'"); + Use(log); + + await new Commands.Revert(_repo.FullPath, Target.SHA, AutoCommit) + .Use(log) + .ExecAsync(); + + log.Complete(); + return true; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs index c85e5865b..ef38668cc 100644 --- a/src/ViewModels/RevisionCompare.cs +++ b/src/ViewModels/RevisionCompare.cs @@ -1,182 +1,402 @@ -using Avalonia.Controls; -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using System; +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class RevisionCompare : ObservableObject { - public Models.Commit StartPoint { - get; - private set; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class RevisionCompare : ObservableObject + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); } - public Models.Commit EndPoint { - get; - private set; + public object StartPoint + { + get => _startPoint; + private set => SetProperty(ref _startPoint, value); } - public List VisibleChanges { - get => _visibleChanges; - private set => SetProperty(ref _visibleChanges, value); + public object EndPoint + { + get => _endPoint; + private set => SetProperty(ref _endPoint, value); } - public List ChangeTree { - get => _changeTree; - private set => SetProperty(ref _changeTree, value); + public string LeftSideDesc + { + get => GetDesc(StartPoint); } - public Models.Change SelectedChange { - get => _selectedChange; - set { - if (SetProperty(ref _selectedChange, value)) { - if (value == null) { - SelectedNode = null; - DiffContext = null; - } else { - SelectedNode = FileTreeNode.SelectByPath(_changeTree, value.Path); - DiffContext = new DiffContext(_repo, new Models.DiffOption(StartPoint.SHA, EndPoint.SHA, value)); - } - } - } + public string RightSideDesc + { + get => GetDesc(EndPoint); + } + + public bool CanResetToLeft + { + get => !_repo.IsBare && _startPoint != null; + } + + public bool CanResetToRight + { + get => !_repo.IsBare && _endPoint != null; + } + + public bool CanSaveAsPatch + { + get => _startPoint != null && _endPoint != null; + } + + public int TotalChanges + { + get => _totalChanges; + private set => SetProperty(ref _totalChanges, value); + } + + public List VisibleChanges + { + get => _visibleChanges; + private set => SetProperty(ref _visibleChanges, value); } - public FileTreeNode SelectedNode { - get => _selectedNode; - set { - if (SetProperty(ref _selectedNode, value)) { - if (value == null) { - SelectedChange = null; - } else { - SelectedChange = value.Backend as Models.Change; + public List SelectedChanges + { + get => _selectedChanges; + set + { + if (SetProperty(ref _selectedChanges, value)) + { + if (value is { Count: 1 }) + { + var option = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), value[0]); + DiffContext = new DiffContext(_repo.FullPath, option, _diffContext); + } + else + { + DiffContext = null; } } } } - public string SearchFilter { + public string SearchFilter + { get => _searchFilter; - set { - if (SetProperty(ref _searchFilter, value)) { + set + { + if (SetProperty(ref _searchFilter, value)) RefreshVisible(); - } } } - public DiffContext DiffContext { + public DiffContext DiffContext + { get => _diffContext; private set => SetProperty(ref _diffContext, value); } - public RevisionCompare(string repo, Models.Commit startPoint, Models.Commit endPoint) { + public RevisionCompare(Repository repo, Models.Commit startPoint, Models.Commit endPoint) + { _repo = repo; - StartPoint = startPoint; - EndPoint = endPoint; + _startPoint = (object)startPoint ?? new Models.Null(); + _endPoint = (object)endPoint ?? new Models.Null(); + Refresh(); + } - Task.Run(() => { - _changes = new Commands.CompareRevisions(_repo, startPoint.SHA, endPoint.SHA).Result(); + public RevisionCompare Clone() + { + return new RevisionCompare(_repo, _startPoint as Models.Commit, _endPoint as Models.Commit); + } - 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); - } - } - } + public void SetTargets(Models.Commit l, Models.Commit r) + { + var hashes = new HashSet(); + hashes.Add(l.SHA); + hashes.Add(r.SHA); - var tree = FileTreeNode.Build(visible); - Dispatcher.UIThread.Invoke(() => { - VisibleChanges = visible; - ChangeTree = tree; - }); - }); + if (_startPoint is Models.Commit s && + _endPoint is Models.Commit e && + hashes.Contains(s.SHA) && + hashes.Contains(e.SHA)) + return; + + _startPoint = l; + _endPoint = r; + Refresh(); } - public void Cleanup() { - _repo = null; - if (_changes != null) _changes.Clear(); - if (_visibleChanges != null) _visibleChanges.Clear(); - if (_changeTree != null) _changeTree.Clear(); - _selectedChange = null; - _selectedNode = null; - _searchFilter = null; - _diffContext = null; + public void OpenChangeWithExternalDiffTool(Models.Change change) + { + var opt = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), change); + new Commands.DiffTool(_repo.FullPath, opt).Open(); } - public void NavigateTo(string commitSHA) { - var repo = Preference.FindRepository(_repo); - if (repo != null) repo.NavigateToCommit(commitSHA); + public void NavigateTo(string commitSHA) + { + _repo?.NavigateToCommit(commitSHA); } - public void ClearSearchFilter() { - SearchFilter = string.Empty; + public void Swap() + { + (StartPoint, EndPoint) = (_endPoint, _startPoint); + VisibleChanges = []; + SelectedChanges = []; + IsLoading = true; + Refresh(); + } + + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo.FullPath, path); + } + + public async Task ResetToLeftAsync(Models.Change change) + { + var sha = GetSHA(_startPoint); + var log = _repo.CreateLog($"Reset File to '{GetDesc(_startPoint)}'"); + + if (change.Index == Models.ChangeState.Added) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + await new Commands.Remove(_repo.FullPath, [change.Path]) + .Use(log) + .ExecAsync(); + } + else if (change.Index == Models.ChangeState.Renamed) + { + var renamed = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(renamed)) + await new Commands.Remove(_repo.FullPath, [change.Path]) + .Use(log) + .ExecAsync(); + + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.OriginalPath, sha); + } + else + { + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, sha); + } + + log.Complete(); + } + + public async Task ResetToRightAsync(Models.Change change) + { + var sha = GetSHA(_endPoint); + var log = _repo.CreateLog($"Reset File to '{GetDesc(_endPoint)}'"); + + if (change.Index == Models.ChangeState.Deleted) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + await new Commands.Remove(_repo.FullPath, [change.Path]) + .Use(log) + .ExecAsync(); + } + else if (change.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, change.OriginalPath); + if (File.Exists(old)) + await new Commands.Remove(_repo.FullPath, [change.OriginalPath]) + .Use(log) + .ExecAsync(); + + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, sha); + } + else + { + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, sha); + } + + log.Complete(); + } + + public async Task ResetMultipleToLeftAsync(List changes) + { + var sha = GetSHA(_startPoint); + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Added) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var old = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(old)) + removes.Add(c.Path); + + checkouts.Add(c.OriginalPath); + } + else + { + checkouts.Add(c.Path); + } + } + + var log = _repo.CreateLog($"Reset Files to '{GetDesc(_startPoint)}'"); + + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); + + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, sha); + + log.Complete(); } - public ContextMenu CreateChangeContextMenu(Models.Change change) { - var menu = new ContextMenu(); - - if (change.Index != Models.ChangeState.Deleted) { - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Click += (_, ev) => { - var window = new Views.FileHistories() { DataContext = new FileHistories(_repo, change.Path) }; - window.Show(); - ev.Handled = true; - }; - - var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.IsEnabled = File.Exists(full); - explore.Click += (_, ev) => { - Native.OS.OpenInFileManager(full, true); - ev.Handled = true; - }; - - menu.Items.Add(history); - menu.Items.Add(explore); + public async Task ResetMultipleToRightAsync(List changes) + { + var sha = GetSHA(_endPoint); + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Deleted) + { + var fullpath = Native.OS.GetAbsPath(_repo.FullPath, c.Path); + if (File.Exists(fullpath)) + removes.Add(c.Path); + } + else if (c.Index == Models.ChangeState.Renamed) + { + var renamed = Native.OS.GetAbsPath(_repo.FullPath, c.OriginalPath); + if (File.Exists(renamed)) + removes.Add(c.OriginalPath); + + checkouts.Add(c.Path); + } + else + { + checkouts.Add(c.Path); + } } - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Click += (_, ev) => { - App.CopyText(change.Path); - ev.Handled = true; - }; + var log = _repo.CreateLog($"Reset Files to '{GetDesc(_endPoint)}'"); - menu.Items.Add(copyPath); - return menu; + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); + + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, sha); + + log.Complete(); + } + + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) + { + var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, changes ?? _changes, GetSHA(_startPoint), GetSHA(_endPoint), saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; } - private void RefreshVisible() { - if (_changes == null) return; + private void RefreshVisible() + { + if (_changes == null) + return; - if (string.IsNullOrEmpty(_searchFilter)) { + if (string.IsNullOrEmpty(_searchFilter)) + { VisibleChanges = _changes; - } else { + } + else + { var visible = new List(); - foreach (var c in _changes) { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) { + foreach (var c in _changes) + { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) visible.Add(c); - } } VisibleChanges = visible; } + } + + private void Refresh() + { + Task.Run(async () => + { + _changes = await new Commands.CompareRevisions(_repo.FullPath, GetSHA(_startPoint), GetSHA(_endPoint)) + .ReadAsync() + .ConfigureAwait(false); + + 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.Post(() => + { + TotalChanges = _changes.Count; + VisibleChanges = visible; + IsLoading = false; + + if (VisibleChanges.Count > 0) + SelectedChanges = [VisibleChanges[0]]; + else + SelectedChanges = []; + }); + }); + } + + private string GetSHA(object obj) + { + return obj is Models.Commit commit ? commit.SHA : string.Empty; + } - ChangeTree = FileTreeNode.Build(_visibleChanges); + private string GetDesc(object obj) + { + return obj is Models.Commit commit ? commit.GetFriendlyName() : App.Text("Worktree"); } - private string _repo = string.Empty; + private Repository _repo; + private bool _isLoading = true; + private object _startPoint = null; + private object _endPoint = null; + private int _totalChanges = 0; private List _changes = null; private List _visibleChanges = null; - private List _changeTree = null; - private Models.Change _selectedChange = null; - private FileTreeNode _selectedNode = 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 000000000..2f94d40d9 --- /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?.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 000000000..2e51c69ce --- /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(async () => + { + var source = await ImageSource.FromLFSObjectAsync(repo, lfs, decoder).ConfigureAwait(false); + var img = new Models.RevisionImageFile(file, source.Bitmap, source.Size); + Dispatcher.UIThread.Post(() => Image = img); + }); + } + + private Models.RevisionImageFile _image = null; + } +} diff --git a/src/ViewModels/Reword.cs b/src/ViewModels/Reword.cs deleted file mode 100644 index de034162b..000000000 --- a/src/ViewModels/Reword.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; - -namespace SourceGit.ViewModels { - public class Reword : Popup { - public Models.Commit Head { - get; - private set; - } - - [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; - Head = head; - Message = head.FullMessage; - View = new Views.Reword() { DataContext = this }; - } - - public override Task Sure() { - if (_message == Head.FullMessage) return null; - - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Editing head commit message ..."; - - return Task.Run(() => { - var succ = new Commands.Commit(_repo.FullPath, _message, true, true).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); - } - - private Repository _repo = null; - private string _message = string.Empty; - } -} diff --git a/src/ViewModels/ScanRepositories.cs b/src/ViewModels/ScanRepositories.cs new file mode 100644 index 000000000..f9ac3e41b --- /dev/null +++ b/src/ViewModels/ScanRepositories.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class ScanRepositories : Popup + { + public bool UseCustomDir + { + get => _useCustomDir; + set => SetProperty(ref _useCustomDir, value); + } + + public string CustomDir + { + get => _customDir; + set => SetProperty(ref _customDir, value); + } + + public List ScanDirs + { + get; + } + + public Models.ScanDir Selected + { + get => _selected; + set => SetProperty(ref _selected, value, true); + } + + public ScanRepositories() + { + ScanDirs = new List(); + + var workspace = Preferences.Instance.GetActiveWorkspace(); + if (!string.IsNullOrEmpty(workspace.DefaultCloneDir)) + ScanDirs.Add(new Models.ScanDir(workspace.DefaultCloneDir, "Workspace")); + + if (!string.IsNullOrEmpty(Preferences.Instance.GitDefaultCloneDir)) + ScanDirs.Add(new Models.ScanDir(Preferences.Instance.GitDefaultCloneDir, "Global")); + + if (ScanDirs.Count > 0) + _selected = ScanDirs[0]; + else + _useCustomDir = true; + + GetManagedRepositories(Preferences.Instance.RepositoryNodes, _managed); + } + + public override async Task Sure() + { + string selectedDir; + if (_useCustomDir) + { + if (string.IsNullOrEmpty(_customDir)) + { + Models.Notification.Send(null, "Missing root directory to scan!", true); + return false; + } + + selectedDir = _customDir; + } + else + { + if (_selected == null || string.IsNullOrEmpty(_selected.Path)) + { + Models.Notification.Send(null, "Missing root directory to scan!", true); + return false; + } + + selectedDir = _selected.Path; + } + + if (!Directory.Exists(selectedDir)) + return true; + + ProgressDescription = $"Scan repositories under '{selectedDir}' ..."; + + var minDelay = Task.Delay(500); + var rootDir = new DirectoryInfo(selectedDir); + var found = new List(); + + await GetUnmanagedRepositoriesAsync(rootDir, found, new EnumerationOptions() + { + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + IgnoreInaccessible = true, + }); + + // Make sure this task takes at least 0.5s to avoid the popup panel disappearing too quickly. + await minDelay; + + var normalizedRoot = rootDir.FullName.Replace('\\', '/').TrimEnd('/'); + for (var i = 0; i < found.Count; i++) + { + var f = found[i]; + ProgressDescription = $"Registering ({i + 1}/{found.Count}) {f}..."; + var parent = new DirectoryInfo(f).Parent!.FullName.Replace('\\', '/').TrimEnd('/'); + if (parent.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(f, null, false, false); + await node.UpdateStatusAsync(false, null); + } + else if (parent.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + var relative = parent.Substring(normalizedRoot.Length).TrimStart('/'); + var group = FindOrCreateGroupRecursive(Preferences.Instance.RepositoryNodes, relative); + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(f, group, false, false); + await node.UpdateStatusAsync(false, null); + } + } + + 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(OperatingSystem.IsLinux() ? node.Id : node.Id.ToLower(CultureInfo.CurrentCulture)); + else + GetManagedRepositories(node.SubNodes, repos); + } + } + + private async Task GetUnmanagedRepositoriesAsync(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; + + ProgressDescription = $"Scanning {subdir.FullName}..."; + + var normalizedSelf = subdir.FullName.Replace('\\', '/').TrimEnd('/'); + if (IsManaged(normalizedSelf)) + continue; + + var gitDir = Path.Combine(subdir.FullName, ".git"); + if (Directory.Exists(gitDir) || File.Exists(gitDir)) + { + var test = await new Commands.QueryRepositoryRootPath(subdir.FullName).GetResultAsync(); + if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) + { + var normalized = test.StdOut.Trim().Replace('\\', '/').TrimEnd('/'); + if (!IsManaged(normalized)) + outs.Add(normalized); + } + + continue; + } + + var isBare = await new Commands.IsBareRepository(subdir.FullName).GetResultAsync(); + if (isBare) + { + outs.Add(normalizedSelf); + continue; + } + + if (depth < 5) + await GetUnmanagedRepositoriesAsync(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 bool IsManaged(string path) + { + if (OperatingSystem.IsLinux()) + return _managed.Contains(path); + + return _managed.Contains(path.ToLower(CultureInfo.CurrentCulture)); + } + + private HashSet _managed = new(); + private bool _useCustomDir = false; + private string _customDir = string.Empty; + private Models.ScanDir _selected = null; + } +} diff --git a/src/ViewModels/SearchCommitContext.cs b/src/ViewModels/SearchCommitContext.cs new file mode 100644 index 000000000..e67046a9c --- /dev/null +++ b/src/ViewModels/SearchCommitContext.cs @@ -0,0 +1,302 @@ +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 SearchCommitContext : ObservableObject + { + public int Method + { + get => _method; + set + { + if (SetProperty(ref _method, value)) + { + UpdateSuggestions(); + StartSearch(); + } + } + } + + public string Filter + { + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + UpdateSuggestions(); + } + } + + public bool OnlySearchCurrentBranch + { + get => _onlySearchCurrentBranch; + set + { + if (SetProperty(ref _onlySearchCurrentBranch, value)) + StartSearch(); + } + } + + public List Suggestions + { + get => _suggestions; + private set => SetProperty(ref _suggestions, value); + } + + public bool IsQuerying + { + get => _isQuerying; + private set => SetProperty(ref _isQuerying, value); + } + + public List Results + { + get => _results; + private set => SetProperty(ref _results, value); + } + + public Models.Commit Selected + { + get => _selected; + set + { + if (SetProperty(ref _selected, value) && value != null) + _repo.NavigateToCommit(value.SHA); + } + } + + public SearchCommitContext(Repository repo) + { + _repo = repo; + } + + public void ClearFilter() + { + Filter = string.Empty; + Selected = null; + Results = null; + } + + public void ClearSuggestions() + { + Suggestions = null; + } + + public void StartSearch() + { + Results = null; + Selected = null; + Suggestions = null; + + if (string.IsNullOrEmpty(_filter)) + return; + + IsQuerying = true; + + if (_cancellation is { IsCancellationRequested: false }) + _cancellation.Cancel(); + + _cancellation = new(); + var token = _cancellation.Token; + + Task.Run(async () => + { + var result = new List(); + var method = (Models.CommitSearchMethod)_method; + var repoPath = _repo.FullPath; + + if (method == Models.CommitSearchMethod.BySHA) + { + var isCommitSHA = await new Commands.IsCommitSHA(repoPath, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + if (isCommitSHA) + { + var commit = await new Commands.QuerySingleCommit(repoPath, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + commit.IsMerged = await new Commands.IsAncestor(repoPath, commit.SHA, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + result.Add(commit); + } + } + else if (_onlySearchCurrentBranch) + { + result = await new Commands.QueryCommits(repoPath, _filter, method, true) + .GetResultAsync() + .ConfigureAwait(false); + + foreach (var c in result) + c.IsMerged = true; + } + else + { + result = await new Commands.QueryCommits(repoPath, _filter, method, false) + .GetResultAsync() + .ConfigureAwait(false); + + if (result.Count > 0) + { + var set = await new Commands.QueryCurrentBranchCommitHashes(repoPath, result[^1].CommitterTime) + .GetResultAsync() + .ConfigureAwait(false); + + foreach (var c in result) + c.IsMerged = set.Contains(c.SHA); + } + } + + Dispatcher.UIThread.Post(() => + { + if (token.IsCancellationRequested) + return; + + IsQuerying = false; + if (_repo.IsSearchingCommits) + { + Results = result; + if (method == Models.CommitSearchMethod.BySHA && result.Count == 1) + Selected = result[0]; + } + }); + }, token); + } + + public void EndSearch() + { + if (_cancellation is { IsCancellationRequested: false }) + _cancellation.Cancel(); + + _worktreeFiles = null; + _authors = null; + + IsQuerying = false; + Suggestions = null; + Results = null; + GC.Collect(); + } + + private void UpdateSuggestions() + { + if (_method == (int)Models.CommitSearchMethod.ByAuthor) + { + if (_authors == null) + { + if (_requestingAuthors) + return; + + _requestingAuthors = true; + + Task.Run(async () => + { + var authors = await new Commands.QueryAuthors(_repo.FullPath) + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + _requestingAuthors = false; + + if (_repo.IsSearchingCommits) + { + _authors = authors; + UpdateSuggestions(); + } + }); + }); + + return; + } + + if (_authors.Count == 0 || _filter.Length < 2) + { + Suggestions = null; + return; + } + + var matched = new List(); + foreach (var author in _authors) + { + if (author.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase) || + author.Email.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + matched.Add(author); + } + + Suggestions = matched; + } + else if (_method == (int)Models.CommitSearchMethod.ByPath) + { + if (_worktreeFiles == null) + { + if (_requestingWorktreeFiles) + return; + + _requestingWorktreeFiles = true; + + Task.Run(async () => + { + var files = await new Commands.QueryRevisionFileNames(_repo.FullPath, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { + _requestingWorktreeFiles = false; + + if (_repo.IsSearchingCommits) + { + _worktreeFiles = files; + UpdateSuggestions(); + } + }); + }); + + return; + } + + if (_worktreeFiles.Count == 0 || _filter.Length < 3) + { + Suggestions = null; + return; + } + + var matched = new List(); + foreach (var file in _worktreeFiles) + { + if (file.Contains(_filter, StringComparison.OrdinalIgnoreCase) && file.Length != _filter.Length) + matched.Add(file); + } + + Suggestions = matched; + } + else + { + Suggestions = null; + return; + } + } + + private Repository _repo = null; + private CancellationTokenSource _cancellation = null; + private int _method = (int)Models.CommitSearchMethod.ByMessage; + private string _filter = string.Empty; + private bool _onlySearchCurrentBranch = false; + private bool _isQuerying = false; + private List _results = null; + private Models.Commit _selected = null; + private bool _requestingWorktreeFiles = false; + private List _worktreeFiles = null; + private bool _requestingAuthors = false; + private List _authors = null; + private List _suggestions = null; + } +} diff --git a/src/ViewModels/SelfUpdate.cs b/src/ViewModels/SelfUpdate.cs new file mode 100644 index 000000000..3b471576b --- /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/SetSubmoduleBranch.cs b/src/ViewModels/SetSubmoduleBranch.cs new file mode 100644 index 000000000..e6edd674d --- /dev/null +++ b/src/ViewModels/SetSubmoduleBranch.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class SetSubmoduleBranch : Popup + { + public Models.Submodule Submodule + { + get; + } + + public string ChangeTo + { + get => _changeTo; + set => SetProperty(ref _changeTo, value); + } + + public SetSubmoduleBranch(Repository repo, Models.Submodule submodule) + { + _repo = repo; + _changeTo = submodule.Branch; + Submodule = submodule; + } + + public override async Task Sure() + { + ProgressDescription = "Set submodule's branch ..."; + + if (_changeTo.Equals(Submodule.Branch, StringComparison.Ordinal)) + return true; + + using var lockWatcher = _repo.LockWatcher(); + var log = _repo.CreateLog("Set Submodule's Branch"); + Use(log); + + var succ = await new Commands.Submodule(_repo.FullPath) + .Use(log) + .SetBranchAsync(Submodule.Path, _changeTo); + + log.Complete(); + return succ; + } + + private readonly Repository _repo; + private string _changeTo; + } +} diff --git a/src/ViewModels/SetUpstream.cs b/src/ViewModels/SetUpstream.cs new file mode 100644 index 000000000..e3ad871f2 --- /dev/null +++ b/src/ViewModels/SetUpstream.cs @@ -0,0 +1,85 @@ +using System; +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 async Task Sure() + { + ProgressDescription = "Setting upstream..."; + Models.Branch upstream = _unset ? null : SelectedRemoteBranch; + + if (upstream == null) + { + if (string.IsNullOrEmpty(Local.Upstream)) + return true; + } + else if (upstream.FullName.Equals(Local.Upstream, StringComparison.Ordinal)) + { + return true; + } + + var log = _repo.CreateLog("Set Upstream"); + Use(log); + + var succ = await new Commands.Branch(_repo.FullPath, Local.Name) + .Use(log) + .SetUpstreamAsync(upstream); + + log.Complete(); + if (succ) + _repo.MarkBranchesDirtyManually(); + return true; + } + + private readonly Repository _repo; + private bool _unset = false; + } +} diff --git a/src/ViewModels/Squash.cs b/src/ViewModels/Squash.cs deleted file mode 100644 index 1c4a55471..000000000 --- a/src/ViewModels/Squash.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; - -namespace SourceGit.ViewModels { - public class Squash : Popup { - public Models.Commit Head { - get; - private set; - } - - public Models.Commit Parent { - get; - private set; - } - - [Required(ErrorMessage = "Commit message is required!!!")] - public string Message { - get => _message; - set => SetProperty(ref _message, value, true); - } - - public Squash(Repository repo, Models.Commit head, Models.Commit parent) { - _repo = repo; - _message = parent.FullMessage; - Head = head; - Parent = parent; - View = new Views.Squash() { DataContext = this }; - } - - public override Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = "Squashing ..."; - - return Task.Run(() => { - var succ = new Commands.Reset(_repo.FullPath, Parent.SHA, "--soft").Exec(); - if (succ) succ = new Commands.Commit(_repo.FullPath, _message, true).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); - } - - private Repository _repo = null; - private string _message = string.Empty; - } -} diff --git a/src/ViewModels/StashChanges.cs b/src/ViewModels/StashChanges.cs index 43d24ab5f..bde579b8a 100644 --- a/src/ViewModels/StashChanges.cs +++ b/src/ViewModels/StashChanges.cs @@ -1,57 +1,161 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class StashChanges : Popup { - - public string Message { +namespace SourceGit.ViewModels +{ + public class StashChanges : Popup + { + public string Message + { get; set; } - public bool CanIgnoreUntracked { - get; - private set; + public bool HasSelectedFiles + { + get => _changes != null; } - public bool IncludeUntracked { - get; - set; + public bool IncludeUntracked + { + get => _repo.UIStates.IncludeUntrackedWhenStash; + set => _repo.UIStates.IncludeUntrackedWhenStash = value; + } + + public bool OnlyStaged + { + get => _repo.UIStates.OnlyStagedWhenStash; + set + { + if (_repo.UIStates.OnlyStagedWhenStash != value) + { + _repo.UIStates.OnlyStagedWhenStash = value; + OnPropertyChanged(); + } + } } - public StashChanges(Repository repo, List changes, bool canIgnoreUntracked) { + public int ChangesAfterStashing + { + get => _repo.UIStates.ChangesAfterStashing; + set => _repo.UIStates.ChangesAfterStashing = value; + } + + public StashChanges(Repository repo, List selectedChanges) + { _repo = repo; - _changes = changes; - - CanIgnoreUntracked = canIgnoreUntracked; - IncludeUntracked = true; - View = new Views.StashChanges() { DataContext = this }; - } - - public override Task Sure() { - var jobs = _changes; - if (CanIgnoreUntracked && !IncludeUntracked) { - jobs = new List(); - foreach (var job in _changes) { - if (job.WorkTree != Models.ChangeState.Untracked && job.WorkTree != Models.ChangeState.Added) { - jobs.Add(job); + _changes = selectedChanges; + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Stash changes ..."; + + var log = _repo.CreateLog("Stash Local Changes"); + Use(log); + + var mode = (DealWithChangesAfterStashing)ChangesAfterStashing; + var keepIndex = mode == DealWithChangesAfterStashing.KeepIndex; + bool succ; + + if (_changes == null) + { + if (OnlyStaged) + { + if (Native.OS.GitVersion >= Models.GitVersions.STASH_PUSH_ONLY_STAGED) + { + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushOnlyStagedAsync(Message, keepIndex); } + else + { + var all = await new Commands.QueryLocalChanges(_repo.FullPath, false) + .Use(log) + .GetResultAsync(); + + var staged = new List(); + foreach (var c in all) + { + if (c.Index != Models.ChangeState.None && c.Index != Models.ChangeState.Untracked) + staged.Add(c); + } + + succ = await StashWithChangesAsync(staged, keepIndex, log); + } + } + else + { + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync(Message, IncludeUntracked, keepIndex); } } + else + { + succ = await StashWithChangesAsync(_changes, keepIndex, log); + } - if (jobs.Count == 0) return null; + if (mode == DealWithChangesAfterStashing.KeepAll && succ) + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .ApplyAsync("stash@{0}", true); - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Stash changes ..."; + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + _repo.MarkStashesDirtyManually(); + return succ; + } - return Task.Run(() => { - new Commands.Stash(_repo.FullPath).Push(jobs, Message); - CallUIThread(() => _repo.SetWatcherEnabled(true)); + private async Task StashWithChangesAsync(List changes, bool keepIndex, 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(); + await File.WriteAllLinesAsync(pathSpecFile, paths); + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync(Message, pathSpecFile, keepIndex) + .ConfigureAwait(false); + 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 = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync(Message, step, keepIndex) + .ConfigureAwait(false); + if (!succ) + break; + } + } + + return succ; + } + + private enum DealWithChangesAfterStashing + { + Discard = 0, + KeepIndex, + KeepAll, } - private Repository _repo = null; - private List _changes = null; + private readonly Repository _repo = null; + private readonly List _changes = null; } } diff --git a/src/ViewModels/StashesPage.cs b/src/ViewModels/StashesPage.cs index c3dc0d8fc..9f4874dba 100644 --- a/src/ViewModels/StashesPage.cs +++ b/src/ViewModels/StashesPage.cs @@ -1,34 +1,85 @@ -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; +using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; -namespace SourceGit.ViewModels { - public class StashesPage : ObservableObject { - public int Count { - get => _stashes == null ? 0 : _stashes.Count; - } +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; - public List Stashes { +namespace SourceGit.ViewModels +{ + public class StashesPage : ObservableObject + { + public List Stashes + { get => _stashes; - set { - if (SetProperty(ref _stashes, value)) { + set + { + if (SetProperty(ref _stashes, value)) + RefreshVisible(); + } + } + + public List VisibleStashes + { + get => _visibleStashes; + private set + { + if (SetProperty(ref _visibleStashes, value)) SelectedStash = null; - } } } - public Models.Stash SelectedStash { + 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) { + set + { + if (SetProperty(ref _selectedStash, value)) + { + if (value == null) + { Changes = null; - } else { - Task.Run(() => { - var changes = new Commands.QueryStashChanges(_repo.FullPath, value.SHA).Result(); - Dispatcher.UIThread.Invoke(() => { - Changes = changes; + _untracked.Clear(); + } + else + { + Task.Run(async () => + { + var changes = await new Commands.CompareRevisions(_repo.FullPath, $"{value.SHA}^", value.SHA) + .ReadAsync() + .ConfigureAwait(false); + + var untracked = new List(); + if (value.Parents.Count == 3) + { + untracked = await new Commands.CompareRevisions(_repo.FullPath, value.UntrackedParent, value.Parents[2]) + .ReadAsync() + .ConfigureAwait(false); + + var needSort = changes.Count > 0 && untracked.Count > 0; + changes.AddRange(untracked); + if (needSort) + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + } + + Dispatcher.UIThread.Post(() => + { + if (value.SHA.Equals(_selectedStash?.SHA ?? string.Empty, StringComparison.Ordinal)) + { + _untracked = untracked; + Changes = changes; + } }); }); } @@ -36,79 +87,201 @@ public Models.Stash SelectedStash { } } - public List Changes { + public List Changes + { get => _changes; - private set { - if (SetProperty(ref _changes, value)) { - SelectedChange = null; - } + private set + { + if (SetProperty(ref _changes, value)) + SelectedChanges = value is { Count: > 0 } ? [value[0]] : []; } } - public Models.Change SelectedChange { - get => _selectedChange; - set { - if (SetProperty(ref _selectedChange, value)) { - if (value == null) { + public List SelectedChanges + { + get => _selectedChanges; + set + { + if (SetProperty(ref _selectedChanges, value)) + { + if (value is not { Count: 1 }) DiffContext = null; - } else { - DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption($"{_selectedStash.SHA}^", _selectedStash.SHA, value)); - } + else if (_untracked.Contains(value[0])) + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.UntrackedParent, _selectedStash.Parents[2], value[0]), _diffContext); + else + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, value[0]), _diffContext); } } } - public DiffContext DiffContext { + public DiffContext DiffContext + { get => _diffContext; private set => SetProperty(ref _diffContext, value); } - public StashesPage(Repository repo) { + public StashesPage(Repository repo) + { _repo = repo; } - public void Cleanup() { - _repo = null; - if (_stashes != null) _stashes.Clear(); - _selectedStash = null; - if (_changes != null) _changes.Clear(); - _selectedChange = null; - _diffContext = null; + public void ClearSearchFilter() + { + SearchFilter = string.Empty; + } + + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo.FullPath, path); + } + + public void Apply(Models.Stash stash) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new ApplyStash(_repo, stash)); + } + + public void CheckoutBranch(Models.Stash stash) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutBranchFromStash(_repo, stash)); + } + + public void Drop(Models.Stash stash) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new DropStash(_repo, stash)); } - public void Apply(object param) { - if (param is Models.Stash stash) { - Task.Run(() => { - new Commands.Stash(_repo.FullPath).Apply(stash.Name); - }); + public async Task SaveStashAsPatchAsync(Models.Stash stash, string saveTo) + { + var opts = new List(); + var changes = await new Commands.CompareRevisions(_repo.FullPath, $"{stash.SHA}^", stash.SHA) + .ReadAsync() + .ConfigureAwait(false); + + foreach (var c in changes) + opts.Add(new Models.DiffOption(stash.Parents[0], stash.SHA, c)); + + if (stash.Parents.Count == 3) + { + var untracked = await new Commands.CompareRevisions(_repo.FullPath, stash.UntrackedParent, stash.Parents[2]) + .ReadAsync() + .ConfigureAwait(false); + + foreach (var c in untracked) + opts.Add(new Models.DiffOption(stash.UntrackedParent, stash.Parents[2], c)); + + changes.AddRange(untracked); } + + var succ = await Commands.SaveChangesAsPatch.ProcessStashChangesAsync(_repo.FullPath, opts, saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); } - public void Pop(object param) { - if (param is Models.Stash stash) { - Task.Run(() => { - new Commands.Stash(_repo.FullPath).Pop(stash.Name); - }); + public void OpenChangeWithExternalDiffTool(Models.Change change) + { + Models.DiffOption opt; + if (_untracked.Contains(change)) + opt = new Models.DiffOption(_selectedStash.UntrackedParent, _selectedStash.Parents[2], change); + else + opt = new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, change); + + new Commands.DiffTool(_repo.FullPath, opt).Open(); + } + + public async Task CheckoutFilesAsync(List changes) + { + var untracked = new List(); + var added = new List(); + var modified = new List(); + + foreach (var c in changes) + { + if (_untracked.Contains(c) && _selectedStash.Parents.Count == 3) + untracked.Add(c.Path); + else if (c.Index == Models.ChangeState.Added && _selectedStash.Parents.Count > 1) + added.Add(c.Path); + else + modified.Add(c.Path); } + + var log = _repo.CreateLog($"Reset File to '{_selectedStash.Name}'"); + + if (untracked.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(untracked, _selectedStash.Parents[2]); + + if (added.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(added, _selectedStash.Parents[1]); + + if (modified.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(modified, _selectedStash.SHA); + + log.Complete(); } - public void Drop(object param) { - if (param is Models.Stash stash && PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new DropStash(_repo.FullPath, stash)); - } + public async Task ApplySelectedChanges(List changes) + { + if (_selectedStash == null) + return; + + var opts = new List(); + foreach (var c in changes) + { + if (_untracked.Contains(c) && _selectedStash.Parents.Count == 3) + opts.Add(new Models.DiffOption(_selectedStash.UntrackedParent, _selectedStash.Parents[2], c)); + else + opts.Add(new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, c)); + } + + var saveTo = Path.GetTempFileName(); + var succ = await Commands.SaveChangesAsPatch.ProcessStashChangesAsync(_repo.FullPath, opts, saveTo); + if (!succ) + return; + + var log = _repo.CreateLog($"Apply changes from '{_selectedStash.Name}'"); + await new Commands.Apply(_repo.FullPath, saveTo, true, string.Empty, string.Empty) + .Use(log) + .ExecAsync(); + + log.Complete(); + File.Delete(saveTo); } - public void Clear() { - if (PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new ClearStashes(_repo)); + 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 = null; + private List _stashes = []; + private List _visibleStashes = []; + private string _searchFilter = string.Empty; private Models.Stash _selectedStash = null; private List _changes = null; - private Models.Change _selectedChange = null; + private List _untracked = []; + private List _selectedChanges = []; private DiffContext _diffContext = null; } } diff --git a/src/ViewModels/Statistics.cs b/src/ViewModels/Statistics.cs index 661c4e32d..add2423e2 100644 --- a/src/ViewModels/Statistics.cs +++ b/src/ViewModels/Statistics.cs @@ -1,32 +1,96 @@ -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.Generic; using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; -namespace SourceGit.ViewModels { - public class Statistics : ObservableObject { - public bool IsLoading { +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 List Branches + { + get => _branches; + private set => SetProperty(ref _branches, value); + } + + public Models.Branch SelectedBranch + { + get => _selectedBranch; + set + { + if (SetProperty(ref _selectedBranch, value)) + LoadStatistics(); + } + } + + public Models.StatisticsMode ViewMode + { + get => _viewMode; + set + { + if (SetProperty(ref _viewMode, value)) + RefreshReport(); } } - - public Models.StatisticsReport SelectedReport { + + public Models.StatisticsReport SelectedReport + { get => _selectedReport; private set => SetProperty(ref _selectedReport, value); } - public Statistics(string repo) { + public Models.StatisticsSamples Samples + { + get => _samples; + private set => SetProperty(ref _samples, value); + } + + public Statistics(string repo) + { _repo = repo; + LoadBranches(); + LoadStatistics(); + } - Task.Run(() => { - var result = new Commands.Statistics(_repo).Result(); - Dispatcher.UIThread.Invoke(() => { + public void ChangeAuthor(Models.StatisticsAuthor author) + { + if (SelectedReport == null) + return; + + Samples = SelectedReport.GetSamples(author); + } + + private void LoadBranches() + { + Task.Run(async () => + { + var branches = await new Commands.QueryBranches(_repo) + .GetResultAsync() + .ConfigureAwait(false); + + branches.Insert(0, _selectedBranch); + Dispatcher.UIThread.Post(() => Branches = branches); + }); + } + + private void LoadStatistics() + { + IsLoading = true; + + Task.Run(async () => + { + var result = await new Commands.Statistics(_repo, Preferences.Instance.MaxHistoryCommits, _selectedBranch) + .ReadAsync() + .ConfigureAwait(false); + + Dispatcher.UIThread.Post(() => + { _data = result; RefreshReport(); IsLoading = false; @@ -34,20 +98,28 @@ public Statistics(string repo) { }); } - private void RefreshReport() { - if (_data == null) return; + private void RefreshReport() + { + if (_data == null) + return; - switch (_selectedIndex) { - case 0: SelectedReport = _data.Year; break; - case 1: SelectedReport = _data.Month; break; - default: SelectedReport = _data.Week; break; - } + SelectedReport = _viewMode switch + { + Models.StatisticsMode.All => _data.All, + Models.StatisticsMode.ThisMonth => _data.Month, + _ => _data.Week, + }; + + Samples = SelectedReport.GetSamples(null); } - private string _repo = string.Empty; + private string _repo = null; private bool _isLoading = true; + private List _branches = new List(); + private Models.Branch _selectedBranch = new Models.Branch() { Name = "--- (All)", IsLocal = true, FullName = "", Head = "---" }; // Fake branch to represent all branches private Models.Statistics _data = null; + private Models.StatisticsMode _viewMode = Models.StatisticsMode.All; private Models.StatisticsReport _selectedReport = null; - private int _selectedIndex = 0; + private Models.StatisticsSamples _samples = null; } } diff --git a/src/ViewModels/SubmoduleCollection.cs b/src/ViewModels/SubmoduleCollection.cs new file mode 100644 index 000000000..ee3406075 --- /dev/null +++ b/src/ViewModels/SubmoduleCollection.cs @@ -0,0 +1,211 @@ +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('/'); + 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/SubmoduleRevisionCompare.cs b/src/ViewModels/SubmoduleRevisionCompare.cs new file mode 100644 index 000000000..bbf41a2db --- /dev/null +++ b/src/ViewModels/SubmoduleRevisionCompare.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class SubmoduleRevisionCompare : ObservableObject + { + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public Models.Commit Base + { + get => _base; + private set => SetProperty(ref _base, value); + } + + public Models.Commit To + { + get => _to; + private set => SetProperty(ref _to, value); + } + + public int TotalChanges + { + get => _totalChanges; + private set => SetProperty(ref _totalChanges, 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 is { Count: 1 }) + DiffContext = new DiffContext(_repo, new Models.DiffOption(_base.SHA, _to.SHA, 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 SubmoduleRevisionCompare(Models.SubmoduleDiff diff) + { + _repo = diff.FullPath; + _base = diff.Old.Commit; + _to = diff.New.Commit; + + Refresh(); + } + + public void Swap() + { + (Base, To) = (To, Base); + Refresh(); + } + + public void ClearSearchFilter() + { + SearchFilter = string.Empty; + } + + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo, path); + } + + public void OpenInExternalDiffTool(Models.Change change) + { + new Commands.DiffTool(_repo, new Models.DiffOption(_base.SHA, _to.SHA, change)).Open(); + } + + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) + { + return await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo, changes, _base.SHA, _to.SHA, saveTo); + } + + private void Refresh() + { + IsLoading = true; + VisibleChanges = []; + SelectedChanges = []; + + Task.Run(async () => + { + _changes = await new Commands.CompareRevisions(_repo, _base.SHA, _to.SHA) + .ReadAsync() + .ConfigureAwait(false); + + 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.Post(() => + { + TotalChanges = _changes.Count; + VisibleChanges = visible; + IsLoading = false; + + if (VisibleChanges.Count > 0) + SelectedChanges = [VisibleChanges[0]]; + else + SelectedChanges = []; + }); + }); + } + + 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 bool _isLoading = true; + private Models.Commit _base = null; + private Models.Commit _to = null; + private int _totalChanges = 0; + 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/TagCollection.cs b/src/ViewModels/TagCollection.cs new file mode 100644 index 000000000..05b04f5b1 --- /dev/null +++ b/src/ViewModels/TagCollection.cs @@ -0,0 +1,398 @@ +using System.Collections; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Collections; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class TagToolTip + { + public string Name { get; private set; } + public bool IsAnnotated { get; private set; } + public Models.User Creator { get; private set; } + public ulong CreatorDate { get; private set; } + public string Message { get; private set; } + + public TagToolTip(Models.Tag t) + { + Name = t.Name; + IsAnnotated = t.IsAnnotated; + Creator = t.Creator; + CreatorDate = t.CreatorDate; + 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 TagToolTip 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 IsSelected + { + get; + set; + } + + public Models.FilterMode FilterMode + { + get => _filterMode; + set => SetProperty(ref _filterMode, value); + } + + public CornerRadius CornerRadius + { + get => _cornerRadius; + set => SetProperty(ref _cornerRadius, value); + } + + 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 TagToolTip(t); + IsExpanded = false; + } + + public TagTreeNode(string path, bool isExpanded, int depth) + { + FullPath = path; + Depth = depth; + IsExpanded = isExpanded; + Counter = 1; + } + + public void UpdateFilterMode(Dictionary filters) + { + if (Tag == null) + { + foreach (var child in Children) + child.UpdateFilterMode(filters); + } + else + { + FilterMode = filters.GetValueOrDefault(FullPath, Models.FilterMode.None); + } + } + + 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('/'); + 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 Models.FilterMode _filterMode = Models.FilterMode.None; + private CornerRadius _cornerRadius = new CornerRadius(4); + private bool _isExpanded = true; + } + + public class TagListItem : ObservableObject + { + public Models.Tag Tag + { + get; + set; + } + + public bool IsSelected + { + get; + set; + } + + public Models.FilterMode FilterMode + { + get => _filterMode; + set => SetProperty(ref _filterMode, value); + } + + public TagToolTip ToolTip + { + get; + set; + } + + public CornerRadius CornerRadius + { + get => _cornerRadius; + set => SetProperty(ref _cornerRadius, value); + } + + private Models.FilterMode _filterMode = Models.FilterMode.None; + private CornerRadius _cornerRadius = new CornerRadius(4); + } + + public class TagCollectionAsList + { + public List TagItems + { + get; + set; + } = []; + + public TagCollectionAsList(List tags) + { + foreach (var tag in tags) + TagItems.Add(new TagListItem() { Tag = tag, ToolTip = new TagToolTip(tag) }); + } + + public void ClearSelection() + { + foreach (var item in TagItems) + { + item.IsSelected = false; + item.CornerRadius = new CornerRadius(4); + } + } + + public void UpdateSelection(IList selectedItems) + { + var set = new HashSet(); + foreach (var item in selectedItems) + { + if (item is TagListItem tagItem) + set.Add(tagItem.Tag.Name); + } + + TagListItem last = null; + foreach (var item in TagItems) + { + item.IsSelected = set.Contains(item.Tag.Name); + if (item.IsSelected) + { + if (last is { IsSelected: true }) + { + last.CornerRadius = new CornerRadius(last.CornerRadius.TopLeft, 0); + item.CornerRadius = new CornerRadius(0, 4); + } + else + { + item.CornerRadius = new CornerRadius(4); + } + } + else + { + item.CornerRadius = new CornerRadius(4); + } + + last = item; + } + } + } + + 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); + } + } + + public void ClearSelection() + { + foreach (var node in Tree) + ClearSelectionRecursively(node); + } + + public void UpdateSelection(IList selectedItems) + { + var set = new HashSet(); + foreach (var item in selectedItems) + { + if (item is TagTreeNode node) + set.Add(node.FullPath); + } + + TagTreeNode last = null; + foreach (var row in Rows) + { + row.IsSelected = set.Contains(row.FullPath); + if (row.IsSelected) + { + if (last is { IsSelected: true }) + { + last.CornerRadius = new CornerRadius(last.CornerRadius.TopLeft, 0); + row.CornerRadius = new CornerRadius(0, 4); + } + else + { + row.CornerRadius = new CornerRadius(4); + } + } + else + { + row.CornerRadius = new CornerRadius(4); + } + + last = row; + } + } + + 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); + } + } + + private static void ClearSelectionRecursively(TagTreeNode node) + { + if (node.IsSelected) + { + node.IsSelected = false; + node.CornerRadius = new CornerRadius(4); + } + + foreach (var child in node.Children) + ClearSelectionRecursively(child); + } + } +} diff --git a/src/ViewModels/TextDiffContext.cs b/src/ViewModels/TextDiffContext.cs new file mode 100644 index 000000000..fd1be431b --- /dev/null +++ b/src/ViewModels/TextDiffContext.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public record TextDiffSelectedChunk(double Y, double Height, int StartIdx, int EndIdx, bool Combined, bool IsOldSide) + { + public static bool IsChanged(TextDiffSelectedChunk oldValue, TextDiffSelectedChunk newValue) + { + if (newValue == null) + return oldValue != null; + + if (oldValue == null) + return true; + + return Math.Abs(newValue.Y - oldValue.Y) > 0.001 || + Math.Abs(newValue.Height - oldValue.Height) > 0.001 || + newValue.StartIdx != oldValue.StartIdx || + newValue.EndIdx != oldValue.EndIdx || + newValue.Combined != oldValue.Combined || + newValue.IsOldSide != oldValue.IsOldSide; + } + } + + public class TextDiffContext : ObservableObject + { + public Models.DiffOption Option => _option; + public Models.TextDiff Data => _data; + + public Vector ScrollOffset + { + get => _scrollOffset; + set => SetProperty(ref _scrollOffset, value); + } + + public BlockNavigation BlockNavigation + { + get => _blockNavigation; + set => SetProperty(ref _blockNavigation, value); + } + + public TextLineRange DisplayRange + { + get => _displayRange; + set => SetProperty(ref _displayRange, value); + } + + public TextDiffSelectedChunk SelectedChunk + { + get => _selectedChunk; + set => SetProperty(ref _selectedChunk, value); + } + + public (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); + } + + public virtual bool IsSideBySide() + { + return false; + } + + public virtual TextDiffContext SwitchMode() + { + return null; + } + + protected void TryKeepPrevState(TextDiffContext prev, List lines) + { + var fastTest = prev != null && + prev._option.IsUnstaged == _option.IsUnstaged && + prev._option.Path.Equals(_option.Path, StringComparison.Ordinal) && + prev._option.OrgPath.Equals(_option.OrgPath, StringComparison.Ordinal) && + prev._option.Revisions.Count == _option.Revisions.Count; + + if (!fastTest) + { + _blockNavigation = new BlockNavigation(lines, 0); + return; + } + + for (int i = 0; i < _option.Revisions.Count; i++) + { + if (!prev._option.Revisions[i].Equals(_option.Revisions[i], StringComparison.Ordinal)) + { + _blockNavigation = new BlockNavigation(lines, 0); + return; + } + } + + _blockNavigation = new BlockNavigation(lines, prev._blockNavigation.GetCurrentBlockIndex()); + } + + protected Models.DiffOption _option = null; + protected Models.TextDiff _data = null; + protected Vector _scrollOffset = Vector.Zero; + protected BlockNavigation _blockNavigation = null; + + private TextLineRange _displayRange = null; + private TextDiffSelectedChunk _selectedChunk = null; + } + + public class CombinedTextDiff : TextDiffContext + { + public CombinedTextDiff(Models.DiffOption option, Models.TextDiff diff, TextDiffContext previous = null) + { + _option = option; + _data = diff; + + TryKeepPrevState(previous, _data.Lines); + } + + public override TextDiffContext SwitchMode() + { + return new TwoSideTextDiff(_option, _data, this); + } + } + + public class TwoSideTextDiff : TextDiffContext + { + public List Old { get; } = []; + public List New { get; } = []; + + public TwoSideTextDiff(Models.DiffOption option, Models.TextDiff diff, TextDiffContext previous = null) + { + _option = option; + _data = diff; + + 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(); + TryKeepPrevState(previous, Old); + } + + public override bool IsSideBySide() + { + return true; + } + + public override TextDiffContext SwitchMode() + { + return new CombinedTextDiff(_option, _data, this); + } + + public void GetCombinedRangeForSingleSide(ref int startLine, ref int endLine, bool isOldSide) + { + endLine = Math.Min(endLine, _data.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 = _data.Lines.IndexOf(firstContent); + endLine = _data.Lines.IndexOf(endContent); + } + + public void GetCombinedRangeForBothSides(ref int startLine, ref int endLine, bool isOldSide) + { + var fromSide = isOldSide ? Old : New; + endLine = Math.Min(endLine, fromSide.Count - 1); + + // Since this function is only used for auto-detected hunk, we just need to find out the a first changed line + // and then use `FindRangeByIndex` to get the range of hunk. + for (int i = startLine; i <= endLine; i++) + { + var line = fromSide[i]; + if (line.Type == Models.TextDiffLineType.Added || line.Type == Models.TextDiffLineType.Deleted) + { + (startLine, endLine) = FindRangeByIndex(_data.Lines, _data.Lines.IndexOf(line)); + return; + } + + if (line.Type == Models.TextDiffLineType.None) + { + var otherSide = isOldSide ? New : Old; + var changedLine = otherSide[i]; // Find the changed line on the other side in the same position + (startLine, endLine) = FindRangeByIndex(_data.Lines, _data.Lines.IndexOf(changedLine)); + return; + } + } + } + + 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()); + } + } + } +} diff --git a/src/ViewModels/TextLineRange.cs b/src/ViewModels/TextLineRange.cs new file mode 100644 index 000000000..63a9fe99e --- /dev/null +++ b/src/ViewModels/TextLineRange.cs @@ -0,0 +1,4 @@ +namespace SourceGit.ViewModels +{ + public record TextLineRange(int Start, int End); +} diff --git a/src/ViewModels/TwoSideTextDiff.cs b/src/ViewModels/TwoSideTextDiff.cs deleted file mode 100644 index f8bc46d47..000000000 --- a/src/ViewModels/TwoSideTextDiff.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Avalonia; -using CommunityToolkit.Mvvm.ComponentModel; -using System.Collections.Generic; - -namespace SourceGit.ViewModels { - public class TwoSideTextDiff : ObservableObject { - public string File { get; set; } = string.Empty; - public List Old { get; set; } = new List(); - public List New { get; set; } = new List(); - public int MaxLineNumber = 0; - - public TwoSideTextDiff(Models.TextDiff diff) { - 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(); - } - - 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()); - } - } - } -} diff --git a/src/ViewModels/UpdateSubmodules.cs b/src/ViewModels/UpdateSubmodules.cs new file mode 100644 index 000000000..323a60b2d --- /dev/null +++ b/src/ViewModels/UpdateSubmodules.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class UpdateSubmodules : Popup + { + public bool HasPreSelectedSubmodule + { + get; + } + + public List Submodules + { + get => _repo.Submodules; + } + + public Models.Submodule SelectedSubmodule + { + get; + set; + } + + public bool UpdateAll + { + get => _updateAll; + set => SetProperty(ref _updateAll, value); + } + + public bool IsEnableInitVisible + { + get; + set; + } = true; + + public bool EnableInit + { + get; + set; + } = true; + + public bool EnableRecursive + { + get; + set; + } = true; + + public bool EnableRemote + { + get; + set; + } = false; + + public UpdateSubmodules(Repository repo, Models.Submodule selected) + { + _repo = repo; + + if (selected != null) + { + _updateAll = false; + SelectedSubmodule = selected; + IsEnableInitVisible = selected.Status == Models.SubmoduleStatus.NotInited; + EnableInit = selected.Status == Models.SubmoduleStatus.NotInited; + HasPreSelectedSubmodule = true; + } + else if (repo.Submodules.Count > 0) + { + SelectedSubmodule = repo.Submodules[0]; + IsEnableInitVisible = true; + HasPreSelectedSubmodule = false; + } + + EnableRecursive = _repo.Settings.EnableRecursiveWhenAutoUpdatingSubmodules; + } + + public override async Task Sure() + { + var targets = new List(); + if (_updateAll) + { + foreach (var submodule in Submodules) + targets.Add(submodule.Path); + } + else if (SelectedSubmodule != null) + { + targets.Add(SelectedSubmodule.Path); + } + + if (targets.Count == 0) + return true; + + var log = _repo.CreateLog("Update Submodule"); + using var lockWatcher = _repo.LockWatcher(); + Use(log); + + await new Commands.Submodule(_repo.FullPath) + .Use(log) + .UpdateAsync(targets, EnableInit, EnableRecursive, EnableRemote); + + log.Complete(); + _repo.MarkSubmodulesDirtyManually(); + 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 000000000..21ab81ab7 --- /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 index e9e434342..49539292d 100644 --- a/src/ViewModels/Welcome.cs +++ b/src/ViewModels/Welcome.cs @@ -1,103 +1,295 @@ -using Avalonia.Collections; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Collections; using CommunityToolkit.Mvvm.ComponentModel; -using System; -namespace SourceGit.ViewModels { - public class Welcome : ObservableObject { - public bool IsClearSearchVisible { - get => !string.IsNullOrEmpty(_searchFilter); +namespace SourceGit.ViewModels +{ + public class Welcome : ObservableObject + { + public static Welcome Instance { get; } = new(); + + public AvaloniaList Rows + { + get; + private set; + } = []; + + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + Refresh(); + } } - public AvaloniaList RepositoryNodes { - get => Preference.Instance.RepositoryNodes; + public Welcome() + { + Refresh(); } - public string SearchFilter { - get => _searchFilter; - set { - if (SetProperty(ref _searchFilter, value)) { - Referesh(); - OnPropertyChanged(nameof(IsClearSearchVisible)); - } + 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 InitRepository(string path) { - if (!Preference.Instance.IsGitConfigured) { - App.RaiseException(PopupHost.Active.GetId(), App.Text("NotConfigured")); + public async Task UpdateStatusAsync(bool force, CancellationToken? token) + { + if (_isUpdatingStatus) return; + + _isUpdatingStatus = true; + + // avoid collection was modified while enumerating. + var nodes = new List(); + nodes.AddRange(Preferences.Instance.RepositoryNodes); + + foreach (var node in nodes) + await node.UpdateStatusAsync(force, token); + + _isUpdatingStatus = false; + } + + 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; - if (PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new Init(path)); + removeCount++; + } + Rows.RemoveRange(idx + 1, removeCount); } } - public void Clone(object param) { - var launcher = param as Launcher; - var page = launcher.ActivePage; + public async Task GetRepositoryRootAsync(string path) + { + if (!Preferences.Instance.IsGitConfigured()) + { + Models.Notification.Send(null, App.Text("NotConfigured"), true); + return null; + } + + var root = path; + if (!Directory.Exists(root)) + { + if (File.Exists(root)) + root = Path.GetDirectoryName(root); + else + return null; + } + + var isBare = await new Commands.IsBareRepository(root).GetResultAsync(); + if (isBare) + return root; + + var rs = await new Commands.QueryRepositoryRootPath(root).GetResultAsync(); + if (!rs.IsSuccess || string.IsNullOrWhiteSpace(rs.StdOut)) + return null; + + return rs.StdOut.Trim(); + } + + public async Task AddRepositoryAsync(string path, RepositoryNode parent, bool moveNode, bool open) + { + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(path, parent, moveNode); + await node.UpdateStatusAsync(false, null); - if (!Preference.Instance.IsGitConfigured) { - App.RaiseException(page.GetId(), App.Text("NotConfigured")); + if (open) + node.Open(); + } + + public void Clone() + { + if (!Preferences.Instance.IsGitConfigured()) + { + Models.Notification.Send(null, App.Text("NotConfigured"), true); return; } - if (PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new Clone(launcher, page)); + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new Clone(activePage.Node.Id); + } + + public void OpenLocalRepository() + { + if (!Preferences.Instance.IsGitConfigured()) + { + Models.Notification.Send(null, App.Text("NotConfigured"), true); + return; } + + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new OpenLocalRepository(activePage.Node.Id, null); } - public void OpenTerminal() { - if (!Preference.Instance.IsGitConfigured) { - App.RaiseException(PopupHost.Active.GetId(), App.Text("NotConfigured")); - } else { + public void OpenTerminal() + { + if (!Preferences.Instance.IsGitConfigured()) + Models.Notification.Send(null, App.Text("NotConfigured"), true); + else Native.OS.OpenTerminal(null); - } } - public void ClearSearchFilter() { + public void ScanDefaultCloneDir() + { + if (!Preferences.Instance.IsGitConfigured()) + { + Models.Notification.Send(null, App.Text("NotConfigured"), true); + return; + } + + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new ScanRepositories(); + } + + public void ClearSearchFilter() + { SearchFilter = string.Empty; } - public void AddFolder() { - if (PopupHost.CanCreatePopup()) PopupHost.ShowPopup(new CreateGroup(null)); + 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) { - Preference.MoveNode(from, to); + public RepositoryNode FindNodeById(string id, RepositoryNode root = null) + { + var collection = (root == null) ? Preferences.Instance.RepositoryNodes : root.SubNodes; + foreach (var node in collection) + { + if (node.Id.Equals(id, StringComparison.Ordinal)) + return node; + + var sub = FindNodeById(id, node); + if (sub != null) + return sub; + } + + return null; } - private void Referesh() { - if (string.IsNullOrWhiteSpace(_searchFilter)) { - foreach (var node in RepositoryNodes) ResetVisibility(node); - } else { - foreach (var node in RepositoryNodes) SetVisibilityBySearch(node); + public RepositoryNode FindParentGroup(RepositoryNode node, RepositoryNode group = null) + { + var collection = (group == null) ? Preferences.Instance.RepositoryNodes : group.SubNodes; + if (collection.Contains(node)) + return group; + + foreach (var item in collection) + { + if (!item.IsRepository) + { + var parent = FindParentGroup(node, item); + if (parent != null) + return parent; + } } + + return null; } - private void ResetVisibility(RepositoryNode node) { + public void MoveNode(RepositoryNode from, RepositoryNode to) + { + Preferences.Instance.MoveNode(from, to, true); + Refresh(); + } + + private void ResetVisibility(RepositoryNode node) + { node.IsVisible = true; - foreach (var subNode in node.SubNodes) ResetVisibility(subNode); + foreach (var subNode in node.SubNodes) + ResetVisibility(subNode); } - private void SetVisibilityBySearch(RepositoryNode node) { - if (!node.IsRepository) { - if (node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) { + 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 { + foreach (var subNode in node.SubNodes) + ResetVisibility(subNode); + } + else + { bool hasVisibleSubNode = false; - foreach (var subNode in node.SubNodes) { + foreach (var subNode in node.SubNodes) + { SetVisibilityBySearch(subNode); hasVisibleSubNode |= subNode.IsVisible; } node.IsVisible = hasVisibleSubNode; } - } else { - node.IsVisible = node.Name.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase); + } + 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 string _searchFilter = string.Empty; + private bool _isUpdatingStatus = false; } } diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs index 47ef5bd0f..c0f273529 100644 --- a/src/ViewModels/WorkingCopy.cs +++ b/src/ViewModels/WorkingCopy.cs @@ -1,733 +1,810 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Platform.Storage; -using Avalonia.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using System; +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; -namespace SourceGit.ViewModels { - public class ConflictContext { - public Models.Change Change { get; set; } - } +namespace SourceGit.ViewModels +{ + public class WorkingCopy : ObservableObject + { + public Repository Repository + { + get => _repo; + } + + 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 bool CanSwitchBranchDirectly + { + get; + set; + } = true; + + public InProgressContext InProgressContext + { + get => _inProgressContext; + private set => SetProperty(ref _inProgressContext, value); + } - public class WorkingCopy : ObservableObject { - public bool IsStaging { + public bool IsStaging + { get => _isStaging; private set => SetProperty(ref _isStaging, value); } - public bool IsUnstaging { + public bool IsUnstaging + { get => _isUnstaging; private set => SetProperty(ref _isUnstaging, value); } - public bool IsCommitting { - get => _isCommitting; + public bool IsCommitting + { + get => _isCommitting; private set => SetProperty(ref _isCommitting, value); } - public bool UseAmend { - get => _useAmend; - set => SetProperty(ref _useAmend, value); + public bool EnableSignOff + { + get => _repo.UIStates.EnableSignOffForCommit; + set => _repo.UIStates.EnableSignOffForCommit = value; } - public List Unstaged { - get => _unstaged; - private set => SetProperty(ref _unstaged, value); + public bool NoVerifyOnCommit + { + get => _repo.UIStates.NoVerifyOnCommit; + set => _repo.UIStates.NoVerifyOnCommit = value; } - public List Staged { - get => _staged; - private set => SetProperty(ref _staged, value); - } + public bool UseAmend + { + get => _useAmend; + set + { + if (SetProperty(ref _useAmend, value)) + { + if (value) + { + var currentBranch = _repo.CurrentBranch; + if (currentBranch == null) + { + _repo.SendNotification("No commits to amend!!!", true); + _useAmend = false; + OnPropertyChanged(); + return; + } - public int Count { - get => _count; - } + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, currentBranch.Head).GetResult(); + } + else + { + CommitMessage = string.Empty; + ResetAuthor = false; + } - public Models.Change SelectedUnstagedChange { - get => _selectedUnstagedChange; - set { - if (SetProperty(ref _selectedUnstagedChange, value) && value != null) { - SelectedStagedChange = null; - SelectedStagedTreeNode = null; - SetDetail(value, true); + Staged = GetStagedChanges(_cached); + VisibleStaged = GetVisibleChanges(_staged); + SelectedStaged = []; } } } - public Models.Change SelectedStagedChange { - get => _selectedStagedChange; - set { - if (SetProperty(ref _selectedStagedChange, value) && value != null) { - SelectedUnstagedChange = null; - SelectedUnstagedTreeNode = null; - SetDetail(value, false); + 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 UnstagedTree { - get => _unstagedTree; - private set => SetProperty(ref _unstagedTree, value); + public List Unstaged + { + get => _unstaged; + private set => SetProperty(ref _unstaged, value); } - public List StagedTree { - get => _stagedTree; - private set => SetProperty(ref _stagedTree, value); + public List VisibleUnstaged + { + get => _visibleUnstaged; + private set => SetProperty(ref _visibleUnstaged, value); + } + + public List Staged + { + get => _staged; + private set => SetProperty(ref _staged, value); } - public FileTreeNode SelectedUnstagedTreeNode { - get => _selectedUnstagedTreeNode; - set { - if (SetProperty(ref _selectedUnstagedTreeNode, value)) { - if (value == null) { - SelectedUnstagedChange = null; - } else { - SelectedUnstagedChange = value.Backend as Models.Change; - SelectedStagedTreeNode = null; - SelectedStagedChange = null; + public List VisibleStaged + { + get => _visibleStaged; + private set => SetProperty(ref _visibleStaged, value); + } - if (value.IsFolder) { + 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 is { Count: > 0 }) + SelectedStaged = []; + + if (value.Count == 1) + SetDetail(value[0], true); + else SetDetail(null, true); - } } } } } - public FileTreeNode SelectedStagedTreeNode { - get => _selectedStagedTreeNode; - set { - if (SetProperty(ref _selectedStagedTreeNode, value)) { - if (value == null) { - SelectedStagedChange = null; - } else { - SelectedStagedChange = value.Backend as Models.Change; - SelectedUnstagedTreeNode = null; - SelectedUnstagedChange = null; - - if (value.IsFolder) { + 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 is { Count: > 0 }) + SelectedUnstaged = []; + + if (value.Count == 1) + SetDetail(value[0], false); + else SetDetail(null, false); - } } } } } - public object DetailContext { + public object DetailContext + { get => _detailContext; private set => SetProperty(ref _detailContext, value); } - public string CommitMessage { + public string CommitMessage + { get => _commitMessage; set => SetProperty(ref _commitMessage, value); } - public WorkingCopy(Repository repo) { + public WorkingCopy(Repository repo) + { _repo = repo; } - public void Cleanup() { - _repo = null; - if (_unstaged != null) _unstaged.Clear(); - if (_staged != null) _staged.Clear(); - if (_unstagedTree != null) _unstagedTree.Clear(); - if (_stagedTree != null) _stagedTree.Clear(); - _selectedUnstagedChange = null; - _selectedStagedChange = null; - _selectedUnstagedTreeNode = null; - _selectedStagedTreeNode = null; - _detailContext = null; - _commitMessage = string.Empty; - } + public void SetData(List changes) + { + if (!IsChanged(_cached, changes)) + { + HasUnsolvedConflicts = _cached.Find(x => x.IsConflicted) != null; + UpdateInProgressState(); + UpdateDetail(); + return; + } + + var lastSelectedUnstaged = new HashSet(); + if (_selectedUnstaged is { Count: > 0 }) + { + foreach (var c in _selectedUnstaged) + lastSelectedUnstaged.Add(c.Path); + } - public bool SetData(List changes) { var unstaged = new List(); - var staged = new List(); - - var viewFile = string.Empty; - var lastSelectedIsUnstaged = false; - if (_selectedUnstagedChange != null) { - viewFile = _selectedUnstagedChange.Path; - lastSelectedIsUnstaged = true; - } else if (_selectedStagedChange != null) { - viewFile = _selectedStagedChange.Path; - } - - var viewChange = null as Models.Change; + var visibleUnstaged = new List(); + var selectedUnstaged = new List(); + var noFilter = string.IsNullOrEmpty(_filter); var hasConflict = false; - foreach (var c in changes) { - if (c.Index == Models.ChangeState.Modified - || c.Index == Models.ChangeState.Added - || c.Index == Models.ChangeState.Deleted - || c.Index == Models.ChangeState.Renamed) { - staged.Add(c); - if (!lastSelectedIsUnstaged && c.Path == viewFile) { - viewChange = c; - } - } - - if (c.WorkTree != Models.ChangeState.None) { + var canSwitchDirectly = true; + foreach (var c in changes) + { + if (c.WorkTree != Models.ChangeState.None) + { unstaged.Add(c); - hasConflict |= c.IsConflit; - if (lastSelectedIsUnstaged && c.Path == viewFile) { - viewChange = c; + hasConflict |= c.IsConflicted; + + if (noFilter || c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + { + visibleUnstaged.Add(c); + if (lastSelectedUnstaged.Contains(c.Path)) + selectedUnstaged.Add(c); } } - } - _count = changes.Count; - - var unstagedTree = FileTreeNode.Build(unstaged); - var stagedTree = FileTreeNode.Build(staged); - Dispatcher.UIThread.Invoke(() => { - _isLoadingData = true; - Unstaged = unstaged; - Staged = staged; - UnstagedTree = unstagedTree; - StagedTree = stagedTree; - _isLoadingData = false; - - // Restore last selection states. - if (viewChange != null) { - var scrollOffset = Vector.Zero; - if (_detailContext is DiffContext old) scrollOffset = old.SyncScrollOffset; - - if (lastSelectedIsUnstaged) { - SelectedUnstagedChange = viewChange; - SelectedUnstagedTreeNode = FileTreeNode.SelectByPath(_unstagedTree, viewFile); - } else { - SelectedStagedChange = viewChange; - SelectedStagedTreeNode = FileTreeNode.SelectByPath(_stagedTree, viewFile); - } + if (!canSwitchDirectly) + continue; - if (_detailContext is DiffContext cur) cur.SyncScrollOffset = scrollOffset; - } else { - SelectedUnstagedChange = null; - SelectedUnstagedTreeNode = null; - SelectedStagedChange = null; - SelectedStagedTreeNode = null; - SetDetail(null, false); - } - }); + if (c.WorkTree == Models.ChangeState.Untracked || c.Index == Models.ChangeState.Added) + continue; - return hasConflict; - } + canSwitchDirectly = false; + } - public void SetDetail(Models.Change change, bool isUnstaged) { - if (_isLoadingData) return; + var staged = GetStagedChanges(changes); + var visibleStaged = GetVisibleChanges(staged); + var selectedStaged = new List(); + if (_selectedStaged is { Count: > 0 }) + { + var set = new HashSet(); + foreach (var c in _selectedStaged) + set.Add(c.Path); + + foreach (var c in visibleStaged) + { + if (set.Contains(c.Path)) + selectedStaged.Add(c); + } + } - if (change == null) { - DetailContext = null; - } else if (change.IsConflit) { - DetailContext = new ConflictContext() { Change = change }; - } else { - DetailContext = new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged)); + if (selectedUnstaged.Count == 0 && selectedStaged.Count == 0 && hasConflict) + { + var firstConflict = visibleUnstaged.Find(x => x.IsConflicted); + selectedUnstaged.Add(firstConflict); } + + _isLoadingData = true; + _cached = changes; + HasUnsolvedConflicts = hasConflict; + CanSwitchBranchDirectly = canSwitchDirectly; + VisibleUnstaged = visibleUnstaged; + VisibleStaged = visibleStaged; + Unstaged = unstaged; + Staged = staged; + SelectedUnstaged = selectedUnstaged; + SelectedStaged = selectedStaged; + _isLoadingData = false; + + UpdateInProgressState(); + UpdateDetail(); } - public async void StageChanges(List changes) { - if (_unstaged.Count == 0 || changes.Count == 0) return; + public async Task StageChangesAsync(List changes, Models.Change next) + { + var canStaged = await GetCanStageChangesAsync(changes); + var count = canStaged.Count; + if (count == 0) + return; - SetDetail(null, true); IsStaging = true; - _repo.SetWatcherEnabled(false); - if (changes.Count == _unstaged.Count) { - await Task.Run(() => new Commands.Add(_repo.FullPath).Exec()); - } else { - for (int i = 0; i < changes.Count; i += 10) { - var count = Math.Min(10, changes.Count - i); - var step = changes.GetRange(i, count); - await Task.Run(() => new Commands.Add(_repo.FullPath, step).Exec()); - } + _selectedUnstaged = next != null ? [next] : []; + + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog("Stage"); + var pathSpecFile = Path.GetTempFileName(); + await using (var writer = new StreamWriter(pathSpecFile)) + { + foreach (var c in canStaged) + await writer.WriteLineAsync(c.Path); } - _repo.RefreshWorkingCopyChanges(); - _repo.SetWatcherEnabled(true); + + await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); + log.Complete(); + + _repo.MarkWorkingCopyDirtyManually(); IsStaging = false; } - public async void UnstageChanges(List changes) { - if (_staged.Count == 0 || changes.Count == 0) return; + public async Task UnstageChangesAsync(List changes, Models.Change next) + { + var count = changes.Count; + if (count == 0) + return; - SetDetail(null, false); IsUnstaging = true; - _repo.SetWatcherEnabled(false); - if (changes.Count == _staged.Count) { - await Task.Run(() => new Commands.Reset(_repo.FullPath).Exec()); - } else { - for (int i = 0; i < changes.Count; i += 10) { - var count = Math.Min(10, changes.Count - i); - var step = changes.GetRange(i, count); - await Task.Run(() => new Commands.Reset(_repo.FullPath, step).Exec()); + _selectedStaged = next != null ? [next] : []; + + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog("Unstage"); + if (_useAmend) + { + log.AppendLine("$ git update-index --index-info "); + await new Commands.UpdateIndexInfo(_repo.FullPath, changes).ExecAsync(); + } + else + { + var pathSpecFile = Path.GetTempFileName(); + await using (var writer = new StreamWriter(pathSpecFile)) + { + foreach (var c in changes) + { + await writer.WriteLineAsync(c.Path); + if (c.Index == Models.ChangeState.Renamed) + await writer.WriteLineAsync(c.OriginalPath); + } } + + await new Commands.Reset(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); } - _repo.RefreshWorkingCopyChanges(); - _repo.SetWatcherEnabled(true); + log.Complete(); + + _repo.MarkWorkingCopyDirtyManually(); IsUnstaging = false; } - public void Discard(List changes, bool isUnstaged) { - if (PopupHost.CanCreatePopup()) { - if (isUnstaged) { - if (changes.Count == _unstaged.Count && _staged.Count == 0) { - PopupHost.ShowPopup(new Discard(_repo)); - } else { - PopupHost.ShowPopup(new Discard(_repo, changes, true)); - } - } else { - if (changes.Count == _staged.Count && _unstaged.Count == 0) { - PopupHost.ShowPopup(new Discard(_repo)); - } else { - PopupHost.ShowPopup(new Discard(_repo, changes, false)); - } - } - } + public async Task SaveChangesToPatchAsync(List changes, bool isUnstaged, string saveTo) + { + var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, changes, isUnstaged, saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); + } + + public void Discard(List changes) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Discard(_repo, changes)); + } + + public void ClearFilter() + { + Filter = string.Empty; } - public async void UseTheirs() { - if (_detailContext is ConflictContext ctx) { - _repo.SetWatcherEnabled(false); - var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).File(ctx.Change.Path, true)); - if (succ) { - await Task.Run(() => new Commands.Add(_repo.FullPath, [ctx.Change]).Exec()); + public async Task UseTheirsAsync(List changes) + { + using var lockWatcher = _repo.LockWatcher(); + + 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 is Models.ConflictReason.BothDeleted or Models.ConflictReason.DeletedByThem or 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); } - _repo.RefreshWorkingCopyChanges(); - _repo.SetWatcherEnabled(true); } + + if (files.Count > 0) + { + var succ = await new Commands.Checkout(_repo.FullPath).Use(log).UseTheirsAsync(files); + if (succ) + needStage.AddRange(files); + } + + if (needStage.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync(pathSpecFile, needStage); + await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); + } + + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); } - public async void UseMine() { - if (_detailContext is ConflictContext ctx) { - _repo.SetWatcherEnabled(false); - var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).File(ctx.Change.Path, false)); - if (succ) { - await Task.Run(() => new Commands.Add(_repo.FullPath, [ctx.Change]).Exec()); + public async Task UseMineAsync(List changes) + { + using var lockWatcher = _repo.LockWatcher(); + + 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 is Models.ConflictReason.BothDeleted or Models.ConflictReason.DeletedByUs or Models.ConflictReason.AddedByThem) + { + var fullpath = Path.Combine(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + File.Delete(fullpath); + + needStage.Add(change.Path); } - _repo.RefreshWorkingCopyChanges(); - _repo.SetWatcherEnabled(true); + else + { + files.Add(change.Path); + } + } + + if (files.Count > 0) + { + var succ = await new Commands.Checkout(_repo.FullPath).Use(log).UseMineAsync(files); + if (succ) + needStage.AddRange(files); + } + + if (needStage.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + await File.WriteAllLinesAsync(pathSpecFile, needStage); + await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); } + + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); } - public async void UseExternalMergeTool() { - if (_detailContext is ConflictContext ctx) { - var type = Preference.Instance.ExternalMergeToolType; - var exec = Preference.Instance.ExternalMergeToolPath; + public async Task UseExternalMergeToolAsync(Models.Change change) + { + return await new Commands.MergeTool(_repo.FullPath, change?.Path).OpenAsync(); + } - var tool = Models.ExternalMergeTools.Supported.Find(x => x.Type == type); - if (tool == null) { - App.RaiseException(_repo.FullPath, "Invalid merge tool in preference setting!"); - return; - } + public void UseExternalDiffTool(Models.Change change, bool isUnstaged) + { + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(change, isUnstaged)).Open(); + } + + public async Task ContinueMergeAsync() + { + if (_inProgressContext != null) + { + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; - var args = tool.Type != 0 ? tool.Cmd : Preference.Instance.ExternalMergeToolCmd; + var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); + if (File.Exists(mergeMsgFile) && !string.IsNullOrWhiteSpace(_commitMessage)) + await File.WriteAllTextAsync(mergeMsgFile, _commitMessage); - _repo.SetWatcherEnabled(false); - await Task.Run(() => Commands.MergeTool.OpenForMerge(_repo.FullPath, exec, args, ctx.Change.Path)); - _repo.SetWatcherEnabled(true); + var log = _repo.CreateLog($"Continue {_inProgressContext.Name}"); + await _inProgressContext.ContinueAsync(log); + log.Complete(); + + CommitMessage = string.Empty; + IsCommitting = false; + } + else + { + _repo.MarkWorkingCopyDirtyManually(); } } - public async void DoCommit(bool autoPush) { - if (!PopupHost.CanCreatePopup()) { - App.RaiseException(_repo.FullPath, "Repository has unfinished job! Please wait!"); - return; + public async Task SkipMergeAsync() + { + if (_inProgressContext != null) + { + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; + + var log = _repo.CreateLog($"Skip {_inProgressContext.Name}"); + await _inProgressContext.SkipAsync(log); + log.Complete(); + + CommitMessage = string.Empty; + IsCommitting = false; + } + else + { + _repo.MarkWorkingCopyDirtyManually(); } + } + + public async Task AbortMergeAsync() + { + if (_inProgressContext != null) + { + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; + + var log = _repo.CreateLog($"Abort {_inProgressContext.Name}"); + await _inProgressContext.AbortAsync(log); + log.Complete(); + + CommitMessage = string.Empty; + IsCommitting = false; + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } + } - if (_staged.Count == 0) { - App.RaiseException(_repo.FullPath, "No files added to commit!"); + public void ApplyCommitMessageTemplate(Models.CommitTemplate tmpl) + { + CommitMessage = tmpl.Apply(_repo.CurrentBranch, _staged); + } + + public async Task ClearCommitMessageHistoryAsync() + { + var sure = await App.AskConfirmAsync(App.Text("WorkingCopy.ClearCommitHistories.Confirm")); + if (sure) + _repo.UIStates.RecentCommitMessages.Clear(); + } + + public async Task CommitAsync(bool autoStage, bool autoPush) + { + if (string.IsNullOrWhiteSpace(_commitMessage)) + return; + + if (!_repo.CanCreatePopup()) + { + _repo.SendNotification("Repository has an unfinished job! Please wait!", true); return; } - if (string.IsNullOrWhiteSpace(_commitMessage)) { - App.RaiseException(_repo.FullPath, "Commit without message is NOT allowed!"); + if (autoStage && HasUnsolvedConflicts) + { + _repo.SendNotification("Repository has unsolved conflict(s). Auto-stage and commit is disabled!", true); return; } - PushCommitMessage(); + if (_repo.CurrentBranch is { IsDetachedHead: true }) + { + var msg = App.Text("WorkingCopy.ConfirmCommitWithDetachedHead"); + var sure = await App.AskConfirmAsync(msg); + if (!sure) + return; + } + + if (!string.IsNullOrEmpty(_filter) && _staged.Count > _visibleStaged.Count) + { + var msg = App.Text("WorkingCopy.ConfirmCommitWithFilter", _staged.Count, _visibleStaged.Count, _staged.Count - _visibleStaged.Count); + var sure = await App.AskConfirmAsync(msg); + if (!sure) + return; + } - SetDetail(null, false); + if (!_useAmend) + { + if ((!autoStage && _staged.Count == 0) || (autoStage && _cached.Count == 0)) + { + var rs = await App.AskConfirmEmptyCommitAsync(_cached.Count > 0, _selectedUnstaged is { Count: > 0 }); + if (rs == Models.ConfirmEmptyCommitResult.Cancel) + return; + + if (rs == Models.ConfirmEmptyCommitResult.StageAllAndCommit) + autoStage = true; + else if (rs == Models.ConfirmEmptyCommitResult.StageSelectedAndCommit) + await StageChangesAsync(_selectedUnstaged, null); + } + } + + using var lockWatcher = _repo.LockWatcher(); IsCommitting = true; - _repo.SetWatcherEnabled(false); - var succ = await Task.Run(() => new Commands.Commit(_repo.FullPath, _commitMessage, _useAmend).Exec()); - if (succ) { - CommitMessage = string.Empty; + _repo.UIStates.AddRecentCommitMessage(_commitMessage); + + if (autoStage && _unstaged.Count > 0) + await StageChangesAsync(_unstaged, null); + + var log = _repo.CreateLog("Commit"); + var succ = await new Commands.Commit(_repo.FullPath, _commitMessage, EnableSignOff, NoVerifyOnCommit, _useAmend, _resetAuthor) + .Use(log) + .RunAsync(); + + log.Complete(); + + if (succ) + { UseAmend = false; + CommitMessage = string.Empty; + + if (autoPush && _repo.Remotes.Count > 0) + { + Models.Branch pushBranch = null; + if (_repo.CurrentBranch == null) + { + var currentBranchName = await new Commands.QueryCurrentBranch(_repo.FullPath).GetResultAsync(); + pushBranch = new Models.Branch() { Name = currentBranchName }; + } - if (autoPush) { - PopupHost.ShowAndStartPopup(new Push(_repo, null)); + if (_repo.CanCreatePopup()) + await _repo.ShowAndStartPopupAsync(new Push(_repo, pushBranch)); } } - _repo.RefreshWorkingCopyChanges(); - _repo.SetWatcherEnabled(true); + + _repo.MarkBranchesDirtyManually(); + _repo.RefreshSubmodules(); // Committing will not change submodule's HEAD (stage already changes it), So we need refresh submodules here manually. IsCommitting = false; } - public ContextMenu CreateContextMenuForUnstagedChanges(List changes) { - if (changes.Count == 0) return null; - - var menu = new ContextMenu(); - if (changes.Count == 1) { - var change = changes[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.Folder.Open"); - explore.IsEnabled = File.Exists(path) || Directory.Exists(path); - 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 stage = new MenuItem(); - stage.Header = App.Text("FileCM.Stage"); - stage.Icon = App.CreateMenuIcon("Icons.File.Add"); - stage.Click += (_, e) => { - StageChanges(changes); - e.Handled = true; - }; - - var discard = new MenuItem(); - discard.Header = App.Text("FileCM.Discard"); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.Click += (_, e) => { - Discard(changes, true); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.Stash"); - stash.Icon = App.CreateMenuIcon("Icons.Stashes"); - stash.Click += (_, e) => { - if (PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); - } - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => { - var topLevel = App.GetTopLevel(); - if (topLevel == 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) { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, true, storageFile.Path.LocalPath)); - if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + private List GetVisibleChanges(List changes) + { + if (string.IsNullOrEmpty(_filter)) + return changes; - e.Handled = true; - }; - - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, e) => { - var window = new Views.FileHistories() { DataContext = new FileHistories(_repo.FullPath, change.Path) }; - window.Show(); - e.Handled = true; - }; - - var assumeUnchanged = new MenuItem(); - assumeUnchanged.Header = App.Text("FileCM.AssumeUnchanged"); - assumeUnchanged.Icon = App.CreateMenuIcon("Icons.File.Ignore"); - assumeUnchanged.IsEnabled = change.WorkTree != Models.ChangeState.Untracked; - assumeUnchanged.Click += (_, e) => { - new Commands.AssumeUnchanged(_repo.FullPath).Add(change.Path); - e.Handled = true; - }; - - 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(explore); - menu.Items.Add(openWith); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(stage); - menu.Items.Add(discard); - menu.Items.Add(stash); - menu.Items.Add(patch); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(assumeUnchanged); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copy); - } else { - var stage = new MenuItem(); - stage.Header = App.Text("FileCM.StageMulti", changes.Count); - stage.Icon = App.CreateMenuIcon("Icons.File.Add"); - stage.Click += (_, e) => { - StageChanges(changes); - e.Handled = true; - }; - - var discard = new MenuItem(); - discard.Header = App.Text("FileCM.DiscardMulti", changes.Count); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.Click += (_, e) => { - Discard(changes, true); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.StashMulti", changes.Count); - stash.Icon = App.CreateMenuIcon("Icons.Stashes"); - stash.Click += (_, e) => { - if (PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); - } - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (o, e) => { - var topLevel = App.GetTopLevel(); - if (topLevel == 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) { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, true, storageFile.Path.LocalPath)); - if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + var visible = new List(); - e.Handled = true; - }; - - menu.Items.Add(stage); - menu.Items.Add(discard); - menu.Items.Add(stash); - menu.Items.Add(patch); - } - - return menu; - } - - public ContextMenu CreateContextMenuForStagedChanges(List changes) { - if (changes.Count == 0) return null; - - var menu = new ContextMenu(); - if (changes.Count == 1) { - var change = changes[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.Folder.Open"); - explore.Click += (o, 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 += (o, e) => { - UnstageChanges(changes); - e.Handled = true; - }; - - var discard = new MenuItem(); - discard.Header = App.Text("FileCM.Discard"); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.Click += (_, e) => { - Discard(changes, false); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.Stash"); - stash.Icon = App.CreateMenuIcon("Icons.Stashes"); - stash.Click += (_, e) => { - if (PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); - } - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (o, e) => { - var topLevel = App.GetTopLevel(); - if (topLevel == 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) { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, false, storageFile.Path.LocalPath)); - if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + foreach (var c in changes) + { + if (c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } - e.Handled = true; - }; - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Click += (o, e) => { - App.CopyText(change.Path); - e.Handled = true; - }; - - menu.Items.Add(explore); - menu.Items.Add(openWith); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(unstage); - menu.Items.Add(discard); - menu.Items.Add(stash); - menu.Items.Add(patch); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copyPath); - } else { - var unstage = new MenuItem(); - unstage.Header = App.Text("FileCM.UnstageMulti", changes.Count); - unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); - unstage.Click += (o, e) => { - UnstageChanges(changes); - e.Handled = true; - }; - - var discard = new MenuItem(); - discard.Header = App.Text("FileCM.DiscardMulti", changes.Count); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.Click += (_, e) => { - Discard(changes, false); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.StashMulti", changes.Count); - stash.Icon = App.CreateMenuIcon("Icons.Stashes"); - stash.Click += (_, e) => { - if (PopupHost.CanCreatePopup()) { - PopupHost.ShowPopup(new StashChanges(_repo, changes, false)); + return visible; + } + + private async Task> GetCanStageChangesAsync(List changes) + { + if (!HasUnsolvedConflicts) + return changes; + + var outs = new List(); + foreach (var c in changes) + { + if (c.IsConflicted) + { + if (c.ConflictReason is Models.ConflictReason.BothAdded or Models.ConflictReason.BothModified) + { + var state = await new Commands.QueryConflictFileState(_repo.FullPath, c).GetResultAsync(); + if (state != Models.ConflictFileState.Resolved) + continue; } - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => { - var topLevel = App.GetTopLevel(); - if (topLevel == 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) { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.Exec(_repo.FullPath, changes, false, storageFile.Path.LocalPath)); - if (succ) App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + else + { + continue; } + } - e.Handled = true; - }; - - menu.Items.Add(unstage); - menu.Items.Add(discard); - menu.Items.Add(stash); - menu.Items.Add(patch); + outs.Add(c); } - return menu; + return outs; } - public ContextMenu CreateContextMenuForCommitMessages() { - var menu = new ContextMenu(); - if (_repo.CommitMessages.Count == 0) { - var empty = new MenuItem(); - empty.Header = App.Text("WorkingCopy.NoCommitHistories"); - empty.IsEnabled = false; - menu.Items.Add(empty); - return menu; + private List GetStagedChanges(List cached) + { + if (_useAmend) + { + var changes = new Commands.QueryStagedChangesWithAmend(_repo.FullPath).GetResult(); + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + return changes; } - var tip = new MenuItem(); - tip.Header = App.Text("WorkingCopy.HasCommitHistories"); - tip.IsEnabled = false; - menu.Items.Add(tip); - menu.Items.Add(new MenuItem() { Header = "-" }); + var rs = new List(); + foreach (var c in cached) + { + if (c.Index != Models.ChangeState.None) + rs.Add(c); + } + return rs; + } - foreach (var message in _repo.CommitMessages) { - var dump = message; + private void UpdateDetail() + { + if (_selectedUnstaged.Count == 1) + SetDetail(_selectedUnstaged[0], true); + else if (_selectedStaged.Count == 1) + SetDetail(_selectedStaged[0], false); + else + SetDetail(null, false); + } - var item = new MenuItem(); - item.Header = dump; - item.Click += (o, e) => { - CommitMessage = dump; - e.Handled = true; - }; + private void UpdateInProgressState() + { + var oldType = _inProgressContext != null ? _inProgressContext.GetType() : null; + + 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"))) + InProgressContext = new RebaseInProgress(_repo); + 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; + + if (_inProgressContext != null && _inProgressContext.GetType() == oldType && !string.IsNullOrEmpty(_commitMessage)) + return; - menu.Items.Add(item); - } + if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "MERGE_MSG"))) + return; - return menu; - } + if (_inProgressContext is not RebaseInProgress { } rebasing) + return; - private void PushCommitMessage() { - var existIdx = _repo.CommitMessages.IndexOf(CommitMessage); - if (existIdx == 0) { + if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "rebase-merge", "message"))) return; - } else if (existIdx > 0) { - _repo.CommitMessages.Move(existIdx, 0); + + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, rebasing.StoppedAt.SHA).GetResult(); + } + + private bool LoadCommitMessageFromFile(string file) + { + if (!File.Exists(file)) + return false; + + var msg = File.ReadAllText(file).Trim(); + if (string.IsNullOrEmpty(msg)) + return false; + + CommitMessage = msg; + return true; + } + + private void SetDetail(Models.Change change, bool isUnstaged) + { + if (_isLoadingData) return; - } - if (_repo.CommitMessages.Count > 9) { - _repo.CommitMessages.RemoveRange(9, _repo.CommitMessages.Count - 9); + 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 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.Equals(c.Path, StringComparison.Ordinal) || o.Index != c.Index || o.WorkTree != c.WorkTree) + return true; } - _repo.CommitMessages.Insert(0, CommitMessage); + return false; } private Repository _repo = null; @@ -736,16 +813,20 @@ private void PushCommitMessage() { private bool _isUnstaging = false; private bool _isCommitting = false; private bool _useAmend = false; - private List _unstaged = null; - private List _staged = null; - private Models.Change _selectedUnstagedChange = null; - private Models.Change _selectedStagedChange = null; - private int _count = 0; - private List _unstagedTree = null; - private List _stagedTree = null; - private FileTreeNode _selectedUnstagedTreeNode = null; - private FileTreeNode _selectedStagedTreeNode = null; + 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 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 000000000..3ae935c76 --- /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/Worktree.cs b/src/ViewModels/Worktree.cs new file mode 100644 index 000000000..51f3deb31 --- /dev/null +++ b/src/ViewModels/Worktree.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class Worktree : ObservableObject + { + public Models.Worktree Backend { get; private set; } + public bool IsMain { get; private set; } + public bool IsCurrent { get; private set; } + public bool IsLast { get; private set; } + public string DisplayPath { get; private set; } + public string Name { get; private set; } + public string Branch { get; private set; } + + public bool IsLocked + { + get => _isLocked; + set => SetProperty(ref _isLocked, value); + } + + public string FullPath => Backend.FullPath; + public string Head => Backend.Head; + + public Worktree(DirectoryInfo repo, Models.Worktree wt, bool isMain, bool isLast) + { + Backend = wt; + IsMain = isMain; + IsCurrent = IsCurrentWorktree(repo, wt); + IsLast = isLast; + DisplayPath = IsCurrent ? string.Empty : Path.GetRelativePath(repo.FullName, wt.FullPath); + Name = GenerateName(); + Branch = GenerateBranchName(); + IsLocked = wt.IsLocked; + } + + public static List Build(string repo, List worktrees) + { + if (worktrees is not { Count: > 1 }) + return []; + + var repoDir = new DirectoryInfo(repo); + var nodes = new List(); + nodes.Add(new(repoDir, worktrees[0], true, false)); + for (int i = 1; i < worktrees.Count; i++) + nodes.Add(new(repoDir, worktrees[i], false, i == worktrees.Count - 1)); + + return nodes; + } + + public bool IsAttachedTo(Models.Branch branch) + { + if (string.IsNullOrEmpty(branch.WorktreePath)) + return false; + + var wtDir = new DirectoryInfo(Backend.FullPath); + var test = new DirectoryInfo(branch.WorktreePath); + return test.FullName.Equals(wtDir.FullName, StringComparison.Ordinal); + } + + private bool IsCurrentWorktree(DirectoryInfo repo, Models.Worktree wt) + { + var wtDir = new DirectoryInfo(wt.FullPath); + return wtDir.FullName.Equals(repo.FullName, StringComparison.Ordinal); + } + + private string GenerateName() + { + if (IsMain) + return Path.GetFileName(Backend.FullPath); + + if (Backend.IsDetached) + return $"detached HEAD at {Backend.Head.AsSpan(10)}"; + + var b = Backend.Branch; + + if (b.StartsWith("refs/heads/", StringComparison.Ordinal)) + return b.Substring(11); + + if (b.StartsWith("refs/remotes/", StringComparison.Ordinal)) + return b.Substring(13); + + return b; + } + + private string GenerateBranchName() + { + if (Backend.IsBare) + return "-- (default)"; + + if (Backend.IsDetached) + return "-- (detached)"; + + if (string.IsNullOrEmpty(Backend.Branch)) + return "-- (unknown)"; + + var b = Backend.Branch; + + if (b.StartsWith("refs/heads/", StringComparison.Ordinal)) + return b.Substring(11); + + if (b.StartsWith("refs/remotes/", StringComparison.Ordinal)) + return b.Substring(13); + + return b; + } + + private bool _isLocked = false; + } +} diff --git a/src/Views/AIAssistant.axaml b/src/Views/AIAssistant.axaml new file mode 100644 index 000000000..9c1db462b --- /dev/null +++ b/src/Views/AIAssistant.axaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + - + IsVisible="{OnPlatform True, macOS=False}"/> - - + - - + + + HorizontalAlignment="Center" + VerticalAlignment="Center"/> - - + + - - - - - + + - - - - - - - + + + - - - - + + - - - - + + + + - - - + + + diff --git a/src/Views/About.axaml.cs b/src/Views/About.axaml.cs index b15e38423..842022cf7 100644 --- a/src/Views/About.axaml.cs +++ b/src/Views/About.axaml.cs @@ -1,39 +1,69 @@ -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; +using System; using System.Reflection; +using System.Text.RegularExpressions; +using Avalonia.Interactivity; -namespace SourceGit.Views { - public partial class About : Window { - public string Version { - get; - private set; - } - - public About() { - var ver = Assembly.GetExecutingAssembly().GetName().Version; - Version = $"{ver.Major}.{ver.Minor}"; - DataContext = this; +namespace SourceGit.Views +{ + public partial class About : ChromelessWindow + { + public About() + { + CloseOnESC = true; InitializeComponent(); - } - private void CloseWindow(object sender, RoutedEventArgs e) { - Close(); + var assembly = Assembly.GetExecutingAssembly(); + var meta = assembly.GetCustomAttributes(); + var foundFriendlyVersion = false; + foreach (var attr in meta) + { + if (attr.Key.Equals("BuildDate", StringComparison.OrdinalIgnoreCase) && DateTime.TryParse(attr.Value, out var date)) + { + TxtReleaseDate.Text = App.Text("About.ReleaseDate", Models.DateTimeFormat.Format(date, true)); + } + else if (attr.Key.Equals("FriendlyVersion", StringComparison.OrdinalIgnoreCase) && REG_FRIENDLY_VERSION().IsMatch(attr.Value)) + { + foundFriendlyVersion = true; + TxtVersion.Text = attr.Value; + } + } + + if (!foundFriendlyVersion) + { + var ver = assembly.GetName().Version; + if (ver != null) + TxtVersion.Text = $"v{ver.Major}.{ver.Minor:D2}"; + } + + var copyright = assembly.GetCustomAttribute(); + if (copyright != null) + TxtCopyright.Text = copyright.Copyright; } - private void OnVisitAvaloniaUI(object sender, PointerPressedEventArgs e) { - Native.OS.OpenBrowser("https://www.avaloniaui.net/"); + private void OnVisitReleaseNotes(object _, RoutedEventArgs e) + { + var ver = TxtVersion.Text ?? string.Empty; + var endOfTagIdx = ver.IndexOf('-'); + if (endOfTagIdx > 0) + ver = ver.Substring(0, endOfTagIdx); + + Native.OS.OpenBrowser($"https://github.com/sourcegit-scm/sourcegit/releases/tag/{ver}"); e.Handled = true; } - private void OnVisitAvaloniaEdit(object sender, PointerPressedEventArgs e) { - Native.OS.OpenBrowser("https://github.com/AvaloniaUI/AvaloniaEdit"); + private void OnVisitWebsite(object _, RoutedEventArgs e) + { + Native.OS.OpenBrowser("https://sourcegit-scm.github.io/"); e.Handled = true; } - private void OnVisitJetBrainsMonoFont(object sender, PointerPressedEventArgs e) { - Native.OS.OpenBrowser("https://www.jetbrains.com/lp/mono/"); + private void OnVisitSourceCode(object _, RoutedEventArgs e) + { + Native.OS.OpenBrowser("https://github.com/sourcegit-scm/sourcegit"); e.Handled = true; } + + [GeneratedRegex(@"^v\d{4}\.\d{1,2}(?:\-\d+\-[0-9a-f]{8})?(?:\-dirty)?$")] + private static partial Regex REG_FRIENDLY_VERSION(); } } diff --git a/src/Views/AddRemote.axaml b/src/Views/AddRemote.axaml index 4b460d795..3903c540a 100644 --- a/src/Views/AddRemote.axaml +++ b/src/Views/AddRemote.axaml @@ -2,56 +2,93 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.AddRemote" x:DataType="vm:AddRemote"> - + + - + + + + + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.Remote.Name}"/> + Text="{Binding Name, Mode=TwoWay}"> + + + + + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.Remote.URL}"/> + Text="{Binding Url, Mode=TwoWay}"> + + + + + + + + - - + - - + Text="{Binding SSHKey, Mode=TwoWay}"> + + + + + + + + + + diff --git a/src/Views/AddRemote.axaml.cs b/src/Views/AddRemote.axaml.cs index f3640e309..3fb283eaa 100644 --- a/src/Views/AddRemote.axaml.cs +++ b/src/Views/AddRemote.axaml.cs @@ -2,19 +2,30 @@ using Avalonia.Interactivity; using Avalonia.Platform.Storage; -namespace SourceGit.Views { - public partial class AddRemote : UserControl { - public AddRemote() { +namespace SourceGit.Views +{ + public partial class AddRemote : UserControl + { + public AddRemote() + { InitializeComponent(); } - private async void SelectSSHKey(object sender, RoutedEventArgs e) { - var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] }; + private async void SelectSSHKey(object _, RoutedEventArgs e) + { var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new("SSHKey") { Patterns = ["*"] }] + }; + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); - if (selected.Count == 1) { - txtSSHKey.Text = selected[0].Path.LocalPath; - } + 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 index f7c70bfd1..0d5f442ef 100644 --- a/src/Views/AddSubmodule.axaml +++ b/src/Views/AddSubmodule.axaml @@ -2,38 +2,62 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.AddSubmodule" x:DataType="vm:AddSubmodule"> - - + + + + + + + + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.URL}"/> + Text="{Binding Url, Mode=TwoWay}"> + + + + + + + + + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.Submodule.RelativePath}"/> + Text="{Binding RelativePath, Mode=TwoWay}"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AddToIgnore.axaml.cs b/src/Views/AddToIgnore.axaml.cs new file mode 100644 index 000000000..ae1745e6a --- /dev/null +++ b/src/Views/AddToIgnore.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class AddToIgnore : UserControl + { + public AddToIgnore() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/AddWorktree.axaml b/src/Views/AddWorktree.axaml new file mode 100644 index 000000000..4f047f489 --- /dev/null +++ b/src/Views/AddWorktree.axaml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/AddWorktree.axaml.cs b/src/Views/AddWorktree.axaml.cs new file mode 100644 index 000000000..a0ddb12c2 --- /dev/null +++ b/src/Views/AddWorktree.axaml.cs @@ -0,0 +1,41 @@ +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.TrimEnd('\\', '/'); + } + } + catch (Exception exception) + { + Models.Notification.Send(null, $"Failed to select location: {exception.Message}", true); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/Alert.axaml b/src/Views/Alert.axaml new file mode 100644 index 000000000..e387ab0f1 --- /dev/null +++ b/src/Views/Alert.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + IsEnabled="{Binding !IgnoreWhiteSpace}" + Grid.IsSharedSizeScope="True"> - - - - - + + + + + + + + + + + + + + + + + + + + + - + IsChecked="{Binding IgnoreWhiteSpace, Mode=TwoWay}" + ToolTip.Tip="--ignore-whitespace"/> + + diff --git a/src/Views/Apply.axaml.cs b/src/Views/Apply.axaml.cs index 3f61a4dd7..d0ee20103 100644 --- a/src/Views/Apply.axaml.cs +++ b/src/Views/Apply.axaml.cs @@ -1,22 +1,40 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform.Storage; -namespace SourceGit.Views { - public partial class Apply : UserControl { - public Apply() { +namespace SourceGit.Views +{ + public partial class Apply : UserControl + { + public Apply() + { InitializeComponent(); } - private async void SelectPatchFile(object sender, RoutedEventArgs e) { + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (DataContext is ViewModels.Apply vm && TopLevel.GetTopLevel(this) is { } toplevel) + vm.Clipboard = toplevel.Clipboard; + } + + private async void SelectPatchFile(object _, RoutedEventArgs e) + { var topLevel = TopLevel.GetTopLevel(this); - if (topLevel == null) return; + if (topLevel == null) + return; + + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }] + }; - 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; - } + 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 000000000..c3a6f0f24 --- /dev/null +++ b/src/Views/ApplyStash.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ApplyStash.axaml.cs b/src/Views/ApplyStash.axaml.cs new file mode 100644 index 000000000..7c8ba227e --- /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 index be2d0ddcc..3e2b59991 100644 --- a/src/Views/Archive.axaml +++ b/src/Views/Archive.axaml @@ -9,29 +9,45 @@ x:Class="SourceGit.Views.Archive" x:DataType="vm:Archive"> - - - - + + + + + + + + + - + - - - - - + + + + + @@ -44,20 +60,21 @@ - - - - + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.Archive.File}"/> + + + + + diff --git a/src/Views/Archive.axaml.cs b/src/Views/Archive.axaml.cs index 63e73448a..c00469690 100644 --- a/src/Views/Archive.axaml.cs +++ b/src/Views/Archive.axaml.cs @@ -2,19 +2,30 @@ using Avalonia.Interactivity; using Avalonia.Platform.Storage; -namespace SourceGit.Views { - public partial class Archive : UserControl { - public Archive() { +namespace SourceGit.Views +{ + public partial class Archive : UserControl + { + public Archive() + { InitializeComponent(); } - private async void SelectOutputFile(object sender, RoutedEventArgs e) { - var options = new FilePickerSaveOptions() { DefaultExtension = ".zip", FileTypeChoices = [ new FilePickerFileType("ZIP") { Patterns = [ "*.zip" ]}] }; + 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; - } + 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 000000000..dabc3df1e --- /dev/null +++ b/src/Views/Askpass.axaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - + IsVisible="{OnPlatform True, macOS=False}"/> - - + - - - - - - - - - - - - - - - + + + + + - - - - - - - - - + + + + - - - - - + + + + + + - + diff --git a/src/Views/AssumeUnchangedManager.axaml.cs b/src/Views/AssumeUnchangedManager.axaml.cs index 616db87d5..914e643e2 100644 --- a/src/Views/AssumeUnchangedManager.axaml.cs +++ b/src/Views/AssumeUnchangedManager.axaml.cs @@ -1,14 +1,22 @@ using Avalonia.Controls; using Avalonia.Interactivity; -namespace SourceGit.Views { - public partial class AssumeUnchangedManager : Window { - public AssumeUnchangedManager() { +namespace SourceGit.Views +{ + public partial class AssumeUnchangedManager : ChromelessWindow + { + public AssumeUnchangedManager() + { + CloseOnESC = true; InitializeComponent(); } - private void CloseWindow(object sender, RoutedEventArgs e) { - Close(); + private async void OnRemoveButtonClicked(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.AssumeUnchangedManager vm && sender is Button button) + await vm.RemoveAsync(button.DataContext as string); + + e.Handled = true; } } } diff --git a/src/Views/Avatar.cs b/src/Views/Avatar.cs index d92729596..82fa0a847 100644 --- a/src/Views/Avatar.cs +++ b/src/Views/Avatar.cs @@ -1,126 +1,306 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Media; -using System; +using System; +using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Security.Cryptography; using System.Text; -namespace SourceGit.Views { - public class Avatar : Control, Models.IAvatarHost { - private static readonly GradientStops[] FALLBACK_GRADIENTS = [ - new GradientStops() { new GradientStop(Colors.Orange, 0), new GradientStop(Color.FromRgb(255, 213, 134), 1) }, - new GradientStops() { new GradientStop(Colors.DodgerBlue, 0), new GradientStop(Colors.LightSkyBlue, 1) }, - new GradientStops() { new GradientStop(Colors.LimeGreen, 0), new GradientStop(Color.FromRgb(124, 241, 124), 1) }, - new GradientStops() { new GradientStop(Colors.Orchid, 0), new GradientStop(Color.FromRgb(248, 161, 245), 1) }, - new GradientStops() { new GradientStop(Colors.Tomato, 0), new GradientStop(Color.FromRgb(252, 165, 150), 1) }, - ]; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; - public static readonly StyledProperty FallbackFontFamilyProperty = - AvaloniaProperty.Register(nameof(FallbackFontFamily)); +namespace SourceGit.Views +{ + public class Avatar : Control, Models.IAvatarHost + { + public static readonly DirectProperty UserProperty = + AvaloniaProperty.RegisterDirect( + nameof(User), + static o => o.User, + static (o, v) => o.User = v); - public FontFamily FallbackFontFamily { - get => GetValue(FallbackFontFamilyProperty); - set => SetValue(FallbackFontFamilyProperty, value); + public Models.User User + { + get => _user; + set => SetAndRaise(UserProperty, ref _user, value); } - public static readonly StyledProperty UserProperty = - AvaloniaProperty.Register(nameof(User)); + public static readonly DirectProperty UseGitHubStyleAvatarProperty = + AvaloniaProperty.RegisterDirect( + nameof(UseGitHubStyleAvatar), + static o => o.UseGitHubStyleAvatar, + static (o, v) => o.UseGitHubStyleAvatar = v); - public Models.User User { - get => GetValue(UserProperty); - set => SetValue(UserProperty, value); + public bool UseGitHubStyleAvatar + { + get => _useGitHubStyleAvatar; + set => SetAndRaise(UseGitHubStyleAvatarProperty, ref _useGitHubStyleAvatar, value); } - static Avatar() { - AffectsRender(FallbackFontFamilyProperty); - UserProperty.Changed.AddClassHandler(OnUserPropertyChanged); + public Avatar() + { + RenderOptions.SetBitmapInterpolationMode(this, BitmapInterpolationMode.HighQuality); + + Bind(UseGitHubStyleAvatarProperty, new Binding() + { + Mode = BindingMode.OneWay, + Source = ViewModels.Preferences.Instance, + Path = "UseGitHubStyleAvatar" + }); } - public Avatar() { - var refetch = new MenuItem() { Header = App.Text("RefetchAvatar") }; - refetch.Click += (o, e) => { - if (User != null) { - Models.AvatarManager.Request(_emailMD5, true); - InvalidateVisual(); + 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 if (!_useGitHubStyleAvatar) + { + var fallback = GetFallbackString(_user.Name); + var typeface = new Typeface("fonts:SourceGit#JetBrains Mono NL"); + var label = new FormattedText( + fallback, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + Math.Max(Bounds.Width * 0.65, 10), + Brushes.White); + + var chars = fallback.ToCharArray(); + var sum = 0; + foreach (var c in chars) + sum += Math.Abs(c); + + var bg = new LinearGradientBrush() + { + GradientStops = FALLBACK_GRADIENTS[sum % FALLBACK_GRADIENTS.Length], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }; + + Point textOrigin = new Point((Bounds.Width - label.Width) * 0.5, (Bounds.Height - label.Height) * 0.5); + context.DrawRectangle(bg, null, new Rect(0, 0, Bounds.Width, Bounds.Height), corner, corner); + context.DrawText(label, textOrigin); + } + 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 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)); } - }; - ContextMenu = new ContextMenu(); - ContextMenu.Items.Add(refetch); - } + for (int row = 0; row < 5; row++) + { + var x = offsetX; + var y = offsetY + stepY * row; + var idx = row * 3 + 2; - public override void Render(DrawingContext context) { - if (User == null) return; + if (switches[idx]) + context.FillRectangle(brush, new Rect(x, y, stepX, stepY)); - var corner = (float)Math.Max(2, Bounds.Width / 16); - var img = Models.AvatarManager.Request(_emailMD5, false); - if (img != null) { - var rect = new Rect(0, 0, Bounds.Width, Bounds.Height); - context.PushClip(new RoundedRect(rect, corner)); - context.DrawImage(img, rect); - } else { - Point textOrigin = new Point((Bounds.Width - _fallbackLabel.Width) * 0.5, (Bounds.Height - _fallbackLabel.Height) * 0.5); - context.DrawRectangle(_fallbackBrush, null, new Rect(0, 0, Bounds.Width, Bounds.Height), corner, corner); - context.DrawText(_fallbackLabel, textOrigin); - } + if (switches[idx - 1]) + context.FillRectangle(brush, new Rect(x + stepX, y, stepX, stepY)); + } + } + + clip.Dispose(); } - public void OnAvatarResourceChanged(string md5) { - if (_emailMD5 == md5) { + public void OnAvatarResourceChanged(string email, Bitmap image) + { + if (email.Equals(_user?.Email, StringComparison.Ordinal)) + { + _img = image; InvalidateVisual(); } } - protected override void OnLoaded(RoutedEventArgs e) { + protected override void OnLoaded(RoutedEventArgs e) + { base.OnLoaded(e); - Models.AvatarManager.Subscribe(this); + Models.AvatarManager.Instance.Subscribe(this); + ContextRequested += OnContextRequested; } - protected override void OnUnloaded(RoutedEventArgs e) { + protected override void OnUnloaded(RoutedEventArgs e) + { base.OnUnloaded(e); - Models.AvatarManager.Unsubscribe(this); + ContextRequested -= OnContextRequested; + Models.AvatarManager.Instance.Unsubscribe(this); } - private static void OnUserPropertyChanged(Avatar avatar, AvaloniaPropertyChangedEventArgs e) { - if (avatar.User == null) { - avatar._emailMD5 = null; + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UserProperty) + { + if (_user == null) + return; + + _img = Models.AvatarManager.Instance.Request(_user.Email, false); + InvalidateVisual(); + } + else if (change.Property == UseGitHubStyleAvatarProperty) + { + if (_img == null) + InvalidateVisual(); + } + } + + private void OnContextRequested(object sender, ContextRequestedEventArgs e) + { + var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + { + e.Handled = true; return; } - var placeholder = string.IsNullOrWhiteSpace(avatar.User.Name) ? "?" : avatar.User.Name.Substring(0, 1); - var chars = placeholder.ToCharArray(); - var sum = 0; - foreach (var c in chars) sum += Math.Abs(c); - - var hash = MD5.Create().ComputeHash(Encoding.Default.GetBytes(avatar.User.Email.ToLower().Trim())); - var builder = new StringBuilder(); - foreach (var c in hash) builder.Append(c.ToString("x2")); - var md5 = builder.ToString(); - if (avatar._emailMD5 != md5) avatar._emailMD5 = md5; - - avatar._fallbackBrush = new LinearGradientBrush { - GradientStops = FALLBACK_GRADIENTS[sum % FALLBACK_GRADIENTS.Length], - StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), - EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + var refetch = new MenuItem(); + refetch.Icon = this.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 = this.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 typeface = avatar.FallbackFontFamily == null ? Typeface.Default : new Typeface(avatar.FallbackFontFamily); + var saveAs = new MenuItem(); + saveAs.Icon = this.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.Create(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; + }; - avatar._fallbackLabel = new FormattedText( - placeholder, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - avatar.Width * 0.65, - Brushes.White); + var menu = new ContextMenu(); + menu.Items.Add(refetch); + menu.Items.Add(load); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(saveAs); - avatar.InvalidateVisual(); + menu.Open(this); } - private FormattedText _fallbackLabel = null; - private LinearGradientBrush _fallbackBrush = null; - private string _emailMD5 = null; + private string GetFallbackString(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return "?"; + + var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var chars = new List(); + foreach (var part in parts) + chars.Add(part[0]); + + if (chars.Count >= 2 && char.IsAsciiLetterOrDigit(chars[0]) && char.IsAsciiLetterOrDigit(chars[^1])) + return string.Format("{0}{1}", chars[0], chars[^1]); + + return name.Substring(0, 1); + } + + private static readonly GradientStops[] FALLBACK_GRADIENTS = [ + new() { new GradientStop(Colors.Orange, 0), new GradientStop(Color.FromRgb(255, 213, 134), 1) }, + new() { new GradientStop(Colors.DodgerBlue, 0), new GradientStop(Colors.LightSkyBlue, 1) }, + new() { new GradientStop(Colors.LimeGreen, 0), new GradientStop(Color.FromRgb(124, 241, 124), 1) }, + new() { new GradientStop(Colors.Orchid, 0), new GradientStop(Color.FromRgb(248, 161, 245), 1) }, + new() { new GradientStop(Colors.Tomato, 0), new GradientStop(Color.FromRgb(252, 165, 150), 1) }, + ]; + + private Models.User _user = null; + private bool _useGitHubStyleAvatar = false; + private Bitmap _img = null; } } diff --git a/src/Views/BisectStateIndicator.cs b/src/Views/BisectStateIndicator.cs new file mode 100644 index 000000000..71750da40 --- /dev/null +++ b/src/Views/BisectStateIndicator.cs @@ -0,0 +1,137 @@ +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 DirectProperty BisectProperty = + AvaloniaProperty.RegisterDirect( + nameof(Bisect), + static o => o.Bisect, + static (o, v) => o.Bisect = v); + + public Models.Bisect Bisect + { + get => _bisect; + set => SetAndRaise(BisectProperty, ref _bisect, value); + } + + public override void Render(DrawingContext context) + { + switch (_flag) + { + case Models.BisectCommitFlag.Good: + RenderImpl(context, Brushes.Green, "Icons.Good"); + break; + case Models.BisectCommitFlag.Bad: + RenderImpl(context, Brushes.Red, "Icons.Bad"); + break; + case Models.BisectCommitFlag.Skipped: + RenderImpl(context, Brushes.Gray, "Icons.Skip"); + break; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == BisectProperty) + InvalidateMeasure(); + else if (change.Property == BackgroundProperty || change.Property == ForegroundProperty) + InvalidateVisual(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var flag = Models.BisectCommitFlag.None; + + if (Bisect is { } bisect && DataContext is Models.Commit commit) + { + var sha = commit.SHA; + if (bisect.Bads.Contains(sha)) + flag = Models.BisectCommitFlag.Bad; + else if (bisect.Goods.Contains(sha)) + flag = Models.BisectCommitFlag.Good; + else if (bisect.Skipped.Contains(sha)) + flag = Models.BisectCommitFlag.Skipped; + } + + if (flag != _flag) + { + _flag = flag; + InvalidateVisual(); + } + + if (flag == Models.BisectCommitFlag.None) + return new Size(0, 0); + + return new Size(36, 16); + } + + 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, string iconKey) + { + var prefix = LoadIcon("Icons.Bisect"); + var icon = LoadIcon(iconKey); + var entireRect = new RoundedRect(new Rect(0, 0, 32, 16), new CornerRadius(2)); + var stateRect = new RoundedRect(new Rect(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(16, 0), new Point(16, 16)); + + using (context.PushTransform(Matrix.CreateTranslation(3, 3))) + context.DrawGeometry(Foreground, null, prefix); + + using (context.PushTransform(Matrix.CreateTranslation(19, 3))) + context.DrawGeometry(Foreground, null, icon); + } + + private Models.Bisect _bisect = null; + private Models.BisectCommitFlag _flag = Models.BisectCommitFlag.None; + } +} diff --git a/src/Views/Blame.axaml b/src/Views/Blame.axaml index aa8232f9f..29b8375e1 100644 --- a/src/Views/Blame.axaml +++ b/src/Views/Blame.axaml @@ -1,77 +1,144 @@ - - - - - - - - + + + + - + - - - - + - - + - - + + + - - + + + + - - - - - + + + - + + - + FontFamily="{DynamicResource Fonts.Monospace}" + FontSize="{Binding Source={x:Static vm:Preferences.Instance}, Path=EditorFontSize}" + TabWidth="{Binding Source={x:Static vm:Preferences.Instance}, Path=EditorTabWidth}" + File="{Binding File, Mode=OneWay}" + BlameData="{Binding Data}" + IsVisible="{Binding IsBinary, Converter={x:Static BoolConverters.Not}}"> + + + + + + + + - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/src/Views/Blame.axaml.cs b/src/Views/Blame.axaml.cs index 56c6d3eef..e1b5fb1ff 100644 --- a/src/Views/Blame.axaml.cs +++ b/src/Views/Blame.axaml.cs @@ -1,46 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + using Avalonia; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Styling; + using AvaloniaEdit; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; using AvaloniaEdit.TextMate; using AvaloniaEdit.Utils; -using System; -using System.Globalization; -using System.IO; -using TextMateSharp.Grammars; -using System.Collections.Generic; -namespace SourceGit.Views { - public class BlameTextEditor : TextEditor { - public class CommitInfoMargin : AbstractMargin { - public CommitInfoMargin(BlameTextEditor editor) { +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; + public override void Render(DrawingContext context) + { + if (_editor.BlameData == null) + return; var view = TextView; - if (view != null && view.VisualLinesValid) { + if (view is { VisualLinesValid: true }) + { var typeface = view.CreateTypeface(); - var underlinePen = new Pen(Brushes.DarkOrange, 1); + var underlinePen = new Pen(Brushes.DarkOrange); + var width = Bounds.Width; + var lineHeight = view.DefaultLineHeight; + var pixelHeight = PixelSnapHelpers.GetPixelSize(view).Height; + + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; - foreach (var line in view.VisualLines) { var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > _editor.BlameData.LineInfos.Count) break; + 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 y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; + if (!info.IsFirstInGroup && y > lineHeight) + continue; var shaLink = new FormattedText( info.CommitSHA, @@ -49,46 +63,54 @@ public override void Render(DrawingContext context) { 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)); + var shaLinkTop = y - shaLink.Height * 0.5; + var underlineY = PixelSnapHelpers.PixelAlign(y + shaLink.Height * 0.5 + 0.5, pixelHeight); + context.DrawText(shaLink, new Point(x, shaLinkTop)); + context.DrawLine(underlinePen, new Point(x, underlineY), new Point(x + shaLink.Width, underlineY)); x += shaLink.Width + 8; - var time = new FormattedText( - info.Time, + var author = new FormattedText( + info.Author, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, _editor.FontSize, _editor.Foreground); - context.DrawText(time, new Point(x, y)); - x += time.Width + 8; + var authorTop = y - author.Height * 0.5; + context.DrawText(author, new Point(x, authorTop)); - var author = new FormattedText( - info.Author, + var timeStr = Models.DateTimeFormat.Format(info.Timestamp, true); + var time = new FormattedText( + timeStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, _editor.FontSize, _editor.Foreground); - context.DrawText(author, new Point(x, y)); + var timeTop = y - time.Height * 0.5; + context.DrawText(time, new Point(width - time.Width, timeTop)); } } } - protected override Size MeasureOverride(Size availableSize) { + protected override Size MeasureOverride(Size availableSize) + { var view = TextView; var maxWidth = 0.0; - if (view != null && view.VisualLinesValid && _editor.BlameData != null) { + if (view is { VisualLinesValid: true } && _editor.BlameData != null) + { var typeface = view.CreateTypeface(); var calculated = new HashSet(); - foreach (var line in view.VisualLines) { + foreach (var line in view.VisualLines) + { var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > _editor.BlameData.LineInfos.Count) break; + if (lineNumber > _editor.BlameData.LineInfos.Count) + break; var info = _editor.BlameData.LineInfos[lineNumber - 1]; - if (calculated.Contains(info.CommitSHA)) continue; - calculated.Add(info.CommitSHA); + if (!calculated.Add(info.CommitSHA)) + continue; var x = 0.0; var shaLink = new FormattedText( @@ -100,42 +122,101 @@ protected override Size MeasureOverride(Size availableSize) { Brushes.DarkOrange); x += shaLink.Width + 8; - var time = new FormattedText( - info.Time, + var author = new FormattedText( + info.Author, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, _editor.FontSize, _editor.Foreground); - x += time.Width + 8; + x += author.Width + 8; - var author = new FormattedText( - info.Author, + var timeStr = Models.DateTimeFormat.Format(info.Timestamp, true); + var time = new FormattedText( + timeStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, _editor.FontSize, _editor.Foreground); - x += author.Width; + x += time.Width; - if (maxWidth < x) maxWidth = x; + if (maxWidth < x) + maxWidth = x; } } return new Size(maxWidth, 0); } - protected override void OnPointerPressed(PointerPressedEventArgs e) { + 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(); + var lineHeight = view.DefaultLineHeight; + + 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.LineTop) - 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, lineHeight); + 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 != null && view.VisualLinesValid) { + 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) { + 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; + 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; @@ -148,8 +229,11 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) { Brushes.DarkOrange); var rect = new Rect(0, y, shaLink.Width, shaLink.Height); - if (rect.Contains(pos)) { - _editor.OnCommitSHAClicked(info.CommitSHA); + if (rect.Contains(pos)) + { + if (DataContext is ViewModels.Blame blame) + blame.NavigateToCommit(info.File, info.CommitSHA); + e.Handled = true; break; } @@ -157,168 +241,264 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) { } } - private BlameTextEditor _editor = null; + private readonly BlameTextEditor _editor = null; } - public class VerticalSeperatorMargin : AbstractMargin { - public VerticalSeperatorMargin(BlameTextEditor editor) { + public class VerticalSeparatorMargin : AbstractMargin + { + public VerticalSeparatorMargin(BlameTextEditor editor) + { _editor = editor; } - public override void Render(DrawingContext context) { - var pen = new Pen(_editor.BorderBrush, 1); - context.DrawLine(pen, new Point(0, 0), new Point(0, Bounds.Height)); + public override void Render(DrawingContext context) + { + var pen = new Pen(_editor.BorderBrush); + context.DrawLine(pen, new Point(0.5, 0), new Point(0.5, Bounds.Height)); } - protected override Size MeasureOverride(Size availableSize) { + protected override Size MeasureOverride(Size availableSize) + { return new Size(1, 0); } - private BlameTextEditor _editor = null; + private readonly BlameTextEditor _editor = null; } - public static readonly StyledProperty BlameDataProperty = - AvaloniaProperty.Register(nameof(BlameData)); + public class LineBackgroundRenderer : IBackgroundRenderer + { + public KnownLayer Layer => KnownLayer.Background; + + public LineBackgroundRenderer(BlameTextEditor owner) + { + _owner = owner; + } + + public void Draw(TextView textView, DrawingContext drawingContext) + { + if (!textView.VisualLinesValid) + return; + + var w = textView.Bounds.Width; + if (double.IsNaN(w) || double.IsInfinity(w) || w <= 0) + return; + + var highlight = _owner._highlight; + if (string.IsNullOrEmpty(highlight)) + return; + + var color = (Color)_owner.FindResource("SystemAccentColor")!; + var brush = new SolidColorBrush(color, 0.2); + var lines = _owner.BlameData.LineInfos; + + foreach (var line in textView.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; + + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > lines.Count) + break; + + var info = lines[lineNumber - 1]; + if (!info.CommitSHA.Equals(highlight, StringComparison.Ordinal)) + continue; + + var startY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - textView.VerticalOffset; + var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - textView.VerticalOffset; + drawingContext.FillRectangle(brush, new Rect(0, startY, w, endY - startY)); + } + } - public Models.BlameData BlameData { - get => GetValue(BlameDataProperty); - set => SetValue(BlameDataProperty, value); + private readonly BlameTextEditor _owner; + } + + public static readonly DirectProperty FileProperty = + AvaloniaProperty.RegisterDirect( + nameof(File), + static o => o.File, + static (o, v) => o.File = v); + + public string File + { + get => _file; + set => SetAndRaise(FileProperty, ref _file, value); + } + + public static readonly DirectProperty BlameDataProperty = + AvaloniaProperty.RegisterDirect( + nameof(BlameData), + static o => o.BlameData, + static (o, v) => o.BlameData = v); + + public Models.BlameData BlameData + { + get => _blameData; + set => SetAndRaise(BlameDataProperty, ref _blameData, value); + } + + public static readonly DirectProperty TabWidthProperty = + AvaloniaProperty.RegisterDirect( + nameof(TabWidth), + static o => o.TabWidth, + static (o, v) => o.TabWidth = v); + + public int TabWidth + { + get => _tabWidth; + set => SetAndRaise(TabWidthProperty, ref _tabWidth, value); } protected override Type StyleKeyOverride => typeof(TextEditor); - public BlameTextEditor() : base(new TextArea(), new TextDocument()) { + public BlameTextEditor() : base(new TextArea(), new TextDocument()) + { IsReadOnly = true; ShowLineNumbers = false; WordWrap = false; - } - public void OnCommitSHAClicked(string sha) { - if (DataContext is ViewModels.Blame blame) { - blame.NavigateToCommit(sha); - } - } + Options.IndentationSize = _tabWidth; + Options.EnableHyperlinks = false; + Options.EnableEmailHyperlinks = false; - protected override void OnLoaded(RoutedEventArgs e) { - base.OnLoaded(e); + _textMate = Models.TextMateHelper.CreateForEditor(this); - TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); - TextArea.LeftMargins.Add(new VerticalSeperatorMargin(this)); TextArea.LeftMargins.Add(new CommitInfoMargin(this) { Margin = new Thickness(8, 0) }); - TextArea.LeftMargins.Add(new VerticalSeperatorMargin(this)); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); + TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); + TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); + TextArea.Caret.PositionChanged += OnTextAreaCaretPositionChanged; + TextArea.TextView.BackgroundRenderers.Add(new LineBackgroundRenderer(this)); TextArea.TextView.ContextRequested += OnTextViewContextRequested; TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; TextArea.TextView.Margin = new Thickness(4, 0); - - if (App.Current?.ActualThemeVariant == ThemeVariant.Dark) { - _registryOptions = new RegistryOptions(ThemeName.DarkPlus); - } else { - _registryOptions = new RegistryOptions(ThemeName.LightPlus); - } - - _textMate = this.InstallTextMate(_registryOptions); - UpdateGrammar(); } - protected override void OnUnloaded(RoutedEventArgs e) { + protected override void OnUnloaded(RoutedEventArgs e) + { base.OnUnloaded(e); TextArea.LeftMargins.Clear(); + TextArea.Caret.PositionChanged -= OnTextAreaCaretPositionChanged; TextArea.TextView.ContextRequested -= OnTextViewContextRequested; TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged; - _registryOptions = null; - _textMate.Dispose(); - _textMate = null; + if (_textMate != null) + { + _textMate.Dispose(); + _textMate = null; + } } - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { base.OnPropertyChanged(change); - if (change.Property == BlameDataProperty) { - if (BlameData != null) { - Text = BlameData.Content; - UpdateGrammar(); - } else { + if (change.Property == FileProperty) + { + if (_file is { Length: > 0 }) + Models.TextMateHelper.SetGrammarByFileName(_textMate, _file); + } + if (change.Property == BlameDataProperty) + { + if (_blameData is { IsBinary: false } blame) + Text = blame.Content; + else Text = string.Empty; - } - } else if (change.Property.Name == "ActualThemeVariant" && change.NewValue != null && _textMate != null) { - if (App.Current?.ActualThemeVariant == ThemeVariant.Dark) { - _textMate.SetTheme(_registryOptions.LoadTheme(ThemeName.DarkPlus)); - } else { - _textMate.SetTheme(_registryOptions.LoadTheme(ThemeName.LightPlus)); - } + } + else if (change.Property == TabWidthProperty) + { + Options.IndentationSize = _tabWidth; + } + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) + { + Models.TextMateHelper.SetThemeByApp(_textMate); } } - private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) { - var selected = SelectedText; - if (string.IsNullOrEmpty(selected)) return; + private void OnTextAreaCaretPositionChanged(object sender, EventArgs e) + { + if (!TextArea.IsFocused || _blameData == null) + return; + + var caret = TextArea.Caret; + if (caret == null || caret.Line > _blameData.LineInfos.Count) + return; + + _highlight = _blameData.LineInfos[caret.Line - 1].CommitSHA; + } - var icon = new Avalonia.Controls.Shapes.Path(); - icon.Width = 10; - icon.Height = 10; - icon.Stretch = Stretch.Uniform; - icon.Data = App.Current?.FindResource("Icons.Copy") as StreamGeometry; + 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 = icon; - copy.Click += (o, ev) => { - App.CopyText(selected); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, ev) => + { + await this.CopyTextAsync(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) { + private void OnTextViewVisualLinesChanged(object sender, EventArgs e) + { + foreach (var margin in TextArea.LeftMargins) + { + if (margin is CommitInfoMargin commitInfo) + { commitInfo.InvalidateMeasure(); break; } } } - private void UpdateGrammar() { - if (_textMate == null || BlameData == null) return; - - var ext = Path.GetExtension(BlameData.File); - if (ext == ".h") { - _textMate.SetGrammar(_registryOptions.GetScopeByLanguageId("cpp")); - } else { - _textMate.SetGrammar(_registryOptions.GetScopeByExtension(ext)); - } - } - - private RegistryOptions _registryOptions = null; + private string _file = null; + private Models.BlameData _blameData = null; + private int _tabWidth = 4; private TextMate.Installation _textMate = null; + private string _highlight = string.Empty; } - public partial class Blame : Window { - public Blame() { - if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - Owner = desktop.MainWindow; - } - + public partial class Blame : ChromelessWindow + { + public Blame() + { InitializeComponent(); } - protected override void OnClosed(EventArgs e) { + protected override void OnClosed(EventArgs e) + { base.OnClosed(e); GC.Collect(); } - private void OnCommitSHAPointerPressed(object sender, PointerPressedEventArgs e) { - if (DataContext is ViewModels.Blame blame) { - var txt = sender as TextBlock; - blame.NavigateToCommit(txt.Text); + 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; + } } - e.Handled = true; } } } diff --git a/src/Views/BlameCommandPalette.axaml b/src/Views/BlameCommandPalette.axaml new file mode 100644 index 000000000..efb2000f8 --- /dev/null +++ b/src/Views/BlameCommandPalette.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/BlameCommandPalette.axaml.cs b/src/Views/BlameCommandPalette.axaml.cs new file mode 100644 index 000000000..56de2d6db --- /dev/null +++ b/src/Views/BlameCommandPalette.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class BlameCommandPalette : UserControl + { + public BlameCommandPalette() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.BlameCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + this.ShowWindow(vm.Launch()); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + if (FileListBox.IsKeyboardFocusWithin) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + else if (e.Key == Key.Down || e.Key == Key.Tab) + { + if (FilterTextBox.IsKeyboardFocusWithin) + { + if (vm.VisibleFiles.Count > 0) + FileListBox.Focus(NavigationMethod.Directional); + + e.Handled = true; + return; + } + + if (FileListBox.IsKeyboardFocusWithin && e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + } + + private void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.BlameCommandPalette vm) + { + this.ShowWindow(vm.Launch()); + e.Handled = true; + } + } + } +} diff --git a/src/Views/Bookmark.cs b/src/Views/Bookmark.cs new file mode 100644 index 000000000..d30a4e315 --- /dev/null +++ b/src/Views/Bookmark.cs @@ -0,0 +1,66 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class Bookmark : Control + { + public static readonly DirectProperty ValueProperty = + AvaloniaProperty.RegisterDirect( + nameof(Value), + static o => o.Value, + static (o, v) => o.Value = v); + + public int Value + { + get => _value; + set => SetAndRaise(ValueProperty, ref _value, value); + } + + public Bookmark() + { + IsHitTestVisible = false; + } + + public override void Render(DrawingContext context) + { + if (_icon == null) + LoadIcon(); + + var brush = Models.Bookmarks.Get(_value) ?? (this.FindResource("Brush.FG1") as IBrush); + var startX = (Bounds.Width - 12.0) * 0.5; + var startY = (Bounds.Height - 12.0) * 0.5; + using (context.PushTransform(Matrix.CreateTranslation(startX, startY))) + context.DrawGeometry(brush, null, _icon); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ValueProperty) + InvalidateVisual(); + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) + InvalidateVisual(); + } + + private void LoadIcon() + { + var geo = this.FindResource("Icons.Bookmark") as StreamGeometry; + _icon = geo!.Clone(); + var iconBounds = _icon.Bounds; + var translation = Matrix.CreateTranslation(-(Vector)iconBounds.Position); + var scale = Math.Min(12.0 / iconBounds.Width, 12.0 / iconBounds.Height); + var transform = translation * Matrix.CreateScale(scale, scale); + if (_icon.Transform == null || _icon.Transform.Value == Matrix.Identity) + _icon.Transform = new MatrixTransform(transform); + else + _icon.Transform = new MatrixTransform(_icon.Transform.Value * transform); + } + + private int _value = 0; + private Geometry _icon = null; + } +} diff --git a/src/Views/BookmarkSelector.cs b/src/Views/BookmarkSelector.cs new file mode 100644 index 000000000..c3da794ae --- /dev/null +++ b/src/Views/BookmarkSelector.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class BookmarkSelector : Control + { + public static readonly DirectProperty BookmarkProperty = + AvaloniaProperty.RegisterDirect( + nameof(Bookmark), + static o => o.Bookmark, + static (o, v) => o.Bookmark = v); + + public int Bookmark + { + get => _bookmark; + set => SetAndRaise(BookmarkProperty, ref _bookmark, value); + } + + public BookmarkSelector() + { + var geo = Application.Current!.FindResource("Icons.Bookmark") as StreamGeometry; + _icon = geo!.Clone(); + var iconBounds = _icon.Bounds; + var translation = Matrix.CreateTranslation(-(Vector)iconBounds.Position); + var scale = Math.Min(14.0 / iconBounds.Width, 14.0 / iconBounds.Height); + var transform = translation * Matrix.CreateScale(scale, scale); + if (_icon.Transform == null || _icon.Transform.Value == Matrix.Identity) + _icon.Transform = new MatrixTransform(transform); + else + _icon.Transform = new MatrixTransform(_icon.Transform.Value * transform); + + var x = 2.0; + for (var i = 0; i < Models.Bookmarks.Brushes.Length; i++) + { + var hitBox = new Rect(x - 2.5, 2.5, 18, 20); + _hitBoxes.Add(hitBox); + x += 26; + } + } + + public override void Render(DrawingContext context) + { + // Just enable clicking anywhere in the control. + context.FillRectangle(Brushes.Transparent, new Rect(0, 0, Bounds.Width, Bounds.Height)); + + var defaultBrush = this.FindResource("Brush.FG1") as IBrush; + var selectedBorder = new Pen(new SolidColorBrush((Color)this.FindResource("SystemAccentColor")!)); + + for (var i = 0; i < _hitBoxes.Count; i++) + { + var hitBox = _hitBoxes[i]; + if (i == _bookmark) + context.DrawRectangle(selectedBorder, hitBox, 3); + + var bursh = Models.Bookmarks.Get(i) ?? defaultBrush; + using (context.PushTransform(Matrix.CreateTranslation(hitBox.X + 3, 5))) + context.DrawGeometry(bursh, null, _icon); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == BookmarkProperty) + InvalidateVisual(); + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) + InvalidateVisual(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + var pos = e.GetPosition(this); + for (var i = 0; i < _hitBoxes.Count; i++) + { + if (_hitBoxes[i].Contains(pos)) + { + Bookmark = i; + break; + } + } + + e.Handled = true; + } + } + + protected override Size MeasureOverride(Size availableSize) + { + return new Size(8 * 14 + 7 * 12 + 4, 24); + } + + private int _bookmark = 0; + private Geometry _icon = null; + private List _hitBoxes = []; + } +} diff --git a/src/Views/BranchOrTagNameTextBox.cs b/src/Views/BranchOrTagNameTextBox.cs new file mode 100644 index 000000000..e7078b17c --- /dev/null +++ b/src/Views/BranchOrTagNameTextBox.cs @@ -0,0 +1,66 @@ +using System; +using System.Text; + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public class BranchOrTagNameTextBox : TextBox + { + protected override Type StyleKeyOverride => typeof(TextBox); + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + PastingFromClipboard += OnPastingFromClipboard; + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + PastingFromClipboard -= OnPastingFromClipboard; + base.OnUnloaded(e); + } + + protected override void OnTextInput(TextInputEventArgs e) + { + if (string.IsNullOrEmpty(e.Text)) + return; + + var builder = new StringBuilder(e.Text.Length); + var chars = e.Text.ToCharArray(); + foreach (var ch in chars) + { + if (char.IsWhiteSpace(ch)) + builder.Append('-'); + else + builder.Append(ch); + } + + e.Text = builder.ToString(); + base.OnTextInput(e); + } + + private async void OnPastingFromClipboard(object sender, RoutedEventArgs e) + { + e.Handled = true; + + try + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard != null) + { + var text = await clipboard.TryGetTextAsync(); + if (!string.IsNullOrEmpty(text)) + OnTextInput(new TextInputEventArgs() { Text = text }); + } + } + catch + { + // Ignore exceptions + } + } + } +} diff --git a/src/Views/BranchSelector.axaml b/src/Views/BranchSelector.axaml new file mode 100644 index 000000000..7f12a82bf --- /dev/null +++ b/src/Views/BranchSelector.axaml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/BranchSelector.axaml.cs b/src/Views/BranchSelector.axaml.cs new file mode 100644 index 000000000..4cd2b70d5 --- /dev/null +++ b/src/Views/BranchSelector.axaml.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class BranchSelectorChoice : TextBlock + { + public static readonly DirectProperty BranchProperty = + AvaloniaProperty.RegisterDirect( + nameof(Branch), + static o => o.Branch, + static (o, v) => o.Branch = v); + + public Models.Branch Branch + { + get => _branch; + set => SetAndRaise(BranchProperty, ref _branch, value); + } + + public static readonly DirectProperty UsePureNameProperty = + AvaloniaProperty.RegisterDirect( + nameof(UsePureName), + static o => o.UsePureName, + static (o, v) => o.UsePureName = v); + + public bool UsePureName + { + get => _usePureName; + set => SetAndRaise(UsePureNameProperty, ref _usePureName, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == BranchProperty || change.Property == UsePureNameProperty) + { + if (_branch != null) + Text = _usePureName ? _branch.Name : _branch.FriendlyName; + else + Text = "---"; + } + } + + private Models.Branch _branch = null; + private bool _usePureName = false; + } + + public partial class BranchSelector : UserControl + { + public static readonly DirectProperty> BranchesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(Branches), + static o => o.Branches, + static (o, v) => o.Branches = v); + + public List Branches + { + get => _branches; + set => SetAndRaise(BranchesProperty, ref _branches, value); + } + + public static readonly DirectProperty> VisibleBranchesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(VisibleBranches), + static o => o.VisibleBranches); + + public List VisibleBranches + { + get => _visibleBranches; + set => SetAndRaise(VisibleBranchesProperty, ref _visibleBranches, value); + } + + public static readonly DirectProperty SelectedBranchProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedBranch), + static o => o.SelectedBranch, + static (o, v) => o.SelectedBranch = v); + + public Models.Branch SelectedBranch + { + get => _selectedBranch; + set => SetAndRaise(SelectedBranchProperty, ref _selectedBranch, value); + } + + public static readonly DirectProperty IsDropDownOpenedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsDropDownOpened), + static o => o.IsDropDownOpened, + static (o, v) => o.IsDropDownOpened = v); + + public bool IsDropDownOpened + { + get => _isDropDownOpened; + set => SetAndRaise(IsDropDownOpenedProperty, ref _isDropDownOpened, value); + } + + public static readonly DirectProperty SearchFilterProperty = + AvaloniaProperty.RegisterDirect( + nameof(SearchFilter), + static o => o.SearchFilter, + static (o, v) => o.SearchFilter = v); + + public string SearchFilter + { + get => _searchFilter; + set => SetAndRaise(SearchFilterProperty, ref _searchFilter, value); + } + + public static readonly DirectProperty UsePureNameProperty = + AvaloniaProperty.RegisterDirect( + nameof(UsePureName), + static o => o.UsePureName, + static (o, v) => o.UsePureName = v); + + public bool UsePureName + { + get => _usePureName; + set => SetAndRaise(UsePureNameProperty, ref _usePureName, value); + } + + public BranchSelector() + { + Focusable = true; + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == BranchesProperty || change.Property == SearchFilterProperty) + { + if (_branches is not { Count: > 0 }) + { + VisibleBranches = []; + } + else if (string.IsNullOrEmpty(_searchFilter)) + { + VisibleBranches = _branches; + } + else + { + var visible = new List(); + var oldSelection = _selectedBranch; + var keepSelection = false; + + foreach (var b in _branches) + { + if (b.FriendlyName.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + { + visible.Add(b); + if (!keepSelection) + keepSelection = (b == oldSelection); + } + } + + VisibleBranches = visible; + if (!keepSelection && visible.Count > 0) + SelectedBranch = visible[0]; + } + } + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (_popup != null) + { + _popup.Opened -= OnPopupOpened; + _popup.Closed -= OnPopupClosed; + } + + _popup = e.NameScope.Get("PART_Popup"); + _popup.Opened += OnPopupOpened; + _popup.Closed += OnPopupClosed; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (e.Key == Key.Space && !IsDropDownOpened) + { + IsDropDownOpened = true; + e.Handled = true; + } + else if (e.Key == Key.Escape && IsDropDownOpened) + { + IsDropDownOpened = false; + e.Handled = true; + } + } + + private void OnPopupOpened(object sender, EventArgs e) + { + var listBox = _popup?.Child?.FindDescendantOfType(); + listBox?.Focus(); + } + + private void OnPopupClosed(object sender, EventArgs e) + { + Focus(NavigationMethod.Directional); + } + + private void OnToggleDropDown(object sender, PointerPressedEventArgs e) + { + IsDropDownOpened = !IsDropDownOpened; + e.Handled = true; + } + + private void OnSearchBoxKeyDown(object _, KeyEventArgs e) + { + if (e.Key == Key.Tab) + { + var listBox = _popup?.Child?.FindDescendantOfType(); + listBox?.Focus(); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + var listBox = _popup?.Child?.FindDescendantOfType(); + if (listBox != null) + { + if (listBox.SelectedIndex > 0) + listBox.SelectedIndex--; + listBox.Focus(); + } + + e.Handled = true; + } + else if (e.Key == Key.Down) + { + var listBox = _popup?.Child?.FindDescendantOfType(); + if (listBox != null) + { + if (listBox.SelectedIndex < listBox.Items.Count - 1) + listBox.SelectedIndex++; + listBox.Focus(); + } + + e.Handled = true; + } + else if (e.Key == Key.Enter) + { + IsDropDownOpened = false; + e.Handled = true; + } + } + + private void OnClearSearchFilter(object sender, RoutedEventArgs e) + { + SearchFilter = string.Empty; + e.Handled = true; + } + + private void OnDropDownListKeyDown(object _, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + IsDropDownOpened = false; + e.Handled = true; + } + else if (e.Key == Key.F && e.KeyModifiers == (OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + var searchBox = _popup?.Child?.FindDescendantOfType(); + if (searchBox != null) + { + searchBox.CaretIndex = SearchFilter?.Length ?? 0; + searchBox.Focus(); + } + + e.Handled = true; + } + } + + private void OnDropDownListSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (e.AddedItems.Count == 1 && e.AddedItems[0] is Models.Branch branch) + SelectedBranch = branch; + } + + private void OnDropDownItemPointerPressed(object sender, PointerPressedEventArgs e) + { + if (sender is Control { DataContext: Models.Branch branch }) + SelectedBranch = branch; + + IsDropDownOpened = false; + e.Handled = true; + } + + private List _branches; + private List _visibleBranches; + private bool _isDropDownOpened; + private string _searchFilter = string.Empty; + private bool _usePureName = false; + private Popup _popup = null; + private Models.Branch _selectedBranch = null; + } +} diff --git a/src/Views/BranchTree.axaml b/src/Views/BranchTree.axaml new file mode 100644 index 000000000..98ded57a1 --- /dev/null +++ b/src/Views/BranchTree.axaml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs new file mode 100644 index 000000000..1c3cfdc3c --- /dev/null +++ b/src/Views/BranchTree.axaml.cs @@ -0,0 +1,1420 @@ +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"); + } + else if (node.Backend is Models.Branch branch) + { + if (branch.IsCurrent) + CreateContent(new Thickness(0, 0, 0, 0), "Icons.CheckCircled", Brushes.Green); + else if (branch.IsLocal && !string.IsNullOrEmpty(branch.WorktreePath)) + CreateContent(new Thickness(2, 0, 0, 0), "Icons.Branch", Brushes.DarkCyan); + else + CreateContent(new Thickness(2, 0, 0, 0), "Icons.Branch"); + } + 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, IBrush fill = null) + { + if (this.FindResource(iconKey) is not StreamGeometry geo) + return; + + var path = new Path() + { + Width = 12, + Height = 12, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = margin, + Data = geo, + }; + + if (fill != null) + path.Fill = fill; + + 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); + } + + 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 OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == FontSizeProperty || + change.Property == FontFamilyProperty || + change.Property == ForegroundProperty) + InvalidateMeasure(); + else if (change.Property == BackgroundProperty) + InvalidateVisual(); + } + + 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 desc = branch.TrackStatusDescription; + if (!string.IsNullOrEmpty(desc)) + { + _label = new FormattedText( + desc, + 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 class BranchTreeNodeTrackStatusTooltip : TextBlock + { + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + Text = string.Empty; + + if (DataContext is not Models.Branch { IsTrackStatusVisible: true } branch) + { + SetCurrentValue(IsVisibleProperty, false); + return; + } + + var ahead = branch.Ahead.Count; + var behind = branch.Behind.Count; + if (ahead > 0) + Text = behind > 0 ? App.Text("BranchTree.AheadBehind", ahead, behind) : App.Text("BranchTree.Ahead", ahead); + else + Text = App.Text("BranchTree.Behind", behind); + + SetCurrentValue(IsVisibleProperty, true); + } + } + + public class BranchTreeNodeDescription : TextBlock + { + protected override Type StyleKeyOverride => typeof(TextBlock); + + public BranchTreeNodeDescription() + { + IsVisible = false; + } + + protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + var visible = false; + + do + { + if (DataContext is not Models.Branch branch) + break; + + if (e.Root is not PopupRoot { Parent: Popup { Parent: Border owner } }) + break; + + var tree = owner.FindAncestorOfType(); + if (tree is not { DataContext: ViewModels.Repository repo }) + break; + + var description = await new Commands.Config(repo.FullPath).GetAsync($"branch.{branch.Name}.description"); + if (string.IsNullOrEmpty(description)) + break; + + Text = description; + visible = true; + } while (false); + + SetCurrentValue(IsVisibleProperty, visible); + } + } + + public partial class BranchTree : UserControl + { + public static readonly DirectProperty> NodesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(Nodes), + static o => o.Nodes, + static (o, v) => o.Nodes = v); + + public List Nodes + { + get => _nodes; + set => SetAndRaise(NodesProperty, ref _nodes, value); + } + + public AvaloniaList Rows + { + get; + } = []; + + 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 static readonly RoutedEvent SearchRequestedEvent = + RoutedEvent.Register(nameof(SearchRequested), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler SearchRequested + { + add { AddHandler(SearchRequestedEvent, value); } + remove { RemoveHandler(SearchRequestedEvent, value); } + } + + public BranchTree() + { + InitializeComponent(); + } + + public void Select(Models.Branch branch) + { + if (branch == null) + return; + + var treePath = new List(); + FindTreePath(treePath, _nodes, branch.Name, 0); + + if (treePath.Count == 0) + return; + + var oldRowCount = Rows.Count; + var rows = Rows; + for (var i = 0; i < treePath.Count - 1; i++) + { + var node = treePath[i]; + if (!node.IsExpanded) + { + node.IsExpanded = true; + + var idx = rows.IndexOf(node); + var subtree = new List(); + MakeRows(subtree, node.Children, node.Depth + 1); + rows.InsertRange(idx + 1, subtree); + } + } + + var target = treePath[^1]; + BranchesPresenter.SelectedItem = target; + BranchesPresenter.ScrollIntoView(target); + + if (oldRowCount != rows.Count) + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + + 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 ctrl = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.KeyModifiers.HasFlag(ctrl) || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + return; + + 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; + + 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; + + ViewModels.BranchTreeNode prev = null; + 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 }) + { + CreateContextMenuForRemote(repo, remote).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 ? CreateContextMenuForLocalBranch(repo, branch) : CreateContextMenuForRemoteBranch(repo, branch); + menu.Open(this); + } + else + { + var menu = new ContextMenu(); + + if (branches.Count == 2) + { + var compare = new MenuItem(); + compare.Header = App.Text("BranchCM.CompareTwo"); + compare.Icon = this.CreateMenuIcon("Icons.Compare"); + compare.Click += (_, ev) => + { + this.ShowWindow(new ViewModels.Compare(repo, branches[0], branches[1])); + ev.Handled = true; + }; + menu.Items.Add(compare); + } + + if (branches.Find(x => x.IsCurrent) == null) + { + var mergeMulti = new MenuItem(); + mergeMulti.Header = App.Text("BranchCM.MergeMultiBranches", branches.Count); + mergeMulti.Icon = this.CreateMenuIcon("Icons.Merge"); + mergeMulti.Click += (_, ev) => + { + repo.MergeMultipleBranches(branches); + ev.Handled = true; + }; + + var deleteMulti = new MenuItem(); + deleteMulti.Header = App.Text("BranchCM.DeleteMultiBranches", branches.Count); + deleteMulti.Icon = this.CreateMenuIcon("Icons.Clear"); + deleteMulti.Tag = "Delete/Back"; + deleteMulti.Click += (_, ev) => + { + repo.DeleteMultipleBranches(branches, branches[0].IsLocal); + ev.Handled = true; + }; + + menu.Items.Add(mergeMulti); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(deleteMulti); + } + + if (menu.Items.Count > 0) + menu.Open(this); + } + } + + private void OnTreeKeyDown(object _, KeyEventArgs e) + { + if (e.Key == Key.F && e.KeyModifiers == (OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + RaiseEvent(new RoutedEventArgs(SearchRequestedEvent)); + e.Handled = true; + } + else if ((e.Key == Key.Delete || e.Key == Key.Back) && e.KeyModifiers == KeyModifiers.None) + { + 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; + } + else if (e.Key == Key.F2 && e.KeyModifiers == KeyModifiers.None) + { + var repo = DataContext as ViewModels.Repository; + if (repo?.Settings == null) + return; + + var selected = BranchesPresenter.SelectedItems; + if (selected == null || selected.Count != 1) + return; + + if (selected[0] is ViewModels.BranchTreeNode { Backend: Models.Branch { IsLocal: true } branch }) + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.RenameBranch(repo, branch)); + + e.Handled = true; + } + } + } + + private async 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) + await repo.CheckoutBranchAsync(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 void FindTreePath(List outPath, List collection, string path, int start) + { + if (start >= path.Length - 1) + return; + + var sepIdx = path.IndexOf('/', start); + var name = sepIdx < 0 ? path.Substring(start) : path.Substring(start, sepIdx - start); + foreach (var node in collection) + { + if (node.Name.Equals(name, StringComparison.Ordinal)) + { + outPath.Add(node); + FindTreePath(outPath, node.Children, path, sepIdx + 1); + } + } + } + + private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, Models.Branch branch) + { + var current = repo.CurrentBranch; + var menu = new ContextMenu(); + var upstream = repo.Branches.Find(x => x.FullName.Equals(branch.Upstream, StringComparison.Ordinal)); + + var push = new MenuItem(); + push.Header = App.Text("BranchCM.Push", branch.Name); + push.Icon = this.CreateMenuIcon("Icons.Push"); + push.IsEnabled = repo.Remotes.Count > 0; + push.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Push(repo, branch)); + e.Handled = true; + }; + + if (branch.IsCurrent) + { + if (!repo.IsBare) + { + if (upstream != null) + { + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", upstream.FriendlyName); + fastForward.Icon = this.CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = branch.Ahead.Count == 0 && branch.Behind.Count > 0; + fastForward.Click += async (_, e) => + { + if (repo.CanCreatePopup()) + await repo.ShowAndStartPopupAsync(new ViewModels.Merge(repo, upstream, branch.Name, true)); + e.Handled = true; + }; + + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.Pull", upstream.FriendlyName); + pull.Icon = this.CreateMenuIcon("Icons.Pull"); + pull.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Pull(repo, null)); + e.Handled = true; + }; + + menu.Items.Add(fastForward); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(pull); + } + } + + menu.Items.Add(push); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var type = repo.GetGitFlowType(branch); + if (type != Models.GitFlowBranchType.None) + { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", branch.Name); + finish.Icon = this.CreateMenuIcon("Icons.GitFlow.Finish"); + finish.IsEnabled = !repo.IsBare; + finish.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.GitFlowFinish(repo, branch, type)); + e.Handled = true; + }; + menu.Items.Add(finish); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (upstream != null) + { + var compareWithUpstream = new MenuItem(); + compareWithUpstream.Header = App.Text("BranchCM.CompareWithSpecial", upstream.FriendlyName); + compareWithUpstream.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithUpstream.Click += (_, _) => + { + this.ShowWindow(new ViewModels.Compare(repo, upstream, branch)); + }; + menu.Items.Add(compareWithUpstream); + } + + var compareWith = new MenuItem(); + compareWith.Header = App.Text("BranchCM.CompareWith"); + compareWith.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWith.Click += (_, _) => + { + new ViewModels.CompareCommandPalette(repo, branch).Open(); + }; + menu.Items.Add(compareWith); + } + else + { + var hasNoWorktree = string.IsNullOrEmpty(branch.WorktreePath); + + var checkout = new MenuItem(); + checkout.Header = App.Text(hasNoWorktree ? "BranchCM.Checkout" : "BranchCM.SwitchToWorktree", branch.Name); + checkout.Icon = this.CreateMenuIcon("Icons.Check"); + checkout.IsEnabled = !repo.IsBare || !hasNoWorktree; + checkout.Click += async (_, e) => + { + await repo.CheckoutBranchAsync(branch); + e.Handled = true; + }; + menu.Items.Add(checkout); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (upstream != null && hasNoWorktree) + { + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForward", upstream.FriendlyName); + fastForward.Icon = this.CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = branch.Ahead.Count == 0 && branch.Behind.Count > 0; + fastForward.Click += async (_, e) => + { + if (repo.CanCreatePopup()) + await repo.ShowAndStartPopupAsync(new ViewModels.ResetWithoutCheckout(repo, 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 = this.CreateMenuIcon("Icons.Fetch"); + fetchInto.IsEnabled = branch.Ahead.Count == 0; + fetchInto.Click += async (_, e) => + { + if (repo.CanCreatePopup()) + await repo.ShowAndStartPopupAsync(new ViewModels.FetchInto(repo, branch, upstream)); + e.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(fetchInto); + } + + menu.Items.Add(push); + + if (!repo.IsBare) + { + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", branch.Name, current.Name); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Merge(repo, branch, current.Name, false)); + e.Handled = true; + }; + + var rebase = new MenuItem(); + rebase.Header = App.Text("BranchCM.Rebase", current.Name, branch.Name); + rebase.Icon = this.CreateMenuIcon("Icons.Rebase"); + rebase.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Rebase(repo, current, branch)); + e.Handled = true; + }; + + var interactiveRebase = new MenuItem(); + interactiveRebase.Header = App.Text("BranchCM.InteractiveRebase.Manually", current.Name, branch.Name); + interactiveRebase.Icon = this.CreateMenuIcon("Icons.InteractiveRebase"); + interactiveRebase.IsEnabled = !current.Head.Equals(branch.Head, StringComparison.Ordinal); + interactiveRebase.Click += async (_, e) => + { + var commit = await new Commands.QuerySingleCommit(repo.FullPath, branch.Head).GetResultAsync(); + await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, commit)); + e.Handled = true; + }; + + menu.Items.Add(merge); + menu.Items.Add(rebase); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(interactiveRebase); + + var type = repo.GetGitFlowType(branch); + if (type != Models.GitFlowBranchType.None) + { + var finish = new MenuItem(); + finish.Header = App.Text("BranchCM.Finish", branch.Name); + finish.Icon = this.CreateMenuIcon("Icons.GitFlow.Finish"); + finish.IsEnabled = !repo.IsBare || !hasNoWorktree; + finish.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.GitFlowFinish(repo, branch, type)); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(finish); + } + } + + if (hasNoWorktree) + { + var selectedCommit = repo.GetSelectedCommitInHistory(); + 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 = this.CreateMenuIcon("Icons.Reset"); + move.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.ResetWithoutCheckout(repo, branch, selectedCommit)); + e.Handled = true; + }; + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(move); + } + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var compareWithCurrent = new MenuItem(); + compareWithCurrent.Header = App.Text("BranchCM.CompareWithHead"); + compareWithCurrent.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithCurrent.Click += (_, _) => + { + this.ShowWindow(new ViewModels.Compare(repo, branch, current)); + }; + menu.Items.Add(compareWithCurrent); + + if (upstream != null) + { + var compareWithUpstream = new MenuItem(); + compareWithUpstream.Header = App.Text("BranchCM.CompareWithSpecial", upstream.FriendlyName); + compareWithUpstream.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithUpstream.Click += (_, _) => + { + this.ShowWindow(new ViewModels.Compare(repo, upstream, branch)); + }; + menu.Items.Add(compareWithUpstream); + } + + var compareWith = new MenuItem(); + compareWith.Header = App.Text("BranchCM.CompareWith"); + compareWith.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWith.Click += (_, _) => + { + new ViewModels.CompareCommandPalette(repo, branch).Open(); + }; + menu.Items.Add(compareWith); + } + + if (!branch.IsDetachedHead) + { + var editDescription = new MenuItem(); + editDescription.Header = App.Text("BranchCM.EditDescription", branch.Name); + editDescription.Icon = this.CreateMenuIcon("Icons.Edit"); + editDescription.Click += async (_, e) => + { + var desc = await new Commands.Config(repo.FullPath).GetAsync($"branch.{branch.Name}.description"); + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.EditBranchDescription(repo, branch, desc)); + e.Handled = true; + }; + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", branch.Name); + rename.Icon = this.CreateMenuIcon("Icons.Rename"); + rename.Tag = "F2"; + rename.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.RenameBranch(repo, branch)); + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", branch.Name); + delete.Icon = this.CreateMenuIcon("Icons.Clear"); + delete.Tag = "Delete/Back"; + delete.IsEnabled = !branch.IsCurrent; + delete.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteBranch(repo, branch)); + e.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(editDescription); + menu.Items.Add(rename); + menu.Items.Add(delete); + } + + var createBranch = new MenuItem(); + createBranch.Icon = this.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreateBranch(repo, branch)); + e.Handled = true; + }; + + var createTag = new MenuItem(); + createTag.Icon = this.CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreateTag(repo, branch)); + e.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(createBranch); + menu.Items.Add(createTag); + + if (upstream != null) + { + var remote = repo.Remotes.Find(x => x.Name.Equals(upstream.Remote, StringComparison.Ordinal)); + if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, upstream.Name)) + { + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + Native.OS.OpenBrowser(prURL); + e.Handled = true; + }; + + menu.Items.Add(createPR); + } + } + menu.Items.Add(new MenuItem() { Header = "-" }); + TryToAddCustomActionsToBranchContextMenu(repo, menu, branch); + + if (!repo.IsBare) + { + var remoteBranches = new List(); + foreach (var b in repo.Branches) + { + if (!b.IsLocal) + remoteBranches.Add(b); + } + + if (remoteBranches.Count > 0) + { + var tracking = new MenuItem(); + tracking.Header = App.Text("BranchCM.Tracking"); + tracking.Icon = this.CreateMenuIcon("Icons.Track"); + tracking.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.SetUpstream(repo, branch, remoteBranches)); + e.Handled = true; + }; + menu.Items.Add(tracking); + } + } + + var archive = new MenuItem(); + archive.Icon = this.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Archive(repo, 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 = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => + { + await this.CopyTextAsync(branch.Name); + e.Handled = true; + }; + menu.Items.Add(copy); + + return menu; + } + + private ContextMenu CreateContextMenuForRemote(ViewModels.Repository repo, 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 = this.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 = this.CreateMenuIcon("Icons.Fetch"); + fetch.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Fetch(repo, remote)); + e.Handled = true; + }; + + var prune = new MenuItem(); + prune.Header = App.Text("RemoteCM.Prune"); + prune.Icon = this.CreateMenuIcon("Icons.Clean"); + prune.Click += async (_, e) => + { + if (repo.CanCreatePopup()) + await repo.ShowAndStartPopupAsync(new ViewModels.PruneRemote(repo, remote)); + e.Handled = true; + }; + + menu.Items.Add(fetch); + menu.Items.Add(prune); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (ViewModels.Preferences.Instance.EnableAutoFetch) + { + var toggleAutoFetch = new MenuItem(); + toggleAutoFetch.Header = App.Text("RemoteCM.EnableAutoFetch"); + toggleAutoFetch.Icon = this.CreateMenuIcon("Icons.AutoFetch"); + toggleAutoFetch.Tag = remote.DisableAutoFetch ? "OFF" : "ON"; + toggleAutoFetch.Click += async (_, e) => + { + await repo.ToggleAutoFetchOnRemoteAsync(remote); + e.Handled = true; + }; + + menu.Items.Add(toggleAutoFetch); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var edit = new MenuItem(); + edit.Header = App.Text("RemoteCM.Edit"); + edit.Icon = this.CreateMenuIcon("Icons.Edit"); + edit.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.EditRemote(repo, remote)); + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("RemoteCM.Delete"); + delete.Icon = this.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteRemote(repo, remote)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("RemoteCM.CopyURL"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => + { + await this.CopyTextAsync(remote.URL); + e.Handled = true; + }; + + menu.Items.Add(edit); + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + TryToAddCustomActionsToRemoteContextMenu(repo, menu, remote); + menu.Items.Add(copy); + return menu; + } + + public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, Models.Branch branch) + { + var menu = new ContextMenu(); + var name = branch.FriendlyName; + + var checkout = new MenuItem(); + checkout.Header = App.Text("BranchCM.Checkout", name); + checkout.Icon = this.CreateMenuIcon("Icons.Check"); + checkout.Click += async (_, e) => + { + await repo.CheckoutBranchAsync(branch); + e.Handled = true; + }; + menu.Items.Add(checkout); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (repo.CurrentBranch is { } current) + { + var pull = new MenuItem(); + pull.Header = App.Text("BranchCM.PullInto", name, current.Name); + pull.Icon = this.CreateMenuIcon("Icons.Pull"); + pull.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Pull(repo, branch)); + e.Handled = true; + }; + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", name, current.Name); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Merge(repo, branch, current.Name, false)); + e.Handled = true; + }; + + var rebase = new MenuItem(); + rebase.Header = App.Text("BranchCM.Rebase", current.Name, name); + rebase.Icon = this.CreateMenuIcon("Icons.Rebase"); + rebase.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Rebase(repo, current, branch)); + e.Handled = true; + }; + + var interactiveRebase = new MenuItem(); + interactiveRebase.Header = App.Text("BranchCM.InteractiveRebase.Manually", current.Name, name); + interactiveRebase.Icon = this.CreateMenuIcon("Icons.InteractiveRebase"); + interactiveRebase.IsEnabled = !current.Head.Equals(branch.Head, StringComparison.Ordinal); + interactiveRebase.Click += async (_, e) => + { + var commit = await new Commands.QuerySingleCommit(repo.FullPath, branch.Head).GetResultAsync(); + await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, commit)); + e.Handled = true; + }; + + var compareWithHead = new MenuItem(); + compareWithHead.Header = App.Text("BranchCM.CompareWithHead"); + compareWithHead.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithHead.Click += (_, _) => + { + this.ShowWindow(new ViewModels.Compare(repo, branch, current)); + }; + + var compareWith = new MenuItem(); + compareWith.Header = App.Text("BranchCM.CompareWith"); + compareWith.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWith.Click += (_, _) => + { + new ViewModels.CompareCommandPalette(repo, branch).Open(); + }; + + menu.Items.Add(pull); + menu.Items.Add(merge); + menu.Items.Add(rebase); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(interactiveRebase); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(compareWithHead); + menu.Items.Add(compareWith); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var editDescription = new MenuItem(); + editDescription.Header = App.Text("BranchCM.EditDescription", branch.Name); + editDescription.Icon = this.CreateMenuIcon("Icons.Edit"); + editDescription.Click += async (_, e) => + { + var desc = await new Commands.Config(repo.FullPath).GetAsync($"branch.{branch.Name}.description"); + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.EditBranchDescription(repo, branch, desc)); + e.Handled = true; + }; + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", name); + delete.Icon = this.CreateMenuIcon("Icons.Clear"); + delete.Tag = "Delete/Back"; + delete.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteBranch(repo, branch)); + e.Handled = true; + }; + + menu.Items.Add(editDescription); + menu.Items.Add(delete); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var createBranch = new MenuItem(); + createBranch.Icon = this.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreateBranch(repo, branch)); + e.Handled = true; + }; + + var createTag = new MenuItem(); + createTag.Icon = this.CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreateTag(repo, branch)); + e.Handled = true; + }; + + menu.Items.Add(createBranch); + menu.Items.Add(createTag); + + var remote = repo.Remotes.Find(x => x.Name.Equals(branch.Remote, StringComparison.Ordinal)); + if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, branch.Name)) + { + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePR"); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + Native.OS.OpenBrowser(prURL); + e.Handled = true; + }; + + menu.Items.Add(createPR); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var archive = new MenuItem(); + archive.Icon = this.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Archive(repo, branch)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => + { + await this.CopyTextAsync(name); + e.Handled = true; + }; + + menu.Items.Add(archive); + menu.Items.Add(new MenuItem() { Header = "-" }); + TryToAddCustomActionsToBranchContextMenu(repo, menu, branch); + menu.Items.Add(copy); + return menu; + } + + private void TryToAddCustomActionsToBranchContextMenu(ViewModels.Repository repo, ContextMenu menu, Models.Branch branch) + { + var actions = repo.GetCustomActions(Models.CustomActionScope.Branch); + if (actions.Count == 0) + return; + + var custom = new MenuItem(); + custom.Header = App.Text("BranchCM.CustomAction"); + custom.Icon = this.CreateMenuIcon("Icons.Action"); + + foreach (var action in actions) + { + var (dup, label) = action; + var item = new MenuItem(); + item.Icon = this.CreateMenuIcon("Icons.Action"); + item.Header = label; + item.Click += async (_, e) => + { + await repo.ExecCustomActionAsync(dup, branch); + e.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo, ContextMenu menu, Models.Remote remote) + { + var actions = repo.GetCustomActions(Models.CustomActionScope.Remote); + if (actions.Count == 0) + return; + + var custom = new MenuItem(); + custom.Header = App.Text("RemoteCM.CustomAction"); + custom.Icon = this.CreateMenuIcon("Icons.Action"); + + foreach (var action in actions) + { + var (dup, label) = action; + var item = new MenuItem(); + item.Icon = this.CreateMenuIcon("Icons.Action"); + item.Header = label; + item.Click += async (_, e) => + { + await repo.ExecCustomActionAsync(dup, remote); + e.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + private List _nodes = null; + private bool _disableSelectionChangingEvent = false; + } +} diff --git a/src/Views/CaptionButtons.axaml b/src/Views/CaptionButtons.axaml index 581f7be1f..948d87e9c 100644 --- a/src/Views/CaptionButtons.axaml +++ b/src/Views/CaptionButtons.axaml @@ -2,22 +2,18 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:v="using:SourceGit.Views" - xmlns:c="using:SourceGit.Converters" - xmlns:vm="using:SourceGit.ViewModels" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="SourceGit.Views.CaptionButtons"> - - - - - - - + x:Class="SourceGit.Views.CaptionButtons" + x:Name="ThisControl"> + + + + + diff --git a/src/Views/CaptionButtons.axaml.cs b/src/Views/CaptionButtons.axaml.cs index 09f70dad8..a22591673 100644 --- a/src/Views/CaptionButtons.axaml.cs +++ b/src/Views/CaptionButtons.axaml.cs @@ -1,33 +1,49 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; -using Avalonia.VisualTree; -namespace SourceGit.Views { - public partial class CaptionButtons : UserControl { - public CaptionButtons() { +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 sender, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); - if (window != null) { + private void MinimizeWindow(object _, RoutedEventArgs e) + { + var window = TopLevel.GetTopLevel(this) as Window; + if (window != null) window.WindowState = WindowState.Minimized; - } + + e.Handled = true; } - private void MaximizeOrRestoreWindow(object sender, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); - if (window != null) { + private void MaximizeOrRestoreWindow(object _, RoutedEventArgs e) + { + var window = TopLevel.GetTopLevel(this) as Window; + if (window != null) window.WindowState = window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - } + + e.Handled = true; } - private void CloseWindow(object sender, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); - if (window != null) { - window.Close(); - } + private void CloseWindow(object _, RoutedEventArgs e) + { + var window = TopLevel.GetTopLevel(this) as Window; + window?.Close(); + + e.Handled = true; } } } - diff --git a/src/Views/CaptionButtonsMacOS.axaml b/src/Views/CaptionButtonsMacOS.axaml deleted file mode 100644 index 642c62626..000000000 --- a/src/Views/CaptionButtonsMacOS.axaml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - diff --git a/src/Views/CaptionButtonsMacOS.axaml.cs b/src/Views/CaptionButtonsMacOS.axaml.cs deleted file mode 100644 index 64b93d210..000000000 --- a/src/Views/CaptionButtonsMacOS.axaml.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.VisualTree; - -namespace SourceGit.Views { - public partial class CaptionButtonsMacOS : UserControl { - public CaptionButtonsMacOS() { - InitializeComponent(); - } - - private void MinimizeWindow(object sender, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); - if (window != null) { - window.WindowState = WindowState.Minimized; - } - } - - private void MaximizeOrRestoreWindow(object sender, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); - if (window != null) { - window.WindowState = window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - } - } - - private void CloseWindow(object sender, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); - if (window != null) { - window.Close(); - } - } - } -} - diff --git a/src/Views/ChangeCollectionView.axaml b/src/Views/ChangeCollectionView.axaml new file mode 100644 index 000000000..be9143d1b --- /dev/null +++ b/src/Views/ChangeCollectionView.axaml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ChangeCollectionView.axaml.cs b/src/Views/ChangeCollectionView.axaml.cs new file mode 100644 index 000000000..0a271664f --- /dev/null +++ b/src/Views/ChangeCollectionView.axaml.cs @@ -0,0 +1,538 @@ +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 container = this.FindAncestorOfType(); + if (container != null) + container.SelectedItem = node; + + var tree = this.FindAncestorOfType(); + tree?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class ChangeCollectionContainer : ListBoxEx + { + protected override Type StyleKeyOverride => typeof(ListBox); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (SelectedItems is [ViewModels.ChangeTreeNode node] && e.KeyModifiers == KeyModifiers.None) + { + if (e.Key == Key.Left) + { + if (node.IsExpanded && node.IsFolder) + { + this.FindAncestorOfType()?.ToggleNodeIsExpanded(node); + e.Handled = true; + } + else if (FindParent(node) is { } parent) + { + Select(parent); + e.Handled = true; + } + } + else if (e.Key == Key.Right && node.IsFolder) + { + if (!node.IsExpanded) + { + this.FindAncestorOfType()?.ToggleNodeIsExpanded(node); + e.Handled = true; + } + else if (node.Children.Count > 0) + { + Select(node.Children[0]); + e.Handled = true; + } + } + } + + if (!e.Handled) + base.OnKeyDown(e); + } + + private ViewModels.ChangeTreeNode FindParent(ViewModels.ChangeTreeNode item) + { + if (item.Depth == 0) + return null; + + var idx = Items.IndexOf(item); + if (idx < 1) + return null; + + for (var i = idx - 1; i >= 0; i--) + { + if (Items[i] is ViewModels.ChangeTreeNode node && node.Depth < item.Depth) + return node; + } + + return null; + } + } + + public partial class ChangeCollectionView : UserControl + { + public static readonly DirectProperty IsUnstagedChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsUnstagedChange), + static o => o.IsUnstagedChange, + static (o, v) => o.IsUnstagedChange = v); + + public bool IsUnstagedChange + { + get => _isUnstagedChange; + set => SetAndRaise(IsUnstagedChangeProperty, ref _isUnstagedChange, value); + } + + public static readonly DirectProperty ViewModeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ViewMode), + static o => o.ViewMode, + static (o, v) => o.ViewMode = v); + + public Models.ChangeViewMode ViewMode + { + get => _viewMode; + set => SetAndRaise(ViewModeProperty, ref _viewMode, value); + } + + public static readonly DirectProperty EnableCompactFoldersProperty = + AvaloniaProperty.RegisterDirect( + nameof(EnableCompactFolders), + static o => o.EnableCompactFolders, + static (o, v) => o.EnableCompactFolders = v); + + public bool EnableCompactFolders + { + get => _enableCompactFolders; + set => SetAndRaise(EnableCompactFoldersProperty, ref _enableCompactFolders, value); + } + + public static readonly DirectProperty> ChangesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(Changes), + static o => o.Changes, + static (o, v) => o.Changes = v); + + public List Changes + { + get => _changes; + set => SetAndRaise(ChangesProperty, ref _changes, value); + } + + public static readonly DirectProperty> SelectedChangesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(SelectedChanges), + static o => o.SelectedChanges, + static (o, v) => o.SelectedChanges = v); + + public List SelectedChanges + { + get => _selectedChanges; + set => SetAndRaise(SelectedChangesProperty, ref _selectedChanges, 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.IsFolder) + { + 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) + { + if (!c.IsConflicted) + 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(); + + if (change.Property == EnableCompactFoldersProperty && ViewMode == Models.ChangeViewMode.Tree) + UpdateDataSource(true); + } + + private void OnRowDataContextChanged(object sender, EventArgs e) + { + if (sender is not Control { DataContext: { } ctx } control) + return; + + if (ctx is ViewModels.ChangeTreeNode node) + { + if (node.Change is { } c) + UpdateRowTips(control, c); + else + ToolTip.SetTip(control, node.FullPath); + } + else if (ctx is Models.Change change) + { + UpdateRowTips(control, change); + } + else + { + ToolTip.SetTip(control, null); + } + } + + private void OnRowDoubleTapped(object sender, TappedEventArgs e) + { + if (sender is not Control { DataContext: { } ctx }) + return; + + if (ctx 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 (ctx 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) + { + SelectedChanges = selected; + } + else + { + bool allEquals = true; + foreach (var c in old) + { + if (!selected.Contains(c)) + { + allEquals = false; + break; + } + } + + if (!allEquals) + SelectedChanges = 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) + { + var 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, EnableCompactFolders); + + var rows = new List(); + MakeTreeRows(rows, tree.Tree); + tree.Rows.AddRange(rows); + + if (selected.Count > 0) + { + var sets = new HashSet(selected); + 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 (selected.Count > 0) + grid.SelectedChanges.AddRange(selected); + + Content = grid; + } + else + { + var list = new ViewModels.ChangeCollectionAsList(); + list.Changes.AddRange(changes); + 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(selected); + 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 _isUnstagedChange = false; + private Models.ChangeViewMode _viewMode = Models.ChangeViewMode.Tree; + private bool _enableCompactFolders = false; + private List _changes = null; + private List _selectedChanges = null; + private bool _disableSelectionChangingEvent = false; + } +} diff --git a/src/Views/ChangeStatusIcon.cs b/src/Views/ChangeStatusIcon.cs index 8d0001e6d..ee48cbc89 100644 --- a/src/Views/ChangeStatusIcon.cs +++ b/src/Views/ChangeStatusIcon.cs @@ -1,99 +1,73 @@ -using Avalonia; +using System; +using System.Globalization; + +using Avalonia; using Avalonia.Controls; using Avalonia.Media; -using System; -using System.Globalization; +using Avalonia.Styling; -namespace SourceGit.Views { - public class ChangeStatusIcon : Control { - 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(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(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), - }, +namespace SourceGit.Views +{ + public class ChangeStatusIcon : Control + { + private static readonly string[] INDICATOR = ["?", "M", "T", "+", "−", "➜", "❏", "?", "!"]; + private static readonly Color[] COLOR = + [ + Colors.Transparent, + Colors.Goldenrod, + Colors.Goldenrod, + Colors.LimeGreen, + Colors.Tomato, + Colors.Orchid, + Colors.Goldenrod, + Colors.SkyBlue, + Colors.OrangeRed, ]; - private static readonly string[] INDICATOR = ["?", "±", "+", "−", "➜", "❏", "U", "★"]; + public static readonly DirectProperty IsUnstagedChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsUnstagedChange), + static o => o.IsUnstagedChange, + static (o, v) => o.IsUnstagedChange = v); - public static readonly StyledProperty IsWorkingCopyChangeProperty = - AvaloniaProperty.Register(nameof(IsWorkingCopyChange)); - - public bool IsWorkingCopyChange { - get => GetValue(IsWorkingCopyChangeProperty); - set => SetValue(IsWorkingCopyChangeProperty, value); + public bool IsUnstagedChange + { + get => _isUnstagedChange; + set => SetAndRaise(IsUnstagedChangeProperty, ref _isUnstagedChange, value); } - public static readonly StyledProperty ChangeProperty = - AvaloniaProperty.Register(nameof(Change)); - - public Models.Change Change { - get => GetValue(ChangeProperty); - set => SetValue(ChangeProperty, value); - } - - public static readonly StyledProperty IconFontFamilyProperty = - AvaloniaProperty.Register(nameof(IconFontFamily)); - - public FontFamily IconFontFamily { - get => GetValue(IconFontFamilyProperty); - set => SetValue(IconFontFamilyProperty, value); - } + public static readonly DirectProperty ChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(Change), + static o => o.Change, + static (o, v) => o.Change = v); - static ChangeStatusIcon() { - AffectsRender(IsWorkingCopyChangeProperty, ChangeProperty, IconFontFamilyProperty); + public Models.Change Change + { + get => _change; + set => SetAndRaise(ChangeProperty, ref _change, value); } - public override void Render(DrawingContext context) { - if (Change == null || Bounds.Width <= 0) return; + public override void Render(DrawingContext context) + { + if (_change == null || Bounds.Width <= 0) + return; - var typeface = IconFontFamily == null ? Typeface.Default : new Typeface(IconFontFamily); + var typeface = new Typeface("fonts:SourceGit#JetBrains Mono NL"); + var idx = (int)(_isUnstagedChange ? _change.WorkTree : _change.Index); + var indicator = INDICATOR[idx]; + var color = COLOR[idx]; + var hsl = color.ToHsl(); + var color2 = ActualThemeVariant == ThemeVariant.Dark + ? new HslColor(hsl.A, hsl.H, hsl.S, hsl.L - 0.1).ToRgb() + : new HslColor(hsl.A, hsl.H, hsl.S, hsl.L + 0.1).ToRgb(); - IBrush background = null; - string indicator; - if (IsWorkingCopyChange) { - if (Change.IsConflit) { - background = Brushes.OrangeRed; - indicator = "!"; - } else { - background = BACKGROUNDS[(int)Change.WorkTree]; - indicator = INDICATOR[(int)Change.WorkTree]; - } - } else { - background = BACKGROUNDS[(int)Change.Index]; - indicator = INDICATOR[(int)Change.Index]; - } + var background = new LinearGradientBrush + { + GradientStops = [new GradientStop(color, 0), new GradientStop(color2, 1)], + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative), + }; var txt = new FormattedText( indicator, @@ -103,10 +77,23 @@ public override void Render(DrawingContext context) { Bounds.Width * 0.8, Brushes.White); - float corner = (float)Math.Max(2, Bounds.Width / 16); - Point textOrigin = new Point((Bounds.Width - txt.Width) * 0.5, (Bounds.Height - txt.Height) * 0.5); + 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(); + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) + InvalidateVisual(); + } + + private bool _isUnstagedChange = false; + private Models.Change _change = null; } } diff --git a/src/Views/ChangeSubmoduleUrl.axaml b/src/Views/ChangeSubmoduleUrl.axaml new file mode 100644 index 000000000..bf3d7b3db --- /dev/null +++ b/src/Views/ChangeSubmoduleUrl.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ChangeSubmoduleUrl.axaml.cs b/src/Views/ChangeSubmoduleUrl.axaml.cs new file mode 100644 index 000000000..287c20d8d --- /dev/null +++ b/src/Views/ChangeSubmoduleUrl.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class ChangeSubmoduleUrl : UserControl + { + public ChangeSubmoduleUrl() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/ChangeViewModeSwitcher.axaml b/src/Views/ChangeViewModeSwitcher.axaml index 32d4f1e94..b72fcbcb8 100644 --- a/src/Views/ChangeViewModeSwitcher.axaml +++ b/src/Views/ChangeViewModeSwitcher.axaml @@ -3,33 +3,40 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:m="using:SourceGit.Models" - xmlns:v="using:SourceGit.Views" - xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.ChangeViewModeSwitcher" - x:DataType="v:ChangeViewModeSwitcher"> - diff --git a/src/Views/ChangeViewModeSwitcher.axaml.cs b/src/Views/ChangeViewModeSwitcher.axaml.cs index 59a041e90..a51eccefd 100644 --- a/src/Views/ChangeViewModeSwitcher.axaml.cs +++ b/src/Views/ChangeViewModeSwitcher.axaml.cs @@ -1,23 +1,46 @@ 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)); +namespace SourceGit.Views +{ + public partial class ChangeViewModeSwitcher : UserControl + { + public static readonly DirectProperty ViewModeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ViewMode), + static o => o.ViewMode, + static (o, v) => o.ViewMode = v); - public Models.ChangeViewMode ViewMode { - get => GetValue(ViewModeProperty); - set => SetValue(ViewModeProperty, value); + public Models.ChangeViewMode ViewMode + { + get => _viewMode; + set => SetAndRaise(ViewModeProperty, ref _viewMode, value); } - public ChangeViewModeSwitcher() { - DataContext = this; + public ChangeViewModeSwitcher() + { InitializeComponent(); } - public void SwitchMode(object param) { - ViewMode = (Models.ChangeViewMode)param; + 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; + } + + private Models.ChangeViewMode _viewMode = Models.ChangeViewMode.List; } } diff --git a/src/Views/Chart.cs b/src/Views/Chart.cs new file mode 100644 index 000000000..6eafbd44d --- /dev/null +++ b/src/Views/Chart.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public record ChartToolTip(string Title, bool HasUser, int All, int User); + + public class Chart : Control + { + public static readonly DirectProperty SamplesProperty = + AvaloniaProperty.RegisterDirect( + nameof(Samples), + static o => o.Samples, + static (o, v) => o.Samples = v); + + public Models.StatisticsSamples Samples + { + get => _samples; + set => SetAndRaise(SamplesProperty, ref _samples, value); + } + + public static readonly StyledProperty LabelFontFamilyProperty = + AvaloniaProperty.Register(nameof(LabelFontFamily)); + + public FontFamily LabelFontFamily + { + get => GetValue(LabelFontFamilyProperty); + set => SetValue(LabelFontFamilyProperty, value); + } + + public static readonly StyledProperty SampleBrushProperty = + AvaloniaProperty.Register(nameof(SampleBrush), Brushes.SkyBlue); + + public IBrush SampleBrush + { + get => GetValue(SampleBrushProperty); + set => SetValue(SampleBrushProperty, value); + } + + public Chart() + { + ToolTip.SetPlacement(this, PlacementMode.Pointer); + ToolTip.SetShowDelay(this, 0); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var samples = _samples; + if (samples == null || samples.Count == 0) + return; + + var w = Bounds.Width; + var h = Bounds.Height; + context.FillRectangle(Brushes.Transparent, new Rect(0, 0, w, h)); + + var count = samples.Count; + var maxValue = samples.MaxValue; + var time = samples.EndTime; + var minTime = samples.StartTime; + var hasUserSamples = samples.HasSpecialUser; + + var labelPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), .5); + var labelTypeface = new Typeface(LabelFontFamily); + var corner = new CornerRadius(2, 2, 0, 0); + var sampleBrush = SampleBrush; + + var leftMargin = 0.0; + for (var i = 1; i <= 8; i++) + { + var percent = i * 0.125; + var value = Math.Floor(maxValue * (1.0 - percent)); + var y = Math.Floor((h - 24) * percent) + 0.5; + + var label = new FormattedText( + $"{value}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + labelTypeface, + 11, + Brushes.Gray); + + if (leftMargin == 0) + leftMargin = label.Width + 24.0; + + context.DrawText(label, new Point(leftMargin - label.Width - 8.0, y - label.Height * 0.5)); + context.DrawLine(labelPen, new Point(leftMargin, y), new Point(w, y)); + } + + var step = Math.Max((w - leftMargin) / count, 10.0); + _maxOffsetX = Math.Max(count * step - (w - leftMargin), 0); + _hitBoxes.Clear(); + _lastHitted = new Rect(0, 0, 0, 0); + + var x = w + Math.Min(_maxOffsetX, _offsetX); + var sampleW = hasUserSamples ? Math.Min(step * 0.5 - 2.5, 14.0) : Math.Min(step - 3, 28.0); + var maxSampleH = h - 24.0; + var maxLabelEndX = w - 4.0; + + using var clip = context.PushClip(new Rect(leftMargin, 0, w - leftMargin, h)); + do + { + var (label, total, user) = samples.GetSample(time); + + if (x - step <= w && total > 0) + { + if (hasUserSamples) + { + var startX = x - step * 0.5 - 1 - sampleW; + var startY = maxSampleH * (1.0 - total * 1.0 / maxValue); + var rect = new Rect(startX, startY, sampleW, maxSampleH - startY); + + using (context.PushOpacity(0.2)) + { + context.DrawRectangle(sampleBrush, null, new RoundedRect(rect, corner)); + } + + if (user > 0) + { + var userStartX = startX + sampleW + 2; + var userStartY = maxSampleH * (1.0 - user * 1.0 / maxValue); + var userRect = new Rect(userStartX, userStartY, sampleW, maxSampleH - userStartY); + context.DrawRectangle(sampleBrush, null, new RoundedRect(userRect, corner)); + } + + var hitRect = new Rect(startX, startY, sampleW * 2 + 2, maxSampleH - startY); + _hitBoxes.Add(new(hitRect, new(label, true, total, user))); + } + else + { + var startX = x - step * 0.5 - sampleW * 0.5; + var startY = maxSampleH * (1.0 - total * 1.0 / maxValue); + var rect = new Rect(startX, startY, sampleW, maxSampleH - startY); + + context.DrawRectangle(sampleBrush, null, new RoundedRect(rect, corner)); + _hitBoxes.Add(new(rect, new(label, false, total, 0))); + } + } + + if (x <= w) + { + var formattedLabel = new FormattedText( + label, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + labelTypeface, + 11, + Brushes.Gray); + + var labelCenterX = x - step * 0.5; + var labelEndX = labelCenterX + formattedLabel.Width * 0.5; + if (labelEndX <= maxLabelEndX) + { + var labelStartX = labelCenterX - formattedLabel.Width * 0.5; + var labelStartY = h - formattedLabel.Height - 2.0; + context.DrawText(formattedLabel, new Point(labelStartX, labelStartY)); + maxLabelEndX = labelStartX - 16.0; + } + } + + if (x <= leftMargin) + break; + + x -= step; + + time = samples.NextSampleTime(time); + if (time < minTime) + break; + } while (true); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SamplesProperty) + { + _offsetX = 0; + InvalidateVisual(); + } + else if (change.Property == SampleBrushProperty) + { + InvalidateVisual(); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _isDraging = true; + _lastDragX = e.GetPosition(this).X; + e.Pointer.Capture(this); + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (_isDraging) + { + var posX = e.GetPosition(this).X; + if (Math.Abs(posX - _lastDragX) < 0.5f) + return; + + var desired = Math.Max(0, Math.Min(_offsetX + posX - _lastDragX, _maxOffsetX)); + if (Math.Abs(desired - _offsetX) < 0.5f) + return; + + _offsetX = desired; + _lastDragX = posX; + _lastHitted = new Rect(0, 0, 0, 0); + InvalidateVisual(); + } + else + { + var p = e.GetPosition(this); + + if (!_lastHitted.Contains(p)) + { + foreach (var box in _hitBoxes) + { + if (box.Rect.Contains(p)) + { + ToolTip.SetTip(this, box.ToolTip); + _lastHitted = box.Rect; + return; + } + } + + _lastHitted = new Rect(0, 0, 0, 0); + ToolTip.SetTip(this, null); + } + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + _isDraging = false; + e.Pointer.Capture(null); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + e.Handled = true; + + var deltaX = e.KeyModifiers == KeyModifiers.Shift ? e.Delta.Y : e.Delta.X; + if (deltaX == 0) + return; + + deltaX = Math.Min(32, Math.Max(-32, _maxOffsetX * deltaX)); + + var desired = Math.Max(0, Math.Min(_offsetX + deltaX, _maxOffsetX)); + if (Math.Abs(desired - _offsetX) < 0.1) + return; + + _offsetX = desired; + _isDraging = false; + _lastHitted = new Rect(0, 0, 0, 0); + InvalidateVisual(); + } + + private record HitBox(Rect Rect, ChartToolTip ToolTip); + + private Models.StatisticsSamples _samples = null; + private double _offsetX = 0; + private double _maxOffsetX = 0; + private bool _isDraging = false; + private double _lastDragX = 0; + private List _hitBoxes = []; + private Rect _lastHitted = new(0, 0, 0, 0); + } +} diff --git a/src/Views/Checkout.axaml b/src/Views/Checkout.axaml index f921326b1..3d91c7072 100644 --- a/src/Views/Checkout.axaml +++ b/src/Views/Checkout.axaml @@ -3,17 +3,43 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SourceGit.ViewModels" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.Checkout" x:DataType="vm:Checkout"> - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/Views/Checkout.axaml.cs b/src/Views/Checkout.axaml.cs index d2d9edcaf..f8398a1d4 100644 --- a/src/Views/Checkout.axaml.cs +++ b/src/Views/Checkout.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class Checkout : UserControl { - public Checkout() { +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 000000000..42fbf544c --- /dev/null +++ b/src/Views/CheckoutAndFastForward.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CheckoutAndFastForward.axaml.cs b/src/Views/CheckoutAndFastForward.axaml.cs new file mode 100644 index 000000000..c54f5a1f9 --- /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/CheckoutBranchFromStash.axaml b/src/Views/CheckoutBranchFromStash.axaml new file mode 100644 index 000000000..037410af5 --- /dev/null +++ b/src/Views/CheckoutBranchFromStash.axaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CheckoutBranchFromStash.axaml.cs b/src/Views/CheckoutBranchFromStash.axaml.cs new file mode 100644 index 000000000..b05e4fceb --- /dev/null +++ b/src/Views/CheckoutBranchFromStash.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CheckoutBranchFromStash : UserControl + { + public CheckoutBranchFromStash() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/CheckoutCommandPalette.axaml b/src/Views/CheckoutCommandPalette.axaml new file mode 100644 index 000000000..ad881f821 --- /dev/null +++ b/src/Views/CheckoutCommandPalette.axaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CheckoutCommandPalette.axaml.cs b/src/Views/CheckoutCommandPalette.axaml.cs new file mode 100644 index 000000000..3601c5422 --- /dev/null +++ b/src/Views/CheckoutCommandPalette.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class CheckoutCommandPalette : UserControl + { + public CheckoutCommandPalette() + { + InitializeComponent(); + } + + protected override async void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.CheckoutCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + await vm.ExecAsync(); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + if (BranchListBox.IsKeyboardFocusWithin) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + else if (e.Key == Key.Down || e.Key == Key.Tab) + { + if (FilterTextBox.IsKeyboardFocusWithin) + { + if (vm.Branches.Count > 0) + BranchListBox.Focus(NavigationMethod.Directional); + + e.Handled = true; + return; + } + + if (BranchListBox.IsKeyboardFocusWithin && e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + } + + private async void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.CheckoutCommandPalette vm) + { + await vm.ExecAsync(); + e.Handled = true; + } + } + } +} diff --git a/src/Views/CheckoutDetached.axaml b/src/Views/CheckoutDetached.axaml new file mode 100644 index 000000000..0c98c4b00 --- /dev/null +++ b/src/Views/CheckoutDetached.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CheckoutDetached.axaml.cs b/src/Views/CheckoutDetached.axaml.cs new file mode 100644 index 000000000..8f2221cb5 --- /dev/null +++ b/src/Views/CheckoutDetached.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CheckoutDetached : UserControl + { + public CheckoutDetached() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/CherryPick.axaml b/src/Views/CherryPick.axaml index a4b654fd1..85cd19514 100644 --- a/src/Views/CherryPick.axaml +++ b/src/Views/CherryPick.axaml @@ -9,24 +9,110 @@ x:Class="SourceGit.Views.CherryPick" x:DataType="vm:CherryPick"> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CherryPick.axaml.cs b/src/Views/CherryPick.axaml.cs index 306d4702c..e4a37e20b 100644 --- a/src/Views/CherryPick.axaml.cs +++ b/src/Views/CherryPick.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class CherryPick : UserControl { - public CherryPick() { +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 000000000..a06ab7565 --- /dev/null +++ b/src/Views/ChromelessWindow.cs @@ -0,0 +1,136 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public class ChromelessWindow : Window + { + public bool UseSystemWindowFrame + { + get => Native.OS.UseSystemWindowFrame; + } + + public bool CloseOnESC + { + get; + set; + } = false; + + 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; + } + } + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (OperatingSystem.IsWindows()) + Native.Win64Utilities.FixWindowFrame(this); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (OperatingSystem.IsWindows() && change.Property == WindowStateProperty) + Native.Win64Utilities.FixWindowFrame(this); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (e.Handled) + return; + + if (e is { Key: Key.Escape, KeyModifiers: KeyModifiers.None } && CloseOnESC) + { + Close(); + e.Handled = true; + return; + } + + if (e.KeyModifiers == (OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + if (e.Key == Key.OemPlus) + { + var zoom = Math.Min(ViewModels.Preferences.Instance.Zoom + 0.05, 2.5); + ViewModels.Preferences.Instance.Zoom = zoom; + e.Handled = true; + } + else if (e.Key == Key.OemMinus) + { + var zoom = Math.Max(ViewModels.Preferences.Instance.Zoom - 0.05, 1); + ViewModels.Preferences.Instance.Zoom = zoom; + e.Handled = true; + } + else if (e.Key == Key.W) + { + Close(); + e.Handled = true; + } + } + } + + private void OnWindowBorderPointerPressed(object sender, PointerPressedEventArgs e) + { + if (sender is Border { Tag: WindowEdge edge } && CanResize) + BeginResizeDrag(edge, e); + } + } +} diff --git a/src/Views/Cleanup.axaml b/src/Views/Cleanup.axaml index 7e5d8ff90..18dbb9eea 100644 --- a/src/Views/Cleanup.axaml +++ b/src/Views/Cleanup.axaml @@ -2,16 +2,21 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.Cleanup" x:DataType="vm:Cleanup"> - + + + + + + diff --git a/src/Views/Cleanup.axaml.cs b/src/Views/Cleanup.axaml.cs index a098d9a4a..bf4e72673 100644 --- a/src/Views/Cleanup.axaml.cs +++ b/src/Views/Cleanup.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class Cleanup : UserControl { - public Cleanup() { +namespace SourceGit.Views +{ + public partial class Cleanup : UserControl + { + public Cleanup() + { InitializeComponent(); } } diff --git a/src/Views/ClearStashes.axaml b/src/Views/ClearStashes.axaml index 666bf2732..05ae7a362 100644 --- a/src/Views/ClearStashes.axaml +++ b/src/Views/ClearStashes.axaml @@ -7,9 +7,15 @@ x:Class="SourceGit.Views.ClearStashes" x:DataType="vm:ClearStashes"> - + + + + + - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Clone.axaml.cs b/src/Views/Clone.axaml.cs index cd52e0bf4..440fcfe06 100644 --- a/src/Views/Clone.axaml.cs +++ b/src/Views/Clone.axaml.cs @@ -1,31 +1,81 @@ +using System; using Avalonia.Controls; +using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Platform.Storage; -namespace SourceGit.Views { - public partial class Clone : UserControl { - public Clone() { +namespace SourceGit.Views +{ + public partial class Clone : UserControl + { + public Clone() + { InitializeComponent(); } - private async void SelectParentFolder(object sender, RoutedEventArgs e) { + protected override async void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (DataContext is not ViewModels.Clone vm) + return; + + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard != null) + { + try + { + var text = await clipboard.TryGetTextAsync(); + if (Models.Remote.IsValidURL(text)) + vm.Remote = text; + } + catch + { + // Ignore exceptions here. + } + } + } + + private async void SelectParentFolder(object _, RoutedEventArgs e) + { var options = new FolderPickerOpenOptions() { AllowMultiple = false }; var toplevel = TopLevel.GetTopLevel(this); - var selected = await toplevel.StorageProvider.OpenFolderPickerAsync(options); - if (selected.Count == 1) { - txtParentFolder.Text = selected[0].Path.LocalPath; + 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) + { + Models.Notification.Send(null, $"Failed to select parent folder: {exception.Message}", true); } e.Handled = true; } - private async void SelectSSHKey(object sender, RoutedEventArgs e) { - var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] }; + private async void SelectSSHKey(object _, RoutedEventArgs e) + { var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new("SSHKey") { Patterns = ["*"] }] + }; + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); - if (selected.Count == 1) { - txtSSHKey.Text = selected[0].Path.LocalPath; - } + if (selected.Count == 1) + TxtSshKey.Text = selected[0].Path.LocalPath; e.Handled = true; } diff --git a/src/Views/ColorPicker.cs b/src/Views/ColorPicker.cs new file mode 100644 index 000000000..58e9a4e41 --- /dev/null +++ b/src/Views/ColorPicker.cs @@ -0,0 +1,246 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class ColorPicker : Control + { + public static readonly DirectProperty ValueProperty = + AvaloniaProperty.RegisterDirect( + nameof(Value), + static o => o.Value, + static (o, v) => o.Value = v); + + public uint Value + { + get => _value; + set => SetAndRaise(ValueProperty, ref _value, value); + } + + // Values are copied from Avalonia: src/Avalonia.Controls.ColorPicker/ColorPalettes/FluentColorPalette.cs + private static readonly Color[,] COLOR_TABLE = new[,] + { + { + Color.FromArgb(255, 255, 67, 67), /* #FF4343 */ + Color.FromArgb(255, 209, 52, 56), /* #D13438 */ + Color.FromArgb(255, 239, 105, 80), /* #EF6950 */ + Color.FromArgb(255, 218, 59, 1), /* #DA3B01 */ + Color.FromArgb(255, 202, 80, 16), /* #CA5010 */ + Color.FromArgb(255, 247, 99, 12), /* #F7630C */ + Color.FromArgb(255, 255, 140, 0), /* #FF8C00 */ + Color.FromArgb(255, 255, 185, 0), /* #FFB900 */ + }, + { + Color.FromArgb(255, 231, 72, 86), /* #E74856 */ + Color.FromArgb(255, 232, 17, 35), /* #E81123 */ + Color.FromArgb(255, 234, 0, 94), /* #EA005E */ + Color.FromArgb(255, 195, 0, 82), /* #C30052 */ + Color.FromArgb(255, 227, 0, 140), /* #E3008C */ + Color.FromArgb(255, 191, 0, 119), /* #BF0077 */ + Color.FromArgb(255, 194, 57, 179), /* #C239B3 */ + Color.FromArgb(255, 154, 0, 137), /* #9A0089 */ + }, + { + Color.FromArgb(255, 0, 120, 215), /* #0078D7 */ + Color.FromArgb(255, 0, 99, 177), /* #0063B1 */ + Color.FromArgb(255, 142, 140, 216), /* #8E8CD8 */ + Color.FromArgb(255, 107, 105, 214), /* #6B69D6 */ + Color.FromArgb(255, 135, 100, 184), /* #8764B8 */ + Color.FromArgb(255, 116, 77, 169), /* #744DA9 */ + Color.FromArgb(255, 177, 70, 194), /* #B146C2 */ + Color.FromArgb(255, 136, 23, 152), /* #881798 */ + }, + { + Color.FromArgb(255, 0, 153, 188), /* #0099BC */ + Color.FromArgb(255, 45, 125, 154), /* #2D7D9A */ + Color.FromArgb(255, 0, 183, 195), /* #00B7C3 */ + Color.FromArgb(255, 3, 131, 135), /* #038387 */ + Color.FromArgb(255, 0, 178, 148), /* #00B294 */ + Color.FromArgb(255, 1, 133, 116), /* #018574 */ + Color.FromArgb(255, 0, 204, 106), /* #00CC6A */ + Color.FromArgb(255, 16, 137, 62), /* #10893E */ + }, + { + Color.FromArgb(255, 122, 117, 116), /* #7A7574 */ + Color.FromArgb(255, 93, 90, 80), /* #5D5A58 */ + Color.FromArgb(255, 104, 118, 138), /* #68768A */ + Color.FromArgb(255, 81, 92, 107), /* #515C6B */ + Color.FromArgb(255, 86, 124, 115), /* #567C73 */ + Color.FromArgb(255, 72, 104, 96), /* #486860 */ + Color.FromArgb(255, 73, 130, 5), /* #498205 */ + Color.FromArgb(255, 16, 124, 16), /* #107C10 */ + }, + { + Color.FromArgb(255, 118, 118, 118), /* #767676 */ + Color.FromArgb(255, 76, 74, 72), /* #4C4A48 */ + Color.FromArgb(255, 105, 121, 126), /* #69797E */ + Color.FromArgb(255, 74, 84, 89), /* #4A5459 */ + Color.FromArgb(255, 100, 124, 100), /* #647C64 */ + Color.FromArgb(255, 82, 94, 84), /* #525E54 */ + Color.FromArgb(255, 132, 117, 69), /* #847545 */ + Color.FromArgb(255, 126, 115, 95), /* #7E735F */ + } + }; + + public override void Render(DrawingContext context) + { + base.Render(context); + + // Color table. + { + // Colors + for (int i = 0; i < 6; i++) + { + for (int j = 0; j < 8; j++) + context.FillRectangle(new SolidColorBrush(COLOR_TABLE[i, j]), new Rect(j * 32, i * 32, 32, 32)); + } + + // Borders + var border = this.FindResource("Brush.Border0") as IBrush; + var pen = new Pen(border, 0.4); + for (int i = 1; i < 6; i++) + context.DrawLine(pen, new Point(0, i * 32), new Point(256, i * 32)); + for (int j = 1; j < 8; j++) + context.DrawLine(pen, new Point(j * 32, 0), new Point(j * 32, 192)); + + // Selected + if (_highlightedTableRect is { } rect) + context.DrawRectangle(new Pen(Brushes.White, 2), rect); + } + + // Palette picker + { + context.DrawRectangle(Brushes.Transparent, null, new Rect(0, 200, 256, 32), 4, 4, _shadow); + + context.DrawRectangle(new SolidColorBrush(_darkestColor), null, _darkestRect); + context.FillRectangle(new SolidColorBrush(_darkerColor), _darkerRect); + context.FillRectangle(new SolidColorBrush(_darkColor), _darkRect); + context.FillRectangle(new SolidColorBrush(_lightColor), _lightRect); + context.FillRectangle(new SolidColorBrush(_lighterColor), _lighterRect); + context.DrawRectangle(new SolidColorBrush(_lightestColor), null, _lightestRect); + + context.DrawRectangle(new SolidColorBrush(_color), null, new Rect(96, 200 - 4, 64, 40), 4, 4, _shadow); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ValueProperty) + { + UpdateColors(); + InvalidateVisual(); + } + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + _highlightedTableRect = null; + } + + protected override Size MeasureOverride(Size availableSize) + { + return new Size(256, 240); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + var p = e.GetPosition(this); + if (_colorTableRect.Contains(p)) + { + var col = (int)Math.Floor(p.X / 32.0); + var row = (int)Math.Floor(p.Y / 32.0); + var rect = new Rect(col * 32 + 2, row * 32 + 2, 28, 28); + if (!rect.Equals(_highlightedTableRect)) + { + _highlightedTableRect = rect; + Value = COLOR_TABLE[row, col].ToUInt32(); + } + + return; + } + + if (_darkestRect.Rect.Contains(p)) + { + _highlightedTableRect = null; + Value = _darkestColor.ToUInt32(); + } + else if (_darkerRect.Contains(p)) + { + _highlightedTableRect = null; + Value = _darkerColor.ToUInt32(); + } + else if (_darkRect.Contains(p)) + { + _highlightedTableRect = null; + Value = _darkColor.ToUInt32(); + } + else if (_lightRect.Contains(p)) + { + _highlightedTableRect = null; + Value = _lightColor.ToUInt32(); + } + else if (_lighterRect.Contains(p)) + { + _highlightedTableRect = null; + Value = _lighterColor.ToUInt32(); + } + else if (_lightestRect.Rect.Contains(p)) + { + _highlightedTableRect = null; + Value = _lightestColor.ToUInt32(); + } + } + + private void UpdateColors() + { + _color = Color.FromUInt32(_value); + + var hsvColor = _color.ToHsv(); + _darkestColor = GetNextColor(hsvColor, -0.3); + _darkerColor = GetNextColor(hsvColor, -0.2); + _darkColor = GetNextColor(hsvColor, -0.1); + _lightColor = GetNextColor(hsvColor, 0.1); + _lighterColor = GetNextColor(hsvColor, 0.2); + _lightestColor = GetNextColor(hsvColor, 0.3); + } + + private Color GetNextColor(HsvColor c, double step) + { + var v = c.V; + v += step; + v = Math.Round(v, 2); + + var newColor = new HsvColor(c.A, c.H, c.S, v); + return newColor.ToRgb(); + } + + private uint _value = 0; + private BoxShadows _shadow = BoxShadows.Parse("0 0 6 0 #A9000000"); + + private Rect _colorTableRect = new(0, 0, 32 * 8, 32 * 6); + private RoundedRect _darkestRect = new(new Rect(0, 200, 32, 32), new CornerRadius(4, 0, 0, 4)); + private Rect _darkerRect = new(32, 200, 32, 32); + private Rect _darkRect = new(64, 200, 32, 32); + private Rect _lightRect = new(160, 200, 32, 32); + private Rect _lighterRect = new(192, 200, 32, 32); + private RoundedRect _lightestRect = new(new Rect(224, 200, 32, 32), new CornerRadius(0, 4, 4, 0)); + private Rect? _highlightedTableRect = null; + + private Color _darkestColor; + private Color _darkerColor; + private Color _darkColor; + private Color _color; + private Color _lightColor; + private Color _lighterColor; + private Color _lightestColor; + } +} diff --git a/src/Views/CommandLogContentPresenter.cs b/src/Views/CommandLogContentPresenter.cs new file mode 100644 index 000000000..1c13fa4f6 --- /dev/null +++ b/src/Views/CommandLogContentPresenter.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Interactivity; +using Avalonia.Media; + +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; +using AvaloniaEdit.TextMate; + +namespace SourceGit.Views +{ + public class CommandLogContentPresenter : TextEditor, Models.ICommandLogReceiver + { + public class LineStyleTransformer : DocumentColorizingTransformer + { + protected override void ColorizeLine(DocumentLine line) + { + var content = CurrentContext.Document.GetText(line); + if (content.StartsWith("$ git ", StringComparison.Ordinal)) + { + ChangeLinePart(line.Offset, line.Offset + 1, v => + { + v.TextRunProperties.SetForegroundBrush(Brushes.Orange); + }); + + ChangeLinePart(line.Offset + 2, line.EndOffset, v => + { + var old = v.TextRunProperties.Typeface; + v.TextRunProperties.SetTypeface(new Typeface(old.FontFamily, old.Style, FontWeight.Bold)); + }); + } + else if (content.StartsWith("remote: ", StringComparison.Ordinal)) + { + ChangeLinePart(line.Offset, line.Offset + 7, v => + { + v.TextRunProperties.SetForegroundBrush(Brushes.SeaGreen); + }); + } + else + { + foreach (var err in _errors) + { + var idx = content.IndexOf(err, StringComparison.Ordinal); + if (idx >= 0) + { + ChangeLinePart(line.Offset + idx, line.Offset + err.Length + 1, v => + { + var old = v.TextRunProperties.Typeface; + v.TextRunProperties.SetForegroundBrush(Brushes.Red); + v.TextRunProperties.SetTypeface(new Typeface(old.FontFamily, old.Style, FontWeight.Bold)); + }); + } + } + } + } + + private readonly List _errors = ["! [rejected]", "! [remote rejected]"]; + } + + public static readonly DirectProperty LogProperty = + AvaloniaProperty.RegisterDirect( + nameof(Log), + static o => o.Log, + static (o, v) => o.Log = v); + + public object Log + { + get => _log; + set => SetAndRaise(LogProperty, ref _log, value); + } + + protected override Type StyleKeyOverride => typeof(TextEditor); + + public CommandLogContentPresenter() : base(new TextArea(), new TextDocument()) + { + IsReadOnly = true; + ShowLineNumbers = false; + WordWrap = false; + + TextArea.TextView.Margin = new Thickness(4, 0); + TextArea.TextView.Options.EnableHyperlinks = false; + TextArea.TextView.Options.EnableEmailHyperlinks = false; + TextArea.TextView.Options.AllowScrollBelowDocument = false; + } + + public void OnReceiveCommandLog(string line) + { + AppendText("\n"); + AppendText(line); + ScrollToEnd(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (_textMate == null) + { + _textMate = Models.TextMateHelper.CreateForEditor(this); + Models.TextMateHelper.SetGrammarByFileName(_textMate, "Log.log"); + TextArea.TextView.LineTransformers.Add(new LineStyleTransformer()); + } + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + + if (_textMate != null) + { + _textMate.Dispose(); + _textMate = null; + } + + GC.Collect(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == LogProperty) + { + if (change.OldValue is ViewModels.CommandLog oldLog) + oldLog.Unsubscribe(this); + + if (change.NewValue is ViewModels.CommandLog newLog) + { + Text = newLog.Content; + newLog.Subscribe(this); + } + else if (change.NewValue is string text) + { + Text = text; + } + else + { + Text = string.Empty; + } + } + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) + { + Models.TextMateHelper.SetThemeByApp(_textMate); + } + } + + private object _log = null; + private TextMate.Installation _textMate = null; + } +} diff --git a/src/Views/CommandLogTime.cs b/src/Views/CommandLogTime.cs new file mode 100644 index 000000000..0810ba415 --- /dev/null +++ b/src/Views/CommandLogTime.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading; + +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace SourceGit.Views +{ + public class CommandLogTime : TextBlock + { + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + StopTimer(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + StopTimer(); + + if (DataContext is ViewModels.CommandLog log) + SetupCommandLog(log); + else + Text = string.Empty; + } + + private void SetupCommandLog(ViewModels.CommandLog log) + { + Text = GetDisplayText(log); + if (log.IsComplete) + return; + + _refreshTimer = new Timer(_ => + { + Dispatcher.UIThread.Invoke(() => + { + Text = GetDisplayText(log); + if (log.IsComplete) + StopTimer(); + }); + }, null, 0, 100); + } + + private void StopTimer() + { + if (_refreshTimer is not null) + { + _refreshTimer.Dispose(); + _refreshTimer = null; + } + } + + private static string GetDisplayText(ViewModels.CommandLog log) + { + var endTime = log.IsComplete ? log.EndTime : DateTime.Now; + var duration = endTime - log.StartTime; + + if (duration.TotalMinutes >= 1) + return $"{duration.TotalMinutes:G3} min"; + + if (duration.TotalSeconds >= 1) + return $"{duration.TotalSeconds:G3} s"; + + return $"{duration.TotalMilliseconds:G3} ms"; + } + + private Timer _refreshTimer = null; + } +} diff --git a/src/Views/CommandPaletteDataTemplates.cs b/src/Views/CommandPaletteDataTemplates.cs new file mode 100644 index 000000000..fb9a4f3c4 --- /dev/null +++ b/src/Views/CommandPaletteDataTemplates.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; + +namespace SourceGit.Views +{ + public class CommandPaletteDataTemplates : IDataTemplate + { + public Control Build(object param) => ControlExtensions.CreateFromViewModels(param); + public bool Match(object data) => data is ViewModels.ICommandPalette; + } +} diff --git a/src/Views/CommitBaseInfo.axaml b/src/Views/CommitBaseInfo.axaml index 17c66f4cd..d848203ca 100644 --- a/src/Views/CommitBaseInfo.axaml +++ b/src/Views/CommitBaseInfo.axaml @@ -7,56 +7,104 @@ xmlns:v="using:SourceGit.Views" xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="SourceGit.Views.CommitBaseInfo"> + x:Class="SourceGit.Views.CommitBaseInfo" + x:Name="ThisControl"> - + - - - - - - + + + + + + + + + + + - - + - - - - - - + + + + + + + + + + + - - - + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -65,51 +113,108 @@ - - - - + PointerEntered="OnSHAPointerEntered" + PointerPressed="OnSHAPressed" + ContextRequested="OnSHAContextRequested" + ToolTip.ShowDelay="0"> + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitBaseInfo.axaml.cs b/src/Views/CommitBaseInfo.axaml.cs index ac25c46bb..fb6be7d02 100644 --- a/src/Views/CommitBaseInfo.axaml.cs +++ b/src/Views/CommitBaseInfo.axaml.cs @@ -1,17 +1,329 @@ +using System; +using System.Collections.Generic; + +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 DirectProperty FullMessageProperty = + AvaloniaProperty.RegisterDirect( + nameof(FullMessage), + static o => o.FullMessage, + static (o, v) => o.FullMessage = v); + + public Models.CommitFullMessage FullMessage + { + get => _fullMessage; + set => SetAndRaise(FullMessageProperty, ref _fullMessage, value); + } + + public static readonly DirectProperty SignInfoProperty = + AvaloniaProperty.RegisterDirect( + nameof(SignInfo), + static o => o.SignInfo, + static (o, v) => o.SignInfo = v); + + public Models.CommitSignInfo SignInfo + { + get => _signInfo; + set => SetAndRaise(SignInfoProperty, ref _signInfo, value); + } + + public static readonly DirectProperty> WebLinksProperty = + AvaloniaProperty.RegisterDirect>( + nameof(WebLinks), + static o => o.WebLinks, + static (o, v) => o.WebLinks = v); + + public List WebLinks + { + get => _webLinks; + set => SetAndRaise(WebLinksProperty, ref _webLinks, value); + } -namespace SourceGit.Views { - public partial class CommitBaseInfo : UserControl { - public CommitBaseInfo() { + public static readonly DirectProperty IsSHACopiedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsSHACopied), + static o => o.IsSHACopied); + + public bool IsSHACopied + { + get => _isSHACopied; + private set => SetAndRaise(IsSHACopiedProperty, ref _isSHACopied, value); + } + + public static readonly DirectProperty SupportsContainsInProperty = + AvaloniaProperty.RegisterDirect( + nameof(SupportsContainsIn), + static o => o.SupportsContainsIn); + + public bool SupportsContainsIn + { + get => _supportsContainsIn; + private set => SetAndRaise(SupportsContainsInProperty, ref _supportsContainsIn, value); + } + + public CommitBaseInfo() + { InitializeComponent(); } - private void OnParentSHAPressed(object sender, PointerPressedEventArgs e) { - if (DataContext is ViewModels.CommitDetail detail) { - detail.NavigateTo((sender as Control).DataContext as string); + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + SupportsContainsIn = DataContext is ViewModels.CommitDetail; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ContentProperty) + { + IsSHACopied = false; + _iconResetTimer?.Stop(); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + _iconResetTimer = new DispatcherTimer(); + _iconResetTimer.Interval = TimeSpan.FromSeconds(1); + _iconResetTimer.Tag = this; + _iconResetTimer.Tick += static (o, _) => + { + if (o is DispatcherTimer { Tag: CommitBaseInfo view } timer) + { + if (view.IsSHACopied) + view.IsSHACopied = false; + + timer.IsEnabled = false; + } + }; + _iconResetTimer.IsEnabled = false; + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + _iconResetTimer.Tag = null; + _iconResetTimer.IsEnabled = false; + + base.OnUnloaded(e); + } + + private void OnDateTimeContextMenuRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is DateTimePresenter presenter) + { + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, ev) => + { + await this.CopyTextAsync(presenter.Text); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copy); + menu.Open(presenter); + e.Handled = true; } + } + + private async void OnCopyCommitSHA(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: Models.Commit commit }) + await this.CopyTextAsync(commit.SHA); + + IsSHACopied = true; + _iconResetTimer?.Start(); 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 async void OnOpenContainsIn(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Button button) + { + var tracking = new CommitRelationTracking(); + var flyout = new Flyout(); + flyout.Content = tracking; + flyout.ShowAt(button); + + await tracking.SetDataAsync(detail); + } + + e.Handled = true; + } + + private async 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.Equals(sha, StringComparison.Ordinal)) + return; + + var c = await detail.GetCommitAsync(sha); + if (c is not null && ctl is { IsEffectivelyVisible: true, DataContext: string newSHA } && sha.Equals(newSHA, StringComparison.Ordinal)) + 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; + } + + private void OnSHAContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is not Control { DataContext: string sha } control) + return; + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, ev) => + { + await this.CopyTextAsync(sha); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copy); + menu.Open(control); + e.Handled = true; + } + + private void OnUserContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is not Control { Tag: Models.User user } control) + return; + + var copyName = new MenuItem(); + copyName.Header = App.Text("CommitDetail.Info.CopyName"); + copyName.Icon = this.CreateMenuIcon("Icons.Copy"); + copyName.Click += async (_, ev) => + { + await this.CopyTextAsync(user.Name); + ev.Handled = true; + }; + + var copyEmail = new MenuItem(); + copyEmail.Header = App.Text("CommitDetail.Info.CopyEmail"); + copyEmail.Icon = this.CreateMenuIcon("Icons.Email"); + copyEmail.Click += async (_, ev) => + { + await this.CopyTextAsync(user.Email); + ev.Handled = true; + }; + + var copyUser = new MenuItem(); + copyUser.Header = App.Text("CommitDetail.Info.CopyNameAndEmail"); + copyUser.Icon = this.CreateMenuIcon("Icons.User"); + copyUser.Click += async (_, ev) => + { + await this.CopyTextAsync(user.ToString()); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copyName); + menu.Items.Add(copyEmail); + menu.Items.Add(copyUser); + menu.Open(control); + e.Handled = true; + } + + private async void OnCopyAllCommitMessage(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail) + await this.CopyTextAsync(detail.FullMessage.Message); + e.Handled = true; + } + + private void OnCommitRefsPresenterPointerReleased(object sender, PointerReleasedEventArgs e) + { + e.Handled = true; + + if (DataContext is ViewModels.CommitDetail && + sender is CommitRefsPresenter presenter && + e.Properties.PointerUpdateKind == PointerUpdateKind.RightButtonReleased) + { + var decorator = presenter.DecoratorAt(e.GetPosition(presenter)); + if (decorator != null) + { + var copy = new MenuItem(); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Header = App.Text("Copy"); + copy.Click += async (_, ev) => + { + await this.CopyTextAsync(decorator.Name); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copy); + menu.Open(presenter); + } + } + } + + private Models.CommitFullMessage _fullMessage = null; + private Models.CommitSignInfo _signInfo = null; + private bool _supportsContainsIn = false; + private List _webLinks = null; + private bool _isSHACopied = false; + private DispatcherTimer _iconResetTimer = null; } } diff --git a/src/Views/CommitChanges.axaml b/src/Views/CommitChanges.axaml index 54ad0b594..d7d245ccf 100644 --- a/src/Views/CommitChanges.axaml +++ b/src/Views/CommitChanges.axaml @@ -4,162 +4,74 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SourceGit.ViewModels" xmlns:v="using:SourceGit.Views" - xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.CommitChanges" x:DataType="vm:CommitDetail"> - - - + + + - + - - - - + - + Text="{Binding SearchChangeFilter, Mode=TwoWay}"> + + + - + + + + + + Margin="4,0,0,0" + ViewMode="{Binding Source={x:Static vm:Preferences.Instance}, Path=CommitChangeViewMode, Mode=TwoWay}"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - + + + + + + + MinWidth="1" + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" + Background="Transparent" + Focusable="False"/> - + diff --git a/src/Views/CommitChanges.axaml.cs b/src/Views/CommitChanges.axaml.cs index db65922d2..0c1dea0df 100644 --- a/src/Views/CommitChanges.axaml.cs +++ b/src/Views/CommitChanges.axaml.cs @@ -1,39 +1,75 @@ +using System; +using System.Text; + using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; -namespace SourceGit.Views { - public partial class CommitChanges : UserControl { - public CommitChanges() { +namespace SourceGit.Views +{ + public partial class CommitChanges : UserControl + { + public CommitChanges() + { InitializeComponent(); } - private void OnDataGridSelectionChanged(object sender, SelectionChangedEventArgs e) { - if (sender is DataGrid datagrid && datagrid.IsVisible && datagrid.SelectedItem != null) { - datagrid.ScrollIntoView(datagrid.SelectedItem, null); - } + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { e.Handled = true; - } - private void OnDataGridContextRequested(object sender, ContextRequestedEventArgs e) { - if (sender is DataGrid datagrid && datagrid.SelectedItem != null) { - var detail = DataContext as ViewModels.CommitDetail; - var menu = detail.CreateChangeContextMenu(datagrid.SelectedItem as Models.Change); - menu.Open(datagrid); - } + if (sender is not ChangeCollectionView { SelectedChanges: { Count: > 0 } changes } view) + return; - e.Handled = true; + var detailView = this.FindAncestorOfType(); + if (detailView == null) + return; + + var container = view.FindDescendantOfType(); + if (container is { SelectedItems.Count: 1, SelectedItem: ViewModels.ChangeTreeNode { IsFolder: true } node }) + detailView.CreateChangeContextMenuByFolder(node, changes)?.Open(view); + else if (changes.Count > 1) + detailView.CreateMultipleChangesContextMenu(changes)?.Open(view); + else + detailView.CreateChangeContextMenu(changes[0])?.Open(view); } - private void OnTreeViewContextRequested(object sender, ContextRequestedEventArgs e) { - if (sender is TreeView view && view.SelectedItem != null) { - var detail = DataContext as ViewModels.CommitDetail; - var node = view.SelectedItem as ViewModels.FileTreeNode; - if (node != null && !node.IsFolder) { - var menu = detail.CreateChangeContextMenu(node.Backend as Models.Change); - menu.Open(view); + private async void OnChangeCollectionViewKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.CommitDetail vm) + return; + + if (sender is not ChangeCollectionView { SelectedChanges: { Count: > 0 } selectedChanges } view) + return; + + var cmdKey = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.Key == Key.C && e.KeyModifiers.HasFlag(cmdKey)) + { + var builder = new StringBuilder(); + var copyAbsPath = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + var container = view.FindDescendantOfType(); + if (container is { SelectedItems.Count: 1, SelectedItem: ViewModels.ChangeTreeNode { IsFolder: true } node }) + { + builder.Append(copyAbsPath ? vm.GetAbsPath(node.FullPath) : node.FullPath); + } + else if (selectedChanges.Count == 1) + { + builder.Append(copyAbsPath ? vm.GetAbsPath(selectedChanges[0].Path) : selectedChanges[0].Path); + } + else + { + foreach (var c in selectedChanges) + builder.AppendLine(copyAbsPath ? vm.GetAbsPath(c.Path) : c.Path); } - } - e.Handled = true; + await this.CopyTextAsync(builder.ToString()); + e.Handled = true; + } + else if (e.Key == Key.F && e.KeyModifiers == cmdKey) + { + CommitChangeSearchBox.Focus(); + e.Handled = true; + } } } } diff --git a/src/Views/CommitDetail.axaml b/src/Views/CommitDetail.axaml index 606259b18..b0fbf5393 100644 --- a/src/Views/CommitDetail.axaml +++ b/src/Views/CommitDetail.axaml @@ -9,68 +9,100 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.CommitDetail" x:DataType="vm:CommitDetail" - Background="{DynamicResource Brush.Window}"> - + x:Name="ThisControl"> + - + + + - - - + + + + - - - - - - - - - - + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + - + - + + + - + diff --git a/src/Views/CommitDetail.axaml.cs b/src/Views/CommitDetail.axaml.cs index 00873a729..91bada728 100644 --- a/src/Views/CommitDetail.axaml.cs +++ b/src/Views/CommitDetail.axaml.cs @@ -1,33 +1,550 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Platform.Storage; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class CommitDetail : UserControl + { + public static readonly DirectProperty IsDetailsPanelExpandedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsDetailsPanelExpanded), + static o => o.IsDetailsPanelExpanded, + static (o, v) => o.IsDetailsPanelExpanded = v); + + public bool IsDetailsPanelExpanded + { + get => _isDetailsPanelExpanded; + set => SetAndRaise(IsDetailsPanelExpandedProperty, ref _isDetailsPanelExpanded, value); + } -namespace SourceGit.Views { - public partial class CommitDetail : UserControl { - public CommitDetail() { + public CommitDetail() + { InitializeComponent(); } - private void OnChangeListDoubleTapped(object sender, TappedEventArgs e) { - if (DataContext is ViewModels.CommitDetail detail) { - var datagrid = sender as DataGrid; - detail.ActivePageIndex = 1; - detail.SelectedChange = datagrid.SelectedItem as Models.Change; + public ContextMenu CreateChangeContextMenuByFolder(ViewModels.ChangeTreeNode node, List changes) + { + if (DataContext is not ViewModels.CommitDetail { Repository: { } repo, Commit: { } commit } vm) + return null; + + var fullPath = Native.OS.GetAbsPath(repo.FullPath, node.FullPath); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = this.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = Directory.Exists(fullPath); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(fullPath); + ev.Handled = true; + }; + + var history = new MenuItem(); + history.Header = App.Text("DirHistories"); + history.Icon = this.CreateMenuIcon("Icons.Histories"); + history.Click += (_, ev) => + { + this.ShowWindow(new ViewModels.DirHistories(repo, node.FullPath, commit.SHA)); + ev.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = this.CreateMenuIcon("Icons.Save"); + patch.Click += async (_, e) => + { + var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider; + 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"] }]; + + try + { + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + await vm.SaveChangesAsPatchAsync(changes, saveTo); + } + } + catch (Exception exception) + { + repo.SendNotification($"Failed to save as patch: {exception.Message}", true); + } + + e.Handled = true; + }; + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; + copyPath.Click += async (_, ev) => + { + await this.CopyTextAsync(node.FullPath); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; + copyFullPath.Click += async (_, e) => + { + await this.CopyTextAsync(fullPath); + e.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(explore); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(history); + menu.Items.Add(patch); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + + return menu; + } + + public ContextMenu CreateMultipleChangesContextMenu(List changes) + { + if (DataContext is not ViewModels.CommitDetail { Repository: { } repo, Commit: { } commit } vm) + return null; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = this.CreateMenuIcon("Icons.Save"); + patch.Click += async (_, e) => + { + var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider; + 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"] }]; + + try + { + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + await vm.SaveChangesAsPatchAsync(changes, saveTo); + } + } + catch (Exception exception) + { + repo.SendNotification($"Failed to save as patch: {exception.Message}", true); + } + + e.Handled = true; + }; + + var menu = new ContextMenu(); + 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 = this.CreateMenuIcon("Icons.File.Checkout"); + resetToThisRevision.Click += async (_, ev) => + { + await vm.ResetMultipleToThisRevisionAsync(changes); + ev.Handled = true; + }; + + var resetToFirstParent = new MenuItem(); + resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); + resetToFirstParent.Icon = this.CreateMenuIcon("Icons.File.Checkout"); + resetToFirstParent.IsEnabled = commit.Parents.Count > 0; + resetToFirstParent.Click += async (_, ev) => + { + await vm.ResetMultipleToParentRevisionAsync(changes); + ev.Handled = true; + }; + + menu.Items.Add(resetToThisRevision); + menu.Items.Add(resetToFirstParent); + menu.Items.Add(new MenuItem { Header = "-" }); } - e.Handled = true; + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; + copyPath.Click += async (_, ev) => + { + var builder = new StringBuilder(); + foreach (var c in changes) + builder.AppendLine(c.Path); + + await this.CopyTextAsync(builder.ToString()); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; + copyFullPath.Click += async (_, e) => + { + var builder = new StringBuilder(); + foreach (var c in changes) + builder.AppendLine(Native.OS.GetAbsPath(repo.FullPath, c.Path)); + + await this.CopyTextAsync(builder.ToString()); + e.Handled = true; + }; + + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + return menu; } - private void OnChangeListContextRequested(object sender, ContextRequestedEventArgs e) { - if (DataContext is ViewModels.CommitDetail detail) { - var datagrid = sender as DataGrid; - if (datagrid.SelectedItem == null) { - e.Handled = true; + public ContextMenu CreateChangeContextMenu(Models.Change change) + { + if (DataContext is not ViewModels.CommitDetail { Repository: { } repo, Commit: { } commit } vm) + return null; + + var openWith = new MenuItem(); + openWith.Header = App.Text("Open"); + openWith.Icon = this.CreateMenuIcon("Icons.OpenWith"); + openWith.IsEnabled = change.Index != Models.ChangeState.Deleted; + if (openWith.IsEnabled) + { + var defaultEditor = new MenuItem(); + defaultEditor.Header = App.Text("Open.SystemDefaultEditor"); + defaultEditor.Click += async (_, ev) => + { + await vm.OpenRevisionFileAsync(change.Path, null); + ev.Handled = true; + }; + + openWith.Items.Add(defaultEditor); + + var tools = Native.OS.ExternalTools; + if (tools.Count > 0) + { + openWith.Items.Add(new MenuItem() { Header = "-" }); + + for (var i = 0; i < tools.Count; i++) + { + var tool = tools[i]; + var item = new MenuItem(); + item.Header = tool.Name; + item.Icon = new Image { Width = 16, Height = 16, Source = tool.IconImage }; + item.Click += async (_, ev) => + { + await vm.OpenRevisionFileAsync(change.Path, tool); + ev.Handled = true; + }; + + openWith.Items.Add(item); + } + } + } + + var openWithMerger = new MenuItem(); + openWithMerger.Header = App.Text("OpenInExternalMergeTool"); + openWithMerger.Icon = this.CreateMenuIcon("Icons.OpenWith"); + openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; + openWithMerger.Click += (_, ev) => + { + vm.OpenChangeInMergeTool(change); + ev.Handled = true; + }; + + var fullPath = Native.OS.GetAbsPath(repo.FullPath, change.Path); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = this.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(fullPath); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(fullPath); + ev.Handled = true; + }; + + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = this.CreateMenuIcon("Icons.Histories"); + history.Click += (_, ev) => + { + this.ShowWindow(new ViewModels.FileHistories(repo.FullPath, change.Path, commit.SHA)); + ev.Handled = true; + }; + + var blame = new MenuItem(); + blame.Header = App.Text("Blame"); + blame.Icon = this.CreateMenuIcon("Icons.Blame"); + blame.IsEnabled = change.Index != Models.ChangeState.Deleted; + blame.Click += (_, ev) => + { + this.ShowWindow(new ViewModels.Blame(repo.FullPath, change.Path, commit)); + ev.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = this.CreateMenuIcon("Icons.Save"); + patch.Click += async (_, e) => + { + var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider; + 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"] }]; + + try + { + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + await vm.SaveChangesAsPatchAsync([change], saveTo); + } + } + catch (Exception exception) + { + repo.SendNotification($"Failed to save as patch: {exception.Message}", true); + } + + e.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(openWith); + menu.Items.Add(openWithMerger); + 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 = this.CreateMenuIcon("Icons.File.Checkout"); + resetToThisRevision.Click += async (_, ev) => + { + await vm.ResetToThisRevisionAsync(change); + ev.Handled = true; + }; + + var resetToFirstParent = new MenuItem(); + resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); + resetToFirstParent.Icon = this.CreateMenuIcon("Icons.File.Checkout"); + resetToFirstParent.IsEnabled = commit.Parents.Count > 0; + resetToFirstParent.Click += async (_, ev) => + { + await vm.ResetToParentRevisionAsync(change); + ev.Handled = true; + }; + + menu.Items.Add(resetToThisRevision); + menu.Items.Add(resetToFirstParent); + menu.Items.Add(new MenuItem { Header = "-" }); + + if (repo.Remotes.Count > 0 && File.Exists(fullPath) && repo.IsLFSEnabled()) + { + var lfs = new MenuItem(); + lfs.Header = App.Text("GitLFS"); + lfs.Icon = this.CreateMenuIcon("Icons.LFS"); + + var lfsLock = new MenuItem(); + lfsLock.Header = App.Text("GitLFS.Locks.Lock"); + lfsLock.Icon = this.CreateMenuIcon("Icons.Lock"); + if (repo.Remotes.Count == 1) + { + lfsLock.Click += async (_, e) => + { + await repo.LockLFSFileAsync(repo.Remotes[0].Name, change.Path); + 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) => + { + await repo.LockLFSFileAsync(remoteName, change.Path); + e.Handled = true; + }; + lfsLock.Items.Add(lockRemote); + } + } + lfs.Items.Add(lfsLock); + + var lfsUnlock = new MenuItem(); + lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); + lfsUnlock.Icon = this.CreateMenuIcon("Icons.Unlock"); + if (repo.Remotes.Count == 1) + { + lfsUnlock.Click += async (_, e) => + { + await repo.UnlockLFSFileAsync(repo.Remotes[0].Name, change.Path, false, true); + 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) => + { + await repo.UnlockLFSFileAsync(remoteName, change.Path, false, true); + e.Handled = true; + }; + lfsUnlock.Items.Add(unlockRemote); + } + } + lfs.Items.Add(lfsUnlock); + + menu.Items.Add(lfs); + menu.Items.Add(new MenuItem() { Header = "-" }); } + } + + var actions = repo.GetCustomActions(Models.CustomActionScope.File); + if (actions.Count > 0) + { + var target = new Models.CustomActionTargetFile(change.Path, vm.Commit); + var custom = new MenuItem(); + custom.Header = App.Text("FileCM.CustomAction"); + custom.Icon = this.CreateMenuIcon("Icons.Action"); + + foreach (var action in actions) + { + var (dup, label) = action; + var item = new MenuItem(); + item.Icon = this.CreateMenuIcon("Icons.Action"); + item.Header = label; + item.Click += async (_, e) => + { + await repo.ExecCustomActionAsync(dup, target); + e.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; + copyPath.Click += async (_, ev) => + { + await this.CopyTextAsync(change.Path); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; + copyFullPath.Click += async (_, e) => + { + await this.CopyTextAsync(fullPath); + e.Handled = true; + }; + + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + return menu; + } + + private void OnTabHeaderPointerPressed(object sender, PointerPressedEventArgs e) + { + if (ViewModels.Preferences.Instance.UseTwoColumnsLayoutInHistories) + return; + + var historiesView = this.FindAncestorOfType(); + if (historiesView is not { DataContext: ViewModels.Histories vm }) + return; + + if (vm.IsCollapseDetails) + vm.IsCollapseDetails = false; + } + + private async void OnCommitListKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.CommitDetail vm) + return; + + if (sender is not ListBox { SelectedItem: Models.Change change }) + return; - var menu = detail.CreateChangeContextMenu(datagrid.SelectedItem as Models.Change); - menu.Open(datagrid); + if (e.Key == Key.C && + e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + await this.CopyTextAsync(vm.GetAbsPath(change.Path)); + else + await this.CopyTextAsync(change.Path); + + e.Handled = true; + return; + } + + if (e.Key == Key.D && + e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control) && + e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + vm.OpenChangeInMergeTool(change); + e.Handled = true; + } + } + + private void OnChangeDoubleTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail detail && sender is Grid { DataContext: Models.Change change }) + { + detail.SelectedChanges = new() { change }; + detail.ActiveTabIndex = 1; } + e.Handled = true; } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is Grid { DataContext: Models.Change change } grid) + CreateChangeContextMenu(change)?.Open(grid); + e.Handled = true; + } + + private bool _isDetailsPanelExpanded = true; } } diff --git a/src/Views/CommitDetailStandalone.axaml b/src/Views/CommitDetailStandalone.axaml new file mode 100644 index 000000000..b43ca9582 --- /dev/null +++ b/src/Views/CommitDetailStandalone.axaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitDetailStandalone.axaml.cs b/src/Views/CommitDetailStandalone.axaml.cs new file mode 100644 index 000000000..70ada5f8f --- /dev/null +++ b/src/Views/CommitDetailStandalone.axaml.cs @@ -0,0 +1,10 @@ +namespace SourceGit.Views +{ + public partial class CommitDetailStandalone : ChromelessWindow + { + public CommitDetailStandalone() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs new file mode 100644 index 000000000..88a8217c6 --- /dev/null +++ b/src/Views/CommitGraph.cs @@ -0,0 +1,205 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class CommitGraph : Control + { + public static readonly DirectProperty GraphProperty = + AvaloniaProperty.RegisterDirect( + nameof(Graph), + static o => o.Graph, + static (o, v) => o.Graph = v); + + public Models.CommitGraph Graph + { + get => _graph; + set => SetAndRaise(GraphProperty, ref _graph, value); + } + + public static readonly DirectProperty LayoutProperty = + AvaloniaProperty.RegisterDirect( + nameof(Layout), + static o => o.Layout, + static (o, v) => o.Layout = v); + + public Models.CommitGraphLayout Layout + { + get => _layout; + set => SetAndRaise(LayoutProperty, ref _layout, value); + } + + public static readonly StyledProperty DotBrushProperty = + AvaloniaProperty.Register(nameof(DotBrush), Brushes.Transparent); + + public IBrush DotBrush + { + get => GetValue(DotBrushProperty); + set => SetValue(DotBrushProperty, value); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (_graph == null || _layout == null) + return; + + var startY = _layout.StartY; + var clipWidth = _layout.ClipWidth; + var clipHeight = Bounds.Height; + var rowHeight = _layout.RowHeight; + var endY = startY + clipHeight + 28; + + using (context.PushClip(new Rect(0, 0, clipWidth, clipHeight))) + using (context.PushTransform(Matrix.CreateTranslation(0, -startY))) + { + DrawCurves(context, _graph, startY, endY, rowHeight); + DrawAnchors(context, _graph, startY, endY, rowHeight); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == GraphProperty || + change.Property == LayoutProperty || + change.Property == DotBrushProperty) + InvalidateVisual(); + } + + 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); + + foreach (var link in graph.Links) + { + 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)); + } + + var pen = link.IsHighlighted ? Models.CommitGraph.Pens[link.Color] : grayedPen; + context.DrawGeometry(null, pen, 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 = line.IsHighlighted ? Models.CommitGraph.Pens[line.Color] : grayedPen; + + 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; + } + } + + context.DrawGeometry(null, pen, 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); + + 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 = dot.IsHighlighted ? Models.CommitGraph.Pens[dot.Color] : 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; + } + } + } + + private Models.CommitGraph _graph = null; + private Models.CommitGraphLayout _layout = null; + } +} diff --git a/src/Views/CommitMessageEditor.axaml b/src/Views/CommitMessageEditor.axaml new file mode 100644 index 000000000..ee1aac3c8 --- /dev/null +++ b/src/Views/CommitMessageEditor.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitMessageToolBox.axaml.cs b/src/Views/CommitMessageToolBox.axaml.cs new file mode 100644 index 000000000..214f7fd8b --- /dev/null +++ b/src/Views/CommitMessageToolBox.axaml.cs @@ -0,0 +1,709 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public record CommitMessageTextBoxSuggestion(TextBox Target, string Text, int ReplaceStart, int ReplaceLen) + { + public void Use() + { + var text = Target.Text ?? string.Empty; + if (ReplaceStart + ReplaceLen > text.Length) + return; + + var builder = new StringBuilder(); + builder + .Append(text.Substring(0, ReplaceStart)) + .Append(Text) + .Append(text.Substring(ReplaceStart + ReplaceLen)); + + Target.Text = builder.ToString(); + Target.CaretIndex = ReplaceStart + Text.Length; + } + } + + public class CommitMessageTextBox : TextBox + { + public static readonly DirectProperty SubjectLengthProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubjectLength), + static o => o.SubjectLength); + + public int SubjectLength + { + get => _subjectLen; + set => SetAndRaise(SubjectLengthProperty, ref _subjectLen, value); + } + + public static readonly DirectProperty SubjectGuideLengthProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubjectGuideLength), + static o => o.SubjectGuideLength, + static (o, v) => o.SubjectGuideLength = v); + + public int SubjectGuideLength + { + get => _subjectGuideLen; + set => SetAndRaise(SubjectGuideLengthProperty, ref _subjectGuideLen, value); + } + + public static readonly DirectProperty SubjectEndYProperty = + AvaloniaProperty.RegisterDirect( + nameof(SubjectEndY), + static o => o.SubjectEndY); + + public double SubjectEndY + { + get => _subjectEndY; + set => SetAndRaise(SubjectEndYProperty, ref _subjectEndY, value); + } + + public static readonly DirectProperty WarnSubjectLengthProperty = + AvaloniaProperty.RegisterDirect( + nameof(WarnSubjectLength), + static o => o.WarnSubjectLength); + + public bool WarnSubjectLength + { + get => _warnSubjectLen; + set => SetAndRaise(WarnSubjectLengthProperty, ref _warnSubjectLen, value); + } + + public static readonly DirectProperty> SuggestionsProperty = + AvaloniaProperty.RegisterDirect>( + nameof(Suggestions), + static o => o.Suggestions); + + public List Suggestions + { + get => _suggestions; + set => SetAndRaise(SuggestionsProperty, ref _suggestions, value); + } + + public static readonly DirectProperty SelectedSuggestionIndexProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedSuggestionIndex), + static o => o.SelectedSuggestionIndex, + static (o, v) => o.SelectedSuggestionIndex = v); + + public int SelectedSuggestionIndex + { + get => _selectedSuggestionIdx; + set => SetAndRaise(SelectedSuggestionIndexProperty, ref _selectedSuggestionIdx, value); + } + + public static readonly DirectProperty SuggestionPopupYProperty = + AvaloniaProperty.RegisterDirect( + nameof(SuggestionPopupY), + static o => o.SuggestionPopupY); + + public double SuggestionPopupY + { + get => _suggestionPopupY; + set => SetAndRaise(SuggestionPopupYProperty, ref _suggestionPopupY, value); + } + + public static readonly StyledProperty GuideLineBrushProperty = + AvaloniaProperty.Register(nameof(GuideLineBrush), Brushes.Gray); + + public IBrush GuideLineBrush + { + get => GetValue(GuideLineBrushProperty); + set => SetValue(GuideLineBrushProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBox); + + public CommitMessageTextBox() + { + AcceptsReturn = true; + AcceptsTab = true; + TextWrapping = TextWrapping.Wrap; + HorizontalContentAlignment = HorizontalAlignment.Left; + VerticalContentAlignment = VerticalAlignment.Top; + Padding = new Thickness(4); + + SetValue(ScrollViewer.HorizontalScrollBarVisibilityProperty, ScrollBarVisibility.Disabled); + SetValue(ScrollViewer.VerticalScrollBarVisibilityProperty, ScrollBarVisibility.Auto); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var font = FontFamily ?? FontFamily.Default; + var pen = new Pen(GuideLineBrush) { DashStyle = DashStyle.Dash }; + var y = SubjectEndY; + if (y > Bounds.Height - 0.05) + return; + + if (y >= 0.05) + { + var w = Bounds.Width; + var subjectEndTip = new FormattedText( + "SUBJECT END", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(font, FontStyle.Italic), + 10, + Brushes.Gray); + context.DrawLine(pen, new Point(0, y), new Point(w, y)); + context.DrawText(subjectEndTip, new Point(w - subjectEndTip.WidthIncludingTrailingWhitespace - 18, y + 1)); + } + + if (y == 0 && _subjectLen == 0) + return; + + var columnTest = new FormattedText( + "W", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(font), + ViewModels.Preferences.Instance.DefaultFontSize, + Brushes.White); + var columnGuideX = columnTest.WidthIncludingTrailingWhitespace * 80 + 5.5; + context.DrawLine(pen, new Point(columnGuideX, Math.Max(0, y)), new Point(columnGuideX, Bounds.Height)); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + _textPresenter = e.NameScope.Get("PART_TextPresenter"); + _scrollViewer = e.NameScope.Find("PART_ScrollViewer"); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + LayoutUpdated += OnLayoutUpdated; + OnLayoutUpdated(null, null); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + LayoutUpdated -= OnLayoutUpdated; + base.OnUnloaded(e); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == TextProperty) + { + var text = Text ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + _subjectEndCharIdx = -1; + SubjectLength = 0; + return; + } + + var subjectLen = 0; + var lastNonLineBreakCharIdx = 0; + var lastLineStart = 0; + for (var i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (ch == '\n') + { + var line = (i > lastLineStart) ? text.Substring(lastLineStart, i - lastLineStart) : string.Empty; + lastLineStart = i + 1; + + if (string.IsNullOrWhiteSpace(line)) + { + if (subjectLen > 0) + break; + + continue; + } + + var validCharLen = line.TrimEnd().Length; + if (subjectLen > 0) + subjectLen += (validCharLen + 1); + else + subjectLen = validCharLen; + } + else if (ch != '\r') + { + lastNonLineBreakCharIdx = i; + } + } + + if (lastLineStart < lastNonLineBreakCharIdx) + { + var validCharLen = text.Substring(lastLineStart).TrimEnd().Length; + if (subjectLen > 0) + subjectLen += (validCharLen + 1); + else + subjectLen = validCharLen; + } + + SubjectLength = subjectLen; + _subjectEndCharIdx = lastNonLineBreakCharIdx; + } + else if (change.Property == SubjectLengthProperty || change.Property == SubjectGuideLengthProperty) + { + WarnSubjectLength = _subjectLen > _subjectGuideLen; + } + else if (change.Property == SubjectEndYProperty) + { + InvalidateVisual(); + } + else if (change.Property == CaretIndexProperty) + { + var text = Text ?? string.Empty; + if (string.IsNullOrEmpty(text)) + { + _suggestionMatchStartIdx = -1; + Suggestions = null; + return; + } + + var caretIdx = CaretIndex; + var startIdx = Math.Max(Math.Min(text.Length - 1, caretIdx - 1), 0); + var hasWhitespace = false; + var column = 0; + for (var i = startIdx; i >= 0; i--) + { + if (i == 0) + { + column = startIdx + 2; + break; + } + + var ch = text[i]; + if (ch == '\n') + { + column = startIdx - i + 1; + break; + } + + if (!hasWhitespace) + hasWhitespace = char.IsWhiteSpace(ch); + } + + var suggestionMatchStartIdx = Math.Max(caretIdx - column + 1, 0); + if (hasWhitespace || column == 1 || suggestionMatchStartIdx < _subjectEndCharIdx) + { + _suggestionMatchStartIdx = -1; + Suggestions = null; + return; + } + + var editLine = text.Substring(suggestionMatchStartIdx); + var prefixEndIdx = editLine.IndexOfAny([' ', '\t', '\r', '\n']); + var prefix = prefixEndIdx > 0 ? editLine.Substring(0, prefixEndIdx) : editLine; + var matches = new List(); + if (prefix.Length >= 2) + { + foreach (var t in _trailers) + { + if (t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + !editLine.StartsWith(t, StringComparison.Ordinal)) + matches.Add(new(this, t, suggestionMatchStartIdx, prefix.Length)); + } + } + + if (matches.Count > 0) + { + _suggestionMatchStartIdx = suggestionMatchStartIdx; + Suggestions = matches; + SelectedSuggestionIndex = 0; + } + else + { + _suggestionMatchStartIdx = -1; + Suggestions = null; + } + } + else if (change.Property == BoundsProperty) + { + // Sync the actual width to TextPresenter. Otherwise, `TextWrapping` will not work well without + // a fixed width. See https://github.com/AvaloniaUI/Avalonia/issues/5819 + if (_textPresenter != null) + _textPresenter.Width = Bounds.Width; + } + } + + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + Suggestions = null; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (_suggestions != null) + { + if (e.Key == Key.Up) + { + if (_selectedSuggestionIdx > 0) + SelectedSuggestionIndex = _selectedSuggestionIdx - 1; + else + SelectedSuggestionIndex = _suggestions.Count - 1; + + e.Handled = true; + } + else if (e.Key == Key.Down) + { + if (_selectedSuggestionIdx < _suggestions.Count - 1) + SelectedSuggestionIndex = _selectedSuggestionIdx + 1; + else + SelectedSuggestionIndex = 0; + + e.Handled = true; + } + else if (e.Key == Key.Enter || e.Key == Key.Tab) + { + var selected = _suggestions[_selectedSuggestionIdx]; + selected.Use(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + Suggestions = null; + e.Handled = true; + } + } + + if (!e.Handled) + base.OnKeyDown(e); + } + + private void OnLayoutUpdated(object sender, EventArgs e) + { + if (_subjectEndCharIdx < 0) + { + SubjectEndY = 0; + } + else + { + var y = _textPresenter?.TextLayout.HitTestTextPosition(_subjectEndCharIdx).Bottom ?? 0.0; + var offset = _scrollViewer?.Offset.Y ?? 0; + SubjectEndY = y - offset + 6; + + if (_suggestionMatchStartIdx >= 0) + { + var popupY = _textPresenter?.TextLayout.HitTestTextPosition(_suggestionMatchStartIdx).Bottom ?? 0; + y = popupY - offset; + if (y < 0.05 || y > Bounds.Height - 0.05) + { + _suggestionMatchStartIdx = -1; + SuggestionPopupY = 0; + Suggestions = null; + } + else + { + SuggestionPopupY = y; + } + } + } + } + + private readonly List _trailers = + [ + "Acked-by: ", + "Assisted-by: ", + "BREAKING CHANGE: ", + "Co-authored-by: ", + "Fixes: ", + "Helped-by: ", + "Issue: ", + "Milestone: ", + "on-behalf-of: @", + "Reference-to: ", + "Refs: ", + "Reviewed-by: ", + "See-also: ", + "Signed-off-by: ", + ]; + + private TextPresenter _textPresenter = null; + private ScrollViewer _scrollViewer = null; + private int _subjectLen = 0; + private int _subjectGuideLen = 0; + private int _subjectEndCharIdx = -1; + private double _subjectEndY = 0; + private bool _warnSubjectLen = false; + private int _suggestionMatchStartIdx = -1; + private List _suggestions = null; + private int _selectedSuggestionIdx = 0; + private double _suggestionPopupY = 0; + } + + public partial class CommitMessageToolBox : UserControl + { + public static readonly DirectProperty ShowAdvancedOptionsProperty = + AvaloniaProperty.RegisterDirect( + nameof(ShowAdvancedOptions), + static o => o.ShowAdvancedOptions, + static (o, v) => o.ShowAdvancedOptions = v); + + public bool ShowAdvancedOptions + { + get => _showAdvancedOptions; + set => SetAndRaise(ShowAdvancedOptionsProperty, ref _showAdvancedOptions, value); + } + + public static readonly DirectProperty CommitMessageProperty = + AvaloniaProperty.RegisterDirect( + nameof(CommitMessage), + static o => o.CommitMessage, + static (o, v) => o.CommitMessage = v); + + public string CommitMessage + { + get => _commitMessage; + set => SetAndRaise(CommitMessageProperty, ref _commitMessage, value); + } + + public CommitMessageToolBox() + { + InitializeComponent(); + } + + private void OnSuggestionTapped(object sender, TappedEventArgs e) + { + if (sender is Control { DataContext: CommitMessageTextBoxSuggestion suggestion }) + suggestion.Use(); + + e.Handled = true; + } + + private async void OnOpenCommitMessagePicker(object sender, RoutedEventArgs e) + { + if (sender is Button button && DataContext is ViewModels.WorkingCopy vm && _showAdvancedOptions) + { + var repo = vm.Repository; + var foreground = this.FindResource("Brush.FG1") as IBrush; + var menu = new ContextMenu() { MaxWidth = 480 }; + + var gitTemplate = await new Commands.Config(repo.FullPath).GetAsync("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 = this.CreateMenuIcon("Icons.Code"), + IsEnabled = false + }); + } + else + { + for (int i = 0; i < templateCount; i++) + { + var icon = this.CreateMenuIcon("Icons.Code"); + icon.Fill = foreground; + + var template = repo.Settings.CommitTemplates[i]; + var item = new MenuItem(); + item.Header = App.Text("WorkingCopy.UseCommitTemplate", template.Name); + item.Icon = icon; + item.Click += (_, ev) => + { + vm.ApplyCommitMessageTemplate(template); + ev.Handled = true; + }; + menu.Items.Add(item); + } + + if (!string.IsNullOrEmpty(gitTemplate)) + { + if (!Path.IsPathRooted(gitTemplate)) + gitTemplate = Native.OS.GetAbsPath(repo.FullPath, 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 icon = this.CreateMenuIcon("Icons.Code"); + icon.Fill = foreground; + + var gitTemplateItem = new MenuItem(); + gitTemplateItem.Header = App.Text("WorkingCopy.UseCommitTemplate", friendlyName); + gitTemplateItem.Icon = icon; + gitTemplateItem.Click += (_, ev) => + { + if (File.Exists(gitTemplate)) + vm.CommitMessage = File.ReadAllText(gitTemplate); + ev.Handled = true; + }; + menu.Items.Add(gitTemplateItem); + } + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var historiesCount = repo.UIStates.RecentCommitMessages.Count; + if (historiesCount == 0) + { + menu.Items.Add(new MenuItem() + { + Header = App.Text("WorkingCopy.NoCommitHistories"), + Icon = this.CreateMenuIcon("Icons.Histories"), + IsEnabled = false + }); + } + else + { + for (int i = 0; i < historiesCount; i++) + { + var dup = repo.UIStates.RecentCommitMessages[i].Trim(); + var header = new TextBlock() + { + Text = dup.ReplaceLineEndings(" "), + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + + var icon = this.CreateMenuIcon("Icons.Histories"); + icon.Fill = foreground; + + var item = new MenuItem(); + item.Header = header; + item.Icon = icon; + item.Click += (_, ev) => + { + vm.CommitMessage = dup; + ev.Handled = true; + }; + + menu.Items.Add(item); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + + var clearIcon = this.CreateMenuIcon("Icons.Clear"); + clearIcon.Fill = foreground; + + var clearHistoryItem = new MenuItem(); + clearHistoryItem.Header = App.Text("WorkingCopy.ClearCommitHistories"); + clearHistoryItem.Icon = clearIcon; + clearHistoryItem.Click += async (_, ev) => + { + await vm.ClearCommitMessageHistoryAsync(); + ev.Handled = true; + }; + + menu.Items.Add(clearHistoryItem); + } + + button.IsEnabled = false; + menu.Placement = PlacementMode.TopEdgeAlignedLeft; + menu.HorizontalOffset = -2; + menu.VerticalOffset = 1; + menu.Closed += (_, _) => button.IsEnabled = true; + menu.Open(button); + } + + e.Handled = true; + } + + private void OnOpenOpenAIHelper(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.WorkingCopy vm && sender is Button button && _showAdvancedOptions) + { + var repo = vm.Repository; + + if (vm.Staged == null || vm.Staged.Count == 0) + { + repo.SendNotification("No files added to commit!", true); + e.Handled = true; + return; + } + + var services = repo.GetPreferredOpenAIServices(); + if (services.Count == 0) + { + repo.SendNotification("Bad configuration for OpenAI", true); + e.Handled = true; + return; + } + + if (services.Count == 1) + { + DoOpenAIAssistant(repo, services[0], vm.Staged); + e.Handled = true; + return; + } + + var menu = new ContextMenu(); + foreach (var service in services) + { + var dup = service; + var item = new MenuItem(); + item.Header = service.Name; + item.Click += (_, ev) => + { + DoOpenAIAssistant(repo, dup, vm.Staged); + ev.Handled = true; + }; + + menu.Items.Add(item); + } + + button.IsEnabled = false; + menu.Placement = PlacementMode.TopEdgeAlignedLeft; + menu.Closed += (_, _) => button.IsEnabled = true; + menu.Open(button); + } + + e.Handled = true; + } + + private void OnOpenConventionalCommitHelper(object _, RoutedEventArgs e) + { + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner == null) + return; + + var conventionalTypesOverride = owner switch + { + Launcher { DataContext: ViewModels.Launcher { ActivePage: { Data: ViewModels.Repository repo } } } => repo.Settings.ConventionalTypesOverride, + RepositoryConfigure { DataContext: ViewModels.RepositoryConfigure config } => config.ConventionalTypesOverride, + CommitMessageEditor editor => editor.ConventionalTypesOverride, + _ => string.Empty + }; + + var vm = new ViewModels.ConventionalCommitMessageBuilder(conventionalTypesOverride, text => CommitMessage = text); + var builder = new ConventionalCommitMessageBuilder() { DataContext = vm }; + builder.Show(owner); + + e.Handled = true; + } + + private void DoOpenAIAssistant(ViewModels.Repository repo, AI.Service service, List changes) + { + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner == null) + return; + + var assistant = new ViewModels.AIAssistant(repo, service, changes); + var view = new AIAssistant() { DataContext = assistant }; + view.Show(owner); + } + + private string _commitMessage = string.Empty; + private bool _showAdvancedOptions = false; + } +} diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs new file mode 100644 index 000000000..51aae1359 --- /dev/null +++ b/src/Views/CommitRefsPresenter.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class CommitRefsIconCache + { + public static CommitRefsIconCache Instance + { + get + { + if (_instance == null) + _instance = new CommitRefsIconCache(); + return _instance; + } + } + + public CommitRefsIconCache() + { + _head = LoadIcon("Icons.Head"); + _branch = LoadIcon("Icons.Branch"); + _remote = LoadIcon("Icons.Remote"); + _tag = LoadIcon("Icons.Tag"); + } + + public Geometry GetIcon(Models.DecoratorType type) + { + return type switch + { + Models.DecoratorType.CurrentBranchHead => _head, + Models.DecoratorType.CurrentCommitHead => _head, + Models.DecoratorType.LocalBranchHead => _branch, + Models.DecoratorType.RemoteBranchHead => _remote, + Models.DecoratorType.Tag => _tag, + _ => null, + }; + } + + private Geometry LoadIcon(string resourceKey) + { + var geo = App.Current.FindResource(resourceKey) 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 static CommitRefsIconCache _instance = null; + private Geometry _head = null; + private Geometry _branch = null; + private Geometry _remote = null; + private Geometry _tag = null; + } + + public class CommitRefsPresenter : Control + { + public class RenderItem + { + public Models.Decorator Decorator { 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 List Remotes { get; set; } = []; + } + + 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 UseCompactBranchNamesProperty = + AvaloniaProperty.Register(nameof(UseCompactBranchNames)); + + public bool UseCompactBranchNames + { + get => GetValue(UseCompactBranchNamesProperty); + set => SetValue(UseCompactBranchNamesProperty, 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); + } + + public Models.Decorator DecoratorAt(Point point) + { + var x = 0.0; + foreach (var item in _items) + { + x += item.Width; + if (point.X < x) + return item.Decorator; + } + + return null; + } + + 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.5; + var y = 0.5; + + context.FillRectangle(Brushes.Transparent, Bounds); + + foreach (var item in _items) + { + if (allowWrap && x > 1.5 && x + item.Width > Bounds.Width) + { + x = 1.5; + y += 20.0; + } + + var entireRect = new RoundedRect(new Rect(x, y, item.Width, 16), new CornerRadius(4)); + if (item.IsHead) + { + if (useGraphColor) + { + if (bg != null) + context.DrawRectangle(bg, null, entireRect); + + using (context.PushOpacity(.6)) + context.DrawRectangle(item.Brush, null, entireRect); + } + } + else + { + if (bg != null) + context.DrawRectangle(bg, null, entireRect); + + var labelRect = new RoundedRect(new Rect(x + 16, y, item.Width - 16, 16), new CornerRadius(4, 0, 0, 4)); + 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)); + + if (item.Remotes.Count > 0) + { + var rx = x + 20 + item.Label.WidthIncludingTrailingWhitespace + 4; + foreach (var remote in item.Remotes) + { + context.DrawLine(new Pen(item.Brush), new Point(rx, y), new Point(rx, y + 16)); + context.DrawText(remote, new Point(rx + 4, y + 8.0 - remote.Height * 0.5)); + rx += remote.WidthIncludingTrailingWhitespace + 9; + } + } + + context.DrawRectangle(null, new Pen(item.Brush), entireRect); + + var icon = CommitRefsIconCache.Instance.GetIcon(item.Decorator.Type); + if (icon != null) + { + using (context.PushTransform(Matrix.CreateTranslation(x + 3, y + 3))) + context.DrawGeometry(fg, null, icon); + } + + x += item.Width + 4; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == FontFamilyProperty || + change.Property == FontSizeProperty || + change.Property == ForegroundProperty || + change.Property == UseGraphColorProperty || + change.Property == UseCompactBranchNamesProperty || + change.Property == BackgroundProperty || + change.Property == ShowTagsProperty) + InvalidateMeasure(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + _items.Clear(); + + if (DataContext is not Models.Commit commit) + return new Size(0, 0); + + var refs = commit.Decorators; + var count = refs.Count; + if (count == 0) + { + InvalidateVisual(); + return new Size(0, 0); + } + + var useCompactBranchNames = UseCompactBranchNames; + var typeface = new Typeface(FontFamily); + var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); + var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); + var fg = Foreground; + var normalBG = UseGraphColor ? Models.CommitGraph.Pens[commit.Color].Brush : Brushes.Gray; + var labelSize = FontSize; + var requiredHeight = 16.0; + var x = 0.0; + var allowWrap = AllowWrap; + var showTags = ShowTags; + var skippedIdx = new HashSet(); + + for (var i = 0; i < count; i++) + { + if (skippedIdx.Contains(i)) + continue; + + var decorator = refs[i]; + if (!showTags && decorator.Type == Models.DecoratorType.Tag) + continue; + + var item = new RenderItem() + { + Decorator = decorator, + Brush = decorator.Type == Models.DecoratorType.Tag ? Brushes.Gray : normalBG, + IsHead = decorator.Type is Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.CurrentCommitHead, + }; + _items.Add(item); + + if (item.IsHead) + { + item.Label = new FormattedText( + decorator.Name, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typefaceHead, + labelSize + 1, + fg); + } + else + { + item.Label = new FormattedText( + decorator.Name, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + labelSize, + fg); + } + + item.Width = item.Label.Width + 24; + + var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); + if (findRemotes) + { + for (var j = i + 1; j < count; j++) + { + var test = refs[j]; + if (test.Type != Models.DecoratorType.RemoteBranchHead) + continue; + + var idxOfSlash = test.Name.IndexOf('/'); + if (idxOfSlash < 1 || idxOfSlash == test.Name.Length - 1) + continue; + + var name = test.Name.Substring(idxOfSlash + 1); + if (decorator.Name.Equals(name, StringComparison.Ordinal)) + { + var remote = new FormattedText( + $"+{test.Name.Substring(0, idxOfSlash)}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typefaceRemote, + labelSize, + fg); + + item.Remotes.Add(remote); + item.Width += remote.Width + 9; + skippedIdx.Add(j); + } + } + } + + x += item.Width + 4; + if (allowWrap) + { + if (x > availableSize.Width) + { + requiredHeight += 20.0; + x = item.Width; + } + } + } + + double requiredWidth = 0; + if (_items.Count > 0) + { + if (allowWrap && requiredHeight > 16.0) + requiredWidth = double.IsInfinity(availableSize.Width) ? x + 2 : availableSize.Width; + else + requiredWidth = x + 2; + } + + InvalidateVisual(); + return new Size(requiredWidth, requiredHeight); + } + + private List _items = new List(); + } +} diff --git a/src/Views/CommitRelationTracking.axaml b/src/Views/CommitRelationTracking.axaml new file mode 100644 index 000000000..53906bfe3 --- /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 000000000..ff3e85469 --- /dev/null +++ b/src/Views/CommitRelationTracking.axaml.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CommitRelationTracking : UserControl + { + public CommitRelationTracking() + { + InitializeComponent(); + } + + public async Task SetDataAsync(ViewModels.CommitDetail detail) + { + LoadingIcon.IsVisible = true; + var containsIn = await detail.GetRefsContainsThisCommitAsync(); + Container.ItemsSource = containsIn; + LoadingIcon.IsVisible = false; + } + } +} diff --git a/src/Views/CommitStatusIndicator.cs b/src/Views/CommitStatusIndicator.cs new file mode 100644 index 000000000..08d29572b --- /dev/null +++ b/src/Views/CommitStatusIndicator.cs @@ -0,0 +1,93 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class CommitStatusIndicator : Control + { + public static readonly DirectProperty CurrentBranchProperty = + AvaloniaProperty.RegisterDirect( + nameof(CurrentBranch), + static o => o.CurrentBranch, + static (o, v) => o.CurrentBranch = v); + + public Models.Branch CurrentBranch + { + get => _currentBranch; + set => SetAndRaise(CurrentBranchProperty, ref _currentBranch, 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 != null) + { + var sha = commit.SHA; + + if (_currentBranch.Ahead.Contains(sha)) + _status = Status.Ahead; + else if (_currentBranch.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 Models.Branch _currentBranch = null; + private Status _status = Status.Normal; + } +} diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs new file mode 100644 index 000000000..694907274 --- /dev/null +++ b/src/Views/CommitSubjectPresenter.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +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 InlineCodeForegroundProperty = + AvaloniaProperty.Register(nameof(InlineCodeForeground), Brushes.White); + + public IBrush InlineCodeForeground + { + get => GetValue(InlineCodeForegroundProperty); + set => SetValue(InlineCodeForegroundProperty, value); + } + + public static readonly StyledProperty ShowStrikethroughProperty = + AvaloniaProperty.Register(nameof(ShowStrikethrough)); + + public bool ShowStrikethrough + { + get => GetValue(ShowStrikethroughProperty); + set => SetValue(ShowStrikethroughProperty, value); + } + + public static readonly DirectProperty SubjectProperty = + AvaloniaProperty.RegisterDirect( + nameof(Subject), + static o => o.Subject, + static (o, v) => o.Subject = v); + + public string Subject + { + get => _subject; + set => SetAndRaise(SubjectProperty, ref _subject, value); + } + + public static readonly DirectProperty> IssueTrackersProperty = + AvaloniaProperty.RegisterDirect>( + nameof(IssueTrackers), + static o => o.IssueTrackers, + static (o, v) => o.IssueTrackers = v); + + public AvaloniaList IssueTrackers + { + get => _issueTrackers; + set => SetAndRaise(IssueTrackersProperty, ref _issueTrackers, 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; + var maxX = 0.0; + foreach (var inline in _inlines) + { + if (inline.X > width) + break; + + 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)); + maxX = Math.Min(width, inline.X + inline.Text.WidthIncludingTrailingWhitespace + 8); + } + else + { + context.DrawText(inline.Text, new Point(inline.X, (height - inline.Text.Height) * 0.5)); + maxX = Math.Min(width, inline.X + inline.Text.WidthIncludingTrailingWhitespace); + } + } + + if (ShowStrikethrough) + context.DrawLine(new Pen(Foreground), new Point(0, height * 0.5), new Point(maxX, height * 0.5)); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SubjectProperty) + { + _needRebuildInlines = true; + GenerateInlineElements(); + InvalidateVisual(); + } + else if (change.Property == IssueTrackersProperty) + { + if (change.OldValue is AvaloniaList oldValue) + oldValue.CollectionChanged -= OnIssueTrackersChanged; + if (change.NewValue is AvaloniaList newValue) + newValue.CollectionChanged += OnIssueTrackersChanged; + + OnIssueTrackersChanged(null, null); + } + 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 || + change.Property == ShowStrikethroughProperty) + { + 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 OnIssueTrackersChanged(object sender, NotifyCollectionChangedEventArgs e) + { + _needRebuildInlines = true; + GenerateInlineElements(); + InvalidateVisual(); + } + + private void GenerateInlineElements() + { + _elements.Clear(); + ClearHoveredIssueLink(); + + var subject = Subject; + if (string.IsNullOrEmpty(subject)) + { + _needRebuildInlines = true; + InvalidateVisual(); + return; + } + + var rules = IssueTrackers ?? []; + foreach (var rule in rules) + rule.Matches(_elements, subject); + + if (subject.StartsWith("fixup! ", StringComparison.Ordinal) || subject.StartsWith("amend! ", StringComparison.Ordinal)) + { + _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, 6, string.Empty)); + } + else if (subject.StartsWith("squash! ", StringComparison.Ordinal)) + { + _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, 7, string.Empty)); + } + else if (subject.StartsWith('[')) + { + var bracketIdx = subject.IndexOf(']'); + if (bracketIdx > 1 && bracketIdx < 50 && _elements.Intersect(0, bracketIdx + 1) == null) + _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, bracketIdx + 1, string.Empty)); + } + else + { + var colonIdx = subject.IndexOf(": ", StringComparison.Ordinal); + if (colonIdx > 0 && colonIdx < 32 && colonIdx < subject.Length - 3 && subject.IndexOf('"', 0, colonIdx) == -1 && _elements.Intersect(0, colonIdx) == null) + { + _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, colonIdx + 1, string.Empty)); + } + else + { + var hyphenIdx = subject.IndexOf(" - ", StringComparison.Ordinal); + if (hyphenIdx > 0 && hyphenIdx < 32 && hyphenIdx < subject.Length - 4 && subject.IndexOf('"', 0, hyphenIdx) == -1 && _elements.Intersect(0, hyphenIdx) == null) + { + _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, hyphenIdx, string.Empty)); + } + } + } + + var codeMatches = REG_INLINECODE_FORMAT().Matches(subject); + foreach (Match match in codeMatches) + { + 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(); + } + + 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 inlineCodeForeground = InlineCodeForeground; + 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, + inlineCodeForeground); + _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(); + + 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 string _subject = string.Empty; + private AvaloniaList _issueTrackers = null; + private Models.InlineElementCollector _elements = new(); + private Models.InlineElement _lastHover = null; + private List _inlines = []; + private bool _needRebuildInlines = false; + } +} diff --git a/src/Views/CommitTimeTextBlock.cs b/src/Views/CommitTimeTextBlock.cs new file mode 100644 index 000000000..66c4090ab --- /dev/null +++ b/src/Views/CommitTimeTextBlock.cs @@ -0,0 +1,180 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Threading; + +namespace SourceGit.Views +{ + public class CommitTimeTextBlock : TextBlock + { + public static readonly DirectProperty ShowAsDateTimeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ShowAsDateTime), + static o => o.ShowAsDateTime, + static (o, v) => o.ShowAsDateTime = v); + + public bool ShowAsDateTime + { + get => _showAsDateTime; + set => SetAndRaise(ShowAsDateTimeProperty, ref _showAsDateTime, value); + } + + public static readonly DirectProperty Use24HoursProperty = + AvaloniaProperty.RegisterDirect( + nameof(Use24Hours), + static o => o.Use24Hours, + static (o, v) => o.Use24Hours = v); + + public bool Use24Hours + { + get => _use24Hours; + set => SetAndRaise(Use24HoursProperty, ref _use24Hours, value); + } + + public static readonly DirectProperty DateTimeFormatProperty = + AvaloniaProperty.RegisterDirect( + nameof(DateTimeFormat), + static o => o.DateTimeFormat, + static (o, v) => o.DateTimeFormat = v); + + public int DateTimeFormat + { + get => _dateTimeFormat; + set => SetAndRaise(DateTimeFormatProperty, ref _dateTimeFormat, value); + } + + public static readonly DirectProperty TimestampProperty = + AvaloniaProperty.RegisterDirect( + nameof(Timestamp), + static o => o.Timestamp, + static (o, v) => o.Timestamp = v); + + public ulong Timestamp + { + get => _timestamp; + set => SetAndRaise(TimestampProperty, ref _timestamp, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == TimestampProperty) + { + SetCurrentValue(TextProperty, GetDisplayText()); + } + else if (change.Property == ShowAsDateTimeProperty) + { + SetCurrentValue(TextProperty, GetDisplayText()); + + if (ShowAsDateTime) + { + _refreshTimer?.Stop(); + HorizontalAlignment = HorizontalAlignment.Left; + } + else + { + _refreshTimer?.Start(); + HorizontalAlignment = HorizontalAlignment.Center; + } + } + else if (change.Property == DateTimeFormatProperty || change.Property == Use24HoursProperty) + { + if (ShowAsDateTime) + SetCurrentValue(TextProperty, GetDisplayText()); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + _refreshTimer = new DispatcherTimer(); + _refreshTimer.Interval = TimeSpan.FromSeconds(10); + _refreshTimer.Tag = this; + _refreshTimer.Tick += static (o, _) => + { + if (o is DispatcherTimer { Tag: CommitTimeTextBlock textBlock }) + { + var text = textBlock.GetDisplayText(); + if (!text.Equals(textBlock.Text, StringComparison.Ordinal)) + textBlock.Text = text; + } + }; + _refreshTimer.IsEnabled = !ShowAsDateTime; + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + _refreshTimer.Tag = null; + _refreshTimer.IsEnabled = false; + + base.OnUnloaded(e); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + SetCurrentValue(TextProperty, GetDisplayText()); + } + + private string GetDisplayText() + { + var timestamp = Timestamp; + if (ShowAsDateTime) + return Models.DateTimeFormat.Format(timestamp); + + 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 bool _showAsDateTime = true; + private bool _use24Hours = true; + private int _dateTimeFormat = 0; + private ulong _timestamp = 0; + private DispatcherTimer _refreshTimer = null; + } +} diff --git a/src/Views/Compare.axaml b/src/Views/Compare.axaml new file mode 100644 index 000000000..3f340d495 --- /dev/null +++ b/src/Views/Compare.axaml @@ -0,0 +1,386 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Compare.axaml.cs b/src/Views/Compare.axaml.cs new file mode 100644 index 000000000..ad5d69312 --- /dev/null +++ b/src/Views/Compare.axaml.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class Compare : ChromelessWindow + { + public Compare() + { + InitializeComponent(); + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.Compare { SelectedChanges: { Count: > 0 } selected } vm && + sender is ChangeCollectionView view) + { + var menu = new ContextMenu(); + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = this.CreateMenuIcon("Icons.Save"); + patch.Click += async (_, e) => + { + var storageProvider = this.StorageProvider; + 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"] }]; + + try + { + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + var succ = await vm.SaveChangesAsPatchAsync(selected, saveTo); + if (succ) + await new Alert().ShowAsync(this, "Save patch successfully.", false); + } + } + catch (Exception exception) + { + await new Alert().ShowAsync(this, $"Failed to save as patch: {exception.Message}", true); + } + + e.Handled = true; + }; + + if (selected.Count == 1) + { + var change = selected[0]; + var openWithMerger = new MenuItem(); + openWithMerger.Header = App.Text("OpenInExternalMergeTool"); + openWithMerger.Icon = this.CreateMenuIcon("Icons.OpenWith"); + openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; + openWithMerger.Click += (_, ev) => + { + vm.OpenInExternalDiffTool(change); + ev.Handled = true; + }; + menu.Items.Add(openWithMerger); + + if (change.Index != Models.ChangeState.Deleted) + { + var full = vm.GetAbsPath(change.Path); + var explore = new MenuItem(); + explore.Header = App.Text("RevealFile"); + explore.Icon = this.CreateMenuIcon("Icons.Explore"); + explore.IsEnabled = File.Exists(full); + explore.Click += (_, ev) => + { + Native.OS.OpenInFileManager(full); + ev.Handled = true; + }; + menu.Items.Add(explore); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(patch); + + if (vm.CanResetFiles) + { + var resetToLeft = new MenuItem(); + resetToLeft.Header = App.Text("ChangeCM.ResetFileTo", vm.BaseName); + resetToLeft.Icon = this.CreateMenuIcon("Icons.File.Checkout"); + resetToLeft.Click += async (_, ev) => + { + await vm.ResetToLeftAsync(change); + ev.Handled = true; + }; + + var resetToRight = new MenuItem(); + resetToRight.Header = App.Text("ChangeCM.ResetFileTo", vm.ToName); + resetToRight.Icon = this.CreateMenuIcon("Icons.File.Checkout"); + resetToRight.Click += async (_, ev) => + { + await vm.ResetToRightAsync(change); + ev.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(resetToLeft); + menu.Items.Add(resetToRight); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; + copyPath.Click += async (_, ev) => + { + await this.CopyTextAsync(change.Path); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; + copyFullPath.Click += async (_, ev) => + { + await this.CopyTextAsync(vm.GetAbsPath(change.Path)); + ev.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + } + else + { + menu.Items.Add(patch); + + if (vm.CanResetFiles) + { + var resetToLeft = new MenuItem(); + resetToLeft.Header = App.Text("ChangeCM.ResetFileTo", vm.BaseName); + resetToLeft.Icon = this.CreateMenuIcon("Icons.File.Checkout"); + resetToLeft.Click += async (_, ev) => + { + await vm.ResetMultipleToLeftAsync(selected); + ev.Handled = true; + }; + + var resetToRight = new MenuItem(); + resetToRight.Header = App.Text("ChangeCM.ResetFileTo", vm.ToName); + resetToRight.Icon = this.CreateMenuIcon("Icons.File.Checkout"); + resetToRight.Click += async (_, ev) => + { + await vm.ResetMultipleToRightAsync(selected); + ev.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(resetToLeft); + menu.Items.Add(resetToRight); + } + + var copyPath = new MenuItem(); + copyPath.Header = App.Text("CopyPath"); + copyPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; + copyPath.Click += async (_, ev) => + { + var builder = new StringBuilder(); + foreach (var c in selected) + builder.AppendLine(c.Path); + + await this.CopyTextAsync(builder.ToString()); + ev.Handled = true; + }; + + var copyFullPath = new MenuItem(); + copyFullPath.Header = App.Text("CopyFullPath"); + copyFullPath.Icon = this.CreateMenuIcon("Icons.Copy"); + copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; + copyFullPath.Click += async (_, ev) => + { + var builder = new StringBuilder(); + foreach (var c in selected) + builder.AppendLine(vm.GetAbsPath(c.Path)); + + await this.CopyTextAsync(builder.ToString()); + ev.Handled = true; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + } + + menu.Open(view); + } + + e.Handled = true; + } + + private void OnCommitListContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is not ViewModels.Compare vm) + return; + + if (sender is ListBox { SelectedItems: { Count: > 0 } selected } listBox) + { + var commits = new List(); + foreach (var o in selected) + { + if (o is Models.Commit c) + commits.Add(c); + } + + if (commits.Count == 0) + return; + + commits.Sort((l, r) => l.CommitterTime.CompareTo(r.CommitterTime)); + + var menu = new ContextMenu(); + var hasCurrentHead = vm.BaseHead.IsCurrentHead || vm.ToHead.IsCurrentHead; + if (hasCurrentHead && listBox.Tag is Models.Commit { IsCurrentHead: false }) + { + var cherryPick = new MenuItem(); + cherryPick.Header = App.Text("CommitCM.CherryPickMultiple"); + cherryPick.Icon = this.CreateMenuIcon("Icons.CherryPick"); + cherryPick.Click += (_, ev) => + { + vm.CherryPick(commits); + ev.Handled = true; + }; + + menu.Items.Add(cherryPick); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, ev) => + { + var builder = new StringBuilder(); + foreach (var c in commits) + builder.Append(c.SHA.Substring(0, 10)).Append(" - ").AppendLine(c.Subject); + + await this.CopyTextAsync(builder.ToString()); + ev.Handled = true; + }; + + menu.Items.Add(copy); + menu.Open(listBox); + e.Handled = true; + } + } + + private void OnCommitListSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (DataContext is ViewModels.Compare vm && + sender is ListBox { SelectedItems: { Count: 1 } selected } listBox && + selected[0] is Models.Commit c) + { + vm.NavigateTo(c.SHA); + e.Handled = true; + } + } + + private void OnPressedSHA(object sender, PointerPressedEventArgs e) + { + if (DataContext is ViewModels.Compare vm && sender is TextBlock block) + vm.NavigateTo(block.Text); + + e.Handled = true; + } + + private async void OnChangeCollectionViewKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.Compare vm) + return; + + if (sender is not ChangeCollectionView { SelectedChanges: { Count: > 0 } selectedChanges }) + return; + + var cmdKey = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.Key == Key.C && e.KeyModifiers.HasFlag(cmdKey)) + { + var builder = new StringBuilder(); + var copyAbsPath = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + if (selectedChanges.Count == 1) + { + builder.Append(copyAbsPath ? vm.GetAbsPath(selectedChanges[0].Path) : selectedChanges[0].Path); + } + else + { + foreach (var c in selectedChanges) + builder.AppendLine(copyAbsPath ? vm.GetAbsPath(c.Path) : c.Path); + } + + await this.CopyTextAsync(builder.ToString()); + e.Handled = true; + } + else if (e.Key == Key.F && e.KeyModifiers == cmdKey) + { + ChangeSearchBox.Focus(); + e.Handled = true; + } + } + } +} diff --git a/src/Views/CompareCommandPalette.axaml b/src/Views/CompareCommandPalette.axaml new file mode 100644 index 000000000..e8b32115c --- /dev/null +++ b/src/Views/CompareCommandPalette.axaml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CompareCommandPalette.axaml.cs b/src/Views/CompareCommandPalette.axaml.cs new file mode 100644 index 000000000..44809f278 --- /dev/null +++ b/src/Views/CompareCommandPalette.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class CompareCommandPalette : UserControl + { + public CompareCommandPalette() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.CompareCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + this.ShowWindow(vm.Launch()); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + if (RefsListBox.IsKeyboardFocusWithin) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + else if (e.Key == Key.Down || e.Key == Key.Tab) + { + if (FilterTextBox.IsKeyboardFocusWithin) + { + if (vm.Refs.Count > 0) + RefsListBox.Focus(NavigationMethod.Directional); + + e.Handled = true; + return; + } + + if (RefsListBox.IsKeyboardFocusWithin && e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + } + + private void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.CompareCommandPalette vm) + { + this.ShowWindow(vm.Launch()); + e.Handled = true; + } + } + } +} diff --git a/src/Views/ConfigureCustomActionControls.axaml b/src/Views/ConfigureCustomActionControls.axaml new file mode 100644 index 000000000..520afeff2 --- /dev/null +++ b/src/Views/ConfigureCustomActionControls.axaml @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ConfigureCustomActionControls.axaml.cs b/src/Views/ConfigureCustomActionControls.axaml.cs new file mode 100644 index 000000000..e28366d70 --- /dev/null +++ b/src/Views/ConfigureCustomActionControls.axaml.cs @@ -0,0 +1,11 @@ +namespace SourceGit.Views +{ + public partial class ConfigureCustomActionControls : ChromelessWindow + { + public ConfigureCustomActionControls() + { + CloseOnESC = true; + InitializeComponent(); + } + } +} diff --git a/src/Views/ConfigureWorkspace.axaml b/src/Views/ConfigureWorkspace.axaml new file mode 100644 index 000000000..aef9515f9 --- /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 000000000..3ce2b4a8a --- /dev/null +++ b/src/Views/ConfigureWorkspace.axaml.cs @@ -0,0 +1,49 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class ConfigureWorkspace : ChromelessWindow + { + public ConfigureWorkspace() + { + CloseOnESC = true; + 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) + { + await new Alert().ShowAsync(this, $"Failed to select default clone directory: {ex.Message}", true); + } + + e.Handled = true; + } + } +} diff --git a/src/Views/Confirm.axaml b/src/Views/Confirm.axaml new file mode 100644 index 000000000..a9055f71a --- /dev/null +++ b/src/Views/Confirm.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Conflict.axaml.cs b/src/Views/Conflict.axaml.cs new file mode 100644 index 000000000..0ed1fa8f1 --- /dev/null +++ b/src/Views/Conflict.axaml.cs @@ -0,0 +1,59 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +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; + } + + private async void OnUseTheirs(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.Conflict vm) + await vm.UseTheirsAsync(); + + e.Handled = true; + } + + private async void OnUseMine(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.Conflict vm) + await vm.UseMineAsync(); + + e.Handled = true; + } + + private void OnMerge(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.Conflict vm) + { + var request = vm.CreateOpenMergeEditorRequest(); + this.ShowWindow(request); + } + + e.Handled = true; + } + + private async void OnMergeExternal(object _, RoutedEventArgs e) + { + if (DataContext is ViewModels.Conflict vm) + await vm.MergeExternalAsync(); + + e.Handled = true; + } + } +} diff --git a/src/Views/ControlExtensions.cs b/src/Views/ControlExtensions.cs new file mode 100644 index 000000000..33c20b4f2 --- /dev/null +++ b/src/Views/ControlExtensions.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public static class ControlExtensions + { + public static Control CreateFromViewModels(object data) + { + var dataTypeName = data.GetType().FullName; + if (string.IsNullOrEmpty(dataTypeName) || !dataTypeName.Contains(".ViewModels.", StringComparison.Ordinal)) + return null; + + var viewTypeName = dataTypeName.Replace(".ViewModels.", ".Views."); + var viewType = Type.GetType(viewTypeName); + if (viewType != null) + return Activator.CreateInstance(viewType) as Control; + + return null; + } + + public static async Task CopyTextAsync(this Control control, string text) + { + var clipboard = TopLevel.GetTopLevel(control)?.Clipboard; + if (clipboard != null) + await clipboard.SetTextAsync(text); + } + + public static Path CreateMenuIcon(this Control control, string iconKey) + { + if (control?.FindResource(iconKey) is StreamGeometry geo) + { + return new Path() + { + Data = geo, + Width = 12, + Height = 12, + Stretch = Stretch.Uniform + }; + } + + return null; + } + + public static void ShowWindow(this Control control, object data) + { + if (data == null) + return; + + if (data is not ChromelessWindow window) + { + window = CreateFromViewModels(data) as ChromelessWindow; + if (window == null) + return; + + window.DataContext = data; + } + + do + { + var owner = TopLevel.GetTopLevel(control) as Window; + if (owner != null) + { + // Get the screen where current window locates. + var screen = owner.Screens.ScreenFromWindow(owner) ?? owner.Screens.Primary; + if (screen == null) + break; + + // Calculate the startup position (Center Screen Mode) of target window + var rect = new PixelRect(PixelSize.FromSize(window.ClientSize, owner.DesktopScaling)); + var centeredRect = screen.WorkingArea.CenterRect(rect); + if (owner.Screens.ScreenFromPoint(centeredRect.Position) == null) + break; + + // Use the startup position + window.WindowStartupLocation = WindowStartupLocation.Manual; + window.Position = centeredRect.Position; + } + } while (false); + + window.Show(); + } + + public static Task ShowDialogAsync(this Control control, object data) + { + var owner = TopLevel.GetTopLevel(control) as Window; + if (owner == null) + return null; + + if (data is ChromelessWindow window) + return window.ShowDialog(owner); + + window = CreateFromViewModels(data) as ChromelessWindow; + if (window != null) + { + window.DataContext = data; + return window.ShowDialog(owner); + } + + return null; + } + } +} diff --git a/src/Views/ConventionalCommitMessageBuilder.axaml b/src/Views/ConventionalCommitMessageBuilder.axaml new file mode 100644 index 000000000..394901dc9 --- /dev/null +++ b/src/Views/ConventionalCommitMessageBuilder.axaml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - @@ -64,20 +233,18 @@ Foreground="{DynamicResource Brush.FG2}" HorizontalAlignment="Center"/> - + - + - - + - - + + - - + @@ -91,27 +258,159 @@ Foreground="{DynamicResource Brush.FG2}" HorizontalAlignment="Center"/> - + - + - - + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + Text="{Binding SSHKey, Mode=TwoWay}"> + + + + + + + + diff --git a/src/Views/EditRemote.axaml.cs b/src/Views/EditRemote.axaml.cs index 16076cdb0..655cbdcd2 100644 --- a/src/Views/EditRemote.axaml.cs +++ b/src/Views/EditRemote.axaml.cs @@ -2,19 +2,30 @@ using Avalonia.Interactivity; using Avalonia.Platform.Storage; -namespace SourceGit.Views { - public partial class EditRemote : UserControl { - public EditRemote() { +namespace SourceGit.Views +{ + public partial class EditRemote : UserControl + { + public EditRemote() + { InitializeComponent(); } - private async void SelectSSHKey(object sender, RoutedEventArgs e) { - var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] }; + private async void SelectSSHKey(object _, RoutedEventArgs e) + { var toplevel = TopLevel.GetTopLevel(this); + if (toplevel == null) + return; + + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new("SSHKey") { Patterns = ["*"] }] + }; + var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); - if (selected.Count == 1) { - txtSSHKey.Text = selected[0].Path.LocalPath; - } + 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 index fa2c0c1fd..8b9d66340 100644 --- a/src/Views/EditRepositoryNode.axaml +++ b/src/Views/EditRepositoryNode.axaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:m="using:SourceGit.Models" xmlns:c="using:SourceGit.Converters" xmlns:vm="using:SourceGit.ViewModels" xmlns:v="using:SourceGit.Views" @@ -10,45 +9,58 @@ x:Class="SourceGit.Views.EditRepositoryNode" x:DataType="vm:EditRepositoryNode"> - + + - + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/Views/EditRepositoryNode.axaml.cs b/src/Views/EditRepositoryNode.axaml.cs index 3f0c6f95a..967eb0aec 100644 --- a/src/Views/EditRepositoryNode.axaml.cs +++ b/src/Views/EditRepositoryNode.axaml.cs @@ -1,10 +1,12 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class EditRepositoryNode : UserControl { - public EditRepositoryNode() { +namespace SourceGit.Views +{ + public partial class EditRepositoryNode : UserControl + { + public EditRepositoryNode() + { InitializeComponent(); } } } - diff --git a/src/Views/ExecuteCustomAction.axaml b/src/Views/ExecuteCustomAction.axaml new file mode 100644 index 000000000..6769f9481 --- /dev/null +++ b/src/Views/ExecuteCustomAction.axaml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ExecuteCustomAction.axaml.cs b/src/Views/ExecuteCustomAction.axaml.cs new file mode 100644 index 000000000..5589101e0 --- /dev/null +++ b/src/Views/ExecuteCustomAction.axaml.cs @@ -0,0 +1,61 @@ +using System; + +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SourceGit.Views +{ + public partial class ExecuteCustomAction : UserControl + { + public ExecuteCustomAction() + { + InitializeComponent(); + } + + private async void SelectPath(object sender, RoutedEventArgs e) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + return; + + var control = sender as Control; + + if (control?.DataContext is not ViewModels.CustomActionControlPathSelector selector) + return; + + if (selector.IsFolder) + { + try + { + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + 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(); + selector.Path = folderPath; + } + } + catch (Exception exception) + { + Models.Notification.Send(null, $"Failed to select parent folder: {exception.Message}", true); + } + } + else + { + var options = new FilePickerOpenOptions() + { + AllowMultiple = false, + FileTypeFilter = [new("SSHKey") { Patterns = ["*"] }] + }; + + var selected = await topLevel.StorageProvider.OpenFilePickerAsync(options); + if (selected.Count == 1) + selector.Path = selected[0].Path.LocalPath; + } + + e.Handled = true; + } + } +} diff --git a/src/Views/ExecuteCustomActionCommandPalette.axaml b/src/Views/ExecuteCustomActionCommandPalette.axaml new file mode 100644 index 000000000..e512c2d75 --- /dev/null +++ b/src/Views/ExecuteCustomActionCommandPalette.axaml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ExecuteCustomActionCommandPalette.axaml.cs b/src/Views/ExecuteCustomActionCommandPalette.axaml.cs new file mode 100644 index 000000000..8986b3b77 --- /dev/null +++ b/src/Views/ExecuteCustomActionCommandPalette.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class ExecuteCustomActionCommandPalette : UserControl + { + public ExecuteCustomActionCommandPalette() + { + InitializeComponent(); + } + + protected override async void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.ExecuteCustomActionCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + await vm.ExecAsync(); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + if (ActionListBox.IsKeyboardFocusWithin) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + else if (e.Key == Key.Down || e.Key == Key.Tab) + { + if (FilterTextBox.IsKeyboardFocusWithin) + { + if (vm.VisibleActions.Count > 0) + ActionListBox.Focus(NavigationMethod.Directional); + + e.Handled = true; + return; + } + + if (ActionListBox.IsKeyboardFocusWithin && e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + } + + private async void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.ExecuteCustomActionCommandPalette vm) + { + await vm.ExecAsync(); + e.Handled = true; + } + } + } +} diff --git a/src/Views/FastForwardWithoutCheckout.axaml.cs b/src/Views/FastForwardWithoutCheckout.axaml.cs deleted file mode 100644 index 23a76bb27..000000000 --- a/src/Views/FastForwardWithoutCheckout.axaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Avalonia.Controls; - -namespace SourceGit.Views { - public partial class FastForwardWithoutCheckout : UserControl { - public FastForwardWithoutCheckout() { - InitializeComponent(); - } - } -} diff --git a/src/Views/Fetch.axaml b/src/Views/Fetch.axaml index 08da55d6c..c9de0e609 100644 --- a/src/Views/Fetch.axaml +++ b/src/Views/Fetch.axaml @@ -4,29 +4,40 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.Fetch" x:DataType="vm:Fetch"> - - - - + + + + + + + + + SelectedItem="{Binding SelectedRemote, Mode=TwoWay}"> + + + + + + - + @@ -34,12 +45,21 @@ + Content="{DynamicResource Text.Fetch.Force}" + IsChecked="{Binding Force, Mode=TwoWay}" + ToolTip.Tip="--force"/> + Height="32" + Content="{DynamicResource Text.Fetch.AllRemotes}" + IsChecked="{Binding FetchAllRemotes, Mode=TwoWay}" + IsVisible="{Binding IsFetchAllRemoteVisible, Mode=OneWay}" + ToolTip.Tip="--all"/> + + diff --git a/src/Views/Fetch.axaml.cs b/src/Views/Fetch.axaml.cs index 8f728d4c4..1212ee3db 100644 --- a/src/Views/Fetch.axaml.cs +++ b/src/Views/Fetch.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class Fetch : UserControl { - public Fetch() { +namespace SourceGit.Views +{ + public partial class Fetch : UserControl + { + public Fetch() + { InitializeComponent(); } } diff --git a/src/Views/FastForwardWithoutCheckout.axaml b/src/Views/FetchInto.axaml similarity index 64% rename from src/Views/FastForwardWithoutCheckout.axaml rename to src/Views/FetchInto.axaml index 8b8f7451a..29f502547 100644 --- a/src/Views/FastForwardWithoutCheckout.axaml +++ b/src/Views/FetchInto.axaml @@ -3,20 +3,26 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" - x:Class="SourceGit.Views.FastForwardWithoutCheckout" - x:DataType="vm:FastForwardWithoutCheckout"> + x:Class="SourceGit.Views.FetchInto" + x:DataType="vm:FetchInto"> - + + + + + + - + - + diff --git a/src/Views/FetchInto.axaml.cs b/src/Views/FetchInto.axaml.cs new file mode 100644 index 000000000..c61c052e6 --- /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 index 66aab09cf..f499c2caf 100644 --- a/src/Views/FileHistories.axaml +++ b/src/Views/FileHistories.axaml @@ -1,114 +1,282 @@ - - - - - - - - + + + + - + - - - - + - - + - - - - - + + + - - - - + + + + - + + + + + - + - - - - - - - - - - - - - + + + + - - + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - - + MinWidth="1" + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" + Background="Transparent" + BorderThickness="1,0,0,0" + BorderBrush="{DynamicResource Brush.Border0}" + Focusable="False"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/src/Views/FileHistories.axaml.cs b/src/Views/FileHistories.axaml.cs index e88f8408b..697aee765 100644 --- a/src/Views/FileHistories.axaml.cs +++ b/src/Views/FileHistories.axaml.cs @@ -1,19 +1,148 @@ +using System; +using System.Collections.Generic; + +using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; -namespace SourceGit.Views { - public partial class FileHistories : Window { - public FileHistories() { +namespace SourceGit.Views +{ + public partial class FileHistories : ChromelessWindow + { + public FileHistories() + { InitializeComponent(); } - private void OnPressedSHA(object sender, PointerPressedEventArgs e) { - if (sender is TextBlock block) { - var histories = DataContext as ViewModels.FileHistories; - histories.NavigateToCommit(block.Text); + private void OnRevisionsPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == ListBox.ItemsSourceProperty && + sender is ListBox { Items: { Count: > 0 } } listBox) + listBox.SelectedIndex = 0; + } + + private void OnRevisionsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is ListBox listBox && DataContext is ViewModels.FileHistories vm) + { + if (listBox.SelectedItems is { } selected) + { + var revs = new List(); + foreach (var item in listBox.SelectedItems) + { + if (item is Models.FileVersion ver) + revs.Add(ver); + } + vm.SelectedRevisions = revs; + } + else + { + vm.SelectedRevisions = []; + } + } + } + + private void OnRevisionsContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is ListBox { SelectedItems: { Count: > 0 } selected } listBox) + { + var copySHA = new MenuItem(); + copySHA.Header = App.Text("SHALinkCM.CopySHA"); + copySHA.Icon = this.CreateMenuIcon("Icons.Copy"); + copySHA.Click += async (_, ev) => + { + var shas = new List(); + foreach (var item in selected) + { + if (item is Models.FileVersion ver) + shas.Add(ver.SHA); + } + await this.CopyTextAsync(string.Join("\n", shas)); + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(copySHA); + menu.Open(listBox); + e.Handled = true; + } + } + + private void OnPressCommitSHA(object sender, PointerPressedEventArgs e) + { + if (sender is TextBlock { DataContext: Models.FileVersion ver } && + DataContext is ViewModels.FileHistories vm) + { + vm.NavigateToCommit(ver); + } + + e.Handled = true; + } + + private async void OnResetToSelectedRevision(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: ViewModels.FileHistoriesSingleRevision single }) + { + await single.ResetToSelectedRevisionAsync(); + await new Alert().ShowAsync(this, "Reset to selected revision successfully.", 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"] }]; + + try + { + var storageFile = await StorageProvider.SaveFilePickerAsync(options); + if (storageFile == null) + return; + + var succ = await compare.SaveAsPatch(storageFile.Path.LocalPath); + if (succ) + await new Alert().ShowAsync(this, "Saved as patch successfully.", false); + } + catch (Exception exception) + { + await new Alert().ShowAsync(this, $"Failed to save as patch: {exception.Message}", 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.FileVersion ver } border && + DataContext is ViewModels.FileHistories vm) + { + var tooltip = ToolTip.GetTip(border); + if (tooltip == null) + ToolTip.SetTip(border, vm.GetCommitFullMessage(ver)); + } + } + + private async void OnOpenFileWithDefaultEditor(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.FileHistories { ViewContent: ViewModels.FileHistoriesSingleRevision revision }) + await revision.OpenWithDefaultEditorAsync(); + + e.Handled = true; + } } } diff --git a/src/Views/FileHistoryCommandPalette.axaml b/src/Views/FileHistoryCommandPalette.axaml new file mode 100644 index 000000000..0027fcc2f --- /dev/null +++ b/src/Views/FileHistoryCommandPalette.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/FileHistoryCommandPalette.axaml.cs b/src/Views/FileHistoryCommandPalette.axaml.cs new file mode 100644 index 000000000..4038c44b2 --- /dev/null +++ b/src/Views/FileHistoryCommandPalette.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class FileHistoryCommandPalette : UserControl + { + public FileHistoryCommandPalette() + { + InitializeComponent(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (DataContext is not ViewModels.FileHistoryCommandPalette vm) + return; + + if (e.Key == Key.Enter) + { + this.ShowWindow(vm.Launch()); + e.Handled = true; + } + else if (e.Key == Key.Up) + { + if (FileListBox.IsKeyboardFocusWithin) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + else if (e.Key == Key.Down || e.Key == Key.Tab) + { + if (FilterTextBox.IsKeyboardFocusWithin) + { + if (vm.VisibleFiles.Count > 0) + FileListBox.Focus(NavigationMethod.Directional); + + e.Handled = true; + return; + } + + if (FileListBox.IsKeyboardFocusWithin && e.Key == Key.Tab) + { + FilterTextBox.Focus(NavigationMethod.Directional); + e.Handled = true; + return; + } + } + } + + private void OnItemTapped(object sender, TappedEventArgs e) + { + if (DataContext is ViewModels.FileHistoryCommandPalette vm) + { + this.ShowWindow(vm.Launch()); + e.Handled = true; + } + } + } +} diff --git a/src/Views/FileModeChange.cs b/src/Views/FileModeChange.cs new file mode 100644 index 000000000..44d6eb5c4 --- /dev/null +++ b/src/Views/FileModeChange.cs @@ -0,0 +1,140 @@ +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class FileModeChange : Control + { + public static readonly DirectProperty OldModeProperty = + AvaloniaProperty.RegisterDirect( + nameof(OldMode), + static o => o.OldMode, + static (o, v) => o.OldMode = v); + + public int OldMode + { + get => _oldMode; + set => SetAndRaise(OldModeProperty, ref _oldMode, value); + } + + public static readonly DirectProperty NewModeProperty = + AvaloniaProperty.RegisterDirect( + nameof(NewMode), + static o => o.NewMode, + static (o, v) => o.NewMode = v); + + public int NewMode + { + get => _newMode; + set => SetAndRaise(NewModeProperty, ref _newMode, 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); + } + + public static readonly StyledProperty BackgroundProperty = + TextBlock.BackgroundProperty.AddOwner(); + + public IBrush Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + public override void Render(DrawingContext context) + { + if (_label == null) + return; + + var rect = new Rect(4, 0, _label.WidthIncludingTrailingWhitespace + 8, Bounds.Height); + context.FillRectangle(Background, rect, 4); + context.DrawText(_label, new Point(8, 9 - _label.Height * 0.5)); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OldModeProperty || + change.Property == NewModeProperty || + change.Property == FontFamilyProperty || + change.Property == ForegroundProperty) + InvalidateMeasure(); + else if (change.Property == BackgroundProperty) + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (_oldMode == 0 && _newMode == 0) + { + _label = null; + ToolTip.SetTip(this, null); + return new Size(0, 0); + } + + _label = new FormattedText( + $"{_oldMode} -> {_newMode}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(FontFamily), + FontSize, + Foreground); + + if (_oldMode == 0) + ToolTip.SetTip(this, $"{App.Text("FileModeChange.New")}{TranslateMode(_newMode)}"); + else if (_newMode == 0) + ToolTip.SetTip(this, $"{App.Text("FileModeChange.Deleted")}{TranslateMode(_oldMode)}"); + else + ToolTip.SetTip(this, $"{App.Text("FileModeChange")}{TranslateMode(_oldMode)} -> {TranslateMode(_newMode)}"); + + return new Size(_label.WidthIncludingTrailingWhitespace + 12, 18); + } + + private string TranslateMode(int mode) + { + var key = mode switch + { + 100644 => "FileModeChange.Normal", + 100755 => "FileModeChange.Executable", + 040000 => "FileModeChange.Directory", + 120000 => "FileModeChange.Symlink", + 160000 => "FileModeChange.Submodule", + _ => "FileModeChange.Unknown" + }; + + return App.Text(key); + } + + private int _oldMode = 0; + private int _newMode = 0; + private FormattedText _label = null; + } +} diff --git a/src/Views/FilterModeInGraph.axaml b/src/Views/FilterModeInGraph.axaml new file mode 100644 index 000000000..d8d4b165e --- /dev/null +++ b/src/Views/FilterModeInGraph.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + diff --git a/src/Views/FilterModeInGraph.axaml.cs b/src/Views/FilterModeInGraph.axaml.cs new file mode 100644 index 000000000..c3987f91c --- /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 000000000..7e3b21d73 --- /dev/null +++ b/src/Views/FilterModeSwitchButton.axaml @@ -0,0 +1,33 @@ + + + diff --git a/src/Views/FilterModeSwitchButton.axaml.cs b/src/Views/FilterModeSwitchButton.axaml.cs new file mode 100644 index 000000000..91c20d823 --- /dev/null +++ b/src/Views/FilterModeSwitchButton.axaml.cs @@ -0,0 +1,169 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public partial class FilterModeSwitchButton : UserControl + { + public static readonly DirectProperty ModeProperty = + AvaloniaProperty.RegisterDirect( + nameof(Mode), + static o => o.Mode, + static (o, v) => o.Mode = v); + + public Models.FilterMode Mode + { + get => _mode; + set => SetAndRaise(ModeProperty, ref _mode, value); + } + + public static readonly DirectProperty IsContextMenuOpeningProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsContextMenuOpening), + static o => o.IsContextMenuOpening); + + public bool IsContextMenuOpening + { + get => _isContextMenuOpening; + set => SetAndRaise(IsContextMenuOpeningProperty, ref _isContextMenuOpening, value); + } + + public static readonly StyledProperty IsHoverParentProperty = + AvaloniaProperty.Register(nameof(IsHoverParent)); + + public bool IsHoverParent + { + get => GetValue(IsHoverParentProperty); + set => SetValue(IsHoverParentProperty, value); + } + + public FilterModeSwitchButton() + { + IsVisible = false; + InitializeComponent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ModeProperty || + change.Property == IsHoverParentProperty || + change.Property == IsContextMenuOpeningProperty) + { + var visible = (Mode != Models.FilterMode.None || IsHoverParent || IsContextMenuOpening); + SetCurrentValue(IsVisibleProperty, visible); + } + } + + private void OnChangeFilterModeButtonClicked(object sender, RoutedEventArgs e) + { + var repoView = this.FindAncestorOfType(); + + if (repoView?.DataContext is not ViewModels.Repository repo) + return; + + if (sender is not Button button) + return; + + var menu = new ContextMenu(); + if (DataContext is ViewModels.TagListItem tagItem) + FillContextMenuForTag(menu, repo, tagItem.Tag, tagItem.FilterMode); + else if (DataContext is ViewModels.TagTreeNode tagNode) + FillContextMenuForTag(menu, repo, tagNode.Tag, tagNode.FilterMode); + else if (DataContext is ViewModels.BranchTreeNode branchNode) + FillContextMenuForBranch(menu, repo, branchNode, branchNode.FilterMode); + + menu.Closed += (_, _) => IsContextMenuOpening = false; + menu.Open(button); + + IsContextMenuOpening = true; + e.Handled = true; + } + + private void FillContextMenuForTag(ContextMenu menu, ViewModels.Repository repo, Models.Tag tag, Models.FilterMode current) + { + if (current != 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 = this.CreateMenuIcon("Icons.Filter"); + include.Header = App.Text("Repository.FilterCommits.Include"); + include.IsEnabled = current != Models.FilterMode.Included; + include.Click += (_, ev) => + { + repo.SetTagFilterMode(tag, Models.FilterMode.Included); + ev.Handled = true; + }; + + var exclude = new MenuItem(); + exclude.Icon = this.CreateMenuIcon("Icons.EyeClose"); + exclude.Header = App.Text("Repository.FilterCommits.Exclude"); + exclude.IsEnabled = current != Models.FilterMode.Excluded; + exclude.Click += (_, ev) => + { + repo.SetTagFilterMode(tag, Models.FilterMode.Excluded); + ev.Handled = true; + }; + + menu.Items.Add(include); + menu.Items.Add(exclude); + } + + private void FillContextMenuForBranch(ContextMenu menu, ViewModels.Repository repo, ViewModels.BranchTreeNode node, Models.FilterMode current) + { + if (current != 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 = this.CreateMenuIcon("Icons.Filter"); + include.Header = App.Text("Repository.FilterCommits.Include"); + include.IsEnabled = current != Models.FilterMode.Included; + include.Click += (_, ev) => + { + repo.SetBranchFilterMode(node, Models.FilterMode.Included, false, true); + ev.Handled = true; + }; + + var exclude = new MenuItem(); + exclude.Icon = this.CreateMenuIcon("Icons.EyeClose"); + exclude.Header = App.Text("Repository.FilterCommits.Exclude"); + exclude.IsEnabled = current != 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); + } + + private Models.FilterMode _mode = Models.FilterMode.None; + private bool _isContextMenuOpening = false; + } +} diff --git a/src/Views/GitFlowFinish.axaml b/src/Views/GitFlowFinish.axaml index f5527c67e..879d88d85 100644 --- a/src/Views/GitFlowFinish.axaml +++ b/src/Views/GitFlowFinish.axaml @@ -4,36 +4,53 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.GitFlowFinish" x:DataType="vm:GitFlowFinish"> - - - - - - + + + + + + + + + + + + + + + + IsChecked="{Binding KeepBranch, Mode=TwoWay}" + ToolTip.Tip="--keep"/> diff --git a/src/Views/GitFlowFinish.axaml.cs b/src/Views/GitFlowFinish.axaml.cs index 8ce65040d..28564766c 100644 --- a/src/Views/GitFlowFinish.axaml.cs +++ b/src/Views/GitFlowFinish.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class GitFlowFinish : UserControl { - public GitFlowFinish() { +namespace SourceGit.Views +{ + public partial class GitFlowFinish : UserControl + { + public GitFlowFinish() + { InitializeComponent(); } } diff --git a/src/Views/GitFlowStart.axaml b/src/Views/GitFlowStart.axaml index cc1ccb897..51827c6d6 100644 --- a/src/Views/GitFlowStart.axaml +++ b/src/Views/GitFlowStart.axaml @@ -4,34 +4,59 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" + xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.GitFlowStart" x:DataType="vm:GitFlowStart"> - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/GitFlowStart.axaml.cs b/src/Views/GitFlowStart.axaml.cs index 564a2a7ea..6498f39d0 100644 --- a/src/Views/GitFlowStart.axaml.cs +++ b/src/Views/GitFlowStart.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class GitFlowStart : UserControl { - public GitFlowStart() { +namespace SourceGit.Views +{ + public partial class GitFlowStart : UserControl + { + public GitFlowStart() + { InitializeComponent(); } } diff --git a/src/Views/GotoRevisionSelector.axaml b/src/Views/GotoRevisionSelector.axaml new file mode 100644 index 000000000..d29d40090 --- /dev/null +++ b/src/Views/GotoRevisionSelector.axaml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/GotoRevisionSelector.axaml.cs b/src/Views/GotoRevisionSelector.axaml.cs new file mode 100644 index 000000000..8483cdbb2 --- /dev/null +++ b/src/Views/GotoRevisionSelector.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace SourceGit.Views +{ + public partial class GotoRevisionSelector : ChromelessWindow + { + public GotoRevisionSelector() + { + CloseOnESC = true; + InitializeComponent(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + RevisionList.Focus(); + } + + private void OnRevisionListKeyDown(object sender, KeyEventArgs e) + { + if (e is not { Key: Key.Enter, KeyModifiers: KeyModifiers.None }) + return; + + if (sender is not ListBox { SelectedItem: Models.Commit commit }) + return; + + Close(commit); + e.Handled = true; + } + + private void OnRevisionListItemTapped(object sender, TappedEventArgs e) + { + if (sender is not Control { DataContext: Models.Commit commit }) + return; + + Close(commit); + e.Handled = true; + } + } +} + diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 7ab7d9946..9555d7e21 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -8,170 +8,428 @@ xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.Histories" - x:DataType="vm:Histories"> - + x:DataType="vm:Histories" + x:Name="ThisControl"> + + + + + + + + + + + + + - + + + + + + + + + + + + + - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + - + + + + + - - + + + + + + + + - + + + + + - - + + + + - + + + + + - - + + + + - + + + + + - - + + + + - + + + - + ClipToBounds="True" + Loaded="OnCommitGraphLoaded"/> - + + + + + - - - - - + Background="Transparent" + BorderBrush="{DynamicResource Brush.Border0}" + Focusable="False" + IsEnabled="{Binding #ThisControl.IsDetailsPanelExpanded, Mode=OneWay}"/> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 775f21e17..777d29e5e 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -1,233 +1,1746 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + using Avalonia; +using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.Platform.Storage; using Avalonia.VisualTree; -using System; -namespace SourceGit.Views { - public class LayoutableGrid : Grid { - public static readonly StyledProperty UseHorizontalProperty = - AvaloniaProperty.Register(nameof(UseHorizontal), false); +namespace SourceGit.Views +{ + public class HistoriesLayout : Grid + { + public static readonly DirectProperty UseHorizontalProperty = + AvaloniaProperty.RegisterDirect( + nameof(UseHorizontal), + static o => o.UseHorizontal, + static (o, v) => o.UseHorizontal = v); - public bool UseHorizontal { - get => GetValue(UseHorizontalProperty); - set => SetValue(UseHorizontalProperty, value); + public bool UseHorizontal + { + get => _useHorizontal; + set => SetAndRaise(UseHorizontalProperty, ref _useHorizontal, value); } protected override Type StyleKeyOverride => typeof(Grid); - static LayoutableGrid() { - UseHorizontalProperty.Changed.AddClassHandler((o, _) => o.RefreshLayout()); + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UseHorizontalProperty && IsLoaded) + RefreshLayout(); } - public override void ApplyTemplate() { - base.ApplyTemplate(); + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); RefreshLayout(); } - private void RefreshLayout() { - if (UseHorizontal) { + private void RefreshLayout() + { + if (UseHorizontal) + { var rowSpan = RowDefinitions.Count; - for (int i = 0; i < Children.Count; i++) { + 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 { + } + else + { var colSpan = ColumnDefinitions.Count; - for (int i = 0; i < Children.Count; i++) { + 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); } } } + + private bool _useHorizontal = false; } - public class CommitGraph : Control { - public static readonly Pen[] Pens = [ - 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 class HistoriesCommitList : DataGrid + { + public static readonly DirectProperty TotalCommitsProperty = + AvaloniaProperty.RegisterDirect( + nameof(TotalCommits), + static o => o.TotalCommits, + static (o, v) => o.TotalCommits = v); - public static readonly StyledProperty GraphProperty = - AvaloniaProperty.Register(nameof(Graph)); + public int TotalCommits + { + get => _totalCommits; + set => SetAndRaise(TotalCommitsProperty, ref _totalCommits, value); + } - public Models.CommitGraph Graph { - get => GetValue(GraphProperty); - set => SetValue(GraphProperty, value); + public static readonly DirectProperty> SelectedCommitsProperty = + AvaloniaProperty.RegisterDirect>( + nameof(SelectedCommits), + static o => o.SelectedCommits, + static (o, v) => o.SelectedCommits = v); + + public List SelectedCommits + { + get => _selectedCommits; + set => SetAndRaise(SelectedCommitsProperty, ref _selectedCommits, value); } - public static readonly StyledProperty BindingDataGridProperty = - AvaloniaProperty.Register(nameof(BindingDataGrid)); + protected override Type StyleKeyOverride => typeof(DataGrid); - public DataGrid BindingDataGrid { - get => GetValue(BindingDataGridProperty); - set => SetValue(BindingDataGridProperty, value); + public HistoriesCommitList() + { + SelectionMode = DataGridSelectionMode.Extended; + CanUserReorderColumns = false; + CanUserResizeColumns = false; + CanUserSortColumns = false; + AutoGenerateColumns = false; + IsReadOnly = true; + HeadersVisibility = DataGridHeadersVisibility.Column; + ClipboardCopyMode = DataGridClipboardCopyMode.None; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; } - static CommitGraph() { - AffectsRender(BindingDataGridProperty, GraphProperty); + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + ApplySelection(); } - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { base.OnPropertyChanged(change); - if (change.Property.Name == "ActualThemeVariant") { - InvalidateVisual(); + if (change.Property == SelectedCommitsProperty && IsLoaded && !_ignoreSelectionChanged) + { + if (change.OldValue is List { Count: 1 } old && + change.NewValue is List { Count: 1 } cur && + old[0] == cur[0]) + ScrollIntoView(old[0], null); + else + ApplySelection(); + } + } + + protected override void OnSelectionChanged(SelectionChangedEventArgs e) + { + base.OnSelectionChanged(e); + + if (ItemsSource is not IList items) + return; + + var commits = new List(); + foreach (var o in SelectedItems) + { + if (o is Models.Commit c) + commits.Add(c); + } + + if (e.AddedItems.Count == 1) + { + ScrollIntoView(e.AddedItems[0], null); + } + else if (e.AddedItems.Count > 1 && e.AddedItems[0] is Models.Commit first) + { + var firstIndex = items.IndexOf(first); + if (firstIndex > 0) + { + var prev = items[firstIndex - 1]; + if (commits.Contains(prev)) + ScrollIntoView(e.AddedItems[^1], null); + else + ScrollIntoView(first, null); + } + } + + if (!_ignoreSelectionChanged) + { + _ignoreSelectionChanged = true; + + var old = SelectedCommits; + if (old.Count != commits.Count) + { + SelectedCommits = commits; + } + else if (commits.Count > 0) + { + var set = new HashSet(); + foreach (var c in old) + set.Add(c.SHA); + + var equals = true; + foreach (var c in commits) + { + if (!set.Contains(c.SHA)) + { + equals = false; + break; + } + } + + if (!equals) + SelectedCommits = commits; + } + + _ignoreSelectionChanged = false; + } + } + + protected override async void OnKeyDown(KeyEventArgs e) + { + if (e.KeyModifiers == KeyModifiers.Alt) + { + if (e.Key == Key.Up) + { + e.Handled = true; + if (this.FindAncestorOfType() is { } histories) + await histories.GotoChild(); + } + else if (e.Key == Key.Down) + { + e.Handled = true; + if (this.FindAncestorOfType() is { } histories) + await histories.GotoParent(); + } + } + else if (e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control) && + SelectedItems is { Count: > 0 } selected && + e.Key == Key.C) + { + var builder = new StringBuilder(); + foreach (var item in selected) + { + if (item is Models.Commit commit) + builder.Append(commit.SHA.AsSpan(0, 10)).Append(" - ").AppendLine(commit.Subject); + } + + e.Handled = true; + await this.CopyTextAsync(builder.ToString()); + } + + if (!e.Handled) + base.OnKeyDown(e); + } + + private void ApplySelection() + { + _ignoreSelectionChanged = true; + + if (SelectedCommits == null || SelectedCommits.Count == 0) + { + SelectedItems.Clear(); + } + else if (SelectedCommits.Count == TotalCommits) + { + SelectAll(); } + else + { + IncrNoSelectionChangeCount(); + SelectedItems.Clear(); + foreach (var c in SelectedCommits) + SelectedItems.Add(c); + DecrNoSelectionChangeCount(); + } + + _ignoreSelectionChanged = false; } - public override void Render(DrawingContext context) { - base.Render(context); + private void IncrNoSelectionChangeCount() + { + var property = typeof(DataGrid).GetProperty("NoSelectionChangeCount", BindingFlags.Instance | BindingFlags.NonPublic); + if (property != null) + { + var old = (int)property.GetValue(this)!; + property.SetValue(this, old + 1); + } + } - var graph = Graph; - var grid = BindingDataGrid; - if (graph == null || grid == null) return; + private void DecrNoSelectionChangeCount() + { + var property = typeof(DataGrid).GetProperty("NoSelectionChangeCount", BindingFlags.Instance | BindingFlags.NonPublic); + if (property != null) + { + var old = (int)property.GetValue(this)!; + property.SetValue(this, old - 1); + } + } + + private bool _ignoreSelectionChanged = false; + private int _totalCommits = 0; + private List _selectedCommits = []; + } + + public partial class Histories : UserControl + { + public static readonly DirectProperty CurrentBranchProperty = + AvaloniaProperty.RegisterDirect( + nameof(CurrentBranch), + static o => o.CurrentBranch, + static (o, v) => o.CurrentBranch = v); + + public Models.Branch CurrentBranch + { + get => _currentBranch; + set => SetAndRaise(CurrentBranchProperty, ref _currentBranch, value); + } + + public static readonly DirectProperty BisectProperty = + AvaloniaProperty.RegisterDirect( + nameof(Bisect), + static o => o.Bisect, + static (o, v) => o.Bisect = v); + + public Models.Bisect Bisect + { + get => _bisect; + set => SetAndRaise(BisectProperty, ref _bisect, value); + } - var rowsPresenter = grid.FindDescendantOfType(); - if (rowsPresenter == null) return; + public static readonly DirectProperty> IssueTrackersProperty = + AvaloniaProperty.RegisterDirect>( + nameof(IssueTrackers), + static o => o.IssueTrackers, + static (o, v) => o.IssueTrackers = v); - // Find the content display offset Y of binding DataGrid. - double rowHeight = grid.RowHeight; + public AvaloniaList IssueTrackers + { + get => _issueTrackers; + set => SetAndRaise(IssueTrackersProperty, ref _issueTrackers, value); + } + + public static readonly DirectProperty IsScrollToTopVisibleProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsScrollToTopVisible), + static o => o.IsScrollToTopVisible, + static (o, v) => o.IsScrollToTopVisible = v); + + public bool IsScrollToTopVisible + { + get => _isScrollToTopVisible; + set => SetAndRaise(IsScrollToTopVisibleProperty, ref _isScrollToTopVisible, value); + } + + public static readonly DirectProperty IsDetailsPanelExpandedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsDetailsPanelExpanded), + static o => o.IsDetailsPanelExpanded, + static (o, v) => o.IsDetailsPanelExpanded = v); + + public bool IsDetailsPanelExpanded + { + get => _isDetailsPanelExpanded; + set => SetAndRaise(IsDetailsPanelExpandedProperty, ref _isDetailsPanelExpanded, value); + } + + public Histories() + { + InitializeComponent(); + } + + public async Task GotoParent() + { + if (DataContext is not ViewModels.Histories vm) + return; + + if (!CommitListContainer.IsKeyboardFocusWithin) + return; + + if (CommitListContainer.SelectedItems is not { Count: 1 } selected) + return; + + if (selected[0] is not Models.Commit { Parents.Count: > 0 } commit) + return; + + if (commit.Parents.Count == 1) + { + vm.NavigateTo(commit.Parents[0]); + return; + } + + var parents = new List(); + foreach (var sha in commit.Parents) + { + var c = await vm.GetCommitAsync(sha); + if (c != null) + parents.Add(c); + } + + if (parents.Count == 1) + { + vm.NavigateTo(parents[0].SHA); + } + else if (parents.Count > 1 && TopLevel.GetTopLevel(this) is Window owner) + { + var dialog = new GotoRevisionSelector(); + dialog.RevisionList.ItemsSource = parents; + + var c = await dialog.ShowDialog(owner); + if (c != null) + vm.NavigateTo(c.SHA); + } + } + + public async Task GotoChild() + { + if (DataContext is not ViewModels.Histories vm) + return; + + if (!CommitListContainer.IsKeyboardFocusWithin) + return; + + if (CommitListContainer.SelectedItems is not { Count: 1 } selected) + return; + + if (selected[0] is not Models.Commit { Parents.Count: > 0 } commit) + return; + + var children = new List(); + var sha = commit.SHA; + foreach (var c in vm.Commits) + { + foreach (var p in c.Parents) + { + if (sha.StartsWith(p, StringComparison.Ordinal)) + children.Add(c); + } + + if (sha.Equals(c.SHA, StringComparison.Ordinal)) + break; + } + + if (children.Count == 1) + { + vm.NavigateTo(children[0].SHA); + } + else if (children.Count > 1 && TopLevel.GetTopLevel(this) is Window owner) + { + var dialog = new GotoRevisionSelector(); + dialog.RevisionList.ItemsSource = children; + + var c = await dialog.ShowDialog(owner); + if (c != null) + vm.NavigateTo(c.SHA); + } + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + if (DataContext is ViewModels.Histories vm) + CommitListContainer.Columns[1].Width = new(vm.AuthorColumnWidth, DataGridLengthUnitType.Pixel); + } + + private void OnCommitListHeaderPointerMoved(object sender, PointerEventArgs e) + { + if (sender is not Border border) + return; + + if (DataContext is not ViewModels.Histories { IsAuthorColumnVisible: true } vm) + return; + + var pos = e.GetPosition(border); + if (_resizingAuthorColumn) + { + var posX = CommitListContainer.Columns[0].ActualWidth; + var maxW = posX + CommitListContainer.Columns[1].ActualWidth - 100; + var delta = posX - pos.X; + var w = Math.Max(Math.Min(vm.AuthorColumnWidth + delta, maxW), 80); + CommitListContainer.Columns[1].Width = new(w, DataGridLengthUnitType.Pixel); + vm.AuthorColumnWidth = w; + } + else + { + var dis = CommitListContainer.Columns[0].ActualWidth - 4 - pos.X; + if (dis < 4 && dis > -4) + { + if (border.Cursor != _resizingCursor) + border.Cursor = _resizingCursor; + } + else if (border.Cursor != Cursor.Default) + { + border.Cursor = Cursor.Default; + } + } + } + + private void OnCommitListHeaderPointerPressed(object sender, PointerPressedEventArgs e) + { + if (sender is not Border border) + return; + + var pos = e.GetPosition(border); + var dis = CommitListContainer.Columns[0].ActualWidth - 4 - pos.X; + if (dis > 4 || dis < -4) + return; + + if (e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) + { + _resizingAuthorColumn = true; + e.Handled = true; + } + } + + private void OnCommitListHeaderPointerReleased(object sender, PointerReleasedEventArgs e) + { + _resizingAuthorColumn = false; + } + + private void OnCommitListHeaderContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is not ViewModels.Histories vm) + return; + + if (sender is not Border border) + return; + + var columnsHeader = new MenuItem(); + columnsHeader.Header = new TextBlock() { Text = App.Text("Histories.ShowColumns"), FontWeight = FontWeight.Bold }; + columnsHeader.IsEnabled = false; + + var authorColumn = new MenuItem(); + authorColumn.Header = App.Text("Histories.Header.Author"); + if (vm.IsAuthorColumnVisible) + authorColumn.Icon = this.CreateMenuIcon("Icons.Check"); + authorColumn.Click += (_, ev) => + { + vm.IsAuthorColumnVisible = !vm.IsAuthorColumnVisible; + ev.Handled = true; + }; + + var shaColumn = new MenuItem(); + shaColumn.Header = App.Text("Histories.Header.SHA"); + if (vm.IsSHAColumnVisible) + shaColumn.Icon = this.CreateMenuIcon("Icons.Check"); + shaColumn.Click += (_, ev) => + { + vm.IsSHAColumnVisible = !vm.IsSHAColumnVisible; + ev.Handled = true; + }; + + var authorTimeColumn = new MenuItem(); + authorTimeColumn.Header = App.Text("Histories.Header.AuthorTime"); + if (vm.IsAuthorTimeColumnVisible) + authorTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); + authorTimeColumn.Click += (_, ev) => + { + vm.IsAuthorTimeColumnVisible = !vm.IsAuthorTimeColumnVisible; + ev.Handled = true; + }; + + var commitTimeColumn = new MenuItem(); + commitTimeColumn.Header = App.Text("Histories.Header.CommitTime"); + if (vm.IsCommitTimeColumnVisible) + commitTimeColumn.Icon = this.CreateMenuIcon("Icons.Check"); + commitTimeColumn.Click += (_, ev) => + { + vm.IsCommitTimeColumnVisible = !vm.IsCommitTimeColumnVisible; + ev.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(columnsHeader); + menu.Items.Add(authorColumn); + menu.Items.Add(shaColumn); + menu.Items.Add(authorTimeColumn); + menu.Items.Add(commitTimeColumn); + menu.Open(border); + e.Handled = true; + } + + private void OnCommitListLayoutUpdated(object _1, EventArgs _2) + { + if (!IsLoaded) + return; + + var dataGrid = CommitListContainer; + var rowsPresenter = dataGrid.FindDescendantOfType(); + if (rowsPresenter == null) + return; + + double rowHeight = dataGrid.RowHeight; double startY = 0; - foreach (var child in rowsPresenter.Children) { - var row = child as DataGridRow; - if (row.IsVisible && row.Bounds.Top <= 0 && row.Bounds.Top > -rowHeight) { - var test = rowHeight * row.GetIndex() - row.Bounds.Top; - if (startY < test) startY = test; + foreach (var child in rowsPresenter.Children) + { + if (child is DataGridRow { IsVisible: true } row) + { + rowHeight = row.Bounds.Height; + + if (row.Bounds.Top <= 0 && row.Bounds.Top > -rowHeight) + { + var test = rowHeight * row.Index - row.Bounds.Top; + if (startY < test) + startY = test; + } } } - // Apply scroll offset. - context.PushClip(new Rect(Bounds.Left, Bounds.Top, grid.Columns[0].ActualWidth, Bounds.Height)); - context.PushTransform(Matrix.CreateTranslation(0, -startY)); + IsScrollToTopVisible = startY >= rowHeight; + + var clipWidth = dataGrid.Columns[0].ActualWidth - 4; + var lastLayout = CommitGraph.Layout; + if (lastLayout == null || + Math.Abs(lastLayout.StartY - startY) > 0.01 || + Math.Abs(lastLayout.ClipWidth - clipWidth) > 0.01 || + Math.Abs(lastLayout.RowHeight - rowHeight) > 0.01) + CommitGraph.Layout = new(startY, clipWidth, rowHeight); + } + + private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) + { + if (DataContext is ViewModels.Histories histories) + CommitListContainer.ScrollIntoView(histories.Commits[0], null); + } + + private void OnCommitListContextRequested(object sender, ContextRequestedEventArgs e) + { + var repoView = this.FindAncestorOfType(); + if (repoView is not { DataContext: ViewModels.Repository repo }) + return; - // Calculate bounds. - var top = startY; - var bottom = startY + grid.Bounds.Height + rowHeight * 2; + var selected = CommitListContainer.SelectedItems; + if (selected is not { Count: > 0 }) + return; - // Draw all curves - DrawCurves(context, top, bottom); + var commits = new List(); + for (var i = selected.Count - 1; i >= 0; i--) + { + if (selected[i] is Models.Commit c) + commits.Add(c); + } - // Draw connect dots - Brush dotFill = null; - if (App.Current.TryGetResource("Brush.Contents", App.Current.ActualThemeVariant, out object res) && res is SolidColorBrush) { - dotFill = res as SolidColorBrush; + if (selected.Count > 1) + { + var menu = CreateContextMenuForMultipleCommits(repo, commits); + menu.Open(CommitListContainer); + } + else if (selected.Count == 1) + { + var menu = CreateContextMenuForSingleCommit(repo, commits[0]); + menu.Open(CommitListContainer); } - foreach (var dot in graph.Dots) { - if (dot.Center.Y < top) continue; - if (dot.Center.Y > bottom) break; - context.DrawEllipse(dotFill, Pens[dot.Color], dot.Center, 3, 3); + e.Handled = true; + } + + private async void OnCommitListDoubleTapped(object sender, TappedEventArgs e) + { + e.Handled = true; + + if (DataContext is ViewModels.Histories histories && + CommitListContainer.SelectedItems is { Count: 1 } && + e.Source is Control { DataContext: Models.Commit c }) + { + if (histories.Bisect != null) + { + histories.CheckoutCommitDetached(c); + return; + } + + if (e.Source is CommitRefsPresenter crp) + { + var decorator = crp.DecoratorAt(e.GetPosition(crp)); + var succ = await histories.CheckoutBranchByDecoratorAsync(decorator); + if (succ) + return; + } + + await histories.CheckoutBranchByCommitAsync(c); } } - private void DrawCurves(DrawingContext context, double top, double bottom) { - foreach (var line in Graph.Paths) { - var last = line.Points[0]; - var size = line.Points.Count; + private void OnCommitGraphLoaded(object sender, RoutedEventArgs e) + { + // Force-update the graph layout to ensure the graph is correctly rendered when it's loaded. + OnCommitListLayoutUpdated(sender, e); + } - if (line.Points[size - 1].Y < top) continue; - if (last.Y > bottom) continue; + private void OnTabHeaderPointerPressed(object sender, PointerPressedEventArgs e) + { + if (ViewModels.Preferences.Instance.UseTwoColumnsLayoutInHistories) + return; - 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 (DataContext is not ViewModels.Histories vm) + return; - if (!started) { - ctx.BeginFigure(last, false); - started = true; - } + if (vm.IsCollapseDetails) + vm.IsCollapseDetails = false; + } + + private void OnOpenDetailsAsStandalone(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Histories vm) + { + if (vm.DetailContext is ViewModels.CommitDetail detail) + { + var standalone = new CommitDetailStandalone(); + standalone.DataContext = detail.Clone(); + this.ShowWindow(standalone); + } + else if (vm.DetailContext is ViewModels.RevisionCompare compare) + { + var standalone = new RevisionCompareStandalone(); + standalone.DataContext = compare.Clone(); + this.ShowWindow(standalone); + } + } + + e.Handled = true; + } + + private ContextMenu CreateContextMenuForMultipleCommits(ViewModels.Repository repo, List selected) + { + var canCherryPick = true; + var canMerge = true; + + foreach (var c in selected) + { + if (c.IsMerged) + { + canMerge = false; + canCherryPick = false; + } + else if (c.Parents.Count > 1) + { + canCherryPick = false; + } + } - if (cur.Y > bottom) { - cur = new Point(cur.X, bottom); - ended = true; + var menu = new ContextMenu(); + + if (!repo.IsBare) + { + if (canCherryPick) + { + var cherryPick = new MenuItem(); + cherryPick.Header = App.Text("CommitCM.CherryPickMultiple"); + cherryPick.Icon = this.CreateMenuIcon("Icons.CherryPick"); + cherryPick.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CherryPick(repo, selected)); + e.Handled = true; + }; + menu.Items.Add(cherryPick); + } + + if (canMerge) + { + var merge = new MenuItem(); + merge.Header = App.Text("CommitCM.MergeMultiple"); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.MergeMultiple(repo, selected)); + e.Handled = true; + }; + menu.Items.Add(merge); + } + + if (canCherryPick || canMerge) + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var saveToPatch = new MenuItem(); + saveToPatch.Icon = this.CreateMenuIcon("Icons.Save"); + saveToPatch.Header = App.Text("CommitCM.SaveAsPatch"); + saveToPatch.Click += async (_, e) => + { + var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider; + if (storageProvider == null) + return; + + var options = new FolderPickerOpenOptions() { AllowMultiple = false }; + try + { + var picker = await storageProvider.OpenFolderPickerAsync(options); + if (picker.Count == 1) + { + 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++) + { + succ = await repo.SaveCommitAsPatchAsync(selected[i], folderPath, i); + if (!succ) + break; } - 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; - var midX = (last.X + cur.X) / 2; - ctx.QuadraticBezierTo(new Point(last.X, midY), new Point(midX, midY)); - ctx.QuadraticBezierTo(new Point(cur.X, midY), cur); - } else { - ctx.QuadraticBezierTo(new Point(last.X, cur.Y), cur); + if (succ) + repo.SendNotification(App.Text("SaveAsPatchSuccess")); + } + } + catch (Exception exception) + { + repo.SendNotification($"Failed to save as patch: {exception.Message}", true); + } + + e.Handled = true; + }; + menu.Items.Add(saveToPatch); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var copyInfos = new MenuItem(); + copyInfos.Header = App.Text("CommitCM.CopySHA") + " - " + App.Text("CommitCM.CopySubject"); + copyInfos.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; + copyInfos.Click += async (_, e) => + { + var builder = new StringBuilder(); + foreach (var c in selected) + builder.Append(c.SHA.AsSpan(0, 10)).Append(" - ").AppendLine(c.Subject); + + await this.CopyTextAsync(builder.ToString()); + e.Handled = true; + }; + + var copyShas = new MenuItem(); + copyShas.Header = App.Text("CommitCM.CopySHA"); + copyShas.Icon = this.CreateMenuIcon("Icons.Hash"); + copyShas.Click += async (_, e) => + { + var builder = new StringBuilder(); + foreach (var c in selected) + builder.AppendLine(c.SHA); + + await this.CopyTextAsync(builder.ToString()); + e.Handled = true; + }; + + var copySubjects = new MenuItem(); + copySubjects.Header = App.Text("CommitCM.CopySubject"); + copySubjects.Icon = this.CreateMenuIcon("Icons.Subject"); + copySubjects.Click += async (_, e) => + { + var builder = new StringBuilder(); + foreach (var c in selected) + builder.AppendLine(c.Subject); + + await this.CopyTextAsync(builder.ToString()); + e.Handled = true; + }; + + var copyMessage = new MenuItem(); + copyMessage.Header = App.Text("CommitCM.CopyCommitMessage"); + copyMessage.Icon = this.CreateMenuIcon("Icons.Message"); + copyMessage.Click += async (_, e) => + { + var vm = DataContext as ViewModels.Histories; + var messages = new List(); + foreach (var c in selected) + { + var message = await vm!.GetCommitFullMessageAsync(c); + messages.Add(message); + } + + await this.CopyTextAsync(string.Join("\n-----\n", messages)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Items.Add(copyInfos); + copy.Items.Add(new MenuItem() { Header = "-" }); + copy.Items.Add(copyShas); + copy.Items.Add(copySubjects); + copy.Items.Add(copyMessage); + menu.Items.Add(copy); + return menu; + } + + private ContextMenu CreateContextMenuForSingleCommit(ViewModels.Repository repo, Models.Commit commit) + { + var current = repo.CurrentBranch; + var vm = DataContext as ViewModels.Histories; + if (current == null || vm == null) + return null; + + var menu = new ContextMenu(); + var tags = new List(); + var isHead = commit.IsCurrentHead; + + if (commit.HasDecorators) + { + foreach (var d in commit.Decorators) + { + switch (d.Type) + { + case Models.DecoratorType.CurrentBranchHead: + FillCurrentBranchMenu(menu, repo, current); + break; + case Models.DecoratorType.LocalBranchHead: + var lb = repo.Branches.Find(x => x.IsLocal && d.Name.Equals(x.Name, StringComparison.Ordinal)); + FillOtherLocalBranchMenu(menu, repo, lb, current, commit.IsMerged); + break; + case Models.DecoratorType.RemoteBranchHead: + var rb = repo.Branches.Find(x => !x.IsLocal && d.Name.Equals(x.FriendlyName, StringComparison.Ordinal)); + FillRemoteBranchMenu(menu, repo, rb, current, commit.IsMerged); + break; + case Models.DecoratorType.Tag: + var t = repo.Tags.Find(x => d.Name.Equals(x.Name, StringComparison.Ordinal)); + if (t != null) + tags.Add(t); + break; + } + } + + if (menu.Items.Count > 0) + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + if (tags.Count > 0) + { + foreach (var tag in tags) + FillTagMenu(menu, repo, tag, current); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var createBranch = new MenuItem(); + createBranch.Icon = this.CreateMenuIcon("Icons.Branch.Add"); + createBranch.Header = App.Text("CreateBranch"); + createBranch.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+B" : "Ctrl+Shift+B"; + createBranch.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreateBranch(repo, commit)); + e.Handled = true; + }; + menu.Items.Add(createBranch); + + var createTag = new MenuItem(); + createTag.Icon = this.CreateMenuIcon("Icons.Tag.Add"); + createTag.Header = App.Text("CreateTag"); + createTag.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+T" : "Ctrl+Shift+T"; + createTag.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreateTag(repo, commit)); + e.Handled = true; + }; + menu.Items.Add(createTag); + menu.Items.Add(new MenuItem() { Header = "-" }); + + if (!repo.IsBare) + { + var target = commit.GetFriendlyName(); + if (target.Length > 40) + target = commit.SHA.Substring(0, 10); + + if (!isHead) + { + var reset = new MenuItem(); + reset.Header = App.Text("CommitCM.Reset", current.Name, target); + reset.Icon = this.CreateMenuIcon("Icons.Reset"); + reset.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Reset(repo, current, commit)); + e.Handled = true; + }; + menu.Items.Add(reset); + } + + if (!commit.IsMerged) + { + var rebase = new MenuItem(); + rebase.Header = App.Text("CommitCM.Rebase", current.Name, target); + rebase.Icon = this.CreateMenuIcon("Icons.Rebase"); + rebase.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Rebase(repo, current, commit)); + e.Handled = true; + }; + menu.Items.Add(rebase); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", target, current.Name); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (repo.CanCreatePopup()) + { + var found = false; + foreach (var d in commit.Decorators) + { + if (d.Type == Models.DecoratorType.LocalBranchHead) + { + var b = repo.Branches.Find(x => x.IsLocal && x.Name.Equals(d.Name, StringComparison.Ordinal)); + if (b != null) + { + found = true; + repo.ShowPopup(new ViewModels.Merge(repo, b, current.Name, false)); + break; + } + } + else if (d.Type == Models.DecoratorType.RemoteBranchHead) + { + var rb = repo.Branches.Find(x => !x.IsLocal && x.FriendlyName.Equals(d.Name, StringComparison.Ordinal)); + if (rb != null) + { + found = true; + repo.ShowPopup(new ViewModels.Merge(repo, rb, current.Name, false)); + break; + } + } + else if (d.Type == Models.DecoratorType.Tag) + { + var t = repo.Tags.Find(x => x.Name.Equals(d.Name, StringComparison.Ordinal)); + if (t != null) + { + found = true; + repo.ShowPopup(new ViewModels.Merge(repo, t, current.Name)); + break; + } + } } - } else { - ctx.LineTo(cur); + + if (!found) + repo.ShowPopup(new ViewModels.Merge(repo, commit, current.Name)); } - if (ended) break; - last = cur; + e.Handled = true; + }; + menu.Items.Add(merge); + + var cherryPick = new MenuItem(); + cherryPick.Header = App.Text("CommitCM.CherryPick"); + cherryPick.Icon = this.CreateMenuIcon("Icons.CherryPick"); + cherryPick.Click += async (_, e) => + { + await vm.CherryPickAsync(commit); + e.Handled = true; + }; + menu.Items.Add(cherryPick); + } + + var revert = new MenuItem(); + revert.Header = App.Text("CommitCM.Revert"); + revert.Icon = this.CreateMenuIcon("Icons.Undo"); + revert.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Revert(repo, commit)); + e.Handled = true; + }; + menu.Items.Add(revert); + + if (!isHead) + { + var checkoutCommit = new MenuItem(); + checkoutCommit.Header = App.Text("CommitCM.Checkout"); + checkoutCommit.Icon = this.CreateMenuIcon("Icons.Detached"); + checkoutCommit.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CheckoutDetached(repo, commit)); + e.Handled = true; + }; + menu.Items.Add(checkoutCommit); + } + + if (commit.IsMerged && commit.Parents.Count > 0) + { + var interactiveRebase = new MenuItem(); + interactiveRebase.Header = App.Text("CommitCM.InteractiveRebase"); + interactiveRebase.Icon = this.CreateMenuIcon("Icons.InteractiveRebase"); + + if (!isHead) + { + var manually = new MenuItem(); + manually.Header = App.Text("CommitCM.InteractiveRebase.Manually", current.Name, target); + manually.Click += async (_, e) => + { + await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, commit)); + e.Handled = true; + }; + + interactiveRebase.Items.Add(manually); + interactiveRebase.Items.Add(new MenuItem() { Header = "-" }); } + + var reword = new MenuItem(); + reword.Header = App.Text("CommitCM.InteractiveRebase.Reword"); + reword.Icon = this.CreateMenuIcon("Icons.Rename"); + reword.Click += async (_, e) => + { + await InteractiveRebaseWithPrefillActionAsync(repo, commit, Models.InteractiveRebaseAction.Reword); + e.Handled = true; + }; + + var edit = new MenuItem(); + edit.Header = App.Text("CommitCM.InteractiveRebase.Edit"); + edit.Icon = this.CreateMenuIcon("Icons.Edit"); + edit.Click += async (_, e) => + { + await InteractiveRebaseWithPrefillActionAsync(repo, commit, Models.InteractiveRebaseAction.Edit); + e.Handled = true; + }; + + var squash = new MenuItem(); + squash.Header = App.Text("CommitCM.InteractiveRebase.Squash"); + squash.Icon = this.CreateMenuIcon("Icons.SquashIntoParent"); + squash.Click += async (_, e) => + { + await InteractiveRebaseWithPrefillActionAsync(repo, commit, Models.InteractiveRebaseAction.Squash); + e.Handled = true; + }; + + var fixup = new MenuItem(); + fixup.Header = App.Text("CommitCM.InteractiveRebase.Fixup"); + fixup.Icon = this.CreateMenuIcon("Icons.Fix"); + fixup.Click += async (_, e) => + { + await InteractiveRebaseWithPrefillActionAsync(repo, commit, Models.InteractiveRebaseAction.Fixup); + e.Handled = true; + }; + + var drop = new MenuItem(); + drop.Header = App.Text("CommitCM.InteractiveRebase.Drop"); + drop.Icon = this.CreateMenuIcon("Icons.Clear"); + drop.Click += async (_, e) => + { + await InteractiveRebaseWithPrefillActionAsync(repo, commit, Models.InteractiveRebaseAction.Drop); + e.Handled = true; + }; + + interactiveRebase.Items.Add(reword); + interactiveRebase.Items.Add(edit); + interactiveRebase.Items.Add(squash); + interactiveRebase.Items.Add(fixup); + interactiveRebase.Items.Add(drop); + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(interactiveRebase); } + else + { + var interactiveRebase = new MenuItem(); + interactiveRebase.Header = App.Text("CommitCM.InteractiveRebase.Manually", current.Name, target); + interactiveRebase.Icon = this.CreateMenuIcon("Icons.InteractiveRebase"); + interactiveRebase.Click += async (_, e) => + { + await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, commit)); + e.Handled = true; + }; - context.DrawGeometry(null, pen, geo); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(interactiveRebase); + } + + menu.Items.Add(new MenuItem() { Header = "-" }); } - foreach (var link in Graph.Links) { - if (link.End.Y < top) continue; - if (link.Start.Y > bottom) break; + if (!isHead) + { + if (current.Ahead.Contains(commit.SHA)) + { + var upstream = repo.Branches.Find(x => x.FullName.Equals(current.Upstream, StringComparison.Ordinal)); + var pushRevision = new MenuItem(); + pushRevision.Header = App.Text("CommitCM.PushRevision", commit.SHA.Substring(0, 10), upstream.FriendlyName); + pushRevision.Icon = this.CreateMenuIcon("Icons.Push"); + pushRevision.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.PushRevision(repo, commit, upstream)); + e.Handled = true; + }; + menu.Items.Add(pushRevision); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var compareWithHead = new MenuItem(); + compareWithHead.Header = App.Text("CommitCM.CompareWithHead"); + compareWithHead.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithHead.Click += async (_, e) => + { + var head = await vm.CompareWithHeadAsync(commit); + if (head != null) + CommitListContainer.SelectedItems.Add(head); + + e.Handled = true; + }; + menu.Items.Add(compareWithHead); - var geo = new StreamGeometry(); - using (var ctx = geo.Open()) { - ctx.BeginFigure(link.Start, false); - ctx.QuadraticBezierTo(link.Control, link.End); + if (repo.LocalChangesCount > 0) + { + var compareWithWorktree = new MenuItem(); + compareWithWorktree.Header = App.Text("CommitCM.CompareWithWorktree"); + compareWithWorktree.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithWorktree.Click += (_, e) => + { + vm.CompareWithWorktree(commit); + e.Handled = true; + }; + menu.Items.Add(compareWithWorktree); } - context.DrawGeometry(null, Pens[link.Color], geo); + menu.Items.Add(new MenuItem() { Header = "-" }); } - } - } - public partial class Histories : UserControl { - public Histories() { - InitializeComponent(); - } + var saveToPatch = new MenuItem(); + saveToPatch.Icon = this.CreateMenuIcon("Icons.Save"); + saveToPatch.Header = App.Text("CommitCM.SaveAsPatch"); + saveToPatch.Click += async (_, e) => + { + var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider; + if (storageProvider == null) + return; - private void OnCommitDataGridLayoutUpdated(object sender, EventArgs e) { - commitGraph.InvalidateVisual(); + 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 succ = await repo.SaveCommitAsPatchAsync(commit, folderPath); + if (succ) + repo.SendNotification(App.Text("SaveAsPatchSuccess")); + } + } + catch (Exception exception) + { + repo.SendNotification($"Failed to save as patch: {exception.Message}", true); + } + + e.Handled = true; + }; + menu.Items.Add(saveToPatch); + + var archive = new MenuItem(); + archive.Icon = this.CreateMenuIcon("Icons.Archive"); + archive.Header = App.Text("Archive"); + archive.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.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 = this.CreateMenuIcon("Icons.Action"); + + foreach (var action in actions) + { + var (dup, label) = action; + var item = new MenuItem(); + item.Icon = this.CreateMenuIcon("Icons.Action"); + item.Header = label; + item.Click += async (_, e) => + { + await repo.ExecCustomActionAsync(dup, commit); + e.Handled = true; + }; + + custom.Items.Add(item); + } + + menu.Items.Add(custom); + menu.Items.Add(new MenuItem() { Header = "-" }); + } + + var copyInfo = new MenuItem(); + copyInfo.Header = App.Text("CommitCM.CopySHA") + " - " + App.Text("CommitCM.CopySubject"); + copyInfo.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; + copyInfo.Click += async (_, e) => + { + await this.CopyTextAsync($"{commit.SHA.AsSpan(0, 10)} - {commit.Subject}"); + e.Handled = true; + }; + + var copySHA = new MenuItem(); + copySHA.Header = App.Text("CommitCM.CopySHA"); + copySHA.Icon = this.CreateMenuIcon("Icons.Hash"); + copySHA.Click += async (_, e) => + { + await this.CopyTextAsync(commit.SHA); + e.Handled = true; + }; + + var copySubject = new MenuItem(); + copySubject.Header = App.Text("CommitCM.CopySubject"); + copySubject.Icon = this.CreateMenuIcon("Icons.Subject"); + copySubject.Click += async (_, e) => + { + await this.CopyTextAsync(commit.Subject); + e.Handled = true; + }; + + var copyMessage = new MenuItem(); + copyMessage.Header = App.Text("CommitCM.CopyCommitMessage"); + copyMessage.Icon = this.CreateMenuIcon("Icons.Message"); + copyMessage.Click += async (_, e) => + { + var message = await vm.GetCommitFullMessageAsync(commit); + await this.CopyTextAsync(message); + e.Handled = true; + }; + + var copyAuthor = new MenuItem(); + copyAuthor.Header = App.Text("CommitCM.CopyAuthor"); + copyAuthor.Icon = this.CreateMenuIcon("Icons.User"); + copyAuthor.Click += async (_, e) => + { + await this.CopyTextAsync(commit.Author.ToString()); + e.Handled = true; + }; + + var copyCommitter = new MenuItem(); + copyCommitter.Header = App.Text("CommitCM.CopyCommitter"); + copyCommitter.Icon = this.CreateMenuIcon("Icons.User"); + copyCommitter.Click += async (_, e) => + { + await this.CopyTextAsync(commit.Committer.ToString()); + e.Handled = true; + }; + + var copyAuthorTime = new MenuItem(); + copyAuthorTime.Header = App.Text("CommitCM.CopyAuthorTime"); + copyAuthorTime.Icon = this.CreateMenuIcon("Icons.DateTime"); + copyAuthorTime.Click += async (_, e) => + { + await this.CopyTextAsync(Models.DateTimeFormat.Format(commit.AuthorTime)); + e.Handled = true; + }; + + var copyCommitterTime = new MenuItem(); + copyCommitterTime.Header = App.Text("CommitCM.CopyCommitterTime"); + copyCommitterTime.Icon = this.CreateMenuIcon("Icons.DateTime"); + copyCommitterTime.Click += async (_, e) => + { + await this.CopyTextAsync(Models.DateTimeFormat.Format(commit.CommitterTime)); + e.Handled = true; + }; + + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Items.Add(copyInfo); + copy.Items.Add(new MenuItem() { Header = "-" }); + copy.Items.Add(copySHA); + copy.Items.Add(copySubject); + copy.Items.Add(copyMessage); + copy.Items.Add(copyAuthor); + copy.Items.Add(copyCommitter); + copy.Items.Add(copyAuthorTime); + copy.Items.Add(copyCommitterTime); + menu.Items.Add(copy); + + return menu; } - private void OnCommitDataGridSelectionChanged(object sender, SelectionChangedEventArgs e) { - if (DataContext is ViewModels.Histories histories) { - histories.Select(commitDataGrid.SelectedItems); + private void FillCurrentBranchMenu(ContextMenu menu, ViewModels.Repository repo, Models.Branch current) + { + var submenu = new MenuItem(); + submenu.Icon = this.CreateMenuIcon("Icons.Branch"); + submenu.Header = current.Name; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new ViewModels.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 = this.CreateMenuIcon("Icons.FastForward"); + fastForward.IsEnabled = current.Ahead.Count == 0 && current.Behind.Count > 0; + fastForward.Click += async (_, e) => + { + var b = repo.Branches.Find(x => x.FriendlyName == upstream); + if (b == null) + return; + + if (repo.CanCreatePopup()) + await repo.ShowAndStartPopupAsync(new ViewModels.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 = this.CreateMenuIcon("Icons.Pull"); + pull.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Pull(repo, null)); + e.Handled = true; + }; + submenu.Items.Add(pull); + } + + var push = new MenuItem(); + push.Header = App.Text("BranchCM.Push", current.Name); + push.Icon = this.CreateMenuIcon("Icons.Push"); + push.IsEnabled = repo.Remotes.Count > 0; + push.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Push(repo, current)); + e.Handled = true; + }; + submenu.Items.Add(push); + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", current.Name); + rename.Icon = this.CreateMenuIcon("Icons.Rename"); + rename.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.RenameBranch(repo, current)); + e.Handled = true; + }; + submenu.Items.Add(rename); + submenu.Items.Add(new MenuItem() { Header = "-" }); - if (histories.DetailContext is ViewModels.CommitDetail detail) { - commitDataGrid.ScrollIntoView(detail.Commit, null); + 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 = this.CreateMenuIcon("Icons.GitFlow.Finish"); + finish.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.GitFlowFinish(repo, current, type)); + e.Handled = true; + }; + submenu.Items.Add(finish); + submenu.Items.Add(new MenuItem() { Header = "-" }); } } - e.Handled = true; + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => + { + await this.CopyTextAsync(current.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); } - private void OnCommitDataGridContextRequested(object sender, ContextRequestedEventArgs e) { - if (DataContext is ViewModels.Histories histories) { - var menu = histories.MakeContextMenu(); - menu?.Open(sender as Control); + private void FillOtherLocalBranchMenu(ContextMenu menu, ViewModels.Repository repo, Models.Branch branch, Models.Branch current, bool merged) + { + var submenu = new MenuItem(); + submenu.Icon = this.CreateMenuIcon("Icons.Branch"); + submenu.Header = branch.Name; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new ViewModels.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 = this.CreateMenuIcon("Icons.Check"); + checkout.Click += async (_, e) => + { + await repo.CheckoutBranchAsync(branch); + e.Handled = true; + }; + submenu.Items.Add(checkout); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", branch.Name, current.Name); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.IsEnabled = !merged; + merge.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Merge(repo, branch, current.Name, false)); + e.Handled = true; + }; + submenu.Items.Add(merge); } - e.Handled = true; + + var push = new MenuItem(); + push.Header = App.Text("BranchCM.Push", branch.Name); + push.Icon = this.CreateMenuIcon("Icons.Push"); + push.IsEnabled = repo.Remotes.Count > 0; + push.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Push(repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(push); + + var rename = new MenuItem(); + rename.Header = App.Text("BranchCM.Rename", branch.Name); + rename.Icon = this.CreateMenuIcon("Icons.Rename"); + rename.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.RenameBranch(repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(rename); + + var delete = new MenuItem(); + delete.Header = App.Text("BranchCM.Delete", branch.Name); + delete.Icon = this.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.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 = this.CreateMenuIcon("Icons.GitFlow.Finish"); + finish.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.GitFlowFinish(repo, branch, type)); + e.Handled = true; + }; + submenu.Items.Add(finish); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + } + + var compare = new MenuItem(); + compare.Header = App.Text("BranchCM.CompareWithSpecial", current.Name); + compare.Icon = this.CreateMenuIcon("Icons.Compare"); + compare.Click += (_, e) => + { + this.ShowWindow(new ViewModels.Compare(repo, current, branch)); + e.Handled = true; + }; + + submenu.Items.Add(compare); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => + { + await this.CopyTextAsync(branch.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); + } + + private void FillRemoteBranchMenu(ContextMenu menu, ViewModels.Repository repo, Models.Branch branch, Models.Branch current, bool merged) + { + if (branch == null) + return; + + var name = branch.FriendlyName; + + var submenu = new MenuItem(); + submenu.Icon = this.CreateMenuIcon("Icons.Branch"); + submenu.Header = name; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new ViewModels.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 = this.CreateMenuIcon("Icons.Check"); + checkout.Click += async (_, e) => + { + await repo.CheckoutBranchAsync(branch); + e.Handled = true; + }; + submenu.Items.Add(checkout); + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", name, current.Name); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.IsEnabled = !merged; + merge.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.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 = this.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteBranch(repo, branch)); + e.Handled = true; + }; + submenu.Items.Add(delete); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var compare = new MenuItem(); + compare.Header = App.Text("BranchCM.CompareWithSpecial", current.Name); + compare.Icon = this.CreateMenuIcon("Icons.Compare"); + compare.Click += (_, e) => + { + this.ShowWindow(new ViewModels.Compare(repo, current, branch)); + e.Handled = true; + }; + + submenu.Items.Add(compare); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => + { + await this.CopyTextAsync(name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); + } + + private void FillTagMenu(ContextMenu menu, ViewModels.Repository repo, Models.Tag tag, Models.Branch current) + { + var submenu = new MenuItem(); + submenu.Header = tag.Name; + submenu.Icon = this.CreateMenuIcon("Icons.Tag"); + submenu.MinWidth = 200; + + var visibility = new MenuItem(); + visibility.Classes.Add("filter_mode_switcher"); + visibility.Header = new ViewModels.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 = this.CreateMenuIcon("Icons.Push"); + push.IsEnabled = repo.Remotes.Count > 0; + push.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.PushTag(repo, tag)); + e.Handled = true; + }; + submenu.Items.Add(push); + + var delete = new MenuItem(); + delete.Header = App.Text("TagCM.Delete", tag.Name); + delete.Icon = this.CreateMenuIcon("Icons.Clear"); + delete.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.DeleteTag(repo, tag)); + e.Handled = true; + }; + submenu.Items.Add(delete); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var compare = new MenuItem(); + compare.Header = App.Text("BranchCM.CompareWithSpecial", current.Name); + compare.Icon = this.CreateMenuIcon("Icons.Compare"); + compare.Click += (_, e) => + { + this.ShowWindow(new ViewModels.Compare(repo, current, tag)); + e.Handled = true; + }; + + submenu.Items.Add(compare); + submenu.Items.Add(new MenuItem() { Header = "-" }); + + var copy = new MenuItem(); + copy.Header = App.Text("TagCM.CopyName"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => + { + await this.CopyTextAsync(tag.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); + + menu.Items.Add(submenu); } + + private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository repo, Models.Commit target, Models.InteractiveRebaseAction action) + { + var prefill = new ViewModels.InteractiveRebasePrefill(target.SHA, action); + var start = action switch + { + Models.InteractiveRebaseAction.Squash or Models.InteractiveRebaseAction.Fixup => $"{target.SHA}~~", + _ => $"{target.SHA}~", + }; + + var on = await new Commands.QuerySingleCommit(repo.FullPath, start).GetResultAsync(); + if (on == null) + repo.SendNotification($"Commit '{start}' is not a valid revision for `git rebase -i`!", true); + else + await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, on, prefill)); + } + + + private Models.Branch _currentBranch = null; + private Models.Bisect _bisect = null; + private AvaloniaList _issueTrackers = null; + private bool _isScrollToTopVisible = false; + private bool _isDetailsPanelExpanded = true; + private bool _resizingAuthorColumn = false; + private Cursor _resizingCursor = new(StandardCursorType.SizeWestEast); } } diff --git a/src/Views/Hotkeys.axaml b/src/Views/Hotkeys.axaml new file mode 100644 index 000000000..d1045cad9 --- /dev/null +++ b/src/Views/Hotkeys.axaml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Hotkeys.axaml.cs b/src/Views/Hotkeys.axaml.cs new file mode 100644 index 000000000..b1669252e --- /dev/null +++ b/src/Views/Hotkeys.axaml.cs @@ -0,0 +1,11 @@ +namespace SourceGit.Views +{ + public partial class Hotkeys : ChromelessWindow + { + public Hotkeys() + { + CloseOnESC = true; + InitializeComponent(); + } + } +} diff --git a/src/Views/ImageContainer.cs b/src/Views/ImageContainer.cs new file mode 100644 index 000000000..8e9926d54 --- /dev/null +++ b/src/Views/ImageContainer.cs @@ -0,0 +1,531 @@ +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 == nameof(ActualThemeVariant) && change.NewValue != null) + { + _bgBrush = null; + InvalidateVisual(); + } + } + + private DrawingBrush _bgBrush = null; + } + + public class ImageView : ImageContainer + { + public static readonly DirectProperty ImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(Image), + static o => o.Image, + static (o, v) => o.Image = v); + + public Bitmap Image + { + get => _image; + set => SetAndRaise(ImageProperty, ref _image, value); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + if (_image != null) + 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 != null) + { + 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 new Size(0, 0); + } + + private Bitmap _image = null; + } + + public class ImageSwipeControl : ImageContainer + { + public static readonly DirectProperty AlphaProperty = + AvaloniaProperty.RegisterDirect( + nameof(Alpha), + static o => o.Alpha, + static (o, v) => o.Alpha = v); + + public double Alpha + { + get => _alpha; + set => SetAndRaise(AlphaProperty, ref _alpha, value); + } + + public static readonly DirectProperty OldImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(OldImage), + static o => o.OldImage, + static (o, v) => o.OldImage = v); + + public Bitmap OldImage + { + get => _oldImage; + set => SetAndRaise(OldImageProperty, ref _oldImage, value); + } + + public static readonly DirectProperty NewImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(NewImage), + static o => o.NewImage, + static (o, v) => o.NewImage = v); + + public Bitmap NewImage + { + get => _newImage; + set => SetAndRaise(NewImageProperty, ref _newImage, value); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var w = Bounds.Width; + var h = Bounds.Height; + var x = w * _alpha; + + if (_oldImage != null && _alpha > 0) + RenderSingleSide(context, _oldImage, new Rect(0, 0, x, h)); + + if (_newImage != null && _alpha < 1) + RenderSingleSide(context, _newImage, 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 OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OldImageProperty || + change.Property == NewImageProperty) + InvalidateMeasure(); + else if (change.Property == AlphaProperty) + InvalidateVisual(); + } + + 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) + { + Alpha = 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) + { + if (_oldImage == null) + return _newImage == null ? new Size(0, 0) : GetDesiredSize(_newImage.Size, availableSize); + + if (_newImage == null) + return GetDesiredSize(_oldImage.Size, availableSize); + + var ls = GetDesiredSize(_oldImage.Size, availableSize); + var rs = GetDesiredSize(_newImage.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 Bitmap _oldImage = null; + private Bitmap _newImage = null; + private double _alpha = 0.5; + private bool _pressedOnSlider = false; + private bool _lastInSlider = false; + } + + public class ImageBlendControl : ImageContainer + { + public static readonly DirectProperty AlphaProperty = + AvaloniaProperty.RegisterDirect( + nameof(Alpha), + static o => o.Alpha, + static (o, v) => o.Alpha = v); + + public double Alpha + { + get => _alpha; + set => SetAndRaise(AlphaProperty, ref _alpha, value); + } + + public static readonly DirectProperty OldImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(OldImage), + static o => o.OldImage, + static (o, v) => o.OldImage = v); + + public Bitmap OldImage + { + get => _oldImage; + set => SetAndRaise(OldImageProperty, ref _oldImage, value); + } + + public static readonly DirectProperty NewImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(NewImage), + static o => o.NewImage, + static (o, v) => o.NewImage = v); + + public Bitmap NewImage + { + get => _newImage; + set => SetAndRaise(NewImageProperty, ref _newImage, value); + } + + 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 void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OldImageProperty || + change.Property == NewImageProperty) + InvalidateMeasure(); + else if (change.Property == AlphaProperty) + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var left = OldImage; + var right = NewImage; + + if (left == null) + return right == null ? new Size(0, 0) : 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 Bitmap _oldImage = null; + private Bitmap _newImage = null; + private double _alpha = 0.5; + private static readonly RenderOptions RO_SRC = new() { BitmapBlendingMode = BitmapBlendingMode.Source, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; + private static readonly RenderOptions RO_DST = new() { BitmapBlendingMode = BitmapBlendingMode.Plus, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; + } + + public class ImageDifferenceControl : ImageContainer + { + public static readonly DirectProperty AlphaProperty = + AvaloniaProperty.RegisterDirect( + nameof(Alpha), + static o => o.Alpha, + static (o, v) => o.Alpha = v); + + public double Alpha + { + get => _alpha; + set => SetAndRaise(AlphaProperty, ref _alpha, value); + } + + public static readonly DirectProperty OldImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(OldImage), + static o => o.OldImage, + static (o, v) => o.OldImage = v); + + public Bitmap OldImage + { + get => _oldImage; + set => SetAndRaise(OldImageProperty, ref _oldImage, value); + } + + public static readonly DirectProperty NewImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(NewImage), + static o => o.NewImage, + static (o, v) => o.NewImage = v); + + public Bitmap NewImage + { + get => _newImage; + set => SetAndRaise(NewImageProperty, ref _newImage, value); + } + + 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.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, Math.Min(1.0, 2.0 - 2.0 * alpha)); + + using (dc.PushRenderOptions(RO_DST)) + RenderSingleSide(dc, right, rt.Size.Width, rt.Size.Height, Math.Min(1.0, 2.0 * 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 void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OldImageProperty || + change.Property == NewImageProperty) + InvalidateMeasure(); + else if (change.Property == AlphaProperty) + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var left = OldImage; + var right = NewImage; + + if (left == null) + return right == null ? new Size(0, 0) : 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 Bitmap _oldImage = null; + private Bitmap _newImage = null; + private double _alpha = 0.5; + private static readonly RenderOptions RO_SRC = new() { BitmapBlendingMode = BitmapBlendingMode.Source, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; + private static readonly RenderOptions RO_DST = new() { BitmapBlendingMode = BitmapBlendingMode.Difference, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; + } +} diff --git a/src/Views/ImageDiffView.axaml b/src/Views/ImageDiffView.axaml new file mode 100644 index 000000000..ca954a7e1 --- /dev/null +++ b/src/Views/ImageDiffView.axaml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ImageDiffView.axaml.cs b/src/Views/ImageDiffView.axaml.cs new file mode 100644 index 000000000..7e32c91aa --- /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 index d74969ebf..a83ea5077 100644 --- a/src/Views/Init.axaml +++ b/src/Views/Init.axaml @@ -7,19 +7,41 @@ x:Class="SourceGit.Views.Init" x:DataType="vm:Init"> - - + + + + + + + + + + + + + + Text="{DynamicResource Text.Init.Path}" + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0"/> + Text="{Binding TargetPath}"/> + + Foreground="{DynamicResource Brush.FG2}" + TextWrapping="Wrap"> + + + + + diff --git a/src/Views/Init.axaml.cs b/src/Views/Init.axaml.cs index 856580744..0e1972942 100644 --- a/src/Views/Init.axaml.cs +++ b/src/Views/Init.axaml.cs @@ -1,10 +1,12 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class Init : UserControl { - public Init() { +namespace SourceGit.Views +{ + public partial class Init : UserControl + { + public Init() + { InitializeComponent(); } } } - diff --git a/src/Views/InitGitFlow.axaml b/src/Views/InitGitFlow.axaml index 6fb6e39fc..916262c7a 100644 --- a/src/Views/InitGitFlow.axaml +++ b/src/Views/InitGitFlow.axaml @@ -2,35 +2,40 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:m="using:SourceGit.Models" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.InitGitFlow" x:DataType="vm:InitGitFlow"> - - + + + + + + + + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.GitFlow.ProductionBranch}"/> + CornerRadius="3" + Text="{Binding Production, Mode=TwoWay}"/> + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.GitFlow.DevelopBranch}"/> + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.GitFlow.FeaturePrefix}"/> + CornerRadius="3" + Text="{Binding FeaturePrefix, Mode=TwoWay}"/> + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.GitFlow.ReleasePrefix}"/> + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.GitFlow.HotfixPrefix}"/> + HorizontalAlignment="Right" VerticalAlignment="Center" + Margin="0,0,8,0" + Text="{DynamicResource Text.GitFlow.TagPrefix}"/> diff --git a/src/Views/InitGitFlow.axaml.cs b/src/Views/InitGitFlow.axaml.cs index aa2b61321..2fbde79a3 100644 --- a/src/Views/InitGitFlow.axaml.cs +++ b/src/Views/InitGitFlow.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; -namespace SourceGit.Views { - public partial class InitGitFlow : UserControl { - public InitGitFlow() { +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 000000000..da9019b79 --- /dev/null +++ b/src/Views/InteractiveRebase.axaml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +