diff --git a/.editorconfig b/.editorconfig index 83b158847..89d2090c3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,7 +12,6 @@ indent_style = space indent_size = 4 dotnet_style_operator_placement_when_wrapping = beginning_of_line tab_width = 4 -end_of_line = crlf dotnet_style_coalesce_expression = true:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion @@ -71,20 +70,20 @@ 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.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.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.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.applicable_kinds = field dotnet_naming_symbols.private_static_fields.required_modifiers = static dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private @@ -93,14 +92,14 @@ 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.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 +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case # use accessibility modifiers dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion @@ -206,6 +205,9 @@ 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 @@ -290,6 +292,7 @@ indent_size = 2 # Shell scripts [*.sh] end_of_line = lf + [*.{cmd,bat}] end_of_line = crlf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d4117364a..b3dd9d5ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,35 +6,48 @@ jobs: strategy: matrix: include: - - name : Windows x64 - os: windows-2019 + - name: Windows x64 + os: windows-2022 runtime: win-x64 - - name : Windows ARM64 - os: windows-2019 + - name: Windows ARM64 + os: windows-2022 runtime: win-arm64 - - name : macOS (Intel) - os: macos-13 + - name: macOS (Intel) + os: macos-15-intel runtime: osx-x64 - - name : macOS (Apple Silicon) + - name: macOS (Apple Silicon) os: macos-latest runtime: osx-arm64 - - name : Linux - os: ubuntu-20.04 + - name: Linux + os: ubuntu-latest runtime: linux-x64 - - name : Linux (arm64) - os: ubuntu-20.04 + container: ubuntu:20.04 + - name: Linux (arm64) + os: ubuntu-latest runtime: linux-arm64 + container: ubuntu:20.04 name: Build ${{ matrix.name }} runs-on: ${{ matrix.os }} + container: ${{ matrix.container || '' }} steps: + - name: Install common CLI tools + if: startsWith(matrix.runtime, 'linux-') + run: | + export DEBIAN_FRONTEND=noninteractive + ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime + apt-get update + apt-get install -y sudo + sudo apt-get install -y curl wget git unzip zip libicu66 tzdata clang - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 + with: + submodules: true - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Configure arm64 packages - if: ${{ matrix.runtime == 'linux-arm64' }} + if: matrix.runtime == 'linux-arm64' run: | sudo dpkg --add-architecture arm64 echo 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ focal main restricted @@ -44,25 +57,25 @@ jobs: 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' }} + if: matrix.runtime == 'linux-arm64' run: | sudo apt-get update - sudo apt-get install clang llvm gcc-aarch64-linux-gnu zlib1g-dev:arm64 + 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-') }} + if: startsWith(matrix.runtime, 'linux-') run: mv publish/SourceGit publish/sourcegit - name: Tar artifact - if: ${{ startsWith(matrix.runtime, 'linux-') || startsWith(matrix.runtime, 'osx-') }} + if: startsWith(matrix.runtime, 'linux-') || startsWith(matrix.runtime, 'osx-') run: | tar -cvf "sourcegit.${{ matrix.runtime }}.tar" -C publish . rm -r publish/* mv "sourcegit.${{ matrix.runtime }}.tar" publish - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: sourcegit.${{ matrix.runtime }} path: publish/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50e02dc95..3204df528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Output version string id: version run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" 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 index cc5201ab3..76d7be77f 100644 --- a/.github/workflows/localization-check.yml +++ b/.github/workflows/localization-check.yml @@ -1,10 +1,9 @@ name: Localization Check on: push: - branches: [ develop ] + branches: [develop] paths: - 'src/Resources/Locales/**' - - 'README.md' workflow_dispatch: workflow_call: @@ -14,12 +13,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20.x' + node-version: '24.x' - name: Install dependencies run: npm install fs-extra@11.2.0 path@0.12.7 xml2js@0.6.2 @@ -32,8 +31,8 @@ jobs: 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 README.md TRANSLATION.md - git commit -m 'doc: Update translation status and missing keys' + 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" diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index e973c1abd..0845774fb 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -7,45 +7,46 @@ on: required: true type: string jobs: - windows-portable: - name: Package portable Windows app - runs-on: ubuntu-latest + windows: + name: Package Windows + runs-on: windows-2022 strategy: matrix: runtime: [win-x64, win-arm64] steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download build - uses: actions/download-artifact@v4 + 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.windows-portable.sh + run: ./build/scripts/package.win.ps1 - name: Upload package artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: package.${{ matrix.runtime }} path: build/sourcegit_*.zip - name: Delete temp artifacts - uses: geekyeggo/delete-artifact@v5 + uses: geekyeggo/delete-artifact@v6 with: name: sourcegit.${{ matrix.runtime }} osx-app: - name: Package OSX app + name: Package macOS runs-on: macos-latest strategy: matrix: runtime: [osx-x64, osx-arm64] steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: sourcegit.${{ matrix.runtime }} path: build @@ -58,30 +59,32 @@ jobs: tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit ./build/scripts/package.osx-app.sh - name: Upload package artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: package.${{ matrix.runtime }} path: build/sourcegit_*.zip - name: Delete temp artifacts - uses: geekyeggo/delete-artifact@v5 + 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@v4 + uses: actions/checkout@v6 - name: Download package dependencies run: | - sudo add-apt-repository universe - sudo apt-get update - sudo apt-get install desktop-file-utils rpm libfuse2 + export DEBIAN_FRONTEND=noninteractive + ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime + apt-get update + apt-get install -y curl wget git dpkg-dev fakeroot tzdata zip unzip desktop-file-utils rpm libfuse2 file build-essential binutils - name: Download build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: sourcegit.${{ matrix.runtime }} path: build @@ -89,12 +92,13 @@ jobs: env: VERSION: ${{ inputs.version }} RUNTIME: ${{ matrix.runtime }} + APPIMAGE_EXTRACT_AND_RUN: 1 run: | mkdir build/SourceGit tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit ./build/scripts/package.linux.sh - name: Upload package artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: package.${{ matrix.runtime }} path: | @@ -102,6 +106,6 @@ jobs: build/sourcegit_*.deb build/sourcegit-*.rpm - name: Delete temp artifacts - uses: geekyeggo/delete-artifact@v5 + uses: geekyeggo/delete-artifact@v6 with: name: sourcegit.${{ matrix.runtime }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c19103e3a..816870a02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,15 +32,15 @@ jobs: contents: write steps: - name: Checkout sources - uses: actions/checkout@v4 + 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 "Release $VERSION" --notes-from-tag + run: gh release create "$TAG" -t "$VERSION" --notes-from-tag - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: package.* path: packages diff --git a/.gitignore b/.gitignore index 0c66b11e4..5ca0482f9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.user *.suo *.code-workspace +*.tss .DS_Store .DocumentRevisions-V100 @@ -28,6 +29,10 @@ node_modules/ package.json package-lock.json + +# Flatpak +!build/resources/flatpak + build/resources/ build/SourceGit/ build/SourceGit.app/ @@ -37,3 +42,6 @@ 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 d49fb1e4a..38a7e99fa 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,35 @@ [![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/Français/Português/Русский/简体中文/繁體中文 +* 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/Amend/Cherry-pick... - * Amend/Reword - * Interactive rebase (Basic) + * Merge/Rebase/Reset/Revert/Cherry-pick... + * Amend/Reword/Squash + * Interactive rebase * Branches * Remotes * Tags @@ -35,84 +49,154 @@ * Revision Diffs * Branch Diff * Image Diff - Side-By-Side/Swipe/Blend +* Git command logs * Search commits * GitFlow * Git LFS +* Bisect * Issue Link * Workspace -* Using AI to generate commit message (C# port of [anjerodev/commitollama](https://github.com/anjerodev/commitollama)) +* 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**. -## Translation Status - -[![en_US](https://img.shields.io/badge/en__US-100%25-brightgreen)](TRANSLATION.md) [![de__DE](https://img.shields.io/badge/de__DE-100.00%25-brightgreen)](TRANSLATION.md) [![es__ES](https://img.shields.io/badge/es__ES-99.14%25-yellow)](TRANSLATION.md) [![fr__FR](https://img.shields.io/badge/fr__FR-98.42%25-yellow)](TRANSLATION.md) [![pt__BR](https://img.shields.io/badge/pt__BR-99.14%25-yellow)](TRANSLATION.md) [![ru__RU](https://img.shields.io/badge/ru__RU-100.00%25-brightgreen)](TRANSLATION.md) [![zh__CN](https://img.shields.io/badge/zh__CN-100.00%25-brightgreen)](TRANSLATION.md) [![zh__TW](https://img.shields.io/badge/zh__TW-100.00%25-brightgreen)](TRANSLATION.md) - ## How to Use -**To use this tool, you need to install Git(>=2.23.0) first.** +**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. +You can download the latest stable from [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) or download workflow artifacts from [GitHub Actions](https://github.com/sourcegit-scm/sourcegit/actions) to try this app based on latest commits. -This software creates a folder `$"{System.Environment.SpecialFolder.ApplicationData}/SourceGit"`, which is platform-dependent, to store user settings, downloaded avatars and crash logs. +This software creates a folder, which is platform-dependent, to store user settings, downloaded avatars and crash logs. -| OS | PATH | -|---------|-----------------------------------------------------| -| Windows | `C:\Users\USER_NAME\AppData\Roaming\SourceGit` | -| Linux | `${HOME}/.config/SourceGit` or `${HOME}/.sourcegit` | -| macOS | `${HOME}/Library/Application Support/SourceGit` | +| OS | PATH | +|---------|-------------------------------------------| +| Windows | `%APPDATA%\SourceGit` | +| Linux | `~/.sourcegit` | +| macOS | `~/Library/Application Support/SourceGit` | > [!TIP] -> You can open the app data dir from the main menu. +> * 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 from `winget` with follow commands: - ```shell - winget install SourceGit - ``` -> [!NOTE] -> `winget` will install this software as a commandline tool. You need run `SourceGit` from console or `Win+R` at the first time. Then you can add it to the taskbar. -* You can install the latest stable by `scoope` with follow commands: +* You can install the latest stable by `scoop` with follow commands: ```shell scoop bucket add extras scoop install sourcegit ``` -* Portable versions can be found in [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) +* 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`. You can simply install it with following command: +* Thanks [@ybeapps](https://github.com/ybeapps) for making `SourceGit` available on `Homebrew`: ```shell - brew tap ybeapps/homebrew-sourcegit - brew install --cask --no-quarantine sourcegit + 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: +* 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: -* `xdg-open` must be installed to support open native file manager. -* Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your linux. +* 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 comaptible HTTP API to generate commit message. You need configurate the service in `Preference` window. +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/chat/completions` +* `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/chat/completions`. For example, when using `Ollama`, it should be `http://localhost:11434/v1/chat/completions` instead of `http://localhost:11434/api/generate` +* 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 @@ -124,43 +208,94 @@ This app supports open repository in external tools listed in the table below. | Visual Studio Code | YES | YES | YES | | Visual Studio Code - Insiders | YES | YES | YES | | VSCodium | YES | YES | YES | -| Fleet | YES | YES | YES | +| Cursor | YES | YES | YES | | Sublime Text | YES | YES | YES | -| Zed | NO | 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 dir and provide the path directly. For example: +> 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. -## Screenshots +## Conventional Commit Helper -* Dark Theme +You can define your own conventional commit types (per-repository) by following steps: - ![Theme Dark](./screenshots/theme_dark.png) - -* Light Theme +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. - ![Theme Light](./screenshots/theme_light.png) +## Contributing -* Custom +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`. - You can find custom themes from [sourcegit-theme](https://github.com/sourcegit-scm/sourcegit-theme.git). And welcome to share your own themes. +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. -## Contributing +In short, here are the commands to get started once [.NET tools are installed](https://dotnet.microsoft.com/en-us/download): -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`. +```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.sln b/SourceGit.sln deleted file mode 100644 index 9c5fcdb1b..000000000 --- a/SourceGit.sln +++ /dev/null @@ -1,120 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34714.143 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGit", "src\SourceGit.csproj", "{2091C34D-4A17-4375-BEF3-4D60BE8113E4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{773082AC-D9C8-4186-8521-4B6A7BEE6158}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{FD384607-ED99-47B7-AF31-FB245841BC92}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{F45A9D95-AF25-42D8-BBAC-8259C9EEE820}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{67B6D05F-A000-40BA-ADB4-C9065F880D7B}" - ProjectSection(SolutionItems) = preProject - .github\workflows\build.yml = .github\workflows\build.yml - .github\workflows\ci.yml = .github\workflows\ci.yml - .github\workflows\package.yml = .github\workflows\package.yml - .github\workflows\release.yml = .github\workflows\release.yml - .github\workflows\localization-check.yml = .github\workflows\localization-check.yml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{49A7C2D6-558C-4FAA-8F5D-EEE81497AED7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "files", "files", "{3AB707DB-A02C-4AFC-BF12-D7DF2B333BAC}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitattributes = .gitattributes - .gitignore = .gitignore - global.json = global.json - LICENSE = LICENSE - README.md = README.md - VERSION = VERSION - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{ABC98884-F023-4EF4-A9C9-5DE9452BE955}" - ProjectSection(SolutionItems) = preProject - build\resources\app\App.icns = build\resources\app\App.icns - build\resources\app\App.plist = build\resources\app\App.plist - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_common", "_common", "{04FD74B1-FBDB-496E-A48F-3D59D71FF952}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "usr", "usr", "{76639799-54BC-45E8-BD90-F45F63ACD11D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "share", "share", "{A3ABAA7C-EE14-4448-B466-6E69C1347E7D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "applications", "applications", "{2AF28D3B-14A8-46A8-B828-157FAAB1B06F}" - ProjectSection(SolutionItems) = preProject - build\resources\_common\usr\share\applications\sourcegit.desktop = build\resources\_common\usr\share\applications\sourcegit.desktop - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "icons", "icons", "{7166EC6C-17F5-4B5E-B38E-1E53C81EACF6}" - ProjectSection(SolutionItems) = preProject - build\resources\_common\usr\share\icons\sourcegit.png = build\resources\_common\usr\share\icons\sourcegit.png - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deb", "deb", "{9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DEBIAN", "DEBIAN", "{F101849D-BDB7-40D4-A516-751150C3CCFC}" - ProjectSection(SolutionItems) = preProject - build\resources\deb\DEBIAN\control = build\resources\deb\DEBIAN\control - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rpm", "rpm", "{9BA0B044-0CC9-46F8-B551-204F149BF45D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SPECS", "SPECS", "{7802CD7A-591B-4EDD-96F8-9BF3F61692E4}" - ProjectSection(SolutionItems) = preProject - build\resources\rpm\SPECS\build.spec = build\resources\rpm\SPECS\build.spec - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "appimage", "appimage", "{5D125DD9-B48A-491F-B2FB-D7830D74C4DC}" - ProjectSection(SolutionItems) = preProject - build\resources\appimage\sourcegit.appdata.xml = build\resources\appimage\sourcegit.appdata.xml - build\resources\appimage\sourcegit.png = build\resources\appimage\sourcegit.png - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C54D4001-9940-477C-A0B6-E795ED0A3209}" - ProjectSection(SolutionItems) = preProject - build\scripts\localization-check.js = build\scripts\localization-check.js - build\scripts\package.linux.sh = build\scripts\package.linux.sh - build\scripts\package.osx-app.sh = build\scripts\package.osx-app.sh - build\scripts\package.windows-portable.sh = build\scripts\package.windows-portable.sh - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {2091C34D-4A17-4375-BEF3-4D60BE8113E4} = {49A7C2D6-558C-4FAA-8F5D-EEE81497AED7} - {FD384607-ED99-47B7-AF31-FB245841BC92} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} - {67B6D05F-A000-40BA-ADB4-C9065F880D7B} = {F45A9D95-AF25-42D8-BBAC-8259C9EEE820} - {ABC98884-F023-4EF4-A9C9-5DE9452BE955} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {04FD74B1-FBDB-496E-A48F-3D59D71FF952} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {76639799-54BC-45E8-BD90-F45F63ACD11D} = {04FD74B1-FBDB-496E-A48F-3D59D71FF952} - {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} = {76639799-54BC-45E8-BD90-F45F63ACD11D} - {2AF28D3B-14A8-46A8-B828-157FAAB1B06F} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} - {7166EC6C-17F5-4B5E-B38E-1E53C81EACF6} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} - {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {F101849D-BDB7-40D4-A516-751150C3CCFC} = {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} - {9BA0B044-0CC9-46F8-B551-204F149BF45D} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {7802CD7A-591B-4EDD-96F8-9BF3F61692E4} = {9BA0B044-0CC9-46F8-B551-204F149BF45D} - {5D125DD9-B48A-491F-B2FB-D7830D74C4DC} = {FD384607-ED99-47B7-AF31-FB245841BC92} - {C54D4001-9940-477C-A0B6-E795ED0A3209} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {7FF1B9C6-B5BF-4A50-949F-4B407A0E31C9} - EndGlobalSection -EndGlobal diff --git a/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 index 5967d21ee..47960c547 100644 --- a/TRANSLATION.md +++ b/TRANSLATION.md @@ -1,89 +1,1561 @@ -### de_DE.axaml: 100.00% +# Translation Status +This document shows the translation status of each locale file in the repository. -
-Missing Keys +## 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 -### es_ES.axaml: 99.14% +- 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 +Missing keys in el_GR.axaml -- Text.Preference.Appearance.FontSize -- Text.Preference.Appearance.FontSize.Default -- Text.Preference.Appearance.FontSize.Editor -- Text.Repository.FilterCommits.Default -- Text.Repository.FilterCommits.Exclude -- Text.Repository.FilterCommits.Include +- Text.Launcher.NewVersion +- Text.Repository.FilterCommits.Collapse +- Text.Repository.FilterCommits.Expand +- Text.Repository.FilterCommits.Summary +- Text.WorkingCopy.FilterChanges
-### fr_FR.axaml: 98.42% - +### ![es__ES](https://img.shields.io/badge/es__ES-99.90%25-yellow)
-Missing Keys - -- Text.CherryPick.AppendSourceToMessage -- Text.CherryPick.Mainline.Tips -- Text.CommitCM.CherryPickMultiple -- Text.Preference.Appearance.FontSize -- Text.Preference.Appearance.FontSize.Default -- Text.Preference.Appearance.FontSize.Editor -- Text.Repository.CustomActions -- Text.Repository.FilterCommits.Default -- Text.Repository.FilterCommits.Exclude -- Text.Repository.FilterCommits.Include -- Text.ScanRepositories +Missing keys in es_ES.axaml -
+- Text.WorkingCopy.FilterChanges -### pt_BR.axaml: 99.14% + +### ![fr__FR](https://img.shields.io/badge/fr__FR-95.49%25-yellow)
-Missing Keys +Missing keys in fr_FR.axaml -- Text.Preference.Appearance.FontSize -- Text.Preference.Appearance.FontSize.Default -- Text.Preference.Appearance.FontSize.Editor -- Text.Repository.FilterCommits.Default -- Text.Repository.FilterCommits.Exclude -- Text.Repository.FilterCommits.Include +- 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
-### ru_RU.axaml: 100.00% +### ![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 +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
-### zh_CN.axaml: 100.00% +### ![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 +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
-### zh_TW.axaml: 100.00% +### ![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 +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 index fb6559a3a..054d00cdb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.39 \ No newline at end of file +2026.17 \ No newline at end of file diff --git a/build/README.md b/build/README.md index a75f4d73e..0698a8fbc 100644 --- a/build/README.md +++ b/build/README.md @@ -5,11 +5,11 @@ ## How to build this project manually -1. Make sure [.NET SDK 8](https://dotnet.microsoft.com/en-us/download) is installed on your machine. +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 replece the `$DESTINATION_FOLDER` with the real path that will store the output executable files. +> 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 index bcf9c813c..966b1d65f 100644 --- a/build/resources/_common/applications/sourcegit.desktop +++ b/build/resources/_common/applications/sourcegit.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=SourceGit Comment=Open-source & Free Git GUI Client -Exec=/opt/sourcegit/sourcegit +Exec=/opt/sourcegit/sourcegit %u Icon=/usr/share/icons/sourcegit.png Terminal=false Type=Application diff --git a/build/resources/app/App.plist b/build/resources/app/App.plist index ba6f40a2b..d20efa5e2 100644 --- a/build/resources/app/App.plist +++ b/build/resources/app/App.plist @@ -20,6 +20,21 @@ 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 index 012c82d37..617737a11 100644 --- a/build/resources/appimage/sourcegit.appdata.xml +++ b/build/resources/appimage/sourcegit.appdata.xml @@ -1,16 +1,72 @@ - com.sourcegit_scm.SourceGit - MIT - MIT - SourceGit - Open-source GUI client for git users - -

Open-source GUI client for git users

-
- https://github.com/sourcegit-scm/sourcegit - com.sourcegit_scm.SourceGit.desktop - - com.sourcegit_scm.SourceGit.desktop - + 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/appimage/sourcegit.png b/build/resources/appimage/sourcegit.png deleted file mode 100644 index 8cdcd3a87..000000000 Binary files a/build/resources/appimage/sourcegit.png and /dev/null differ diff --git a/build/resources/deb/DEBIAN/control b/build/resources/deb/DEBIAN/control index 7cfed3309..3f944e3ab 100755 --- a/build/resources/deb/DEBIAN/control +++ b/build/resources/deb/DEBIAN/control @@ -1,7 +1,8 @@ Package: sourcegit -Version: 8.23 +Version: 2025.10 Priority: optional -Depends: libx11-6, libice6, libsm6 +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 index d31b6587f..669fdf846 100644 --- a/build/resources/rpm/SPECS/build.spec +++ b/build/resources/rpm/SPECS/build.spec @@ -7,6 +7,8 @@ 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 @@ -18,10 +20,10 @@ mkdir -p %{buildroot}/opt/sourcegit mkdir -p %{buildroot}/%{_bindir} mkdir -p %{buildroot}/usr/share/applications mkdir -p %{buildroot}/usr/share/icons -cp -f ../../../SourceGit/* %{buildroot}/opt/sourcegit/ +cp -f %{_topdir}/../../SourceGit/* %{buildroot}/opt/sourcegit/ ln -rsf %{buildroot}/opt/sourcegit/sourcegit %{buildroot}/%{_bindir} -cp -r ../../_common/applications %{buildroot}/%{_datadir} -cp -r ../../_common/icons %{buildroot}/%{_datadir} +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 diff --git a/build/scripts/localization-check.js b/build/scripts/localization-check.js index 45db82bec..8d636b5bb 100644 --- a/build/scripts/localization-check.js +++ b/build/scripts/localization-check.js @@ -6,7 +6,6 @@ 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 readmeFile = path.join(repoRoot, 'README.md'); const parser = new xml2js.Parser(); @@ -15,45 +14,70 @@ async function parseXml(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 translationRates = []; - const badges = []; - const files = (await fs.readdir(localesDir)).filter(file => file !== 'en_US.axaml' && file.endsWith('.axaml')); - // Add en_US badge first - badges.push(`[![en_US](https://img.shields.io/badge/en__US-100%25-brightgreen)](TRANSLATION.md)`); + 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)); - const translationRate = ((enUSKeys.size - missingKeys.length) / enUSKeys.size) * 100; - translationRates.push(`### ${file}: ${translationRate.toFixed(2)}%\n`); - translationRates.push(`
\nMissing Keys\n\n${missingKeys.map(key => `- ${key}`).join('\n')}\n\n
`); + // Sort and clean up extra translations + const sortedAndCleaned = await filterAndSortTranslations(localeData, enUSKeys, enUSData); + localeData.ResourceDictionary['x:String'] = sortedAndCleaned; - // Add badges - const locale = file.replace('.axaml', '').replace('_', '__'); - const badgeColor = translationRate === 100 ? 'brightgreen' : translationRate >= 75 ? 'yellow' : 'red'; - badges.push(`[![${locale}](https://img.shields.io/badge/${locale}-${translationRate.toFixed(2)}%25-${badgeColor})](TRANSLATION.md)`); - } + // 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'; - await fs.writeFile(outputFile, translationRates.join('\n\n') + '\n', 'utf8'); + 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)`); + } + } - // Update README.md - let readmeContent = await fs.readFile(readmeFile, 'utf8'); - const badgeSection = `## Translation Status\n\n${badges.join(' ')}`; - console.log(badgeSection); - readmeContent = readmeContent.replace(/## Translation Status\n\n.*\n\n/, badgeSection + '\n\n'); - await fs.writeFile(readmeFile, readmeContent, 'utf8'); + 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 index 5abb058b3..262f141ae 100755 --- a/build/scripts/package.linux.sh +++ b/build/scripts/package.linux.sh @@ -5,6 +5,10 @@ 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= @@ -32,6 +36,7 @@ if [[ ! -f "appimagetool" ]]; then fi rm -f SourceGit/*.dbg +rm -f SourceGit/*.pdb mkdir -p SourceGit.AppDir/opt mkdir -p SourceGit.AppDir/usr/share/metainfo @@ -41,7 +46,7 @@ cp -r SourceGit SourceGit.AppDir/opt/sourcegit desktop-file-install resources/_common/applications/sourcegit.desktop --dir SourceGit.AppDir/usr/share/applications \ --set-icon com.sourcegit_scm.SourceGit --set-key=Exec --set-value=AppRun mv SourceGit.AppDir/usr/share/applications/{sourcegit,com.sourcegit_scm.SourceGit}.desktop -cp resources/appimage/sourcegit.png SourceGit.AppDir/com.sourcegit_scm.SourceGit.png +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 @@ -56,8 +61,26 @@ 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 -sed -i -e "s/^Version:.*/Version: $VERSION/" -e "s/^Architecture:.*/Architecture: $arch/" resources/deb/DEBIAN/control -dpkg-deb --root-owner-group --build resources/deb "sourcegit_$VERSION-1_$arch.deb" + +# 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 index 2d43e24af..8f3ddc77c 100755 --- a/build/scripts/package.osx-app.sh +++ b/build/scripts/package.osx-app.sh @@ -12,5 +12,6 @@ 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/build/scripts/package.windows-portable.sh b/build/scripts/package.windows-portable.sh deleted file mode 100755 index 6bd3879b7..000000000 --- a/build/scripts/package.windows-portable.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -o -set -u -set pipefail - -cd build - -rm -rf SourceGit/*.pdb - -zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit 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 index a27a2b823..32035c656 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.0", "rollForward": "latestMajor", "allowPrerelease": false } -} \ No newline at end of file +} diff --git a/screenshots/theme_dark.png b/screenshots/theme_dark.png index 85e184815..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 2e8cf6fca..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 index 8a4850299..06c0afbf1 100644 --- a/src/App.Commands.cs +++ b/src/App.Commands.cs @@ -1,6 +1,6 @@ using System; using System.Windows.Input; -using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; namespace SourceGit { @@ -25,12 +25,76 @@ public Command(Action action) private Action _action = null; } - public static readonly Command OpenPreferenceCommand = new Command(_ => OpenDialog(new Views.Preference())); - public static readonly Command OpenHotkeysCommand = new Command(_ => OpenDialog(new Views.Hotkeys())); - public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir)); - public static readonly Command OpenAboutCommand = new Command(_ => OpenDialog(new Views.About())); - public static readonly Command CheckForUpdateCommand = new Command(_ => Check4Update(true)); - public static readonly Command QuitCommand = new Command(_ => Quit(0)); - public static readonly Command CopyTextBlockCommand = new Command(p => CopyTextBlock(p as TextBlock)); + 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 index 70567af5f..8eef67ae7 100644 --- a/src/App.JsonCodeGen.cs +++ b/src/App.JsonCodeGen.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -7,6 +9,21 @@ 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) @@ -39,18 +56,22 @@ public override void Write(Utf8JsonWriter writer, GridLength value, JsonSerializ IgnoreReadOnlyFields = true, IgnoreReadOnlyProperties = true, Converters = [ + typeof(DateTimeConverter), typeof(ColorConverter), typeof(GridLengthConverter), ] )] - [JsonSerializable(typeof(Models.ExternalToolPaths))] + [JsonSerializable(typeof(Models.ExternalToolCustomization))] [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] [JsonSerializable(typeof(Models.JetBrainsState))] - [JsonSerializable(typeof(Models.OpenAIChatRequest))] - [JsonSerializable(typeof(Models.OpenAIChatResponse))] [JsonSerializable(typeof(Models.ThemeOverrides))] [JsonSerializable(typeof(Models.Version))] [JsonSerializable(typeof(Models.RepositorySettings))] - [JsonSerializable(typeof(ViewModels.Preference))] + [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 fc55776e1..bfc05cfb4 100644 --- a/src/App.axaml +++ b/src/App.axaml @@ -12,18 +12,27 @@ + + + + + + + + + @@ -31,12 +40,16 @@ - - + + - + + + + + diff --git a/src/App.axaml.cs b/src/App.axaml.cs index 0615724a4..a22f764d6 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -1,9 +1,6 @@ using System; -using System.Collections.Generic; using System.IO; using System.Net.Http; -using System.Reflection; -using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -14,7 +11,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Media.Fonts; -using Avalonia.Platform.Storage; +using Avalonia.Platform; using Avalonia.Styling; using Avalonia.Threading; @@ -22,6 +19,7 @@ namespace SourceGit { public partial class App : Application { + #region App Entry Point [STAThread] public static void Main(string[] args) { @@ -29,27 +27,26 @@ public static void Main(string[] args) AppDomain.CurrentDomain.UnhandledException += (_, e) => { - LogException(e.ExceptionObject as Exception); + Native.OS.LogException(e.ExceptionObject as Exception); }; TaskScheduler.UnobservedTaskException += (_, e) => { - LogException(e.Exception); e.SetObserved(); }; try { - if (TryLaunchedAsRebaseTodoEditor(args, out int exitTodo)) + if (TryLaunchAsRebaseTodoEditor(args, out int exitTodo)) Environment.Exit(exitTodo); - else if (TryLaunchedAsRebaseMessageEditor(args, out int exitMessage)) + else if (TryLaunchAsRebaseMessageEditor(args, out int exitMessage)) Environment.Exit(exitMessage); else BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } catch (Exception ex) { - LogException(ex); + Native.OS.LogException(ex); } } @@ -74,61 +71,43 @@ public static AppBuilder BuildAvaloniaApp() Native.OS.SetupApp(builder); return builder; } + #endregion - public override void Initialize() + #region Utility Functions + public static async Task AskConfirmAsync(string message, Models.ConfirmButtonType buttonType = Models.ConfirmButtonType.OkCancel) { - AvaloniaXamlLoader.Load(this); - - var pref = ViewModels.Preference.Instance; - pref.PropertyChanged += (_, _) => pref.Save(); - - SetLocale(pref.Locale); - SetTheme(pref.Theme, pref.ThemeOverrides); - SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily, pref.OnlyUseMonoFontInEditor); - } - - public override void OnFrameworkInitializationCompleted() - { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) { - BindingPlugins.DataValidators.RemoveAt(0); - - if (TryLaunchedAsCoreEditor(desktop)) - return; - - if (TryLaunchedAsAskpass(desktop)) - return; - - TryLaunchedAsNormal(desktop); + var confirm = new Views.Confirm(); + confirm.SetData(message, buttonType); + return await confirm.ShowDialog(owner); } - } - public static void OpenDialog(Window window) - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) - window.ShowDialog(owner); + return false; } - public static void RaiseException(string context, string message) + public static async Task AskConfirmEmptyCommitAsync(bool hasLocalChanges, bool hasSelectedUnstaged) { - if (Current is App app && app._launcher != null) - app._launcher.DispatchNotification(context, message, true); - } + 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); + } - public static void SendNotification(string context, string message) - { - if (Current is App app && app._launcher != null) - app._launcher.DispatchNotification(context, message, false); + return Models.ConfirmEmptyCommitResult.Cancel; } public static void SetLocale(string localeKey) { - var app = Current as App; - if (app == null) - return; + var locale = Models.Locale.Supported.Find(x => x.Key.Equals(localeKey, StringComparison.OrdinalIgnoreCase)); + var finalLocaleKey = locale?.Key ?? "en_US"; - var targetLocale = app.Resources[localeKey] as ResourceDictionary; - if (targetLocale == null || targetLocale == app._activeLocale) + if (Current is not App app || + app.Resources[finalLocaleKey] is not ResourceDictionary targetLocale || + targetLocale == app._activeLocale) return; if (app._activeLocale != null) @@ -140,8 +119,7 @@ public static void SetLocale(string localeKey) public static void SetTheme(string theme, string themeOverridesFile) { - var app = Current as App; - if (app == null) + if (Current is not App app) return; if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) @@ -162,7 +140,8 @@ public static void SetTheme(string theme, string themeOverridesFile) try { var resDic = new ResourceDictionary(); - var overrides = JsonSerializer.Deserialize(File.ReadAllText(themeOverridesFile), JsonCodeGen.Default.ThemeOverrides); + 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)) @@ -176,8 +155,6 @@ public static void SetTheme(string theme, string themeOverridesFile) else Models.CommitGraph.SetDefaultPens(overrides.GraphPenThickness); - Models.Commit.OpacityForNotMerged = overrides.OpacityForNotMergedCommits; - app.Resources.MergedDictionaries.Add(resDic); app._themeOverrides = resDic; } @@ -192,10 +169,9 @@ public static void SetTheme(string theme, string themeOverridesFile) } } - public static void SetFonts(string defaultFont, string monospaceFont, bool onlyUseMonospaceFontInEditor) + public static void SetFonts(string defaultFont, string monospaceFont) { - var app = Current as App; - if (app == null) + if (Current is not App app) return; if (app._fontsOverrides != null) @@ -204,6 +180,9 @@ public static void SetFonts(string defaultFont, string monospaceFont, bool onlyU 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)); @@ -212,8 +191,8 @@ public static void SetFonts(string defaultFont, string monospaceFont, bool onlyU { if (!string.IsNullOrEmpty(defaultFont)) { - monospaceFont = $"fonts:SourceGit#JetBrains Mono,{defaultFont}"; - resDic.Add("Fonts.Monospace", new FontFamily(monospaceFont)); + monospaceFont = $"fonts:SourceGit#JetBrains Mono NL,{defaultFont}"; + resDic.Add("Fonts.Monospace", FontFamily.Parse(monospaceFont)); } } else @@ -221,20 +200,7 @@ public static void SetFonts(string defaultFont, string monospaceFont, bool onlyU if (!string.IsNullOrEmpty(defaultFont) && !monospaceFont.Contains(defaultFont, StringComparison.Ordinal)) monospaceFont = $"{monospaceFont},{defaultFont}"; - resDic.Add("Fonts.Monospace", new FontFamily(monospaceFont)); - } - - if (onlyUseMonospaceFontInEditor) - { - if (string.IsNullOrEmpty(defaultFont)) - resDic.Add("Fonts.Primary", new FontFamily("fonts:Inter#Inter")); - else - resDic.Add("Fonts.Primary", new FontFamily(defaultFont)); - } - else - { - if (!string.IsNullOrEmpty(monospaceFont)) - resDic.Add("Fonts.Primary", new FontFamily(monospaceFont)); + resDic.Add("Fonts.Monospace", FontFamily.Parse(monospaceFont)); } if (resDic.Count > 0) @@ -244,27 +210,6 @@ public static void SetFonts(string defaultFont, string monospaceFont, bool onlyU } } - public static async void CopyText(string data) - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - if (desktop.MainWindow?.Clipboard is { } clipboard) - await clipboard.SetTextAsync(data ?? ""); - } - } - - public static async Task GetClipboardTextAsync() - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - if (desktop.MainWindow?.Clipboard is { } clipboard) - { - return await clipboard.GetTextAsync(); - } - } - return default; - } - public static string Text(string key, params object[] args) { var fmt = Current?.FindResource($"Text.{key}") as string; @@ -277,155 +222,71 @@ public static string Text(string key, params object[] args) 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; - - var geo = Current?.FindResource(key) as StreamGeometry; - if (geo != null) - icon.Data = geo; - - return icon; - } - - public static IStorageProvider GetStorageProvider() - { - if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - return desktop.MainWindow?.StorageProvider; - - return null; - } - - public static ViewModels.Launcher GetLauncer() + public static ViewModels.Launcher GetLauncher() { return Current is App app ? app._launcher : null; } - public static ViewModels.Repository FindOpenedRepository(string repoPath) - { - if (Current is App app && app._launcher != null) - { - foreach (var page in app._launcher.Pages) - { - var id = page.Node.Id.Replace("\\", "/"); - if (id == repoPath && page.Data is ViewModels.Repository repo) - return repo; - } - } - - return null; - } - public static void Quit(int exitCode) { if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.MainWindow?.Close(); desktop.Shutdown(exitCode); - } else - { Environment.Exit(exitCode); - } - } - - private static void CopyTextBlock(TextBlock textBlock) - { - if (textBlock == null) - return; - - if (textBlock.Inlines is { Count: > 0 } inlines) - CopyText(inlines.Text); - else if (!string.IsNullOrEmpty(textBlock.Text)) - CopyText(textBlock.Text); } + #endregion - private static void LogException(Exception ex) + #region Overrides + public override void Initialize() { - if (ex == null) - return; - - var builder = new StringBuilder(); - builder.Append($"Crash::: {ex.GetType().FullName}: {ex.Message}\n\n"); - builder.Append("----------------------------\n"); - builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}\n"); - builder.Append($"OS: {Environment.OSVersion.ToString()}\n"); - builder.Append($"Framework: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\n"); - builder.Append($"Source: {ex.Source}\n"); - builder.Append($"---------------------------\n\n"); - builder.Append(ex.StackTrace); - while (ex.InnerException != null) - { - ex = ex.InnerException; - builder.Append($"\n\nInnerException::: {ex.GetType().FullName}: {ex.Message}\n"); - builder.Append(ex.StackTrace); - } + AvaloniaXamlLoader.Load(this); - var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); - var file = Path.Combine(Native.OS.DataDir, $"crash_{time}.log"); - File.WriteAllText(file, builder.ToString()); + var pref = ViewModels.Preferences.Instance; + SetLocale(pref.Locale); + SetTheme(pref.Theme, pref.ThemeOverrides); + SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily); } - private static void Check4Update(bool manually = false) + public override void OnFrameworkInitializationCompleted() { - Task.Run(async () => + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - try + BindingPlugins.DataValidators.RemoveAt(0); + + // Disable tooltip if window is not active. + ToolTip.ToolTipOpeningEvent.AddClassHandler((c, e) => { - // Fetch lastest release information. - var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(5) }; - var data = await client.GetStringAsync("https://sourcegit-scm.github.io/data/version.json"); + var topLevel = TopLevel.GetTopLevel(c); + if (topLevel is not Window { IsActive: true }) + e.Cancel = true; + }); - // Parse json into Models.Version. - var ver = JsonSerializer.Deserialize(data, JsonCodeGen.Default.Version); - if (ver == null) - return; + if (TryLaunchAsFileHistoryViewer(desktop)) + return; - // Check if already up-to-date. - if (!ver.IsNewVersion) - { - if (manually) - ShowSelfUpdateResult(new Models.AlreadyUpToDate()); - return; - } + if (TryLaunchAsBlameViewer(desktop)) + return; - // Should not check ignored tag if this is called manually. - if (!manually) - { - var pref = ViewModels.Preference.Instance; - if (ver.TagName == pref.IgnoreUpdateTag) - return; - } + if (TryLaunchAsCoreEditor(desktop)) + return; - ShowSelfUpdateResult(ver); - } - catch (Exception e) - { - if (manually) - ShowSelfUpdateResult(e); - } - }); - } + if (TryLaunchAsAskpass(desktop)) + return; - private static void ShowSelfUpdateResult(object data) - { - Dispatcher.UIThread.Post(() => - { - OpenDialog(new Views.SelfUpdate() { DataContext = new ViewModels.SelfUpdate() { Data = data } }); - }); + TryLaunchAsNormal(desktop); + } } + #endregion - private static bool TryLaunchedAsRebaseTodoEditor(string[] args, out int exitCode) + #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]; + var file = args[1].Replace('\\', '/').Trim('\"').Trim(); var filename = Path.GetFileName(file); if (!filename.Equals("git-rebase-todo", StringComparison.OrdinalIgnoreCase)) return true; @@ -434,44 +295,18 @@ private static bool TryLaunchedAsRebaseTodoEditor(string[] args, out int exitCod if (!dirInfo.Exists || !dirInfo.Name.Equals("rebase-merge", StringComparison.Ordinal)) return true; - var jobsFile = Path.Combine(dirInfo.Parent!.FullName, "sourcegit_rebase_jobs.json"); + var jobsFile = Path.Combine(dirInfo.Parent!.FullName, "sourcegit.interactive_rebase"); if (!File.Exists(jobsFile)) return true; - var collection = JsonSerializer.Deserialize(File.ReadAllText(jobsFile), JsonCodeGen.Default.InteractiveRebaseJobCollection); - var lines = new List(); - foreach (var job in collection.Jobs) - { - switch (job.Action) - { - case Models.InteractiveRebaseAction.Pick: - lines.Add($"p {job.SHA}"); - break; - case Models.InteractiveRebaseAction.Edit: - lines.Add($"e {job.SHA}"); - break; - case Models.InteractiveRebaseAction.Reword: - lines.Add($"r {job.SHA}"); - break; - case Models.InteractiveRebaseAction.Squash: - lines.Add($"s {job.SHA}"); - break; - case Models.InteractiveRebaseAction.Fixup: - lines.Add($"f {job.SHA}"); - break; - default: - lines.Add($"d {job.SHA}"); - break; - } - } - - File.WriteAllLines(file, lines); - + using var stream = File.OpenRead(jobsFile); + var collection = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.InteractiveRebaseJobCollection); + collection.WriteTodoList(file); exitCode = 0; return true; } - private static bool TryLaunchedAsRebaseMessageEditor(string[] args, out int exitCode) + private static bool TryLaunchAsRebaseMessageEditor(string[] args, out int exitCode) { exitCode = -1; @@ -480,47 +315,129 @@ private static bool TryLaunchedAsRebaseMessageEditor(string[] args, out int exit exitCode = 0; - var file = args[1]; + 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 jobsFile = Path.Combine(gitDir, "sourcegit_rebase_jobs.json"); - if (!File.Exists(jobsFile)) + 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 collection = JsonSerializer.Deserialize(File.ReadAllText(jobsFile), JsonCodeGen.Default.InteractiveRebaseJobCollection); - var doneFile = Path.Combine(gitDir, "rebase-merge", "done"); - if (!File.Exists(doneFile)) + 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; + } - var done = File.ReadAllText(doneFile).Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); - if (done.Length > collection.Jobs.Count) + 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; + } + + 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 job = collection.Jobs[done.Length - 1]; - File.WriteAllText(file, job.Message); + 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; + } + var relFile = Path.GetRelativePath(repo, file); + var viewer = new Views.Blame() + { + DataContext = new ViewModels.Blame(repo, relFile, head) + }; + desktop.MainWindow = viewer; return true; } - private bool TryLaunchedAsCoreEditor(IClassicDesktopStyleApplicationLifetime desktop) + private bool TryLaunchAsCoreEditor(IClassicDesktopStyleApplicationLifetime desktop) { var args = desktop.Args; - if (args == null || args.Length <= 1 || !args[0].Equals("--core-editor", StringComparison.Ordinal)) + if (args is not { Length: > 1 } || !args[0].Equals("--core-editor", StringComparison.Ordinal)) return false; - var file = args[1]; + var file = args[1].Replace('\\', '/').Trim('\"').Trim(); if (!File.Exists(file)) + { desktop.Shutdown(-1); - else - desktop.MainWindow = new Views.StandaloneCommitMessageEditor(file); + return true; + } + var editor = new Views.CommitMessageEditor(); + editor.AsStandalone(file); + desktop.MainWindow = editor; return true; } - private bool TryLaunchedAsAskpass(IClassicDesktopStyleApplicationLifetime desktop) + private bool TryLaunchAsAskpass(IClassicDesktopStyleApplicationLifetime desktop) { var launchAsAskpass = Environment.GetEnvironmentVariable("SOURCEGIT_LAUNCH_AS_ASKPASS"); if (launchAsAskpass is not "TRUE") @@ -529,30 +446,158 @@ private bool TryLaunchedAsAskpass(IClassicDesktopStyleApplicationLifetime deskto var args = desktop.Args; if (args?.Length > 0) { - desktop.MainWindow = new Views.Askpass(args[0]); + var askpass = new Views.Askpass(); + askpass.TxtDescription.Text = args[0]; + desktop.MainWindow = askpass; return true; } return false; } - private void TryLaunchedAsNormal(IClassicDesktopStyleApplicationLifetime desktop) + private void TryLaunchAsNormal(IClassicDesktopStyleApplicationLifetime desktop) { - Native.OS.SetupEnternalTools(); + _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 != null && desktop.Args.Length == 1 && Directory.Exists(desktop.Args[0])) - startupRepo = desktop.Args[0]; + 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; + }; - var pref = ViewModels.Preference.Instance; + _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 ResourceDictionary _themeOverrides = null; 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/Commands/Add.cs b/src/Commands/Add.cs index c3d1d3e64..916063d82 100644 --- a/src/Commands/Add.cs +++ b/src/Commands/Add.cs @@ -1,31 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands +namespace SourceGit.Commands { public class Add : Command { - public Add(string repo, bool includeUntracked) + public Add(string repo, string pathspecFromFile) { WorkingDirectory = repo; Context = repo; - Args = includeUntracked ? "add ." : "add -u ."; - } - - public Add(string repo, List changes) - { - WorkingDirectory = repo; - Context = repo; - - 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 d1c9ffbcc..ca6ffe8d9 100644 --- a/src/Commands/Apply.cs +++ b/src/Commands/Apply.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Text; + +namespace SourceGit.Commands { public class Apply : Command { @@ -6,14 +8,19 @@ public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceM { WorkingDirectory = repo; Context = repo; - Args = "apply "; + + var builder = new StringBuilder(1024); + builder.Append("apply "); + if (ignoreWhitespace) - Args += "--ignore-whitespace "; + builder.Append("--ignore-whitespace "); else - Args += $"--whitespace={whitespaceMode} "; + builder.Append("--whitespace=").Append(whitespaceMode).Append(' '); + if (!string.IsNullOrEmpty(extra)) - Args += $"{extra} "; - Args += $"\"{file}\""; + builder.Append(extra).Append(' '); + + Args = builder.Append(file.Quoted()).ToString(); } } } diff --git a/src/Commands/Archive.cs b/src/Commands/Archive.cs index d4f6241c0..e2214aac8 100644 --- a/src/Commands/Archive.cs +++ b/src/Commands/Archive.cs @@ -1,23 +1,12 @@ -using System; - -namespace SourceGit.Commands +namespace SourceGit.Commands { public class Archive : Command { - public Archive(string repo, string revision, string saveTo, Action outputHandler) + 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 readonly Action _outputHandler; } } diff --git a/src/Commands/AssumeUnchanged.cs b/src/Commands/AssumeUnchanged.cs index 1898122aa..2ddaa3c33 100644 --- a/src/Commands/AssumeUnchanged.cs +++ b/src/Commands/AssumeUnchanged.cs @@ -1,75 +1,14 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands +namespace SourceGit.Commands { - public partial class AssumeUnchanged + public class AssumeUnchanged : Command { - [GeneratedRegex(@"^(\w)\s+(.+)$")] - private static partial Regex REG_PARSE(); - - class ViewCommand : Command - { - 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_PARSE().Match(line); - if (!match.Success) - return; - - if (match.Groups[1].Value == "h") - { - _outs.Add(match.Groups[2].Value); - } - } - - private readonly 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}\""; - } - } - - public AssumeUnchanged(string repo) + public AssumeUnchanged(string repo, string file, bool bAdd) { - _repo = repo; - } + var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; - public List View() - { - return new ViewCommand(_repo).Result(); + WorkingDirectory = repo; + Context = repo; + Args = $"update-index {mode} -- {file.Quoted()}"; } - - public void Add(string file) - { - new ModCommand(_repo, file, true).Exec(); - } - - public void Remove(string file) - { - new ModCommand(_repo, file, false).Exec(); - } - - private readonly 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 de221b07b..0f247d736 100644 --- a/src/Commands/Blame.cs +++ b/src/Commands/Blame.cs @@ -1,30 +1,43 @@ using System; using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace SourceGit.Commands { public partial class Blame : Command { - [GeneratedRegex(@"^\^?([0-9a-f]+)\s+.*\((.*)\s+(\d+)\s+[\-\+]?\d+\s+\d+\) (.*)")] + [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() + public async Task ReadAsync() { - var succ = Exec(); - if (!succ) + 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) { - return new Models.BlameData(); + ParseLine(line); + + if (_result.IsBinary) + break; } if (_needUnifyCommitSHA) @@ -32,9 +45,7 @@ public Models.BlameData Result() foreach (var line in _result.LineInfos) { if (line.CommitSHA.Length > _minSHALen) - { line.CommitSHA = line.CommitSHA.Substring(0, _minSHALen); - } } } @@ -42,14 +53,9 @@ public Models.BlameData Result() return _result; } - protected override void OnReadline(string line) + private void ParseLine(string line) { - if (_result.IsBinary) - return; - if (string.IsNullOrEmpty(line)) - return; - - if (line.IndexOf('\0', StringComparison.Ordinal) >= 0) + if (line.Contains('\0')) { _result.IsBinary = true; _result.LineInfos.Clear(); @@ -60,19 +66,20 @@ protected override void OnReadline(string 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 = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString("yyyy/MM/dd"); + 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() { IsFirstInGroup = commit != _lastSHA, CommitSHA = commit, + File = file, Author = author, - Time = when, + Timestamp = timestamp, }; _result.LineInfos.Add(info); diff --git a/src/Commands/Branch.cs b/src/Commands/Branch.cs index 4ec8da825..6ed017841 100644 --- a/src/Commands/Branch.cs +++ b/src/Commands/Branch.cs @@ -1,66 +1,59 @@ -namespace SourceGit.Commands +using System.Text; +using System.Threading.Tasks; + +namespace SourceGit.Commands { - public static class Branch + public class Branch : Command { - public static bool Create(string repo, string name, string basedOn) + public Branch(string repo, string name) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"branch {name} {basedOn}"; - return cmd.Exec(); + WorkingDirectory = repo; + Context = repo; + _name = name; } - public static bool Rename(string repo, string name, string to) + public async Task CreateAsync(string basedOn, bool force) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"branch -M {name} {to}"; - return cmd.Exec(); + 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 SetUpstream(string repo, string name, string upstream) + public async Task RenameAsync(string to) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; + Args = $"branch -M {_name} {to}"; + return await ExecAsync().ConfigureAwait(false); + } - if (string.IsNullOrEmpty(upstream)) - cmd.Args = $"branch {name} --unset-upstream"; + public async Task SetUpstreamAsync(Models.Branch tracking) + { + if (tracking == null) + Args = $"branch {_name} --unset-upstream"; else - cmd.Args = $"branch {name} -u {upstream}"; + Args = $"branch {_name} -u {tracking.FriendlyName}"; - return cmd.Exec(); + return await ExecAsync().ConfigureAwait(false); } - public static bool DeleteLocal(string repo, string name) + public async Task DeleteLocalAsync(bool force) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"branch -D {name}"; - return cmd.Exec(); + Args = $"branch {(force ? "-D" : "-d")} {_name}"; + return await ExecAsync().ConfigureAwait(false); } - public static bool DeleteRemote(string repo, string remote, string name) + public async Task DeleteRemoteAsync(string remote, bool force) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - - bool exists = new Remote(repo).HasBranch(remote, name); - if (exists) - { - cmd.SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); - cmd.Args = $"push {remote} --delete {name}"; - } - else - { - cmd.Args = $"branch -D -r {remote}/{name}"; - } - - return cmd.Exec(); + 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 306d62ff7..024636bf9 100644 --- a/src/Commands/Checkout.cs +++ b/src/Commands/Checkout.cs @@ -1,6 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -12,69 +12,80 @@ public Checkout(string repo) Context = repo; } - public bool Branch(string branch, Action onProgress) + public async Task BranchAsync(string branch, bool force) { - Args = $"checkout --recurse-submodules --progress {branch}"; - TraitErrorAsOutput = true; - _outputHandler = onProgress; - return Exec(); + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(branch); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } + + 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); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public bool Branch(string branch, string basedOn, Action onProgress) + public async Task CommitAsync(string commitId, bool force) { - Args = $"checkout --recurse-submodules --progress -b {branch} {basedOn}"; - TraitErrorAsOutput = true; - _outputHandler = onProgress; - return Exec(); + var option = force ? "--force" : string.Empty; + Args = $"checkout {option} --detach --progress {commitId}"; + return await ExecAsync().ConfigureAwait(false); } - public bool UseTheirs(List files) + public async Task UseTheirsAsync(List files) { var builder = new StringBuilder(); builder.Append("checkout --theirs --"); foreach (var f in files) - { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } + builder.Append(' ').Append(f.Quoted()); Args = builder.ToString(); - return Exec(); + return await ExecAsync().ConfigureAwait(false); } - public bool UseMine(List files) + public async Task UseMineAsync(List files) { var builder = new StringBuilder(); builder.Append("checkout --ours --"); foreach (var f in files) - { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } + builder.Append(' ').Append(f.Quoted()); + Args = builder.ToString(); - return Exec(); + return await ExecAsync().ConfigureAwait(false); } - public bool FileWithRevision(string file, string revision) + public async Task FileWithRevisionAsync(string file, string revision) { - Args = $"checkout --no-overlay {revision} -- \"{file}\""; - return Exec(); + Args = $"checkout --no-overlay {revision} -- {file.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Commit(string commitId, Action onProgress) + public async Task MultipleFilesWithRevisionAsync(List files, string revision) { - Args = $"checkout --detach --progress {commitId}"; - TraitErrorAsOutput = true; - _outputHandler = onProgress; - return Exec(); - } + var builder = new StringBuilder(); + builder + .Append("checkout --no-overlay ") + .Append(revision) + .Append(" --"); - protected override void OnReadline(string line) - { - _outputHandler?.Invoke(line); - } + foreach (var f in files) + builder.Append(' ').Append(f.Quoted()); - private Action _outputHandler; + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); + } } } diff --git a/src/Commands/CherryPick.cs b/src/Commands/CherryPick.cs index 0c82b9fda..d9d4faea2 100644 --- a/src/Commands/CherryPick.cs +++ b/src/Commands/CherryPick.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Text; + +namespace SourceGit.Commands { public class CherryPick : Command { @@ -7,14 +9,16 @@ public CherryPick(string repo, string commits, bool noCommit, bool appendSourceT WorkingDirectory = repo; Context = repo; - Args = "cherry-pick "; + var builder = new StringBuilder(1024); + builder.Append("cherry-pick "); if (noCommit) - Args += "-n "; + builder.Append("-n "); if (appendSourceToMessage) - Args += "-x "; + builder.Append("-x "); if (!string.IsNullOrEmpty(extraParams)) - Args += $"{extraParams} "; - Args += commits; + builder.Append(extraParams).Append(' '); + + Args = builder.Append(commits).ToString(); } } } diff --git a/src/Commands/Clean.cs b/src/Commands/Clean.cs index a10e58731..119b73aa6 100644 --- a/src/Commands/Clean.cs +++ b/src/Commands/Clean.cs @@ -1,31 +1,18 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands +namespace SourceGit.Commands { public class Clean : Command { - public Clean(string repo, bool includeIgnored) + public Clean(string repo, Models.CleanMode mode) { WorkingDirectory = repo; Context = repo; - Args = includeIgnored ? "clean -qfdx" : "clean -qfd"; - } - public Clean(string repo, List files) - { - var builder = new StringBuilder(); - builder.Append("clean -qfd --"); - foreach (var f in files) + Args = mode switch { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } - - WorkingDirectory = repo; - Context = repo; - Args = builder.ToString(); + 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 683b88462..ffd11bda9 100644 --- a/src/Commands/Clone.cs +++ b/src/Commands/Clone.cs @@ -1,33 +1,24 @@ -using System; +using System.Text; namespace SourceGit.Commands { public class Clone : Command { - private readonly Action _notifyProgress; - - public Clone(string ctx, string path, string url, string localName, string sshKey, string extraArgs, Action ouputHandler) + public Clone(string ctx, string path, string url, string localName, string sshKey, string extraArgs) { Context = ctx; WorkingDirectory = path; - TraitErrorAsOutput = true; SSHKey = sshKey; - Args = "clone --progress --verbose --recurse-submodules "; + var builder = new StringBuilder(1024); + builder.Append("clone --progress --verbose "); if (!string.IsNullOrEmpty(extraArgs)) - Args += $"{extraArgs} "; - - Args += $"{url} "; - + builder.Append(extraArgs).Append(' '); + builder.Append(url.Quoted()).Append(' '); if (!string.IsNullOrEmpty(localName)) - Args += localName; + builder.Append(localName.Quoted()); - _notifyProgress = ouputHandler; - } - - 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 8d3044106..977c81012 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -3,23 +3,20 @@ using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; - -using Avalonia.Threading; +using System.Threading; +using System.Threading.Tasks; namespace SourceGit.Commands { public partial class Command { - public class CancelToken - { - public bool Requested { get; set; } = false; - } - - public class ReadToEndResult + public class Result { public bool IsSuccess { get; set; } = false; - public string StdOut { get; set; } = ""; - public string StdErr { get; set; } = ""; + 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 @@ -30,110 +27,93 @@ public enum EditorType } public string Context { get; set; } = string.Empty; - public CancelToken Cancel { get; set; } = null; public string WorkingDirectory { get; set; } = null; - public EditorType Editor { get; set; } = EditorType.CoreEditor; // Only used in Exec() mode + 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 bool TraitErrorAsOutput { get; set; } = false; + public Models.ICommandLog Log { get; set; } = null; - public bool Exec() + public async Task ExecAsync() { - var start = CreateGitStartInfo(); - var errs = new List(); - var proc = new Process() { StartInfo = start }; - var isCancelled = false; + Log?.AppendLine($"$ git {Args}\n"); - proc.OutputDataReceived += (_, e) => - { - if (Cancel != null && Cancel.Requested) - { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) - proc.Kill(true); - return; - } + var errs = new List(); - if (e.Data != null) - OnReadline(e.Data); - }; + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.OutputDataReceived += (_, e) => HandleOutput(e.Data, errs); + proc.ErrorDataReceived += (_, e) => HandleOutput(e.Data, errs); - proc.ErrorDataReceived += (_, e) => + var captured = new CapturedProcess() { Process = proc }; + var capturedLock = new object(); + try { - if (Cancel != null && Cancel.Requested) + proc.Start(); + + // Not safe, please only use `CancellationToken` in readonly commands. + if (CancellationToken.CanBeCanceled) { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) - proc.Kill(true); - return; + CancellationToken.Register(() => + { + lock (capturedLock) + { + if (captured is { Process: { HasExited: false } }) + captured.Process.Kill(true); + } + }); } + } + catch (Exception e) + { + if (RaiseError) + RaiseException(e.Message); - if (string.IsNullOrEmpty(e.Data)) - return; - if (TraitErrorAsOutput) - OnReadline(e.Data); + Log?.AppendLine(string.Empty); + return false; + } - // 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 (REG_PROGRESS().IsMatch(e.Data)) - return; - errs.Add(e.Data); - }; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); try { - proc.Start(); + await proc.WaitForExitAsync(CancellationToken).ConfigureAwait(false); } catch (Exception e) { - if (RaiseError) - { - Dispatcher.UIThread.Invoke(() => - { - App.RaiseException(Context, e.Message); - }); - } - return false; + HandleOutput(e.Message, errs); } - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - proc.WaitForExit(); + lock (capturedLock) + { + captured.Process = null; + } - int exitCode = proc.ExitCode; - proc.Close(); + Log?.AppendLine(string.Empty); - if (!isCancelled && exitCode != 0 && errs.Count > 0) + if (!CancellationToken.IsCancellationRequested && proc.ExitCode != 0) { if (RaiseError) { - Dispatcher.UIThread.Invoke(() => - { - App.RaiseException(Context, string.Join("\n", errs)); - }); + var errMsg = string.Join("\n", errs).Trim(); + if (!string.IsNullOrEmpty(errMsg)) + RaiseException(errMsg); } + return false; } return true; } - public ReadToEndResult ReadToEnd() + protected Result ReadToEnd() { - var start = CreateGitStartInfo(); - var proc = new Process() { StartInfo = start }; + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); try { @@ -141,80 +121,96 @@ public ReadToEndResult ReadToEnd() } catch (Exception e) { - return new ReadToEndResult() - { - IsSuccess = false, - StdOut = string.Empty, - StdErr = e.Message, - }; + return Result.Failed(e.Message); } - var rs = new ReadToEndResult() - { - StdOut = proc.StandardOutput.ReadToEnd(), - StdErr = proc.StandardError.ReadToEnd(), - }; - + var rs = new Result() { IsSuccess = true }; + rs.StdOut = proc.StandardOutput.ReadToEnd(); + rs.StdErr = proc.StandardError.ReadToEnd(); proc.WaitForExit(); - rs.IsSuccess = proc.ExitCode == 0; - proc.Close(); + rs.IsSuccess = proc.ExitCode == 0; return rs; } - protected virtual void OnReadline(string line) + protected async Task ReadToEndAsync() { - // Implemented by derived class + 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; } - private ProcessStartInfo CreateGitStartInfo() + protected ProcessStartInfo CreateGitStartInfo(bool redirect) { var start = new ProcessStartInfo(); start.FileName = Native.OS.GitExecutable; - start.Arguments = "--no-pager -c core.quotepath=off -c credential.helper=manager "; start.UseShellExecute = false; start.CreateNoWindow = true; - start.RedirectStandardOutput = true; - start.RedirectStandardError = true; - start.StandardOutputEncoding = Encoding.UTF8; - start.StandardErrorEncoding = Encoding.UTF8; + + 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 = Process.GetCurrentProcess().MainModule!.FileName; - if (!OperatingSystem.IsLinux()) - start.Environment.Add("DISPLAY", "required"); + 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}'"); + start.Environment.Add("GIT_SSH_COMMAND", $"ssh -i '{SSHKey}' -F '/dev/null'"); - // Force using en_US.UTF-8 locale to avoid GCM crash + // Force using en_US.UTF-8 locale if (OperatingSystem.IsLinux()) - start.Environment.Add("LANG", "en_US.UTF-8"); + { + start.Environment.Add("LANG", "C"); + start.Environment.Add("LC_ALL", "C"); + } - // Fix macOS `PATH` env - if (OperatingSystem.IsMacOS() && !string.IsNullOrEmpty(Native.OS.CustomPathEnv)) - start.Environment.Add("PATH", Native.OS.CustomPathEnv); + var builder = new StringBuilder(2048); + builder + .Append("--no-pager -c core.quotepath=off -c credential.helper=") + .Append(Native.OS.CredentialHelper) + .Append(' '); - // Force using this app as git editor. switch (Editor) { case EditorType.CoreEditor: - start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --core-editor\" "; + builder.Append($"""-c core.editor="\"{selfExecFile}\" --core-editor" """); break; case EditorType.RebaseEditor: - start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --rebase-message-editor\" -c sequence.editor=\"\\\"{selfExecFile}\\\" --rebase-todo-editor\" -c rebase.abbreviateCommands=true "; + builder.Append($"""-c core.editor="\"{selfExecFile}\" --rebase-message-editor" -c sequence.editor="\"{selfExecFile}\" --rebase-todo-editor" -c rebase.abbreviateCommands=true """); break; default: - start.Arguments += "-c core.editor=true "; + builder.Append("-c core.editor=true "); break; } - // Append command args - start.Arguments += Args; + builder.Append(Args); + start.Arguments = builder.ToString(); // Working directory if (!string.IsNullOrEmpty(WorkingDirectory)) @@ -223,6 +219,40 @@ private ProcessStartInfo CreateGitStartInfo() 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 cb0867936..b756c4ab4 100644 --- a/src/Commands/Commit.cs +++ b/src/Commands/Commit.cs @@ -1,40 +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 signOff) + public Commit(string repo, string message, bool signOff, bool noVerify, bool amend, bool resetAuthor) { _tmpFile = Path.GetTempFileName(); - File.WriteAllText(_tmpFile, message); + _message = message; WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - Args = $"commit --allow-empty --file=\"{_tmpFile}\""; - if (amend) - Args += " --amend --no-edit"; + + var builder = new StringBuilder(); + builder.Append("commit --allow-empty --file="); + builder.Append(_tmpFile.Quoted()); + builder.Append(' '); + if (signOff) - Args += " --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 bool Run() + public async Task RunAsync() { - var succ = Exec(); - try { + await File.WriteAllTextAsync(_tmpFile, _message).ConfigureAwait(false); + var succ = await ExecAsync().ConfigureAwait(false); File.Delete(_tmpFile); + return succ; } catch { - // Ignore + return false; } - - return succ; } - private string _tmpFile = string.Empty; + private readonly string _tmpFile; + private readonly string _message; } } diff --git a/src/Commands/CompareRevisions.cs b/src/Commands/CompareRevisions.cs index c4674c8e5..c7ee58915 100644 --- a/src/Commands/CompareRevisions.cs +++ b/src/Commands/CompareRevisions.cs @@ -1,13 +1,16 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; +using System.Diagnostics; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace SourceGit.Commands { public partial class CompareRevisions : Command { - [GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] + [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) { @@ -18,47 +21,75 @@ public CompareRevisions(string repo, string start, string end) Args = $"diff --name-status {based} {end}"; } - public List Result() + public CompareRevisions(string repo, string start, string end, string path) { - Exec(); - _changes.Sort((l, r) => string.Compare(l.Path, r.Path, StringComparison.Ordinal)); - return _changes; + 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) + public async Task> ReadAsync() { - var match = REG_FORMAT().Match(line); - if (!match.Success) - return; + 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); + } - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; + continue; + } - switch (status[0]) + 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 '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 { - 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; + //ignore changes; } - } - private readonly List _changes = new List(); + return changes; + } } } diff --git a/src/Commands/Config.cs b/src/Commands/Config.cs index 2fb833253..75f4d738a 100644 --- a/src/Commands/Config.cs +++ b/src/Commands/Config.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -17,11 +19,9 @@ public Config(string repository) Context = repository; _isLocal = true; } - - RaiseError = false; } - public Dictionary ListAll() + public Dictionary ReadAll() { Args = "config -l"; @@ -29,15 +29,35 @@ public Dictionary ListAll() var rs = new Dictionary(); if (output.IsSuccess) { - var lines = output.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + 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 idx = line.IndexOf('=', StringComparison.Ordinal); - if (idx != -1) + var parts = line.Split('=', 2); + if (parts.Length == 2) { - var key = line.Substring(0, idx).Trim(); - var val = line.Substring(idx + 1).Trim(); - rs[key] = val; + var key = parts[0].ToLower(CultureInfo.CurrentCulture); // Always use lower case for key + var value = parts[1]; + rs[key] = value; } } } @@ -51,16 +71,24 @@ public string Get(string key) return ReadToEnd().StdOut.Trim(); } - public bool Set(string key, string value, bool allowEmpty = false) + public async Task GetAsync(string key) + { + Args = $"config {key}"; + + 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}\""; + Args = $"config {scope} {key} {value.Quoted()}"; - return Exec(); + 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/CountLocalChangesWithoutUntracked.cs b/src/Commands/CountLocalChangesWithoutUntracked.cs deleted file mode 100644 index afb62840f..000000000 --- a/src/Commands/CountLocalChangesWithoutUntracked.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; - -namespace SourceGit.Commands -{ - public class CountLocalChangesWithoutUntracked : Command - { - public CountLocalChangesWithoutUntracked(string repo) - { - WorkingDirectory = repo; - Context = repo; - Args = "status -uno --ignore-submodules=dirty --porcelain"; - } - - public int Result() - { - var rs = ReadToEnd(); - if (rs.IsSuccess) - { - var lines = rs.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries); - return lines.Length; - } - - return 0; - } - } -} diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs index dea155928..89248ddc1 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -1,6 +1,10 @@ -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 { @@ -9,189 +13,310 @@ public partial class Diff : Command [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] private static partial Regex REG_INDICATOR(); - [GeneratedRegex(@"^index\s([0-9a-f]{6,40})\.\.([0-9a-f]{6,40})(\s[1-9]{6})?")] + [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 string PREFIX_LFS_NEW = "+version https://git-lfs.github.com/spec/"; - private const string PREFIX_LFS_DEL = "-version https://git-lfs.github.com/spec/"; - private const string PREFIX_LFS_MODIFY = " version https://git-lfs.github.com/spec/"; + private const char PREFIX_CONTEXT = ' '; + private const char PREFIX_DELETED = '-'; + private const char PREFIX_ADDED = '+'; + private const char PREFIX_COMMAND = '\\'; - public Diff(string repo, Models.DiffOption opt, int unified, bool ignoreWhitespace) + 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() - { - Repo = repo, - Option = opt, - }; + _result.TextDiff = new Models.TextDiff(); WorkingDirectory = repo; Context = repo; + var builder = new StringBuilder(256); + builder.Append("diff --no-color --no-ext-diff --full-index --patch "); if (ignoreWhitespace) - Args = $"diff --no-ext-diff --patch --ignore-cr-at-eol --ignore-all-space --unified={unified} {opt}"; - else - Args = $"diff --no-ext-diff --patch --ignore-cr-at-eol --unified={unified} {opt}"; + 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() + public async Task ReadAsync() { - Exec(); + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + 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.IsBinary || _result.IsLFS) + await proc.WaitForExitAsync(CancellationToken).ConfigureAwait(false); + } + catch + { + // Ignore exceptions. + } + + if (_isLFS || _result.IsBinary || _result.TextDiff.Lines.Count == 0) { _result.TextDiff = null; } else { - ProcessInlineHighlights(); + if (_isInChunk) + { + ProcessInlineHighlights(); + _isInChunk = false; + } + + if (_result.TextDiff.Lines.Count < 4) + { + var isSubmoduleChange = true; + + 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; + } + } + + if (isSubmoduleChange) + { + _result.IsSubmoduleChange = true; + _result.TextDiff = null; + return _result; + } + } - if (_result.TextDiff.Lines.Count == 0) - _result.TextDiff = null; - else - _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); + _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; } return _result; } - protected override void OnReadline(string line) + private void ParseLine(ArraySegment lineBytes) { - if (line.StartsWith("old mode ", StringComparison.Ordinal)) - { - _result.OldMode = line.Substring(9); + // 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 (line.StartsWith("new mode ", StringComparison.Ordinal)) + // If the current line is not a chunk-body, try to parse it as chunk-indicator + if (ParseChunkStartLine(line)) { - _result.NewMode = line.Substring(9); + _isInChunk = true; return; } - if (_result.IsBinary) + // 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; - if (_result.IsLFS) + var match = REG_HASH_CHANGE().Match(line); + if (match.Success) { - var ch = line[0]; - if (ch == '-') - { - if (line.StartsWith("-oid sha256:", StringComparison.Ordinal)) - { - _result.LFSDiff.Old.Oid = line.Substring(12); - } - else if (line.StartsWith("-size ", StringComparison.Ordinal)) - { - _result.LFSDiff.Old.Size = long.Parse(line.Substring(6)); - } - } - else if (ch == '+') - { - if (line.StartsWith("+oid sha256:", StringComparison.Ordinal)) - { - _result.LFSDiff.New.Oid = line.Substring(12); - } - else if (line.StartsWith("+size ", StringComparison.Ordinal)) - { - _result.LFSDiff.New.Size = long.Parse(line.Substring(6)); - } - } - else if (line.StartsWith(" size ", StringComparison.Ordinal)) - { - _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(line.Substring(6)); - } + if (string.IsNullOrEmpty(_result.OldHash)) + _result.OldHash = match.Groups[1].Value; + _result.NewHash = match.Groups[2].Value; return; } - if (_result.TextDiff.Lines.Count == 0) + if (line.StartsWith(SPECIAL_BINARY, StringComparison.Ordinal)) + _result.IsBinary = true; + } + + private bool ParseChunkStartLine(string line) + { + var match = REG_INDICATOR().Match(line); + if (match.Success) { - if (line.StartsWith("Binary", StringComparison.Ordinal)) - { - _result.IsBinary = true; - return; - } + _oldLine = int.Parse(match.Groups[1].Value); + _newLine = int.Parse(match.Groups[2].Value); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, null, 0, 0); + _result.TextDiff.Lines.Add(_last); + return true; + } - if (string.IsNullOrEmpty(_result.OldHash)) - { - var match = REG_HASH_CHANGE().Match(line); - if (!match.Success) - return; + return false; + } - _result.OldHash = match.Groups[1].Value; - _result.NewHash = match.Groups[2].Value; - } - else - { - var match = REG_INDICATOR().Match(line); - if (!match.Success) - return; + 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; - _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)); - } + if (prefix == PREFIX_DELETED) + { + _result.TextDiff.DeletedLines++; + _last = new Models.TextDiffLine(Models.TextDiffLineType.Deleted, content, rawContent, _oldLine, 0); + _deleted.Add(_last); + _oldLine++; + return true; } - else + + if (prefix == PREFIX_ADDED) { - if (line.Length == 0) - { - ProcessInlineHighlights(); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine)); - _oldLine++; - _newLine++; - return; - } + _result.TextDiff.AddedLines++; + _last = new Models.TextDiffLine(Models.TextDiffLineType.Added, content, rawContent, 0, _newLine); + _added.Add(_last); + _newLine++; + return true; + } - var ch = line[0]; - if (ch == '-') - { - if (_oldLine == 1 && _newLine == 0 && line.StartsWith(PREFIX_LFS_DEL, StringComparison.Ordinal)) - { - _result.IsLFS = true; - _result.LFSDiff = new Models.LFSDiff(); - return; - } + if (prefix == PREFIX_CONTEXT) + { + ProcessInlineHighlights(); - _deleted.Add(new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0)); - _oldLine++; + _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 (ch == '+') + else if (prefix == PREFIX_ADDED) { - if (_oldLine == 0 && _newLine == 1 && line.StartsWith(PREFIX_LFS_NEW, StringComparison.Ordinal)) - { - _result.IsLFS = true; - _result.LFSDiff = new Models.LFSDiff(); - return; - } - - _added.Add(new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine)); - _newLine++; + 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 (ch != '\\') + else if (prefix == PREFIX_CONTEXT) { - 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 (_oldLine == 1 && _newLine == 1 && line.StartsWith(PREFIX_LFS_MODIFY, StringComparison.Ordinal)) - { - _result.IsLFS = true; - _result.LFSDiff = new Models.LFSDiff(); - return; - } + if (content.StartsWith(LFS_SIZE_PREFIX, StringComparison.Ordinal)) + _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(content.AsSpan(5)); + } + return true; + } - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), _oldLine, _newLine)); - _oldLine++; - _newLine++; - } + 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() @@ -215,14 +340,10 @@ private void ProcessInlineHighlights() foreach (var chunk in chunks) { if (chunk.DeletedCount > 0) - { - left.Highlights.Add(new Models.TextInlineRange(chunk.DeletedStart, chunk.DeletedCount)); - } + left.Highlights.Add(new Models.TextRange(chunk.DeletedStart, chunk.DeletedCount)); if (chunk.AddedCount > 0) - { - right.Highlights.Add(new Models.TextInlineRange(chunk.AddedStart, chunk.AddedCount)); - } + right.Highlights.Add(new Models.TextRange(chunk.AddedStart, chunk.AddedCount)); } } } @@ -241,7 +362,10 @@ private void ProcessInlineHighlights() 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 a279bb84b..8d08b90a7 100644 --- a/src/Commands/Discard.cs +++ b/src/Commands/Discard.cs @@ -1,39 +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, bool includeIgnored) + /// + /// Discard all local changes (unstaged & staged) + /// + public static async Task AllAsync(string repo, bool includeModified, bool includeUntracked, bool includeIgnored, Models.ICommandLog log) { - new Restore(repo).Exec(); - new Clean(repo, includeIgnored).Exec(); + 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 Changes(string repo, List changes) + /// + /// Discard selected changes (only unstaged). + /// + /// + /// + /// + public static async Task ChangesAsync(string repo, List changes, Models.ICommandLog log) { - var needClean = new List(); - var needCheckout = new List(); + var restores = new List(); - foreach (var c in changes) + try { - if (c.WorkTree == Models.ChangeState.Untracked || c.WorkTree == Models.ChangeState.Added) - needClean.Add(c.Path); - else - needCheckout.Add(c.Path); + 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) + catch (Exception e) { - var count = Math.Min(10, needClean.Count - i); - new Clean(repo, needClean.GetRange(i, count)).Exec(); + Models.Notification.Send(repo, $"Failed to discard changes. Reason: {e.Message}", true); } - for (int i = 0; i < needCheckout.Count; i += 10) + if (restores.Count > 0) { - var count = Math.Min(10, needCheckout.Count - i); - new Restore(repo, needCheckout.GetRange(i, count), "--worktree --recurse-submodules").Exec(); + 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/ExecuteCustomAction.cs b/src/Commands/ExecuteCustomAction.cs deleted file mode 100644 index 4981b2f97..000000000 --- a/src/Commands/ExecuteCustomAction.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Diagnostics; -using System.Text; - -using Avalonia.Threading; - -namespace SourceGit.Commands -{ - public static class ExecuteCustomAction - { - public static void Run(string repo, string file, string args, Action outputHandler) - { - var start = new ProcessStartInfo(); - start.FileName = file; - start.Arguments = args; - start.UseShellExecute = false; - start.CreateNoWindow = true; - start.RedirectStandardOutput = true; - start.RedirectStandardError = true; - start.StandardOutputEncoding = Encoding.UTF8; - start.StandardErrorEncoding = Encoding.UTF8; - start.WorkingDirectory = repo; - - // Force using en_US.UTF-8 locale to avoid GCM crash - if (OperatingSystem.IsLinux()) - start.Environment.Add("LANG", "en_US.UTF-8"); - - // Fix macOS `PATH` env - if (OperatingSystem.IsMacOS() && !string.IsNullOrEmpty(Native.OS.CustomPathEnv)) - start.Environment.Add("PATH", Native.OS.CustomPathEnv); - - var proc = new Process() { StartInfo = start }; - var builder = new StringBuilder(); - - proc.OutputDataReceived += (_, e) => - { - if (e.Data != null) - outputHandler?.Invoke(e.Data); - }; - - proc.ErrorDataReceived += (_, e) => - { - if (e.Data != null) - { - outputHandler?.Invoke(e.Data); - builder.AppendLine(e.Data); - } - }; - - try - { - proc.Start(); - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - proc.WaitForExit(); - } - catch (Exception e) - { - Dispatcher.UIThread.Invoke(() => - { - App.RaiseException(repo, e.Message); - }); - } - - var exitCode = proc.ExitCode; - proc.Close(); - - if (exitCode != 0) - { - var errMsg = builder.ToString(); - Dispatcher.UIThread.Invoke(() => - { - App.RaiseException(repo, errMsg); - }); - } - } - } -} diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs index 834cd7fc2..914361257 100644 --- a/src/Commands/Fetch.cs +++ b/src/Commands/Fetch.cs @@ -1,44 +1,53 @@ -using System; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { public class Fetch : Command { - public Fetch(string repo, string remote, bool noTags, bool prune, Action outputHandler) + public Fetch(string repo, string remote, bool noTags, bool force) { - _outputHandler = outputHandler; + _remote = remote; + WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); - Args = "fetch --progress --verbose "; - if (noTags) - Args += "--no-tags "; - else - Args += "--tags "; + var builder = new StringBuilder(512); + builder.Append("fetch --progress --verbose "); + builder.Append(noTags ? "--no-tags " : "--tags "); + if (force) + builder.Append("--force "); + builder.Append(remote); + + Args = builder.ToString(); + } + + public Fetch(string repo, string remote) + { + _remote = remote; - if (prune) - Args += "--prune "; + WorkingDirectory = repo; + Context = repo; + RaiseError = false; - Args += remote; + Args = $"fetch --progress --verbose {remote}"; } - public Fetch(string repo, Models.Branch local, Models.Branch remote, Action outputHandler) + public Fetch(string repo, Models.Branch local, Models.Branch remote) { - _outputHandler = outputHandler; + _remote = remote.Remote; + WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - SSHKey = new Config(repo).Get($"remote.{remote.Remote}.sshkey"); Args = $"fetch --progress --verbose {remote.Remote} {remote.Name}:{local.Name}"; } - protected override void OnReadline(string line) + public async Task RunAsync() { - _outputHandler?.Invoke(line); + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{_remote}.sshkey").ConfigureAwait(false); + return await ExecAsync().ConfigureAwait(false); } - private readonly Action _outputHandler; + private readonly string _remote; } } diff --git a/src/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs index 2c7359c0d..cf97f8b07 100644 --- a/src/Commands/FormatPatch.cs +++ b/src/Commands/FormatPatch.cs @@ -6,7 +6,8 @@ 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 393b915e7..0b27f487f 100644 --- a/src/Commands/GC.cs +++ b/src/Commands/GC.cs @@ -1,23 +1,12 @@ -using System; - -namespace SourceGit.Commands +namespace SourceGit.Commands { public class GC : Command { - public GC(string repo, Action outputHandler) + public GC(string repo) { - _outputHandler = outputHandler; WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; Args = "gc --prune=now"; } - - protected override void OnReadline(string line) - { - _outputHandler?.Invoke(line); - } - - private readonly Action _outputHandler; } } diff --git a/src/Commands/GenerateCommitMessage.cs b/src/Commands/GenerateCommitMessage.cs deleted file mode 100644 index e4f25f380..000000000 --- a/src/Commands/GenerateCommitMessage.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; - -namespace SourceGit.Commands -{ - /// - /// A C# version of https://github.com/anjerodev/commitollama - /// - public class GenerateCommitMessage - { - public class GetDiffContent : Command - { - public GetDiffContent(string repo, Models.DiffOption opt) - { - WorkingDirectory = repo; - Context = repo; - Args = $"diff --diff-algorithm=minimal {opt}"; - } - } - - public GenerateCommitMessage(Models.OpenAIService service, string repo, List changes, CancellationToken cancelToken, Action onProgress) - { - _service = service; - _repo = repo; - _changes = changes; - _cancelToken = cancelToken; - _onProgress = onProgress; - } - - public string Result() - { - try - { - var summarybuilder = new StringBuilder(); - var bodyBuilder = new StringBuilder(); - foreach (var change in _changes) - { - if (_cancelToken.IsCancellationRequested) - return ""; - - _onProgress?.Invoke($"Analyzing {change.Path}..."); - - var summary = GenerateChangeSummary(change); - summarybuilder.Append("- "); - summarybuilder.Append(summary); - summarybuilder.Append("(file: "); - summarybuilder.Append(change.Path); - summarybuilder.Append(")"); - summarybuilder.AppendLine(); - - bodyBuilder.Append("- "); - bodyBuilder.Append(summary); - bodyBuilder.AppendLine(); - } - - if (_cancelToken.IsCancellationRequested) - return ""; - - _onProgress?.Invoke($"Generating commit message..."); - - var body = bodyBuilder.ToString(); - var subject = GenerateSubject(summarybuilder.ToString()); - return string.Format("{0}\n\n{1}", subject, body); - } - catch (Exception e) - { - App.RaiseException(_repo, $"Failed to generate commit message: {e}"); - return ""; - } - } - - private string GenerateChangeSummary(Models.Change change) - { - var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd(); - var diff = rs.IsSuccess ? rs.StdOut : "unknown change"; - - var rsp = _service.Chat(_service.AnalyzeDiffPrompt, $"Here is the `git diff` output: {diff}", _cancelToken); - if (rsp != null && rsp.Choices.Count > 0) - return rsp.Choices[0].Message.Content; - - return string.Empty; - } - - private string GenerateSubject(string summary) - { - var rsp = _service.Chat(_service.GenerateSubjectPrompt, $"Here are the summaries changes:\n{summary}", _cancelToken); - if (rsp != null && rsp.Choices.Count > 0) - return rsp.Choices[0].Message.Content; - - return string.Empty; - } - - private Models.OpenAIService _service; - private string _repo; - private List _changes; - private CancellationToken _cancelToken; - private Action _onProgress; - } -} 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 5e05ed832..0438d76de 100644 --- a/src/Commands/GitFlow.cs +++ b/src/Commands/GitFlow.cs @@ -1,164 +1,113 @@ -using System; -using System.Collections.Generic; - -using Avalonia.Threading; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { - public static class GitFlow + public class GitFlow : Command { - public class BranchDetectResult + public GitFlow(string repo) { - public bool IsGitFlowBranch { get; set; } = false; - public string Type { get; set; } = string.Empty; - public string Prefix { get; set; } = string.Empty; + WorkingDirectory = repo; + Context = repo; } - public static bool IsEnabled(string repo, List branches) + public async Task InitAsync(string production, string develop, string feature, string release, string hotfix, string tag) { - var localBrancheNames = new HashSet(); - foreach (var branch in branches) + if (Native.OS.GitFlowVersion == Models.GitFlowVersion.Next) { - if (branch.IsLocal) - localBrancheNames.Add(branch.Name); + 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/"); + + if (!string.IsNullOrEmpty(tag)) + builder.Append(" --tag=").Append(tag); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - var config = new Config(repo).ListAll(); - if (!config.TryGetValue("gitflow.branch.master", out string master) || !localBrancheNames.Contains(master)) - return false; - - if (!config.TryGetValue("gitflow.branch.develop", out string develop) || !localBrancheNames.Contains(develop)) - return false; - - return config.ContainsKey("gitflow.prefix.feature") && - config.ContainsKey("gitflow.prefix.release") && - config.ContainsKey("gitflow.prefix.hotfix"); + 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 await ExecAsync().ConfigureAwait(false); } - public static bool Init(string repo, List branches, string master, string develop, string feature, string release, string hotfix, string version) + public async Task StartAsync(Models.GitFlowBranchType type, string name, Models.Branch based) { - var current = branches.Find(x => x.IsCurrent); - - var masterBranch = branches.Find(x => x.Name == master); - if (masterBranch == null && current != null) - Branch.Create(repo, master, current.Head); - - var devBranch = branches.Find(x => x.Name == develop); - if (devBranch == null && current != null) - Branch.Create(repo, develop, current.Head); - - var config = new Config(repo); - config.Set("gitflow.branch.master", master); - config.Set("gitflow.branch.develop", develop); - config.Set("gitflow.prefix.feature", feature); - config.Set("gitflow.prefix.bugfix", "bugfix/"); - config.Set("gitflow.prefix.release", release); - config.Set("gitflow.prefix.hotfix", hotfix); - config.Set("gitflow.prefix.support", "support/"); - config.Set("gitflow.prefix.versiontag", version, true); - - var init = new Command(); - init.WorkingDirectory = repo; - init.Context = repo; - init.Args = "flow init -d"; - return init.Exec(); - } + var builder = new StringBuilder(); + builder.Append("flow "); - public static string GetPrefix(string repo, string type) - { - return new Config(repo).Get($"gitflow.prefix.{type}"); - } - - public static BranchDetectResult DetectType(string repo, List branches, string branch) - { - var rs = new BranchDetectResult(); - var localBrancheNames = new HashSet(); - foreach (var b in branches) + switch (type) { - if (b.IsLocal) - localBrancheNames.Add(b.Name); + 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; } - var config = new Config(repo).ListAll(); - if (!config.TryGetValue("gitflow.branch.master", out string master) || !localBrancheNames.Contains(master)) - return rs; - - if (!config.TryGetValue("gitflow.branch.develop", out string develop) || !localBrancheNames.Contains(develop)) - return rs; - - if (!config.TryGetValue("gitflow.prefix.feature", out var feature) || - !config.TryGetValue("gitflow.prefix.release", out var release) || - !config.TryGetValue("gitflow.prefix.hotfix", out var hotfix)) - return rs; - - if (branch.StartsWith(feature, StringComparison.Ordinal)) - { - rs.IsGitFlowBranch = true; - rs.Type = "feature"; - rs.Prefix = feature; - } - else if (branch.StartsWith(release, StringComparison.Ordinal)) - { - rs.IsGitFlowBranch = true; - rs.Type = "release"; - rs.Prefix = release; - } - else if (branch.StartsWith(hotfix, StringComparison.Ordinal)) - { - rs.IsGitFlowBranch = true; - rs.Type = "hotfix"; - rs.Prefix = hotfix; - } + builder.Append(" start ").Append(name); + if (based != null) + builder.Append(' ').Append(based.Name); - return rs; + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public static bool Start(string repo, string type, string name) + public async Task FinishAsync(Models.GitFlowBranchType type, string name, bool rebase, bool squash, bool keepBranch) { - if (!SUPPORTED_BRANCH_TYPES.Contains(type)) - { - Dispatcher.UIThread.Post(() => - { - App.RaiseException(repo, "Bad branch type!!!"); - }); + var builder = new StringBuilder(); + builder.Append("flow "); - return false; - } - - var start = new Command(); - start.WorkingDirectory = repo; - start.Context = repo; - start.Args = $"flow {type} start {name}"; - return start.Exec(); - } - - public static bool Finish(string repo, string type, string name, bool keepBranch) - { - if (!SUPPORTED_BRANCH_TYPES.Contains(type)) + switch (type) { - Dispatcher.UIThread.Post(() => - { - App.RaiseException(repo, "Bad branch type!!!"); - }); - - return false; + 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; } - var option = keepBranch ? "-k" : string.Empty; - var finish = new Command(); - finish.WorkingDirectory = repo; - finish.Context = repo; - finish.Args = $"flow {type} finish {option} {name}"; - return finish.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); } - - private static readonly List SUPPORTED_BRANCH_TYPES = new List() - { - "feature", - "release", - "bugfix", - "hotfix", - "support", - }; } } diff --git a/src/Commands/GitIgnore.cs b/src/Commands/GitIgnore.cs deleted file mode 100644 index e666eba67..000000000 --- a/src/Commands/GitIgnore.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.IO; - -namespace SourceGit.Commands -{ - public static class GitIgnore - { - public static void Add(string repo, string pattern) - { - var file = Path.Combine(repo, ".gitignore"); - if (!File.Exists(file)) - File.WriteAllLines(file, [pattern]); - else - File.AppendAllLines(file, [pattern]); - } - } -} 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 de59b5a41..9dbe05459 100644 --- a/src/Commands/IsBinary.cs +++ b/src/Commands/IsBinary.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -7,17 +8,18 @@ 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() + public async Task GetResultAsync() { - return REG_TEST().IsMatch(ReadToEnd().StdOut); + 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/IsConflictResolved.cs b/src/Commands/IsConflictResolved.cs deleted file mode 100644 index 13bc12ac9..000000000 --- a/src/Commands/IsConflictResolved.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SourceGit.Commands -{ - public class IsConflictResolved : Command - { - public IsConflictResolved(string repo, Models.Change change) - { - var opt = new Models.DiffOption(change, true); - - WorkingDirectory = repo; - Context = repo; - Args = $"diff -a --ignore-cr-at-eol --check {opt}"; - } - } -} diff --git a/src/Commands/IsLFSFiltered.cs b/src/Commands/IsLFSFiltered.cs index 2a7234bbb..5c45f8bc6 100644 --- a/src/Commands/IsLFSFiltered.cs +++ b/src/Commands/IsLFSFiltered.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Threading.Tasks; + +namespace SourceGit.Commands { public class IsLFSFiltered : Command { @@ -6,7 +8,7 @@ public IsLFSFiltered(string repo, string path) { WorkingDirectory = repo; Context = repo; - Args = $"check-attr -z filter \"{path}\""; + Args = $"check-attr -z filter {path.Quoted()}"; RaiseError = false; } @@ -14,13 +16,23 @@ public IsLFSFiltered(string repo, string sha, string path) { WorkingDirectory = repo; Context = repo; - Args = $"check-attr --source {sha} -z filter \"{path}\""; + Args = $"check-attr --source {sha} -z filter {path.Quoted()}"; RaiseError = false; } - public bool Result() + public bool GetResult() + { + return Parse(ReadToEnd()); + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return Parse(rs); + } + + private bool Parse(Result rs) { - var rs = ReadToEnd(); 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 c9ab7b418..6e4283359 100644 --- a/src/Commands/LFS.cs +++ b/src/Commands/LFS.cs @@ -1,123 +1,112 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.RegularExpressions; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; namespace SourceGit.Commands { - public partial class LFS + public class LFS : Command { - [GeneratedRegex(@"^(.+)\s+(\w+)\s+\w+:(\d+)$")] - private static partial Regex REG_LOCK(); - - class SubCmd : Command - { - public SubCmd(string repo, string args, Action onProgress) - { - WorkingDirectory = repo; - Context = repo; - Args = args; - TraitErrorAsOutput = true; - _outputHandler = onProgress; - } - - protected override void OnReadline(string line) - { - _outputHandler?.Invoke(line); - } - - private readonly Action _outputHandler; - } - public LFS(string repo) { - _repo = repo; + WorkingDirectory = repo; + Context = repo; } - public bool IsEnabled() + public async Task InstallAsync() { - var path = Path.Combine(_repo, ".git", "hooks", "pre-push"); - if (!File.Exists(path)) - return false; - - var content = File.ReadAllText(path); - return content.Contains("git lfs pre-push"); + Args = "lfs install --local"; + return await ExecAsync().ConfigureAwait(false); } - public bool Install() + public async Task TrackAsync(string pattern, bool isFilenameMode) { - return new SubCmd(_repo, "lfs install --local", null).Exec(); - } + var builder = new StringBuilder(); + builder.Append("lfs track "); + builder.Append(isFilenameMode ? "--filename " : string.Empty); + builder.Append(pattern.Quoted()); - public bool Track(string pattern, bool isFilenameMode = false) - { - var opt = isFilenameMode ? "--filename" : ""; - return new SubCmd(_repo, $"lfs track {opt} \"{pattern}\"", null).Exec(); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public void Fetch(string remote, Action outputHandler) + public async Task FetchAsync(string remote) { - new SubCmd(_repo, $"lfs fetch {remote}", outputHandler).Exec(); + Args = $"lfs fetch {remote}"; + await ExecAsync().ConfigureAwait(false); } - public void Pull(string remote, Action outputHandler) + public async Task PullAsync(string remote) { - new SubCmd(_repo, $"lfs pull {remote}", outputHandler).Exec(); + Args = $"lfs pull {remote}"; + await ExecAsync().ConfigureAwait(false); } - public void Push(string remote, Action outputHandler) + public async Task PushAsync(string remote) { - new SubCmd(_repo, $"lfs push {remote}", outputHandler).Exec(); + Args = $"lfs push {remote}"; + await ExecAsync().ConfigureAwait(false); } - public void Prune(Action outputHandler) + public async Task PruneAsync() { - new SubCmd(_repo, "lfs prune", outputHandler).Exec(); + Args = "lfs prune"; + await ExecAsync().ConfigureAwait(false); } - public List Locks(string remote) + public async Task> GetLocksAsync(string remote) { - var locks = new List(); - var cmd = new SubCmd(_repo, $"lfs locks --remote={remote}", null); - var rs = cmd.ReadToEnd(); + Args = $"lfs locks --json --remote={remote}"; + + var rs = await ReadToEndAsync().ConfigureAwait(false); if (rs.IsSuccess) { - var lines = rs.StdOut.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) + try { - var match = REG_LOCK().Match(line); - if (match.Success) - { - locks.Add(new Models.LFSLock() - { - File = match.Groups[1].Value, - User = match.Groups[2].Value, - ID = long.Parse(match.Groups[3].Value), - }); - } + var locks = JsonSerializer.Deserialize(rs.StdOut, JsonCodeGen.Default.ListLFSLock); + return locks; + } + catch + { + // Ignore exceptions. } } - return locks; + return []; } - public bool Lock(string remote, string file) + public async Task LockAsync(string remote, string file) { - return new SubCmd(_repo, $"lfs lock --remote={remote} \"{file}\"", null).Exec(); + Args = $"lfs lock --remote={remote} {file.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Unlock(string remote, string file, bool force) + public async Task UnlockAsync(string remote, string file, bool force) { - var opt = force ? "-f" : ""; - return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} \"{file}\"", null).Exec(); + 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); } - public bool Unlock(string remote, long id, bool force) + public async Task UnlockMultipleAsync(string remote, List files, bool force) { - var opt = force ? "-f" : ""; - return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} --id={id}", null).Exec(); - } + var builder = new StringBuilder(); + builder + .Append("lfs unlock --remote=") + .Append(remote) + .Append(force ? " -f" : " "); - private readonly string _repo; + 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 cf2e285fe..bcb8811f9 100644 --- a/src/Commands/Merge.cs +++ b/src/Commands/Merge.cs @@ -1,23 +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) + public Merge(string repo, string source, string mode, bool edit) { - _outputHandler = outputHandler; WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - Args = $"merge --progress {source} {mode}"; + + 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(); } - protected override void OnReadline(string line) + public Merge(string repo, List targets, bool autoCommit, string strategy) { - _outputHandler?.Invoke(line); - } + 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 "); - private readonly Action _outputHandler = null; + 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 d3478d592..45e17cc6e 100644 --- a/src/Commands/MergeTool.cs +++ b/src/Commands/MergeTool.cs @@ -1,69 +1,65 @@ -using System.IO; - -using Avalonia.Threading; +using System; +using System.Threading.Tasks; namespace SourceGit.Commands { - public static class MergeTool + public class MergeTool : Command { - public static bool OpenForMerge(string repo, int toolType, string toolPath, string file) + public MergeTool(string repo, string file) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.RaiseError = true; + WorkingDirectory = repo; + Context = repo; + _file = string.IsNullOrEmpty(file) ? string.Empty : file.Quoted(); + } - if (toolType == 0) + public async Task OpenAsync() + { + var tool = Native.OS.GetDiffMergeTool(false); + if (tool == null) { - cmd.Args = $"mergetool \"{file}\""; - return cmd.Exec(); + RaiseException("Invalid diff/merge tool in preference setting!"); + return false; } - if (!File.Exists(toolPath)) + if (string.IsNullOrEmpty(tool.Cmd)) { - Dispatcher.UIThread.Post(() => App.RaiseException(repo, $"Can NOT found external merge tool in '{toolPath}'!")); - return false; - } + var ok = await CheckGitConfigurationAsync(); + if (!ok) + return false; - var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); - if (supported == null) + Args = $"mergetool -g --no-prompt {_file}"; + } + else { - Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); - return false; + 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}"; } - cmd.Args = $"-c mergetool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.Cmd}\" -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, int toolType, string toolPath, Models.DiffOption option) + private async Task CheckGitConfigurationAsync() { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.RaiseError = true; - - if (toolType == 0) - { - cmd.Args = $"difftool -g --no-prompt {option}"; - return cmd.Exec(); - } + var tool = await new Config(WorkingDirectory).GetAsync("merge.guitool"); + if (string.IsNullOrEmpty(tool)) + tool = await new Config(WorkingDirectory).GetAsync("merge.tool"); - if (!File.Exists(toolPath)) + if (string.IsNullOrEmpty(tool)) { - Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, $"Can NOT found external diff tool in '{toolPath}'!")); + RaiseException("Missing git configuration: merge.guitool"); return false; } - var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); - if (supported == null) + if (tool.StartsWith("vimdiff", StringComparison.Ordinal) || + tool.StartsWith("nvimdiff", StringComparison.Ordinal)) { - Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); + RaiseException($"CLI based merge tool \"{tool}\" is not supported by this app!"); return false; } - cmd.Args = $"-c difftool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.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 732530f5f..75b433e73 100644 --- a/src/Commands/Pull.cs +++ b/src/Commands/Pull.cs @@ -1,33 +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, bool noTags, bool prune, Action outputHandler) + public Pull(string repo, string remote, string branch, bool useRebase) { - _outputHandler = outputHandler; + _remote = remote; + WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); - Args = "pull --verbose --progress --tags "; - if (useRebase) - Args += "--rebase "; - if (noTags) - Args += "--no-tags "; - if (prune) - Args += "--prune "; + var builder = new StringBuilder(512); + builder + .Append("pull --verbose --progress --rebase=") + .Append(useRebase ? "true" : "false") + .Append(' ') + .Append(remote) + .Append(' ') + .Append(branch); - Args += $"{remote} {branch}"; + Args = builder.ToString(); } - protected override void OnReadline(string line) + public async Task RunAsync() { - _outputHandler?.Invoke(line); + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{_remote}.sshkey").ConfigureAwait(false); + return await ExecAsync().ConfigureAwait(false); } - private readonly Action _outputHandler; + private readonly string _remote; } } diff --git a/src/Commands/Push.cs b/src/Commands/Push.cs index 69b859ab9..44394dc41 100644 --- a/src/Commands/Push.cs +++ b/src/Commands/Push.cs @@ -1,49 +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 checkSubmodules, bool track, bool force, Action onProgress) + public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool checkSubmodules, bool track, bool force) { - _outputHandler = onProgress; + _remote = remote; WorkingDirectory = repo; Context = repo; - TraitErrorAsOutput = true; - SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); - Args = "push --progress --verbose "; + var builder = new StringBuilder(1024); + builder.Append("push --progress --verbose "); if (withTags) - Args += "--tags "; + builder.Append("--tags "); if (checkSubmodules) - Args += "--recurse-submodules=check "; + builder.Append("--recurse-submodules=check "); if (track) - Args += "-u "; + builder.Append("-u "); if (force) - Args += "--force-with-lease "; + builder.Append("--force-with-lease "); - Args += $"{remote} {local}:{remoteBranch}"; + 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; - SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); - Args = "push "; + var builder = new StringBuilder(512); + builder.Append("push "); if (isDelete) - Args += "--delete "; + builder.Append("--delete "); + builder.Append(remote).Append(' ').Append(refname); - Args += $"{remote} refs/tags/{tag}"; + Args = builder.ToString(); } - protected override void OnReadline(string line) + public async Task RunAsync() { - _outputHandler?.Invoke(line); + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{_remote}.sshkey").ConfigureAwait(false); + return await ExecAsync().ConfigureAwait(false); } - private readonly 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 95f97214a..b741fb5c0 100644 --- a/src/Commands/QueryBranches.cs +++ b/src/Commands/QueryBranches.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -14,31 +15,55 @@ public QueryBranches(string repo) { WorkingDirectory = repo; Context = repo; - Args = "branch -l --all -v --format=\"%(refname)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)\""; + Args = "branch -l --all -v --format=\"%(refname)%00%(committerdate:unix)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)%00%(worktreepath)\""; } - public List Result() + public async Task> GetResultAsync() { var branches = new List(); - var rs = ReadToEnd(); + var rs = await ReadToEndAsync().ConfigureAwait(false); if (!rs.IsSuccess) return branches; - var lines = rs.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries); + 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); + 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; } - private Models.Branch ParseLine(string line) + private Models.Branch ParseLine(string line, HashSet mismatched) { var parts = line.Split('\0'); - if (parts.Length != 5) + if (parts.Length != 7) return null; var branch = new Models.Branch(); @@ -57,12 +82,12 @@ private Models.Branch ParseLine(string line) else if (refName.StartsWith(PREFIX_REMOTE, StringComparison.Ordinal)) { var name = refName.Substring(PREFIX_REMOTE.Length); - var shortNameIdx = name.IndexOf('/', StringComparison.Ordinal); - if (shortNameIdx < 0) + 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 @@ -71,16 +96,22 @@ private Models.Branch ParseLine(string line) branch.IsLocal = true; } + ulong.TryParse(parts[1], out var committerDate); + branch.FullName = refName; - branch.Head = parts[1]; - branch.IsCurrent = parts[2] == "*"; - branch.Upstream = parts[3]; + branch.CommitterDate = committerDate; + branch.Head = parts[2]; + branch.IsCurrent = parts[3] == "*"; + branch.Upstream = parts[4]; + branch.IsUpstreamGone = false; - if (branch.IsLocal && !string.IsNullOrEmpty(parts[4]) && !parts[4].Equals("=", StringComparison.Ordinal)) - branch.TrackStatus = new QueryTrackStatus(WorkingDirectory, branch.Name, branch.Upstream).Result(); - else - branch.TrackStatus = new Models.BranchTrackStatus(); + if (branch.IsLocal && + !string.IsNullOrEmpty(branch.Upstream) && + !string.IsNullOrEmpty(parts[5]) && + !parts[5].Equals("=", StringComparison.Ordinal)) + mismatched.Add(branch.FullName); + branch.WorktreePath = parts[6]; return branch; } } diff --git a/src/Commands/QueryCommitFullMessage.cs b/src/Commands/QueryCommitFullMessage.cs index c8f1867da..2e9db9298 100644 --- a/src/Commands/QueryCommitFullMessage.cs +++ b/src/Commands/QueryCommitFullMessage.cs @@ -1,3 +1,5 @@ +using System.Threading.Tasks; + namespace SourceGit.Commands { public class QueryCommitFullMessage : Command @@ -6,15 +8,19 @@ public QueryCommitFullMessage(string repo, string sha) { WorkingDirectory = repo; Context = repo; - Args = $"show --no-show-signature --pretty=format:%B -s {sha}"; + Args = $"show --no-show-signature --format=%B -s {sha}"; } - public string Result() + public string GetResult() { var rs = ReadToEnd(); - if (rs.IsSuccess) - return rs.StdOut.TrimEnd(); - return string.Empty; + 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 index 5c81cf57f..179e4cddd 100644 --- a/src/Commands/QueryCommitSignInfo.cs +++ b/src/Commands/QueryCommitSignInfo.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Threading.Tasks; + +namespace SourceGit.Commands { public class QueryCommitSignInfo : Command { @@ -7,18 +9,18 @@ public QueryCommitSignInfo(string repo, string sha, bool useFakeSignersFile) WorkingDirectory = repo; Context = repo; - const string baseArgs = "show --no-show-signature --pretty=format:\"%G?%n%GS%n%GK\" -s"; + const string baseArgs = "show --no-show-signature --format=%G?%n%GS%n%GK -s"; const string fakeSignersFileArg = "-c gpg.ssh.allowedSignersFile=/dev/null"; Args = $"{(useFakeSignersFile ? fakeSignersFileArg : string.Empty)} {baseArgs} {sha}"; } - public Models.CommitSignInfo Result() + public async Task GetResultAsync() { - var rs = ReadToEnd(); + var rs = await ReadToEndAsync().ConfigureAwait(false); if (!rs.IsSuccess) return null; - var raw = rs.StdOut.Trim(); + var raw = rs.StdOut.Trim().ReplaceLineEndings("\n"); if (raw.Length <= 1) return null; @@ -29,7 +31,6 @@ public Models.CommitSignInfo Result() Signer = lines[1], Key = lines[2] }; - } } } diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs index 5875301ec..1a5073988 100644 --- a/src/Commands/QueryCommits.cs +++ b/src/Commands/QueryCommits.cs @@ -1,146 +1,111 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { public class QueryCommits : Command { - public QueryCommits(string repo, string limits, bool needFindHead = true) + public QueryCommits(string repo, string limits, bool markMerged = true) { WorkingDirectory = repo; Context = repo; - Args = "log --date-order --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s " + limits; - _findFirstMerged = needFindHead; + 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 QueryCommits(string repo, string filter, Models.CommitSearchMethod method, bool onlyCurrentBranch) { - string search = onlyCurrentBranch ? string.Empty : "--branches --remotes "; + 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 (method == Models.CommitSearchMethod.ByUser) + if (!onlyCurrentBranch) + builder.Append("--branches --remotes "); + + if (method == Models.CommitSearchMethod.ByAuthor) + { + builder.Append("-i --author=").Append(filter.Quoted()); + } + else if (method == Models.CommitSearchMethod.ByMessage) { - search += $"-i --author=\"{filter}\" --committer=\"{filter}\""; + 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"); } - else if (method == Models.CommitSearchMethod.ByFile) + else if (method == Models.CommitSearchMethod.ByPath) { - search += $"-- \"{filter}\""; + builder.Append("-- ").Append(filter.Quoted()); } else { - var argsBuilder = new StringBuilder(); - argsBuilder.Append(search); - - var words = filter.Split(new[] { ' ', '\t', '\r' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var word in words) - { - var escaped = word.Trim().Replace("\"", "\\\"", StringComparison.Ordinal); - argsBuilder.Append($"--grep=\"{escaped}\" "); - } - argsBuilder.Append("--all-match -i"); - - search = argsBuilder.ToString(); + builder.Append("-G").Append(filter.Quoted()); } WorkingDirectory = repo; Context = repo; - Args = $"log -1000 --date-order --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s " + search; - _findFirstMerged = false; + Args = builder.ToString(); + _markMerged = false; } - public List Result() + public async Task> GetResultAsync() { - var rs = ReadToEnd(); - if (!rs.IsSuccess) - return _commits; - - var nextPartIdx = 0; - var start = 0; - var end = rs.StdOut.IndexOf('\n', start); - while (end > 0) + var commits = new List(); + try { - var line = rs.StdOut.Substring(start, end - start); - switch (nextPartIdx) + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + + var findHead = false; + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) { - case 0: - _current = new Models.Commit() { SHA = line }; - _commits.Add(_current); - break; - case 1: - ParseParent(line); - break; - case 2: - _current.ParseDecorators(line); - if (_current.IsMerged && !_isHeadFounded) - _isHeadFounded = true; - break; - case 3: - _current.Author = Models.User.FindOrAdd(line); - break; - case 4: - _current.AuthorTime = ulong.Parse(line); - break; - case 5: - _current.Committer = Models.User.FindOrAdd(line); - break; - case 6: - _current.CommitterTime = ulong.Parse(line); - break; - case 7: - _current.Subject = line; - nextPartIdx = -1; - break; + 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; } - nextPartIdx++; - - start = end + 1; - end = rs.StdOut.IndexOf('\n', start); - } - - if (start < rs.StdOut.Length) - _current.Subject = rs.StdOut.Substring(start); - - if (_findFirstMerged && !_isHeadFounded && _commits.Count > 0) - MarkFirstMerged(); + await proc.WaitForExitAsync().ConfigureAwait(false); - return _commits; - } - - private void ParseParent(string data) - { - if (data.Length < 8) - return; - - _current.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); - } - - private void MarkFirstMerged() - { - Args = $"log --since=\"{_commits[^1].CommitterTimeStr}\" --format=\"%H\""; - - var rs = ReadToEnd(); - var shas = rs.StdOut.Split('\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)) + if (_markMerged && !findHead && commits.Count > 0) { - c.IsMerged = true; - break; + 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 commits; } - private List _commits = new List(); - private Models.Commit _current = null; - private bool _findFirstMerged = false; - private bool _isHeadFounded = false; + 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/QueryCommitsWithFullMessage.cs b/src/Commands/QueryCommitsWithFullMessage.cs deleted file mode 100644 index 116cb3cd8..000000000 --- a/src/Commands/QueryCommitsWithFullMessage.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace SourceGit.Commands -{ - public class QueryCommitsWithFullMessage : Command - { - public QueryCommitsWithFullMessage(string repo, string args) - { - _boundary = $"----- BOUNDARY OF COMMIT {Guid.NewGuid()} -----"; - - WorkingDirectory = repo; - Context = repo; - Args = $"log --date-order --no-show-signature --decorate=full --pretty=format:\"%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%B%n{_boundary}\" {args}"; - } - - public List Result() - { - var rs = ReadToEnd(); - if (!rs.IsSuccess) - return _commits; - - var nextPartIdx = 0; - var start = 0; - var end = rs.StdOut.IndexOf('\n', start); - while (end > 0) - { - var line = rs.StdOut.Substring(start, end - start); - switch (nextPartIdx) - { - case 0: - _current = new Models.CommitWithMessage(); - _current.Commit.SHA = line; - _commits.Add(_current); - break; - case 1: - ParseParent(line); - break; - case 2: - _current.Commit.ParseDecorators(line); - break; - case 3: - _current.Commit.Author = Models.User.FindOrAdd(line); - break; - case 4: - _current.Commit.AuthorTime = ulong.Parse(line); - break; - case 5: - _current.Commit.Committer = Models.User.FindOrAdd(line); - break; - case 6: - _current.Commit.CommitterTime = ulong.Parse(line); - break; - default: - if (line.Equals(_boundary, StringComparison.Ordinal)) - nextPartIdx = -1; - else - _current.Message += line; - break; - } - - nextPartIdx++; - - start = end + 1; - end = rs.StdOut.IndexOf('\n', start); - } - - return _commits; - } - - private void ParseParent(string data) - { - if (data.Length < 8) - return; - - _current.Commit.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); - } - - private List _commits = new List(); - private Models.CommitWithMessage _current = null; - private 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/QueryCurrentRevisionFiles.cs b/src/Commands/QueryCurrentRevisionFiles.cs deleted file mode 100644 index 217ea20e2..000000000 --- a/src/Commands/QueryCurrentRevisionFiles.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace SourceGit.Commands -{ - public class QueryCurrentRevisionFiles : Command - { - public QueryCurrentRevisionFiles(string repo) - { - WorkingDirectory = repo; - Context = repo; - Args = "ls-tree -r --name-only HEAD"; - } - - public string[] Result() - { - var rs = ReadToEnd(); - if (rs.IsSuccess) - return rs.StdOut.Split('\n', System.StringSplitOptions.RemoveEmptyEntries); - - return []; - } - } -} diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs index f887859c5..d111b5f21 100644 --- a/src/Commands/QueryFileContent.cs +++ b/src/Commands/QueryFileContent.cs @@ -1,17 +1,18 @@ using System; using System.Diagnostics; using System.IO; +using System.Threading.Tasks; namespace SourceGit.Commands { public static class QueryFileContent { - public static Stream Run(string repo, string revision, string file) + 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}\""; + starter.Arguments = $"show {revision}:{file.Quoted()}"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; @@ -20,19 +21,47 @@ public static Stream Run(string repo, string revision, string file) var stream = new MemoryStream(); try { - var proc = new Process() { StartInfo = starter }; - proc.Start(); - proc.StandardOutput.BaseStream.CopyTo(stream); - proc.WaitForExit(); - proc.Close(); + 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); + } + + stream.Position = 0; + return stream; + } - stream.Position = 0; + 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) { - App.RaiseException(repo, $"Failed to query file content: {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 9016d8266..c1ad51dfe 100644 --- a/src/Commands/QueryFileSize.cs +++ b/src/Commands/QueryFileSize.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -11,15 +12,12 @@ 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() + public async Task GetResultAsync() { - if (_result != 0) - return _result; - - var rs = ReadToEnd(); + var rs = await ReadToEndAsync().ConfigureAwait(false); if (rs.IsSuccess) { var match = REG_FORMAT().Match(rs.StdOut); @@ -29,7 +27,5 @@ public long Result() return 0; } - - private readonly 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 e3a94baf6..5a91b2173 100644 --- a/src/Commands/QueryGitDir.cs +++ b/src/Commands/QueryGitDir.cs @@ -8,19 +8,23 @@ public QueryGitDir(string workDir) { WorkingDirectory = workDir; Args = "rev-parse --git-dir"; - RaiseError = false; } - public string Result() + public string GetResult() { - var rs = ReadToEnd().StdOut; - if (string.IsNullOrEmpty(rs)) + 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 bdef9bf85..d1bd30a2c 100644 --- a/src/Commands/QueryLocalChanges.cs +++ b/src/Commands/QueryLocalChanges.cs @@ -1,6 +1,8 @@ -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 { @@ -8,150 +10,168 @@ public partial class QueryLocalChanges : Command { [GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] private static partial Regex REG_FORMAT(); - private static readonly string[] UNTRACKED = ["no", "all"]; - public QueryLocalChanges(string repo, bool includeUntracked = true) + 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) + public async Task> GetResultAsync() { - var match = REG_FORMAT().Match(line); - if (!match.Success) - return; + var outs = new List(); + + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; + while (await proc.StandardOutput.ReadLineAsync().ConfigureAwait(false) is { } line) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + continue; - switch (status) + var change = new Models.Change() { Path = match.Groups[2].Value }; + var status = match.Groups[1].Value; + + switch (status) + { + case " M": + change.Set(Models.ChangeState.None, Models.ChangeState.Modified); + break; + case " T": + change.Set(Models.ChangeState.None, Models.ChangeState.TypeChanged); + break; + case " A": + change.Set(Models.ChangeState.None, Models.ChangeState.Added); + break; + case " D": + change.Set(Models.ChangeState.None, Models.ChangeState.Deleted); + break; + case " R": + change.Set(Models.ChangeState.None, Models.ChangeState.Renamed); + break; + case " C": + change.Set(Models.ChangeState.None, Models.ChangeState.Copied); + break; + case "M": + change.Set(Models.ChangeState.Modified); + break; + case "MM": + change.Set(Models.ChangeState.Modified, Models.ChangeState.Modified); + break; + case "MT": + change.Set(Models.ChangeState.Modified, Models.ChangeState.TypeChanged); + break; + case "MD": + change.Set(Models.ChangeState.Modified, Models.ChangeState.Deleted); + break; + case "T": + change.Set(Models.ChangeState.TypeChanged); + break; + case "TM": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.Modified); + break; + case "TT": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.TypeChanged); + break; + case "TD": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.Deleted); + break; + case "A": + change.Set(Models.ChangeState.Added); + break; + case "AM": + change.Set(Models.ChangeState.Added, Models.ChangeState.Modified); + break; + case "AT": + change.Set(Models.ChangeState.Added, Models.ChangeState.TypeChanged); + break; + case "AD": + change.Set(Models.ChangeState.Added, Models.ChangeState.Deleted); + break; + case "D": + change.Set(Models.ChangeState.Deleted); + break; + case "R": + change.Set(Models.ChangeState.Renamed); + break; + case "RM": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.Modified); + break; + case "RT": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.TypeChanged); + break; + case "RD": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.Deleted); + break; + case "C": + change.Set(Models.ChangeState.Copied); + break; + case "CM": + change.Set(Models.ChangeState.Copied, Models.ChangeState.Modified); + break; + case "CT": + change.Set(Models.ChangeState.Copied, Models.ChangeState.TypeChanged); + break; + case "CD": + change.Set(Models.ChangeState.Copied, Models.ChangeState.Deleted); + break; + case "DD": + change.ConflictReason = Models.ConflictReason.BothDeleted; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "AU": + change.ConflictReason = Models.ConflictReason.AddedByUs; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UD": + change.ConflictReason = Models.ConflictReason.DeletedByThem; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UA": + change.ConflictReason = Models.ConflictReason.AddedByThem; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "DU": + change.ConflictReason = Models.ConflictReason.DeletedByUs; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "AA": + change.ConflictReason = Models.ConflictReason.BothAdded; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UU": + change.ConflictReason = Models.ConflictReason.BothModified; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "??": + change.Set(Models.ChangeState.None, Models.ChangeState.Untracked); + break; + } + + if (change.Index != Models.ChangeState.None || change.WorkTree != Models.ChangeState.None) + outs.Add(change); + } + } + catch { - 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 "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; + // Ignore exceptions. } - _changes.Add(change); + return outs; } - - private readonly 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 index e3b73ccce..186b6a80b 100644 --- a/src/Commands/QueryRefsContainsCommit.cs +++ b/src/Commands/QueryRefsContainsCommit.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -12,26 +13,28 @@ public QueryRefsContainsCommit(string repo, string commit) Args = $"for-each-ref --format=\"%(refname)\" --contains {commit}"; } - public List Result() + public async Task> GetResultAsync() { - var rs = new List(); + var outs = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return outs; - var output = ReadToEnd(); - if (!output.IsSuccess) - return rs; - - var lines = output.StdOut.Split('\n'); + 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)) - rs.Add(new() { Name = line.Substring("refs/heads/".Length), Type = Models.DecoratorType.LocalBranchHead }); + outs.Add(new() { Name = line.Substring("refs/heads/".Length), Type = Models.DecoratorType.LocalBranchHead }); else if (line.StartsWith("refs/remotes/", StringComparison.Ordinal)) - rs.Add(new() { Name = line.Substring("refs/remotes/".Length), Type = Models.DecoratorType.RemoteBranchHead }); + outs.Add(new() { Name = line.Substring("refs/remotes/".Length), Type = Models.DecoratorType.RemoteBranchHead }); else if (line.StartsWith("refs/tags/", StringComparison.Ordinal)) - rs.Add(new() { Name = line.Substring("refs/tags/".Length), Type = Models.DecoratorType.Tag }); + outs.Add(new() { Name = line.Substring("refs/tags/".Length), Type = Models.DecoratorType.Tag }); } - return rs; + return outs; } } } diff --git a/src/Commands/QueryRemotes.cs b/src/Commands/QueryRemotes.cs index b5b41b4a1..f8a23b7aa 100644 --- a/src/Commands/QueryRemotes.cs +++ b/src/Commands/QueryRemotes.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -15,29 +17,57 @@ public QueryRemotes(string repo) Args = "remote -v"; } - public List Result() + public async Task> GetResultAsync() { - Exec(); - return _loaded; - } + 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() + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) { - Name = match.Groups[1].Value, - URL = match.Groups[2].Value, - }; + 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) + }; - private readonly List _loaded = new List(); + if (outs.Find(x => x.Name == remote.Name) != null) + continue; + + 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 016621c86..89d259296 100644 --- a/src/Commands/QueryRepositoryRootPath.cs +++ b/src/Commands/QueryRepositoryRootPath.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Threading.Tasks; + +namespace SourceGit.Commands { public class QueryRepositoryRootPath : Command { @@ -7,5 +9,15 @@ public QueryRepositoryRootPath(string path) WorkingDirectory = path; Args = "rev-parse --show-toplevel"; } + + 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 bcad9129d..a7eaaa9e7 100644 --- a/src/Commands/QueryRevisionObjects.cs +++ b/src/Commands/QueryRevisionObjects.cs @@ -1,5 +1,8 @@ using System.Collections.Generic; +using System.Diagnostics; +using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -12,48 +15,56 @@ public QueryRevisionObjects(string repo, string sha, string parentFolder) { WorkingDirectory = repo; Context = repo; - Args = $"ls-tree {sha}"; + var builder = new StringBuilder(1024); + builder.Append("ls-tree ").Append(sha); if (!string.IsNullOrEmpty(parentFolder)) - Args += $" -- \"{parentFolder}\""; - } + builder.Append(" -- ").Append(parentFolder.Quoted()); - public List Result() - { - Exec(); - return _objects; + Args = builder.ToString(); } - protected override void OnReadline(string line) + public async Task> GetResultAsync() { - var match = REG_FORMAT().Match(line); - if (!match.Success) - return; - - var obj = new Models.Object(); - obj.SHA = match.Groups[2].Value; - obj.Type = Models.ObjectType.Blob; - obj.Path = match.Groups[3].Value; + var outs = new List(); - switch (match.Groups[1].Value) + try { - case "blob": + 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; - 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.Path = match.Groups[3].Value; + + 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; } - - private List _objects = new List(); } } diff --git a/src/Commands/QuerySingleCommit.cs b/src/Commands/QuerySingleCommit.cs index 1e1c9ea4a..822f5c4a5 100644 --- a/src/Commands/QuerySingleCommit.cs +++ b/src/Commands/QuerySingleCommit.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -8,34 +9,43 @@ public QuerySingleCommit(string repo, string sha) { WorkingDirectory = repo; Context = repo; - Args = $"show --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s -s {sha}"; + Args = $"show --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s -s {sha}"; } - public Models.Commit Result() + public Models.Commit GetResult() { var rs = ReadToEnd(); - if (rs.IsSuccess && !string.IsNullOrEmpty(rs.StdOut)) - { - var commit = new Models.Commit(); - var lines = rs.StdOut.Split('\n'); - if (lines.Length < 8) - return null; + 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; - 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 commit = new Models.Commit(); + var lines = rs.StdOut.Split('\n'); + if (lines.Length < 8) + return null; - return commit; - } + 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 null; + return commit; } } } diff --git a/src/Commands/QueryStagedChangesWithAmend.cs b/src/Commands/QueryStagedChangesWithAmend.cs index f5c9659f7..0cd420592 100644 --- a/src/Commands/QueryStagedChangesWithAmend.cs +++ b/src/Commands/QueryStagedChangesWithAmend.cs @@ -6,87 +6,92 @@ namespace SourceGit.Commands { public partial class QueryStagedChangesWithAmend : Command { - [GeneratedRegex(@"^:[\d]{6} ([\d]{6}) ([0-9a-f]{40}) [0-9a-f]{40} ([ACDMTUX])\d{0,6}\t(.*)$")] + [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]{40}) [0-9a-f]{40} R\d{0,6}\t(.*\t.*)$")] + [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; - Args = "diff-index --cached -M HEAD^"; } - public List Result() + public List GetResult() { + Args = "show --no-show-signature --format=\"%H %P\" -s HEAD"; var rs = ReadToEnd(); - if (rs.IsSuccess) + 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 changes = new List(); - var lines = rs.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) + var match = REG_FORMAT2().Match(line); + if (match.Success) { - var match = REG_FORMAT2().Match(line); - if (match.Success) + var change = new Models.Change() { - var change = new Models.Change() + Path = match.Groups[4].Value, + DataForAmend = new Models.ChangeDataForAmend() { - Path = match.Groups[3].Value, - DataForAmend = new Models.ChangeDataForAmend() - { - FileMode = match.Groups[1].Value, - ObjectHash = match.Groups[2].Value, - }, - }; - change.Set(Models.ChangeState.Renamed); - changes.Add(change); - continue; - } + 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) + match = REG_FORMAT1().Match(line); + if (match.Success) + { + var change = new Models.Change() { - var change = new Models.Change() + Path = match.Groups[4].Value, + DataForAmend = new Models.ChangeDataForAmend() { - Path = match.Groups[4].Value, - DataForAmend = new Models.ChangeDataForAmend() - { - FileMode = match.Groups[1].Value, - ObjectHash = match.Groups[2].Value, - }, - }; + FileMode = match.Groups[1].Value, + ObjectHash = match.Groups[2].Value, + ParentSHA = parent, + }, + }; - var type = match.Groups[3].Value; - switch (type) - { - case "A": - change.Set(Models.ChangeState.Added); - break; - case "C": - change.Set(Models.ChangeState.Copied); - break; - case "D": - change.Set(Models.ChangeState.Deleted); - break; - case "M": - change.Set(Models.ChangeState.Modified); - break; - case "T": - change.Set(Models.ChangeState.TypeChanged); - break; - case "U": - change.Set(Models.ChangeState.Unmerged); - break; - } - changes.Add(change); + 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; } - return []; + return changes; } } } diff --git a/src/Commands/QueryStagedFileBlobGuid.cs b/src/Commands/QueryStagedFileBlobGuid.cs deleted file mode 100644 index 3f52a5f2d..000000000 --- a/src/Commands/QueryStagedFileBlobGuid.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Text.RegularExpressions; - -namespace SourceGit.Commands -{ - public partial class QueryStagedFileBlobGuid : Command - { - [GeneratedRegex(@"^\d+\s+([0-9a-f]+)\s+.*$")] - private static partial Regex REG_FORMAT(); - - public QueryStagedFileBlobGuid(string repo, string file) - { - WorkingDirectory = repo; - Context = repo; - Args = $"ls-files -s -- \"{file}\""; - } - - public string Result() - { - var rs = ReadToEnd(); - var match = REG_FORMAT().Match(rs.StdOut.Trim()); - if (match.Success) - { - return match.Groups[1].Value; - } - - return string.Empty; - } - } -} diff --git a/src/Commands/QueryStashChanges.cs b/src/Commands/QueryStashChanges.cs deleted file mode 100644 index 3b8d2db69..000000000 --- a/src/Commands/QueryStashChanges.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands -{ - public partial class QueryStashChanges : Command - { - [GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] - private static partial Regex REG_FORMAT(); - - 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 readonly List _changes = new List(); - } -} diff --git a/src/Commands/QueryStashes.cs b/src/Commands/QueryStashes.cs index 6d089f8ed..2b292f711 100644 --- a/src/Commands/QueryStashes.cs +++ b/src/Commands/QueryStashes.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -8,41 +10,60 @@ public QueryStashes(string repo) { WorkingDirectory = repo; Context = repo; - Args = "stash list --pretty=format:%H%n%ct%n%gd%n%s"; + Args = "stash list -z --no-show-signature --format=\"%H%n%P%n%ct%n%gd%n%B\""; } - public List Result() + public async Task> GetResultAsync() { - Exec(); - return _stashes; - } + var outs = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return outs; - protected override void OnReadline(string line) - { - switch (_nextLineIdx) + var items = rs.StdOut.Split('\0', StringSplitOptions.RemoveEmptyEntries); + foreach (var item in items) { - case 0: - _current = new Models.Stash() { SHA = line }; - _stashes.Add(_current); - break; - case 1: - _current.Time = ulong.Parse(line); - break; - case 2: - _current.Name = line; - break; - case 3: - _current.Message = line; - break; - } + var current = new Models.Stash(); - _nextLineIdx++; - if (_nextLineIdx > 3) - _nextLineIdx = 0; - } + 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++; - private readonly List _stashes = new List(); - private Models.Stash _current = null; - private int _nextLineIdx = 0; + 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; + } } } 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 5fd6e3d56..6063b3cf8 100644 --- a/src/Commands/QuerySubmodules.cs +++ b/src/Commands/QuerySubmodules.cs @@ -1,17 +1,19 @@ -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 partial class QuerySubmodules : Command { - [GeneratedRegex(@"^[\-\+ ][0-9a-f]+\s(.*)\s\(.*\)$")] - private static partial Regex REG_FORMAT1(); - [GeneratedRegex(@"^[\-\+ ][0-9a-f]+\s(.*)$")] - private static partial Regex REG_FORMAT2(); - [GeneratedRegex(@"^\s?[\w\?]{1,4}\s+(.+)$")] + [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) { @@ -20,59 +22,138 @@ public QuerySubmodules(string repo) Args = "submodule status"; } - public List Result() + public async Task> GetResultAsync() { var submodules = new List(); - var rs = ReadToEnd(); - if (!rs.IsSuccess) - return submodules; + var rs = await ReadToEndAsync().ConfigureAwait(false); - var builder = new StringBuilder(); - var lines = rs.StdOut.Split('\n', System.StringSplitOptions.RemoveEmptyEntries); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var map = new Dictionary(); + var needCheckLocalChanges = false; foreach (var line in lines) { - var match = REG_FORMAT1().Match(line); + var match = REG_FORMAT_STATUS().Match(line); if (match.Success) { - var path = match.Groups[1].Value; - builder.Append($"\"{path}\" "); - submodules.Add(new Models.Submodule() { Path = path }); - continue; + var stat = match.Groups[1].Value; + var sha = match.Groups[2].Value; + var path = match.Groups[3].Value; + + var module = new Models.Submodule() { Path = path, SHA = sha }; + switch (stat[0]) + { + case '-': + module.Status = Models.SubmoduleStatus.NotInited; + break; + case '+': + module.Status = Models.SubmoduleStatus.RevisionChanged; + break; + case 'U': + module.Status = Models.SubmoduleStatus.Unmerged; + break; + default: + module.Status = Models.SubmoduleStatus.Normal; + needCheckLocalChanges = true; + break; + } + + map.Add(path, module); + submodules.Add(module); } + } - match = REG_FORMAT2().Match(line); - if (match.Success) + if (submodules.Count > 0) + { + Args = "config --file .gitmodules --list"; + rs = await ReadToEndAsync().ConfigureAwait(false); + if (rs.IsSuccess) { - var path = match.Groups[1].Value; - builder.Append($"\"{path}\" "); - submodules.Add(new Models.Submodule() { Path = path }); + 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 (submodules.Count > 0) + if (needCheckLocalChanges) { - Args = $"status -uno --porcelain -- {builder}"; - rs = ReadToEnd(); + 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; - var dirty = new HashSet(); - lines = rs.StdOut.Split('\n', System.StringSplitOptions.RemoveEmptyEntries); + lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { - var match = REG_FORMAT_STATUS().Match(line); + var match = REG_FORMAT_DIRTY().Match(line); if (match.Success) { var path = match.Groups[1].Value; - dirty.Add(path); + if (map.TryGetValue(path, out var m)) + m.Status = Models.SubmoduleStatus.Modified; } } - - foreach (var submodule in submodules) - submodule.IsDirty = dirty.Contains(submodule.Path); } return submodules; } + + 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 3aa20dc2f..9033a8d04 100644 --- a/src/Commands/QueryTags.cs +++ b/src/Commands/QueryTags.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -11,35 +12,44 @@ public QueryTags(string repo) Context = repo; WorkingDirectory = repo; - Args = $"tag -l --sort=-creatordate --format=\"{_boundary}%(refname)%00%(objectname)%00%(*objectname)%00%(contents:subject)%0a%0a%(contents:body)\""; + 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() + public async Task> GetResultAsync() { var tags = new List(); - var rs = ReadToEnd(); + var rs = await ReadToEndAsync().ConfigureAwait(false); if (!rs.IsSuccess) return tags; var records = rs.StdOut.Split(_boundary, StringSplitOptions.RemoveEmptyEntries); foreach (var record in records) { - var subs = record.Split('\0', StringSplitOptions.None); - if (subs.Length != 4) + var subs = record.Split('\0'); + if (subs.Length != 7) continue; - var message = subs[3].Trim(); + 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 = subs[0].Substring(10), - SHA = string.IsNullOrEmpty(subs[2]) ? subs[1] : subs[2], - Message = string.IsNullOrEmpty(message) ? null : message, + 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 string _boundary = string.Empty; + private readonly string _boundary; } } diff --git a/src/Commands/QueryTrackStatus.cs b/src/Commands/QueryTrackStatus.cs index 0bf9746fd..f00074f82 100644 --- a/src/Commands/QueryTrackStatus.cs +++ b/src/Commands/QueryTrackStatus.cs @@ -1,34 +1,32 @@ using System; +using System.Threading.Tasks; namespace SourceGit.Commands { public class QueryTrackStatus : Command { - public QueryTrackStatus(string repo, string local, string upstream) + public QueryTrackStatus(string repo) { WorkingDirectory = repo; Context = repo; - Args = $"rev-list --left-right {local}...{upstream}"; } - public Models.BranchTrackStatus Result() + public async Task GetResultAsync(Models.Branch local, Models.Branch remote) { - var status = new Models.BranchTrackStatus(); + Args = $"rev-list --left-right {local.Head}...{remote.Head}"; - var rs = ReadToEnd(); + var rs = await ReadToEndAsync().ConfigureAwait(false); if (!rs.IsSuccess) - return status; + return; - var lines = rs.StdOut.Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { if (line[0] == '>') - status.Behind.Add(line.Substring(1)); + local.Behind.Add(line.Substring(1)); else - status.Ahead.Add(line.Substring(1)); + local.Ahead.Add(line.Substring(1)); } - - return status; } } } 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 2ec50f3c0..fdc585c01 100644 --- a/src/Commands/Rebase.cs +++ b/src/Commands/Rebase.cs @@ -1,26 +1,22 @@ -namespace SourceGit.Commands +using System.Text; + +namespace SourceGit.Commands { public class Rebase : Command { - public Rebase(string repo, string basedOn, bool autoStash) + public Rebase(string repo, string basedOn, bool autoStash, bool noVerify) { WorkingDirectory = repo; Context = repo; - Args = "rebase "; + + var builder = new StringBuilder(512); + builder.Append("-c core.commentChar=\"^\" -c core.commentString=\"±\" rebase "); if (autoStash) - Args += "--autostash "; - Args += basedOn; - } - } + builder.Append("--autostash "); + if (noVerify) + builder.Append("--no-verify "); - public class InteractiveRebase : Command - { - public InteractiveRebase(string repo, string basedOn) - { - WorkingDirectory = repo; - Context = repo; - Editor = EditorType.RebaseEditor; - Args = $"rebase -i --autosquash {basedOn}"; + Args = builder.Append(basedOn).ToString(); } } } diff --git a/src/Commands/Remote.cs b/src/Commands/Remote.cs index beaf412b0..6a1503774 100644 --- a/src/Commands/Remote.cs +++ b/src/Commands/Remote.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Threading.Tasks; + +namespace SourceGit.Commands { public class Remote : Command { @@ -8,50 +10,50 @@ public Remote(string 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 string GetURL(string name, bool isPush) + public async Task GetURLAsync(string name, bool isPush) { Args = "remote get-url" + (isPush ? " --push " : " ") + name; - var rs = ReadToEnd(); + var rs = await ReadToEndAsync(); return rs.IsSuccess ? rs.StdOut.Trim() : string.Empty; } - public bool SetURL(string name, string url, bool isPush) + public async Task SetURLAsync(string name, string url, bool isPush) { Args = "remote set-url" + (isPush ? " --push " : " ") + $"{name} {url}"; - return Exec(); + return await ExecAsync(); } - public bool HasBranch(string remote, string branch) + public async Task HasBranchAsync(string remote, string branch) { - SSHKey = new Config(WorkingDirectory).Get($"remote.{remote}.sshkey"); + SSHKey = await new Config(WorkingDirectory).GetAsync($"remote.{remote}.sshkey"); Args = $"ls-remote {remote} {branch}"; - var rs = ReadToEnd(); + 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 da2721353..f640d0de4 100644 --- a/src/Commands/Reset.cs +++ b/src/Commands/Reset.cs @@ -1,38 +1,19 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands +namespace SourceGit.Commands { public class Reset : Command { - public Reset(string repo) - { - WorkingDirectory = repo; - Context = repo; - Args = "reset"; - } - - public Reset(string repo, List changes) + public Reset(string repo, string revision, string mode) { 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 {mode} {revision}"; } - public Reset(string repo, string revision, string mode) + public Reset(string repo, string pathspec) { WorkingDirectory = repo; Context = repo; - Args = $"reset {mode} {revision}"; + Args = $"reset --pathspec-from-file={pathspec.Quoted()}"; } } } diff --git a/src/Commands/Restore.cs b/src/Commands/Restore.cs index 7a3635438..bf3bd0a55 100644 --- a/src/Commands/Restore.cs +++ b/src/Commands/Restore.cs @@ -1,30 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands +namespace SourceGit.Commands { public class Restore : Command { - public Restore(string repo) + public Restore(string repo, string pathspecFile) { WorkingDirectory = repo; Context = repo; - Args = "restore . --source=HEAD --staged --worktree --recurse-submodules"; - } - - public Restore(string repo, List files, string extra) - { - 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 2e7afd11d..f42a62d05 100644 --- a/src/Commands/Revert.cs +++ b/src/Commands/Revert.cs @@ -1,4 +1,6 @@ -namespace SourceGit.Commands +using System.Text; + +namespace SourceGit.Commands { public class Revert : Command { @@ -6,9 +8,16 @@ public Revert(string repo, string commit, bool autoCommit) { WorkingDirectory = repo; Context = repo; - Args = $"revert -m 1 {commit} --no-edit"; + + var builder = new StringBuilder(512); + builder + .Append("revert -m 1 ") + .Append(commit) + .Append(" --no-edit"); if (!autoCommit) - Args += " --no-commit"; + builder.Append(" --no-commit"); + + Args = builder.ToString(); } } } diff --git a/src/Commands/SaveChangesAsPatch.cs b/src/Commands/SaveChangesAsPatch.cs index 461bbfb53..4ec83a6a6 100644 --- a/src/Commands/SaveChangesAsPatch.cs +++ b/src/Commands/SaveChangesAsPatch.cs @@ -2,20 +2,19 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; - -using Avalonia.Threading; +using System.Threading.Tasks; namespace SourceGit.Commands { public static class SaveChangesAsPatch { - public static bool ProcessLocalChanges(string repo, List changes, bool isUnstaged, string saveTo) + public static async Task ProcessLocalChangesAsync(string repo, List changes, bool isUnstaged, string saveTo) { - using (var sw = File.Create(saveTo)) + await using (var sw = File.Create(saveTo)) { foreach (var change in changes) { - if (!ProcessSingleChange(repo, new Models.DiffOption(change, isUnstaged), sw)) + if (!await ProcessSingleChangeAsync(repo, new Models.DiffOption(change, isUnstaged), sw)) return false; } } @@ -23,13 +22,13 @@ public static bool ProcessLocalChanges(string repo, List changes, return true; } - public static bool ProcessRevisionCompareChanges(string repo, List changes, string baseRevision, string targetRevision, string saveTo) + public static async Task ProcessRevisionCompareChangesAsync(string repo, List changes, string baseRevision, string targetRevision, string saveTo) { - using (var sw = File.Create(saveTo)) + await using (var sw = File.Create(saveTo)) { foreach (var change in changes) { - if (!ProcessSingleChange(repo, new Models.DiffOption(baseRevision, targetRevision, change), sw)) + if (!await ProcessSingleChangeAsync(repo, new Models.DiffOption(baseRevision, targetRevision, change), sw)) return false; } } @@ -37,12 +36,25 @@ public static bool ProcessRevisionCompareChanges(string repo, List 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.GitExecutable; - starter.Arguments = $"diff --ignore-cr-at-eol --unified=4 {opt}"; + 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; @@ -50,21 +62,14 @@ private static bool ProcessSingleChange(string repo, Models.DiffOption opt, File 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; + 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) { - Dispatcher.UIThread.Invoke(() => - { - App.RaiseException(repo, "Save change to patch failed: " + e.Message); - }); + 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 99e890937..410d15b07 100644 --- a/src/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -1,32 +1,31 @@ using System; using System.Diagnostics; using System.IO; - -using Avalonia.Threading; +using System.Threading.Tasks; namespace SourceGit.Commands { public static class SaveRevisionFile { - public static void Run(string repo, string revision, string file, string saveTo) + public static async Task RunAsync(string repo, string revision, string file, string saveTo) { - var isLFSFiltered = new IsLFSFiltered(repo, revision, file).Result(); + 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 tmpFile = saveTo + ".tmp"; - if (ExecCmd(repo, $"show {revision}:\"{file}\"", tmpFile)) - { - ExecCmd(repo, $"lfs smudge", saveTo, tmpFile); - } - File.Delete(tmpFile); + var pointerStream = await QueryFileContent.RunAsync(repo, revision, file).ConfigureAwait(false); + await ExecCmdAsync(repo, "lfs smudge", saveTo, pointerStream).ConfigureAwait(false); } else { - ExecCmd(repo, $"show {revision}:\"{file}\"", saveTo); + 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; @@ -39,41 +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)) + await using (var sw = File.Create(outputFile)) { try { - var proc = new Process() { StartInfo = starter }; - proc.Start(); + using var proc = Process.Start(starter)!; - if (inputFile != null) + if (input != null) { - using (StreamReader sr = new StreamReader(inputFile)) - { - while (true) - { - var line = sr.ReadLine(); - if (line == null) - break; - proc.StandardInput.WriteLine(line); - } - } + 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; + await proc.StandardOutput.BaseStream.CopyToAsync(sw).ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); } catch (Exception e) { - Dispatcher.UIThread.Invoke(() => - { - App.RaiseException(repo, "Save file failed: " + e.Message); - }); - return false; + Models.Notification.Send(repo, "Save file failed: " + e.Message, true); } } } diff --git a/src/Commands/Stash.cs b/src/Commands/Stash.cs index 77f1af536..31b5b71cc 100644 --- a/src/Commands/Stash.cs +++ b/src/Commands/Stash.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -11,79 +12,92 @@ public Stash(string repo) Context = repo; } - public bool Push(string message) + public async Task PushAsync(string message, bool includeUntracked = true, bool keepIndex = false) { - Args = $"stash push -m \"{message}\""; - return Exec(); + 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, bool onlyStaged, bool keepIndex) + public async Task PushAsync(string message, List changes, bool keepIndex) { var builder = new StringBuilder(); - builder.Append("stash push "); - if (onlyStaged) - builder.Append("--staged "); + builder.Append("stash push --include-untracked "); if (keepIndex) builder.Append("--keep-index "); - builder.Append("-m \""); - builder.Append(message); - builder.Append("\" -- "); + 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); + } - if (onlyStaged) - { - foreach (var c in changes) - builder.Append($"\"{c.Path}\" "); - } - else - { - var needAdd = new List(); - foreach (var c in changes) - { - builder.Append($"\"{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); + } + 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 Exec(); + return await ExecAsync().ConfigureAwait(false); + } + + 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) + public async Task CheckoutBranchAsync(string name, string branch) { - Args = $"stash apply -q {name}"; - return Exec(); + Args = $"stash branch {branch.Quoted()} {name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Pop(string name) + public async Task PopAsync(string name) { - Args = $"stash pop -q {name}"; - return Exec(); + Args = $"stash pop -q --index {name.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Drop(string name) + public async Task DropAsync(string name) { - Args = $"stash drop -q {name}"; - return Exec(); + 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 511c43e84..3c6ed017b 100644 --- a/src/Commands/Statistics.cs +++ b/src/Commands/Statistics.cs @@ -1,34 +1,40 @@ -using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { public class Statistics : Command { - public Statistics(string repo, int max) + public Statistics(string repo, int max, Models.Branch specBranch) { WorkingDirectory = repo; Context = repo; - Args = $"log --date-order --branches --remotes -{max} --pretty=format:\"%ct$%aN\""; + + 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(); } - public Models.Statistics Result() + public async Task ReadAsync() { var statistics = new Models.Statistics(); - var rs = ReadToEnd(); + var rs = await ReadToEndAsync().ConfigureAwait(false); if (!rs.IsSuccess) return statistics; - var start = 0; - var end = rs.StdOut.IndexOf('\n', start); - while (end > 0) - { - ParseLine(statistics, rs.StdOut.Substring(start, end - start)); - start = end + 1; - end = rs.StdOut.IndexOf('\n', start); - } - - if (start < rs.StdOut.Length) - ParseLine(statistics, rs.StdOut.Substring(start)); + var sr = new StringReader(rs.StdOut); + while (sr.ReadLine() is { } line) + ParseLine(statistics, line); statistics.Complete(); return statistics; @@ -36,13 +42,9 @@ public Models.Statistics Result() private void ParseLine(Models.Statistics statistics, string line) { - var dateEndIdx = line.IndexOf('$', StringComparison.Ordinal); - if (dateEndIdx == -1) - return; - - var dateStr = line.Substring(0, dateEndIdx); - if (double.TryParse(dateStr, out var date)) - statistics.AddCommit(line.Substring(dateEndIdx + 1), date); + 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 9a2737034..d8b1dbcb7 100644 --- a/src/Commands/Submodule.cs +++ b/src/Commands/Submodule.cs @@ -1,4 +1,6 @@ -using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -10,57 +12,69 @@ public Submodule(string repo) Context = repo; } - public bool Add(string url, string relativePath, bool recursive, Action outputHandler) + public async Task AddAsync(string url, string relativePath, bool recursive) { - _outputHandler = outputHandler; - Args = $"submodule add {url} \"{relativePath}\""; - if (!Exec()) + 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}\""; - return Exec(); - } + Args = $"submodule update --init --recursive -- {relativePath.Quoted()}"; else - { - Args = $"submodule update --init -- \"{relativePath}\""; - return true; - } + Args = $"submodule update --init -- {relativePath.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); + } + + public async Task SetURLAsync(string path, string url) + { + Args = $"submodule set-url -- {path.Quoted()} {url.Quoted()}"; + return await ExecAsync().ConfigureAwait(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()}"; + + return await ExecAsync().ConfigureAwait(false); } - public bool Update(string module, bool init, bool recursive, bool useRemote, Action outputHandler) + public async Task UpdateAsync(List modules, bool init, bool recursive, bool useRemote) { - Args = "submodule update"; + var builder = new StringBuilder(); + builder.Append("submodule update"); if (init) - Args += " --init"; + builder.Append(" --init"); if (recursive) - Args += " --recursive"; + builder.Append(" --recursive"); if (useRemote) - Args += " --remote"; - if (!string.IsNullOrEmpty(module)) - Args += $" -- \"{module}\""; + builder.Append(" --remote"); + if (modules.Count > 0) + { + builder.Append(" --"); + foreach (var module in modules) + builder.Append(' ').Append(module.Quoted()); + } - _outputHandler = outputHandler; - return Exec(); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public bool Delete(string relativePath) + public async Task DeinitAsync(string module, bool force) { - Args = $"submodule deinit -f \"{relativePath}\""; - if (!Exec()) - return false; - - Args = $"rm -rf \"{relativePath}\""; - return Exec(); + Args = force ? $"submodule deinit -f -- {module.Quoted()}" : $"submodule deinit -- {module.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - protected override void OnReadline(string line) + public async Task DeleteAsync(string module) { - _outputHandler?.Invoke(line); + Args = $"rm -rf {module.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - - private Action _outputHandler; } } diff --git a/src/Commands/Tag.cs b/src/Commands/Tag.cs index fa11e3665..7af76a9e4 100644 --- a/src/Commands/Tag.cs +++ b/src/Commands/Tag.cs @@ -1,59 +1,59 @@ -using System.Collections.Generic; -using System.IO; +using System.IO; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { - public static class Tag + public class Tag : Command { - public static bool Add(string repo, string name, string basedOn) + public Tag(string repo, string name) { - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"tag {name} {basedOn}"; - return cmd.Exec(); + WorkingDirectory = repo; + Context = repo; + _name = name; } - public static bool Add(string repo, string name, string basedOn, string message, bool sign) + public async Task AddAsync(string basedOn) { - var param = sign ? "--sign -a" : "--no-sign -a"; - var cmd = new Command(); - cmd.WorkingDirectory = repo; - cmd.Context = repo; - cmd.Args = $"tag {param} {name} {basedOn} "; + Args = $"tag --no-sign {_name} {basedOn}"; + return await ExecAsync().ConfigureAwait(false); + } + + 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(); - File.WriteAllText(tmp, message); - cmd.Args += $"-F \"{tmp}\""; - } - else - { - cmd.Args += $"-m {name}"; + 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 cmd.Exec(); + builder.Append(" -m "); + builder.Append(_name); + + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public static bool Delete(string repo, string name, List remotes) + public async Task DeleteAsync() { - 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(); - } - } - - return true; + Args = $"tag --delete {_name}"; + return await ExecAsync().ConfigureAwait(false); } + + private readonly string _name; } } diff --git a/src/Commands/UnstageChangesForAmend.cs b/src/Commands/UpdateIndexInfo.cs similarity index 69% rename from src/Commands/UnstageChangesForAmend.cs rename to src/Commands/UpdateIndexInfo.cs index c930f1369..d8c47ca91 100644 --- a/src/Commands/UnstageChangesForAmend.cs +++ b/src/Commands/UpdateIndexInfo.cs @@ -2,14 +2,13 @@ using System.Collections.Generic; using System.Diagnostics; using System.Text; - -using Avalonia.Threading; +using System.Threading.Tasks; namespace SourceGit.Commands { - public class UnstageChangesForAmend + public class UpdateIndexInfo { - public UnstageChangesForAmend(string repo, List changes) + public UpdateIndexInfo(string repo, List changes) { _repo = repo; @@ -19,17 +18,15 @@ public UnstageChangesForAmend(string repo, List changes) { _patchBuilder.Append("0 0000000000000000000000000000000000000000\t"); _patchBuilder.Append(c.Path); - _patchBuilder.Append("\0100644 "); + _patchBuilder.Append("\n100644 "); _patchBuilder.Append(c.DataForAmend.ObjectHash); _patchBuilder.Append("\t"); _patchBuilder.Append(c.OriginalPath); - _patchBuilder.Append("\n"); } else if (c.Index == Models.ChangeState.Added) { _patchBuilder.Append("0 0000000000000000000000000000000000000000\t"); _patchBuilder.Append(c.Path); - _patchBuilder.Append("\n"); } else if (c.Index == Models.ChangeState.Deleted) { @@ -37,7 +34,6 @@ public UnstageChangesForAmend(string repo, List changes) _patchBuilder.Append(c.DataForAmend.ObjectHash); _patchBuilder.Append("\t"); _patchBuilder.Append(c.Path); - _patchBuilder.Append("\n"); } else { @@ -46,12 +42,13 @@ public UnstageChangesForAmend(string repo, List changes) _patchBuilder.Append(c.DataForAmend.ObjectHash); _patchBuilder.Append("\t"); _patchBuilder.Append(c.Path); - _patchBuilder.Append("\n"); } + + _patchBuilder.Append('\n'); } } - public bool Exec() + public async Task ExecAsync() { var starter = new ProcessStartInfo(); starter.WorkingDirectory = _repo; @@ -63,35 +60,32 @@ public bool Exec() starter.RedirectStandardInput = true; starter.RedirectStandardOutput = false; starter.RedirectStandardError = true; + starter.StandardInputEncoding = new UTF8Encoding(false); + starter.StandardErrorEncoding = Encoding.UTF8; try { - var proc = new Process() { StartInfo = starter }; - proc.Start(); - proc.StandardInput.Write(_patchBuilder.ToString()); + using var proc = Process.Start(starter)!; + await proc.StandardInput.WriteAsync(_patchBuilder.ToString()); proc.StandardInput.Close(); - var err = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); + var err = await proc.StandardError.ReadToEndAsync().ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); var rs = proc.ExitCode == 0; - proc.Close(); if (!rs) - Dispatcher.UIThread.Invoke(() => App.RaiseException(_repo, err)); + Models.Notification.Send(_repo, err, true); return rs; } catch (Exception e) { - Dispatcher.UIThread.Invoke(() => - { - App.RaiseException(_repo, "Failed to unstage changes: " + e.Message); - }); + Models.Notification.Send(_repo, "Failed to update index: " + e.Message, true); return false; } } - private string _repo = ""; - private StringBuilder _patchBuilder = new StringBuilder(); + private readonly string _repo; + private readonly StringBuilder _patchBuilder = new(); } } diff --git a/src/Commands/UpdateRef.cs b/src/Commands/UpdateRef.cs deleted file mode 100644 index ba1b3d2ff..000000000 --- a/src/Commands/UpdateRef.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; - -namespace SourceGit.Commands -{ - public class UpdateRef : Command - { - public UpdateRef(string repo, string refName, string toRevision, Action outputHandler) - { - _outputHandler = outputHandler; - - WorkingDirectory = repo; - Context = repo; - Args = $"update-ref {refName} {toRevision}"; - } - - protected override void OnReadline(string line) - { - _outputHandler?.Invoke(line); - } - - private Action _outputHandler; - } -} diff --git a/src/Commands/Version.cs b/src/Commands/Version.cs deleted file mode 100644 index ed7c68927..000000000 --- a/src/Commands/Version.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SourceGit.Commands -{ - public class Version : Command - { - public Version() - { - Args = "--version"; - 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 index 7516b1e3f..7b70e2ab4 100644 --- a/src/Commands/Worktree.cs +++ b/src/Commands/Worktree.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.Commands { @@ -11,42 +13,47 @@ public Worktree(string repo) Context = repo; } - public List List() + public async Task> ReadAllAsync() { Args = "worktree list --porcelain"; - var rs = ReadToEnd(); + var rs = await ReadToEndAsync().ConfigureAwait(false); var worktrees = new List(); - var last = null as Models.Worktree; + Models.Worktree last = null; if (rs.IsSuccess) { - var lines = rs.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + 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; } - else if (line.StartsWith("bare", StringComparison.Ordinal)) + + if (last == null) + continue; + + if (line.StartsWith("bare", StringComparison.Ordinal)) { - last!.IsBare = true; + last.IsBare = true; } else if (line.StartsWith("HEAD ", StringComparison.Ordinal)) { - last!.Head = line.Substring(5).Trim(); + last.Head = line.Substring(5).Trim(); } else if (line.StartsWith("branch ", StringComparison.Ordinal)) { - last!.Branch = line.Substring(7).Trim(); + last.Branch = line.Substring(7).Trim(); } else if (line.StartsWith("detached", StringComparison.Ordinal)) { - last!.IsDetached = true; + last.IsDetached = true; } else if (line.StartsWith("locked", StringComparison.Ordinal)) { - last!.IsLocked = true; + last.IsLocked = true; } } } @@ -54,65 +61,51 @@ public Worktree(string repo) return worktrees; } - public bool Add(string fullpath, string name, bool createNew, string tracking, Action outputHandler) + public async Task AddAsync(string fullpath, string name, bool createNew, string tracking) { - Args = "worktree add "; - + var builder = new StringBuilder(1024); + builder.Append("worktree add "); if (!string.IsNullOrEmpty(tracking)) - Args += "--track "; - + builder.Append("--track "); if (!string.IsNullOrEmpty(name)) - { - if (createNew) - Args += $"-b {name} "; - else - Args += $"-B {name} "; - } - - Args += $"\"{fullpath}\" "; + builder.Append(createNew ? "-b " : "-B ").Append(name).Append(' '); + builder.Append(fullpath.Quoted()).Append(' '); if (!string.IsNullOrEmpty(tracking)) - Args += tracking; + builder.Append(tracking); + else if (!string.IsNullOrEmpty(name) && !createNew) + builder.Append(name); - _outputHandler = outputHandler; - return Exec(); + Args = builder.ToString(); + return await ExecAsync().ConfigureAwait(false); } - public bool Prune(Action outputHandler) + public async Task PruneAsync() { Args = "worktree prune -v"; - _outputHandler = outputHandler; - return Exec(); + return await ExecAsync().ConfigureAwait(false); } - public bool Lock(string fullpath) + public async Task LockAsync(string fullpath) { - Args = $"worktree lock \"{fullpath}\""; - return Exec(); + Args = $"worktree lock {fullpath.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Unlock(string fullpath) + public async Task UnlockAsync(string fullpath) { - Args = $"worktree unlock \"{fullpath}\""; - return Exec(); + Args = $"worktree unlock {fullpath.Quoted()}"; + return await ExecAsync().ConfigureAwait(false); } - public bool Remove(string fullpath, bool force, Action outputHandler) + public async Task RemoveAsync(string fullpath, bool force) { if (force) - Args = $"worktree remove -f \"{fullpath}\""; + Args = $"worktree remove -f {fullpath.Quoted()}"; else - Args = $"worktree remove \"{fullpath}\""; + Args = $"worktree remove {fullpath.Quoted()}"; - _outputHandler = outputHandler; - return Exec(); + return await ExecAsync().ConfigureAwait(false); } - - protected override void OnReadline(string line) - { - _outputHandler?.Invoke(line); - } - - private Action _outputHandler = null; } } diff --git a/src/Converters/BoolConverters.cs b/src/Converters/BoolConverters.cs index 2d738700b..5c5dd9047 100644 --- a/src/Converters/BoolConverters.cs +++ b/src/Converters/BoolConverters.cs @@ -1,10 +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 readonly FuncValueConverter ToPageTabWidth = - new FuncValueConverter(x => x ? 200 : double.NaN); + 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/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 index 5b7c0a03d..871a80b3e 100644 --- a/src/Converters/DoubleConverters.cs +++ b/src/Converters/DoubleConverters.cs @@ -1,4 +1,5 @@ -using Avalonia.Data.Converters; +using Avalonia; +using Avalonia.Data.Converters; namespace SourceGit.Converters { @@ -11,9 +12,12 @@ public static class DoubleConverters new FuncValueConverter(v => v - 1.0); public static readonly FuncValueConverter ToPercentage = - new FuncValueConverter(v => (v * 100).ToString("F3") + "%"); + new FuncValueConverter(v => (v * 100).ToString("F0") + "%"); public static readonly FuncValueConverter OneMinusToPercentage = - new FuncValueConverter(v => ((1.0 - v) * 100).ToString("F3") + "%"); + 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 index c486af5e7..016613e83 100644 --- a/src/Converters/FilterModeConverters.cs +++ b/src/Converters/FilterModeConverters.cs @@ -8,15 +8,12 @@ public static class FilterModeConverters public static readonly FuncValueConverter ToBorderBrush = new FuncValueConverter(v => { - switch (v) + return v switch { - case Models.FilterMode.Included: - return Brushes.Green; - case Models.FilterMode.Excluded: - return Brushes.Red; - default: - return Brushes.Transparent; - } + 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 17a88da25..f44fa96d5 100644 --- a/src/Converters/IntConverters.cs +++ b/src/Converters/IntConverters.cs @@ -1,43 +1,26 @@ using Avalonia; -using Avalonia.Controls; using Avalonia.Data.Converters; -using Avalonia.Media; namespace SourceGit.Converters { public static class IntConverters { public static readonly FuncValueConverter IsGreaterThanZero = - new FuncValueConverter(v => v > 0); + new(v => v > 0); public static readonly FuncValueConverter IsGreaterThanFour = - new FuncValueConverter(v => v > 4); + new(v => v > 4); public static readonly FuncValueConverter IsZero = - new FuncValueConverter(v => v == 0); - - public static readonly FuncValueConverter IsOne = - new FuncValueConverter(v => v == 1); + new(v => v == 0); public static readonly FuncValueConverter IsNotOne = - new FuncValueConverter(v => v != 1); - - public static readonly FuncValueConverter IsSubjectLengthBad = - new FuncValueConverter(v => v > ViewModels.Preference.Instance.SubjectGuideLength); - - public static readonly FuncValueConverter IsSubjectLengthGood = - new FuncValueConverter(v => v <= ViewModels.Preference.Instance.SubjectGuideLength); + new(v => v != 1); public static readonly FuncValueConverter ToTreeMargin = - new FuncValueConverter(v => new Thickness(v * 16, 0, 0, 0)); + new(v => new Thickness(v * 16, 0, 0, 0)); - public static readonly FuncValueConverter ToBookmarkBrush = - new FuncValueConverter(bookmark => - { - if (bookmark == 0) - return Application.Current?.FindResource("Brush.FG1") as IBrush; - else - return Models.Bookmarks.Brushes[bookmark]; - }); + 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 index dbd183bd3..81f5564a7 100644 --- a/src/Converters/InteractiveRebaseActionConverters.cs +++ b/src/Converters/InteractiveRebaseActionConverters.cs @@ -6,46 +6,26 @@ namespace SourceGit.Converters public static class InteractiveRebaseActionConverters { public static readonly FuncValueConverter ToIconBrush = - new FuncValueConverter(v => + new(v => { - switch (v) + return v switch { - case Models.InteractiveRebaseAction.Pick: - return Brushes.Green; - case Models.InteractiveRebaseAction.Edit: - return Brushes.Orange; - case Models.InteractiveRebaseAction.Reword: - return Brushes.Orange; - case Models.InteractiveRebaseAction.Squash: - return Brushes.LightGray; - case Models.InteractiveRebaseAction.Fixup: - return Brushes.LightGray; - default: - return Brushes.Red; - } + 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 FuncValueConverter(v => - { - switch (v) - { - case Models.InteractiveRebaseAction.Pick: - return "Pick"; - case Models.InteractiveRebaseAction.Edit: - return "Edit"; - case Models.InteractiveRebaseAction.Reword: - return "Reword"; - case Models.InteractiveRebaseAction.Squash: - return "Squash"; - case Models.InteractiveRebaseAction.Fixup: - return "Fixup"; - default: - return "Drop"; - } - }); + new(v => v.ToString()); + + public static readonly FuncValueConverter IsDrop = + new(v => v == Models.InteractiveRebaseAction.Drop); - public static readonly FuncValueConverter CanEditMessage = - new FuncValueConverter(v => v == Models.InteractiveRebaseAction.Reword || v == Models.InteractiveRebaseAction.Squash); + public static readonly FuncValueConverter ToOpacity = + new(v => v > Models.InteractiveRebaseAction.Reword ? 0.65 : 1.0); } } diff --git a/src/Converters/ListConverters.cs b/src/Converters/ListConverters.cs index 81cac8b7d..e0c5967e8 100644 --- a/src/Converters/ListConverters.cs +++ b/src/Converters/ListConverters.cs @@ -8,7 +8,7 @@ namespace SourceGit.Converters public static class ListConverters { public static readonly FuncValueConverter ToCount = - new FuncValueConverter(v => v == null ? " (0)" : $" ({v.Count})"); + new FuncValueConverter(v => v == null ? "(0)" : $"({v.Count})"); public static readonly FuncValueConverter IsNullOrEmpty = new FuncValueConverter(v => v == null || v.Count == 0); 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 dd7cfa498..23dae2ab3 100644 --- a/src/Converters/PathConverters.cs +++ b/src/Converters/PathConverters.cs @@ -1,6 +1,4 @@ -using System; using System.IO; - using Avalonia.Data.Converters; namespace SourceGit.Converters @@ -14,17 +12,6 @@ public static class PathConverters new(v => Path.GetDirectoryName(v) ?? ""); public static readonly FuncValueConverter RelativeToHome = - new(v => - { - if (OperatingSystem.IsWindows()) - return v; - - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; - if (v.StartsWith(home, StringComparison.Ordinal)) - return "~" + v.Substring(prefixLen); - - return v; - }); + new(Native.OS.GetRelativePathToHome); } } diff --git a/src/Converters/StringConverters.cs b/src/Converters/StringConverters.cs index 585e0f02f..4a6fd33d3 100644 --- a/src/Converters/StringConverters.cs +++ b/src/Converters/StringConverters.cs @@ -1,13 +1,13 @@ using System; using System.Globalization; -using System.Text.RegularExpressions; using Avalonia.Data.Converters; +using Avalonia.Input; using Avalonia.Styling; namespace SourceGit.Converters { - public static partial class StringConverters + public static class StringConverters { public class ToLocaleConverter : IValueConverter { @@ -68,22 +68,6 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu public static readonly FuncValueConverter ToShortSHA = new FuncValueConverter(v => v == null ? string.Empty : (v.Length > 10 ? v.Substring(0, 10) : v)); - public static readonly FuncValueConverter UnderRecommendGitVersion = - new(v => - { - var match = REG_GIT_VERSION().Match(v ?? ""); - 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); - - return new Version(major, minor, build) < MINIMAL_GIT_VERSION; - } - - return true; - }); - public static readonly FuncValueConverter TrimRefsPrefix = new FuncValueConverter(v => { @@ -96,9 +80,16 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu return v; }); - [GeneratedRegex(@"^[\s\w]*(\d+)\.(\d+)[\.\-](\d+).*$")] - private static partial Regex REG_GIT_VERSION(); + 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); - private static readonly Version MINIMAL_GIT_VERSION = new Version(2, 23, 0); + public static readonly FuncValueConverter FromKeyGesture = + new FuncValueConverter(v => v?.ToString("p", null) ?? string.Empty); } } diff --git a/src/Models/ApplyWhiteSpaceMode.cs b/src/Models/ApplyWhiteSpaceMode.cs index 6fbce0b25..9bcd04b3d 100644 --- a/src/Models/ApplyWhiteSpaceMode.cs +++ b/src/Models/ApplyWhiteSpaceMode.cs @@ -1,16 +1,17 @@ namespace SourceGit.Models { - public class ApplyWhiteSpaceMode + public class ApplyWhiteSpaceMode(string n, string d, string a) { - public string Name { get; set; } - public string Desc { get; set; } - public string Arg { get; set; } + 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 313553f9a..9616ac64d 100644 --- a/src/Models/AvatarManager.cs +++ b/src/Models/AvatarManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -17,7 +17,7 @@ namespace SourceGit.Models { public interface IAvatarHost { - void OnAvatarResourceChanged(string email); + void OnAvatarResourceChanged(string email, Bitmap image); } public partial class AvatarManager @@ -26,23 +26,21 @@ public static AvatarManager Instance { get { - if (_instance == null) - _instance = new AvatarManager(); - - return _instance; + return _instance ??= new AvatarManager(); } } private static AvatarManager _instance = null; - [GeneratedRegex(@"^(?:(\d+)\+)?(.+?)@users\.noreply\.github\.com$")] + [GeneratedRegex(@"^(?:(\d+)\+)?(.+?)@.+\.github\.com$")] private static partial Regex REG_GITHUB_USER_EMAIL(); - private object _synclock = new object(); + 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() { @@ -50,14 +48,17 @@ public void Start() if (!Directory.Exists(_storePath)) Directory.CreateDirectory(_storePath); - var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/github.png", UriKind.RelativeOrAbsolute)); - _resources.Add("noreply@github.com", new Bitmap(icon)); + LoadDefaultAvatar("noreply@github.com", "github.png"); + LoadDefaultAvatar("unrealbot@epicgames.com", "unreal.png"); - Task.Run(() => + Task.Run(async () => { + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(2); + while (true) { - var email = null as string; + string email = null; lock (_synclock) { @@ -75,25 +76,27 @@ public void Start() } var md5 = GetEmailHash(email); - var matchGithubUser = REG_GITHUB_USER_EMAIL().Match(email); - var url = matchGithubUser.Success ? - $"https://avatars.githubusercontent.com/{matchGithubUser.Groups[2].Value}" : - $"https://www.gravatar.com/avatar/{md5}?d=404"; + var 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; + Bitmap img = null; try { - var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(2) }; - var task = client.GetAsync(url); - task.Wait(); - - var rsp = task.Result; + var rsp = await client.GetAsync(url); if (rsp.IsSuccessStatusCode) { using (var stream = rsp.Content.ReadAsStream()) { - using (var writer = File.OpenWrite(localFile)) + using (var writer = File.Create(localFile)) { stream.CopyTo(writer); } @@ -115,10 +118,10 @@ public void Start() _requesting.Remove(email); } - Dispatcher.UIThread.InvokeAsync(() => + Dispatcher.UIThread.Post(() => { _resources[email] = img; - NotifyResourceChanged(email); + NotifyResourceChanged(email, img); }); } @@ -140,17 +143,16 @@ public Bitmap Request(string email, bool forceRefetch) { if (forceRefetch) { - if (email.Equals("noreply@github.com", StringComparison.Ordinal)) + if (_defaultAvatars.Contains(email)) return null; - if (_resources.ContainsKey(email)) - _resources.Remove(email); + _resources.Remove(email); var localFile = Path.Combine(_storePath, GetEmailHash(email)); if (File.Exists(localFile)) File.Delete(localFile); - NotifyResourceChanged(email); + NotifyResourceChanged(email, null); } else { @@ -178,29 +180,58 @@ public Bitmap Request(string email, bool forceRefetch) lock (_synclock) { - if (!_requesting.Contains(email)) - _requesting.Add(email); + _requesting.Add(email); } return null; } + 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 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.Create().ComputeHash(Encoding.Default.GetBytes(lowered)); - var builder = new StringBuilder(); - foreach (var c in hash) - builder.Append(c.ToString("x2")); - return builder.ToString(); + var hash = MD5.HashData(Encoding.Default.GetBytes(lowered)); + return Convert.ToHexStringLower(hash); } - private void NotifyResourceChanged(string email) + private void NotifyResourceChanged(string email, Bitmap image) { foreach (var avatar in _avatars) - { - avatar.OnAvatarResourceChanged(email); - } + 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 3eb8d8bf3..a8fac34c8 100644 --- a/src/Models/Blame.cs +++ b/src/Models/Blame.cs @@ -6,15 +6,15 @@ 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 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 37cf689b7..ae3b2abd3 100644 --- a/src/Models/Bookmarks.cs +++ b/src/Models/Bookmarks.cs @@ -1,11 +1,9 @@ -using System.Collections.Generic; - -namespace SourceGit.Models +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, @@ -15,12 +13,9 @@ public static class Bookmarks Avalonia.Media.Brushes.Purple, ]; - public static readonly List Supported = new List(); - - static Bookmarks() + public static Avalonia.Media.IBrush Get(int i) { - for (int i = 0; i < Brushes.Length; i++) - Supported.Add(i); + return (i >= 0 && i < Brushes.Length) ? Brushes[i] : null; } } } diff --git a/src/Models/Branch.cs b/src/Models/Branch.cs index ac6b8c67d..47aa2153a 100644 --- a/src/Models/Branch.cs +++ b/src/Models/Branch.cs @@ -2,37 +2,43 @@ namespace SourceGit.Models { - public class BranchTrackStatus + public enum BranchSortMode { - public List Ahead { get; set; } = new List(); - public List Behind { get; set; } = new List(); - - public override string ToString() - { - if (Ahead.Count == 0 && Behind.Count == 0) - return string.Empty; - - var track = ""; - if (Ahead.Count > 0) - track += $"{Ahead.Count}↑"; - if (Behind.Count > 0) - track += $" {Behind.Count}↓"; - return track.Trim(); - } + 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 BranchTrackStatus TrackStatus { 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/CRLFMode.cs b/src/Models/CRLFMode.cs index 3f510f00c..46f5442f8 100644 --- a/src/Models/CRLFMode.cs +++ b/src/Models/CRLFMode.cs @@ -2,23 +2,16 @@ namespace SourceGit.Models { - public class CRLFMode + public class CRLFMode(string name, string value, string desc) { - public string Name { get; set; } - public string Value { get; set; } - public string Desc { get; set; } + public string Name { get; set; } = name; + public string Value { get; set; } = value; + public string Desc { get; set; } = desc; 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 36fe20ac4..baf6e8500 100644 --- a/src/Models/Change.cs +++ b/src/Models/Change.cs @@ -1,6 +1,4 @@ -using System; - -namespace SourceGit.Models +namespace SourceGit.Models { public enum ChangeViewMode { @@ -18,14 +16,27 @@ public enum ChangeState Deleted, Renamed, Copied, - Unmerged, - Untracked + Untracked, + Conflicted, + } + + 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 @@ -35,49 +46,72 @@ public class Change 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) { Index = index; WorkTree = workTree; - if (index == ChangeState.Renamed || workTree == ChangeState.Renamed) + if (index == ChangeState.Renamed || index == ChangeState.Copied || workTree == ChangeState.Renamed) { - var idx = Path.IndexOf('\t', StringComparison.Ordinal); - if (idx >= 0) + var parts = Path.Split('\t', 2); + if (parts.Length < 2) + parts = Path.Split(" -> ", 2); + if (parts.Length == 2) { - OriginalPath = Path.Substring(0, idx); - Path = Path.Substring(idx + 1); - } - else - { - idx = Path.IndexOf(" -> ", StringComparison.Ordinal); - if (idx > 0) - { - OriginalPath = Path.Substring(0, idx); - Path = Path.Substring(idx + 4); - } + 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); } + + 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 534cf5bb7..de299ed6b 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -1,49 +1,64 @@ using System; using System.Collections.Generic; - -using Avalonia; -using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.Models { public enum CommitSearchMethod { - ByUser, + BySHA = 0, + ByAuthor, ByMessage, - ByFile, + ByPath, + ByContent, } - public class Commit + public class Commit : ObservableObject { - public static double OpacityForNotMerged - { - get; - set; - } = 0.65; - 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 List Parents { get; set; } = new List(); - public List Decorators { get; set; } = new List(); - public bool HasDecorators => Decorators.Count > 0; - - public string AuthorTimeStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss"); - public string CommitterTimeStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss"); - public string AuthorTimeShortStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString("yyyy/MM/dd"); + public List Parents { get; set; } = new(); + public List Decorators { get; set; } = new(); public bool IsMerged { get; set; } = false; + 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 int Color { get; set; } = 0; - public double Opacity => IsMerged ? 1 : OpacityForNotMerged; - public FontWeight FontWeight => IsCurrentHead ? FontWeight.Bold : FontWeight.Regular; - public Thickness Margin { get; set; } = new Thickness(0); - public IBrush Brush => CommitGraph.Pens[Color].Brush; + public string GetFriendlyName() + { + var branchDecorator = Decorators.Find(x => x.Type is DecoratorType.LocalBranchHead or DecoratorType.RemoteBranchHead); + if (branchDecorator != null) + return branchDecorator.Name; + + var tagDecorator = Decorators.Find(x => x.Type is DecoratorType.Tag); + if (tagDecorator != null) + return tagDecorator.Name; + + return SHA[..10]; + } + + public void ParseParents(string data) + { + if (data.Length < 8) + return; + + Parents.AddRange(data.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } public void ParseDecorators(string data) { @@ -103,17 +118,19 @@ public void ParseDecorators(string data) Decorators.Sort((l, r) => { - if (l.Type != r.Type) - return (int)l.Type - (int)r.Type; - else - return string.Compare(l.Name, r.Name, StringComparison.Ordinal); + var delta = (int)l.Type - (int)r.Type; + if (delta != 0) + return delta; + return NumericSort.Compare(l.Name, r.Name); }); } + + private bool _isHighlightedInGraph = false; } - public class CommitWithMessage + public class CommitFullMessage { - public Commit Commit { get; set; } = new Commit(); - public string Message { get; set; } = ""; + 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 31f5a40e5..8557f271d 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -6,6 +6,16 @@ namespace SourceGit.Models { + public record CommitGraphLayout(double StartY, double ClipWidth, double RowHeight); + + public enum CommitGraphHighlighting + { + All = 0, + CurrentBranchOnly, + SelectedCommitsOnly, + CurrentBranchAndSelectedCommits, + } + public class CommitGraph { public static List Pens { get; } = []; @@ -25,10 +35,11 @@ public static void SetPens(List colors, double thickness) s_penCount = colors.Count; } - public class Path(int color) + public class Path(int color, bool isHighlighted) { public List Points { get; } = []; public int Color { get; } = color; + public bool IsHighlighted { get; } = isHighlighted; } public class Link @@ -37,6 +48,7 @@ public class Link public Point Control; public Point End; public int Color; + public bool IsHighlighted; } public enum DotType @@ -51,29 +63,47 @@ public class Dot public DotType Type; public Point Center; public int Color; + public bool IsHighlighted; } public List Paths { get; } = []; public List Links { get; } = []; public List Dots { get; } = []; - public static CommitGraph Parse(List commits, bool firstParentOnlyEnabled) + 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 = 28; - const double halfHeight = 14; + const double unitHeight = 1; + const double halfHeight = 0.5; var temp = new CommitGraph(); var unsolved = new List(); var ended = new List(); var offsetY = -halfHeight; - var colorIdx = 0; + var colorPicker = new ColorPicker(); + var merged = new HashSet(); foreach (var commit in commits) { - var major = null as PathHelper; - var isMerged = commit.IsMerged; + PathHelper major = null; + + // 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 += unitHeight; @@ -81,6 +111,7 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable // Find first curves that links to this commit and marks others that links to this commit ended. var offsetX = 4 - halfWidth; var maxOffsetOld = unsolved.Count > 0 ? unsolved[^1].LastX : offsetX + unitWidth; + var isHighlighted = false; foreach (var l in unsolved) { if (l.Next.Equals(commit.SHA, StringComparison.Ordinal)) @@ -89,6 +120,7 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable { offsetX += unitWidth; major = l; + isHighlighted = major.IsHighlighted; if (commit.Parents.Count > 0) { @@ -105,10 +137,10 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable { l.End(major.LastX, offsetY, halfHeight); ended.Add(l); - } - isMerged = isMerged || l.IsMerged; - major.IsMerged = isMerged; + if (!isHighlighted && l.IsHighlighted) + isHighlighted = true; + } } else { @@ -119,28 +151,71 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable // Remove ended curves from unsolved foreach (var l in ended) + { + colorPicker.Recycle(l.Path.Color); unsolved.Remove(l); + } ended.Clear(); - // Create new curve for branch head + // 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; + + // If no path found, create new curve for branch head + // Otherwise, create new curve for new merged commit if (major == null) { offsetX += unitWidth; if (commit.Parents.Count > 0) { - major = new PathHelper(commit.Parents[0], isMerged, colorIdx, new Point(offsetX, offsetY)); + major = new PathHelper(commit.Parents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY)); unsolved.Add(major); temp.Paths.Add(major.Path); } - - colorIdx = (colorIdx + 1) % s_penCount; + } + else if (isHighlighted && !major.IsHighlighted && commit.Parents.Count > 0) + { + major.Highlight(); + temp.Paths.Add(major.Path); } // Calculate link position of this commit. var position = new Point(major?.LastX ?? offsetX, offsetY); var dotColor = major?.Path.Color ?? 0; - var anchor = new Dot() { Center = position, Color = dotColor }; + var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted }; if (commit.IsCurrentHead) anchor.Type = DotType.Head; else if (commit.Parents.Count > 1) @@ -158,16 +233,20 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal)); if (parent != null) { - // Try to change the merge state of linked graph - if (isMerged) - parent.IsMerged = true; + 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 + Color = parent.Path.Color, + IsHighlighted = isHighlighted, }); } else @@ -175,18 +254,16 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable offsetX += unitWidth; // Create new curve for parent commit that not includes before - var l = new PathHelper(parentHash, isMerged, colorIdx, position, new Point(offsetX, position.Y + halfHeight)); + var l = new PathHelper(parentHash, isHighlighted, colorPicker.Next(), position, new Point(offsetX, position.Y + halfHeight)); unsolved.Add(l); temp.Paths.Add(l.Path); - colorIdx = (colorIdx + 1) % s_penCount; } } } - // Margins & merge state (used by Views.Histories). - commit.IsMerged = isMerged; - commit.Margin = new Thickness(Math.Max(offsetX, maxOffsetOld) + halfWidth + 2, 0, 0, 0); + // Margins & colors (used by Views.Histories). commit.Color = dotColor; + commit.LeftMargin = Math.Max(offsetX, maxOffsetOld) + halfWidth + 2; } // Deal with curves haven't ended yet. @@ -205,32 +282,52 @@ public static CommitGraph Parse(List commits, bool firstParentOnlyEnable 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; } + public Path Path { get; private set; } public string Next { get; set; } - public bool IsMerged { get; set; } public double LastX { get; private set; } + public bool IsHighlighted { get => Path.IsHighlighted; } - public PathHelper(string next, bool isMerged, int color, Point start) + public PathHelper(string next, bool IsHighlighted, int color, Point start) { Next = next; - IsMerged = isMerged; LastX = start.X; _lastY = start.Y; - Path = new Path(color); + Path = new Path(color, IsHighlighted); Path.Points.Add(start); } - public PathHelper(string next, bool isMerged, int color, Point start, Point to) + public PathHelper(string next, bool IsHighlighted, int color, Point start, Point to) { Next = next; - IsMerged = isMerged; LastX = to.X; _lastY = to.Y; - Path = new Path(color); + Path = new Path(color, IsHighlighted); Path.Points.Add(start); Path.Points.Add(to); } @@ -310,6 +407,19 @@ public void End(double x, double y, double halfHeight) _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) @@ -327,7 +437,6 @@ private void Add(double x, double y) private static readonly List s_defaultPenColors = [ Colors.Orange, Colors.ForestGreen, - Colors.Gray, Colors.Turquoise, Colors.Olive, Colors.Magenta, diff --git a/src/Models/CommitLink.cs b/src/Models/CommitLink.cs index 955779a8e..fa78f206c 100644 --- a/src/Models/CommitLink.cs +++ b/src/Models/CommitLink.cs @@ -1,8 +1,51 @@ -namespace SourceGit.Models +using System; +using System.Collections.Generic; + +namespace SourceGit.Models { public class CommitLink { - public string Name { get; set; } = null; - public string URLPrefix { get; set; } = null; + public 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 index 44b95e610..99317e94e 100644 --- a/src/Models/CommitSignInfo.cs +++ b/src/Models/CommitSignInfo.cs @@ -13,21 +13,13 @@ public IBrush Brush { get { - switch (VerifyResult) + return VerifyResult switch { - case 'G': - case 'U': - return Brushes.Green; - case 'X': - case 'Y': - case 'R': - return Brushes.DarkOrange; - case 'B': - case 'E': - return Brushes.Red; - default: - return Brushes.Transparent; - } + 'G' or 'U' => Brushes.Green, + 'X' or 'Y' or 'R' => Brushes.DarkOrange, + 'B' or 'E' => Brushes.Red, + _ => Brushes.Transparent, + }; } } @@ -35,25 +27,17 @@ public string ToolTip { get { - switch (VerifyResult) + return VerifyResult switch { - case 'G': - return "Good signature."; - case 'U': - return "Good signature with unknown validity."; - case 'X': - return "Good signature but has expired."; - case 'Y': - return "Good signature made by expired key."; - case 'R': - return "Good signature made by a revoked key."; - case 'B': - return "Bad signature."; - case 'E': - return "Signature cannot be checked."; - default: - return "No signature."; - } + '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 index b34fa5a52..3f331543b 100644 --- a/src/Models/CommitTemplate.cs +++ b/src/Models/CommitTemplate.cs @@ -1,17 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; +using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.Models { - public partial class CommitTemplate : ObservableObject + public class CommitTemplate : ObservableObject { - [GeneratedRegex(@"\$\{files(\:\d+)?\}")] - private static partial Regex REG_COMMIT_TEMPLATE_FILES(); - public string Name { get => _name; @@ -26,55 +20,8 @@ public string Content public string Apply(Branch branch, List changes) { - var content = _content - .Replace("${files_num}", $"{changes.Count}") - .Replace("${branch_name}", branch.Name); - - var matches = REG_COMMIT_TEMPLATE_FILES().Matches(content); - if (matches.Count == 0) - return content; - - var builder = new StringBuilder(); - var last = 0; - for (int i = 0; i < matches.Count; i++) - { - var match = matches[i]; - if (!match.Success) - continue; - - var start = match.Index; - if (start != last) - builder.Append(content.Substring(last, start - last)); - - var countStr = match.Groups[1].Value; - var paths = new List(); - var more = string.Empty; - if (countStr is { Length: <= 1 }) - { - foreach (var c in changes) - paths.Add(c.Path); - } - else - { - var count = Math.Min(int.Parse(countStr.Substring(1)), changes.Count); - for (int j = 0; j < count; j++) - paths.Add(changes[j].Path); - - if (count < changes.Count) - more = $" and {changes.Count - count} other files"; - } - - builder.Append(string.Join(", ", paths)); - if (!string.IsNullOrEmpty(more)) - builder.Append(more); - - last = start + match.Length; - } - - if (last != content.Length - 1) - builder.Append(content.Substring(last)); - - return builder.ToString(); + var te = new TemplateEngine(); + return te.Eval(_content, branch, changes); } private string _name = 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 index 4fb61d87f..bf2763a41 100644 --- a/src/Models/ConventionalCommitType.cs +++ b/src/Models/ConventionalCommitType.cs @@ -1,28 +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 static readonly List Supported = new List() - { - new ConventionalCommitType("feat", "Adding a new feature"), - new ConventionalCommitType("fix", "Fixing a bug"), - new ConventionalCommitType("docs", "Updating documentation"), - new ConventionalCommitType("style", "Elements or code styles without changing the code logic"), - new ConventionalCommitType("test", "Adding or updating tests"), - new ConventionalCommitType("chore", "Making changes to the build process or auxiliary tools and libraries"), - new ConventionalCommitType("revert", "Undoing a previous commit"), - new ConventionalCommitType("refactor", "Restructuring code without changing its external behavior") - }; - - public ConventionalCommitType(string type, string description) + 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 index 8452a42d0..59652d32f 100644 --- a/src/Models/CustomAction.cs +++ b/src/Models/CustomAction.cs @@ -1,4 +1,5 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.Models { @@ -6,6 +7,68 @@ 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 @@ -34,9 +97,22 @@ public string 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 index f308a90cf..9775c61a4 100644 --- a/src/Models/DealWithLocalChanges.cs +++ b/src/Models/DealWithLocalChanges.cs @@ -2,8 +2,8 @@ { public enum DealWithLocalChanges { - DoNothing, - StashAndReaply, + DoNothing = 0, + StashAndReapply, Discard, } } diff --git a/src/Models/DiffOption.cs b/src/Models/DiffOption.cs index 98387e7f5..af64b39a4 100644 --- a/src/Models/DiffOption.cs +++ b/src/Models/DiffOption.cs @@ -1,11 +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; + public bool IsLocalChange => _revisions.Count == 0; public bool IsUnstaged => _isUnstaged; public List Revisions => _revisions; public string Path => _path; @@ -18,8 +19,9 @@ public class DiffOption /// public DiffOption(Change change, bool isUnstaged) { - _workingCopyChange = change; _isUnstaged = isUnstaged; + _path = change.Path; + _orgPath = change.OriginalPath; if (isUnstaged) { @@ -28,24 +30,16 @@ public DiffOption(Change change, bool isUnstaged) case ChangeState.Added: case ChangeState.Untracked: _extra = "--no-index"; - _path = change.Path; _orgPath = "/dev/null"; break; - default: - _path = change.Path; - _orgPath = change.OriginalPath; - break; } } else { if (change.DataForAmend != null) - _extra = "--cached HEAD^"; + _extra = $"--cached {change.DataForAmend.ParentSHA}"; else _extra = "--cached"; - - _path = change.Path; - _orgPath = change.OriginalPath; } } @@ -56,24 +50,67 @@ public DiffOption(Change change, bool isUnstaged) /// public DiffOption(Commit commit, Change change) { - var baseRevision = commit.Parents.Count == 0 ? "4b825dc642cb6eb9a060e54bf8d69288fbee4904" : $"{commit.SHA}^"; - _revisions.Add(baseRevision); + _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) + /// + public DiffOption(FileVersion ver) { - var baseRevision = commit.Parents.Count == 0 ? "4b825dc642cb6eb9a060e54bf8d69288fbee4904" : $"{commit.SHA}^"; - _revisions.Add(baseRevision); - _revisions.Add(commit.SHA); - _path = file; + 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; + } } /// @@ -93,7 +130,6 @@ public DiffOption(string baseRevision, string targetRevision, Change change) /// /// Converts to diff command arguments. /// - /// public override string ToString() { var builder = new StringBuilder(); @@ -102,19 +138,22 @@ public override string ToString() 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}\""); + builder.Append($"{_orgPath.Quoted()} "); + builder.Append(_path.Quoted()); return builder.ToString(); } - private readonly Change _workingCopyChange = null; private readonly bool _isUnstaged = false; private readonly string _path; private readonly string _orgPath = string.Empty; private readonly string _extra = string.Empty; - private readonly List _revisions = new List(); + private readonly List _revisions = []; + private readonly bool _ignorePaths = false; } } diff --git a/src/Models/DiffResult.cs b/src/Models/DiffResult.cs index e0ae82e00..ddb3d7e07 100644 --- a/src/Models/DiffResult.cs +++ b/src/Models/DiffResult.cs @@ -1,8 +1,5 @@ -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; - -using Avalonia; +using System.Collections.Generic; +using System.IO; using Avalonia.Media.Imaging; namespace SourceGit.Models @@ -16,618 +13,46 @@ public enum TextDiffLineType Deleted, } - public class TextInlineRange + public class TextRange(int p, int n) { - public int Start { get; set; } - public int End { get; set; } - public TextInlineRange(int p, int n) { Start = p; End = p + n - 1; } + public int Start { get; set; } = p; + public int End { get; set; } = p + n - 1; } 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 partial class TextDiff { - public string File { get; set; } = string.Empty; public List Lines { get; set; } = new List(); - public Vector ScrollOffset { get; set; } = Vector.Zero; public int MaxLineNumber = 0; - - public string Repo { get; set; } = null; - public DiffOption Option { get; set; } = null; - - public TextDiffSelection MakeSelection(int startLine, int endLine, bool isCombined, bool isOldSide) - { - var rs = new TextDiffSelection(); - rs.StartLine = startLine; - rs.EndLine = endLine; - - for (int i = 0; i < startLine - 1; i++) - { - var line = Lines[i]; - if (line.Type == TextDiffLineType.Added) - { - rs.HasLeftChanges = true; - rs.IgnoredAdds++; - } - else if (line.Type == TextDiffLineType.Deleted) - { - rs.HasLeftChanges = true; - rs.IgnoredDeletes++; - } - } - - for (int i = startLine - 1; i < endLine; i++) - { - var line = Lines[i]; - if (line.Type == TextDiffLineType.Added) - { - if (isCombined) - { - rs.HasChanges = true; - break; - } - else if (isOldSide) - { - rs.HasLeftChanges = true; - } - else - { - rs.HasChanges = true; - } - } - else if (line.Type == TextDiffLineType.Deleted) - { - if (isCombined) - { - rs.HasChanges = true; - break; - } - else if (isOldSide) - { - rs.HasChanges = true; - } - else - { - rs.HasLeftChanges = true; - } - } - } - - if (!rs.HasLeftChanges) - { - for (int i = endLine; i < Lines.Count; i++) - { - var line = Lines[i]; - if (line.Type == TextDiffLineType.Added || line.Type == TextDiffLineType.Deleted) - { - rs.HasLeftChanges = true; - break; - } - } - } - - return rs; - } - - public void GenerateNewPatchFromSelection(Change change, string fileBlobGuid, TextDiffSelection selection, bool revert, string output) - { - var isTracked = !string.IsNullOrEmpty(fileBlobGuid); - var fileGuid = isTracked ? fileBlobGuid.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 match = REG_INDICATOR().Match(indicator.Content); - var oldStart = int.Parse(match.Groups[1].Value); - var newStart = int.Parse(match.Groups[2].Value) + ignoreRemoves - ignoreAdds; - var oldCount = 0; - var newCount = 0; - for (int i = idx + 1; i < end; i++) - { - var test = Lines[i]; - if (test.Type == TextDiffLineType.Indicator) - break; - - if (test.Type == TextDiffLineType.Normal) - { - oldCount++; - newCount++; - } - else if (test.Type == TextDiffLineType.Added) - { - if (i < start - 1) - { - if (revert) - { - newCount++; - oldCount++; - } - } - else - { - newCount++; - } - - if (i == end - 1 && tailed) - { - newCount++; - oldCount++; - } - } - else if (test.Type == TextDiffLineType.Deleted) - { - if (i < start - 1) - { - if (!revert) - { - newCount++; - oldCount++; - } - } - else - { - oldCount++; - } - - if (i == end - 1 && tailed) - { - newCount++; - oldCount++; - } - } - } - - if (oldCount == 0 && newCount == 0) - return false; - - builder.Append($"\n@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); - return true; - } - - private bool ProcessIndicatorForPatchSingleSide(StringBuilder builder, TextDiffLine indicator, int idx, int start, int end, int ignoreRemoves, int ignoreAdds, bool revert, bool isOldSide, bool tailed) - { - var match = REG_INDICATOR().Match(indicator.Content); - var oldStart = int.Parse(match.Groups[1].Value); - var newStart = int.Parse(match.Groups[2].Value) + ignoreRemoves - ignoreAdds; - var oldCount = 0; - var newCount = 0; - for (int i = idx + 1; i < end; i++) - { - var test = Lines[i]; - if (test.Type == TextDiffLineType.Indicator) - break; - - if (test.Type == TextDiffLineType.Normal) - { - oldCount++; - newCount++; - } - else if (test.Type == TextDiffLineType.Added) - { - if (i < start - 1) - { - if (revert) - { - newCount++; - oldCount++; - } - } - else - { - if (isOldSide) - { - if (revert) - { - newCount++; - oldCount++; - } - } - else - { - newCount++; - } - } - - if (i == end - 1 && tailed) - { - newCount++; - oldCount++; - } - } - else if (test.Type == TextDiffLineType.Deleted) - { - if (i < start - 1) - { - if (!revert) - { - newCount++; - oldCount++; - } - } - else - { - if (isOldSide) - { - oldCount++; - } - else - { - if (!revert) - { - newCount++; - oldCount++; - } - } - } - - if (i == end - 1 && tailed) - { - newCount++; - oldCount++; - } - } - } - - if (oldCount == 0 && newCount == 0) - return false; - - builder.Append($"\n@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); - return true; - } - - [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] - private static partial Regex REG_INDICATOR(); + public 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 class LFSDiff @@ -654,33 +79,34 @@ public class ImageDiff public string NewImageSize => New != null ? $"{New.PixelSize.Width} x {New.PixelSize.Height}" : "0 x 0"; } - public class NoOrEOLChange + public class EmptyFile { + public const string SHA1 = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"; + public const string SHA256 = "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813"; } - public class FileModeDiff - { - public string Old { get; set; } = string.Empty; - public string New { get; set; } = string.Empty; - } + 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 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 string OldMode { get; set; } = string.Empty; - public string NewMode { 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; - - public string FileModeChange => string.IsNullOrEmpty(OldMode) ? string.Empty : $"{OldMode} → {NewMode}"; } } 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/ExternalMerger.cs b/src/Models/ExternalMerger.cs index a09f808c2..655a1d58a 100644 --- a/src/Models/ExternalMerger.cs +++ b/src/Models/ExternalMerger.cs @@ -7,14 +7,13 @@ namespace SourceGit.Models { - public class ExternalMerger + public class ExternalMerger(string icon, string name, string finder, string mergeCmd, string diffCmd) { - public int Type { get; set; } - public string Icon { get; set; } - public string Name { get; set; } - public string Exec { get; set; } - public string Cmd { get; set; } - public string DiffCmd { get; set; } + public 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 { @@ -32,78 +31,69 @@ static ExternalMerger() if (OperatingSystem.IsWindows()) { Supported = new List() { - new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), - new ExternalMerger(1, "vscode", "Visual Studio Code", "Code.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(2, "vscode_insiders", "Visual Studio Code - Insiders", "Code - Insiders.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(3, "vs", "Visual Studio", "vsDiffMerge.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\" /m", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(4, "tortoise_merge", "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\""), - new ExternalMerger(5, "kdiff3", "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(6, "beyond_compare", "Beyond Compare", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(7, "win_merge", "WinMerge", "WinMergeU.exe", "\"$MERGED\"", "-u -e \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(8, "codium", "VSCodium", "VSCodium.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(9, "p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger("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(0, "git", "Use Git Settings", "", "", ""), - new ExternalMerger(1, "xcode", "FileMerge", "/usr/bin/opendiff", "\"$BASE\" \"$LOCAL\" \"$REMOTE\" -ancestor \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(2, "vscode", "Visual Studio Code", "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(3, "vscode_insiders", "Visual Studio Code - Insiders", "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(4, "kdiff3", "KDiff3", "/Applications/kdiff3.app/Contents/MacOS/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(5, "beyond_compare", "Beyond Compare", "/Applications/Beyond Compare.app/Contents/MacOS/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(6, "codium", "VSCodium", "/Applications/VSCodium.app/Contents/Resources/app/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(7, "p4merge", "P4Merge", "/Applications/p4merge.app/Contents/Resources/launchp4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + 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(0, "git", "Use Git Settings", "", "", ""), - new ExternalMerger(1, "vscode", "Visual Studio Code", "/usr/share/code/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(2, "vscode_insiders", "Visual Studio Code - Insiders", "/usr/share/code-insiders/code-insiders", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(3, "kdiff3", "KDiff3", "/usr/bin/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(4, "beyond_compare", "Beyond Compare", "/usr/bin/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(5, "meld", "Meld", "/usr/bin/meld", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(6, "codium", "VSCodium", "/usr/share/codium/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), - new ExternalMerger(7, "p4merge", "P4Merge", "/usr/local/bin/p4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + 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(0, "git", "Use Git Settings", "", "", ""), + new ExternalMerger("git", "Use Git Settings", "", "", ""), }; } } - public ExternalMerger(int type, string icon, string name, string exec, string cmd, string diffCmd) - { - Type = type; - Icon = icon; - Name = name; - Exec = exec; - Cmd = cmd; - DiffCmd = diffCmd; - } - - public string[] GetPatterns() + public string[] GetPatternsToFindExecFile() { if (OperatingSystem.IsWindows()) - { - return Exec.Split(';'); - } - else - { - var patterns = new List(); - var choices = Exec.Split(';', StringSplitOptions.RemoveEmptyEntries); - foreach (var c in choices) - { - patterns.Add(Path.GetFileName(c)); - } - return patterns.ToArray(); - } + 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 index 103e91bca..7f93cf8bd 100644 --- a/src/Models/ExternalTool.cs +++ b/src/Models/ExternalTool.cs @@ -12,14 +12,30 @@ namespace SourceGit.Models { public class ExternalTool { - public string Name { get; private set; } - public Bitmap IconImage { get; private set; } = null; + public class LaunchOption + { + public string Title { get; set; } + public string Args { get; set; } + + public LaunchOption(string title, string args) + { + Title = title; + Args = args; + } + } - public ExternalTool(string name, string icon, string execFile, Func execArgsGenerator = null) + 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; - _execArgsGenerator = execArgsGenerator ?? (repo => $"\"{repo}\""); + ExecFile = execFile; + SupportOpenFolder = supportOpenFolder; + + _optionsGenerator = optionsGenerator; try { @@ -33,62 +49,70 @@ public ExternalTool(string name, string icon, string execFile, Func MakeLaunchOptions(string repo) + { + return _optionsGenerator?.Invoke(repo); + } + + public void Launch(string args) { - Process.Start(new ProcessStartInfo() + if (File.Exists(ExecFile)) { - WorkingDirectory = repo, - FileName = _execFile, - Arguments = _execArgsGenerator.Invoke(repo), - UseShellExecute = false, - }); + Process.Start(new ProcessStartInfo() + { + FileName = ExecFile, + Arguments = args, + UseShellExecute = false, + }); + } } - private string _execFile = string.Empty; - private Func _execArgsGenerator = null; + 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("version")] - public int Version { get; set; } = 0; - [JsonPropertyName("appVersion")] - public string AppVersion { get; set; } = string.Empty; [JsonPropertyName("tools")] public List Tools { get; set; } = new List(); } public class JetBrainsTool { - [JsonPropertyName("channelId")] - public string ChannelId { get; set; } - [JsonPropertyName("toolId")] - public string ToolId { get; set; } [JsonPropertyName("productCode")] public string ProductCode { get; set; } - [JsonPropertyName("tag")] - public string Tag { get; set; } [JsonPropertyName("displayName")] public string DisplayName { get; set; } [JsonPropertyName("displayVersion")] public string DisplayVersion { get; set; } - [JsonPropertyName("buildNumber")] - public string BuildNumber { get; set; } [JsonPropertyName("installLocation")] public string InstallLocation { get; set; } [JsonPropertyName("launchCommand")] public string LaunchCommand { get; set; } } - public class ExternalToolPaths + 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 Founded + public List Tools { get; private set; @@ -100,49 +124,49 @@ public ExternalToolsFinder() try { if (File.Exists(customPathsConfig)) - _customPaths = JsonSerializer.Deserialize(File.ReadAllText(customPathsConfig), JsonCodeGen.Default.ExternalToolPaths); + { + using var stream = File.OpenRead(customPathsConfig); + _customization = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.ExternalToolCustomization); + } } catch { // Ignore } - if (_customPaths == null) - _customPaths = new ExternalToolPaths(); + _customization ??= new ExternalToolCustomization(); } - public void TryAdd(string name, string icon, Func finder, Func execArgsGenerator = null) + public void TryAdd(string name, string icon, Func finder, Func> optionsGenerator = null, bool supportOpenFolder = true) { - if (_customPaths.Tools.TryGetValue(name, out var customPath) && File.Exists(customPath)) + if (_customization.Excludes.Contains(name)) + return; + + if (_customization.Tools.TryGetValue(name, out var customPath) && File.Exists(customPath)) { - Founded.Add(new ExternalTool(name, icon, customPath, execArgsGenerator)); + Tools.Add(new ExternalTool(name, icon, customPath, optionsGenerator, supportOpenFolder)); } else { var path = finder(); if (!string.IsNullOrEmpty(path) && File.Exists(path)) - Founded.Add(new ExternalTool(name, icon, path, execArgsGenerator)); + Tools.Add(new ExternalTool(name, icon, path, optionsGenerator, supportOpenFolder)); } } public void VSCode(Func platformFinder) { - TryAdd("Visual Studio Code", "vscode", platformFinder); + TryAdd("Visual Studio Code", "vscode", platformFinder, GenerateVSCodeLaunchOptions); } public void VSCodeInsiders(Func platformFinder) { - TryAdd("Visual Studio Code - Insiders", "vscode_insiders", platformFinder); + TryAdd("Visual Studio Code - Insiders", "vscode_insiders", platformFinder, GenerateVSCodeLaunchOptions); } public void VSCodium(Func platformFinder) { - TryAdd("VSCodium", "codium", platformFinder); - } - - public void Fleet(Func platformFinder) - { - TryAdd("Fleet", "fleet", platformFinder); + TryAdd("VSCodium", "codium", platformFinder, GenerateVSCodeLaunchOptions); } public void SublimeText(Func platformFinder) @@ -155,27 +179,60 @@ public void Zed(Func platformFinder) TryAdd("Zed", "zed", platformFinder); } + public void Cursor(Func platformFinder) + { + TryAdd("Cursor", "cursor", platformFinder); + } + public void FindJetBrainsFromToolbox(Func platformFinder) { - var exclude = new List { "fleet", "dotmemory", "dottrace", "resharper-u", "androidstudio" }; - var supported_icons = new List { "CL", "DB", "DL", "DS", "GO", "JB", "PC", "PS", "PY", "QA", "QD", "RD", "RM", "RR", "WRS", "WS" }; + var 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)) { - var stateData = JsonSerializer.Deserialize(File.ReadAllText(state), JsonCodeGen.Default.JetBrainsState); - foreach (var tool in stateData.Tools) + try { - if (exclude.Contains(tool.ToolId.ToLowerInvariant())) - continue; - - Founded.Add(new ExternalTool( - $"{tool.DisplayName} {tool.DisplayVersion}", - supported_icons.Contains(tool.ProductCode) ? $"JetBrains/{tool.ProductCode}" : "JetBrains/JB", - Path.Combine(tool.InstallLocation, tool.LaunchCommand))); + 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 ExternalToolPaths _customPaths = null; + 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/GitFlow.cs b/src/Models/GitFlow.cs new file mode 100644 index 000000000..a648d4db2 --- /dev/null +++ b/src/Models/GitFlow.cs @@ -0,0 +1,108 @@ +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 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 IsValid + { + get + { + return !string.IsNullOrEmpty(ProductionBranch) && + !string.IsNullOrEmpty(DevelopmentBranch) && + !string.IsNullOrEmpty(FeaturePrefix) && + !string.IsNullOrEmpty(ReleasePrefix) && + !string.IsNullOrEmpty(HotfixPrefix); + } + } + + 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/Filter.cs b/src/Models/HistoryFilter.cs similarity index 86% rename from src/Models/Filter.cs rename to src/Models/HistoryFilter.cs index af4569fad..b09f074cb 100644 --- a/src/Models/Filter.cs +++ b/src/Models/HistoryFilter.cs @@ -18,7 +18,7 @@ public enum FilterMode Excluded, } - public class Filter : ObservableObject + public class HistoryFilter : ObservableObject { public string Pattern { @@ -43,11 +43,11 @@ public bool IsBranch get => Type != FilterType.Tag; } - public Filter() + public HistoryFilter() { } - public Filter(string pattern, FilterType type, FilterMode mode) + public HistoryFilter(string pattern, FilterType type, FilterMode mode) { _pattern = pattern; _mode = mode; 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/Hyperlink.cs b/src/Models/Hyperlink.cs deleted file mode 100644 index 81dc980e4..000000000 --- a/src/Models/Hyperlink.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace SourceGit.Models -{ - public class Hyperlink - { - public int Start { get; set; } = 0; - public int Length { get; set; } = 0; - public string Link { get; set; } = ""; - public bool IsCommitSHA { get; set; } = false; - - public Hyperlink(int start, int length, string link, bool isCommitSHA = false) - { - Start = start; - Length = length; - Link = link; - IsCommitSHA = isCommitSHA; - } - - public bool Intersect(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/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 index 12b1adbae..2fc7c6125 100644 --- a/src/Models/IRepository.cs +++ b/src/Models/IRepository.cs @@ -2,8 +2,7 @@ { public interface IRepository { - string FullPath { get; set; } - string GitDir { get; set; } + bool MayHaveSubmodules(); void RefreshBranches(); void RefreshWorktrees(); 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 index 0980587a3..ac7e29d4f 100644 --- a/src/Models/InteractiveRebase.cs +++ b/src/Models/InteractiveRebase.cs @@ -1,4 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; namespace SourceGit.Models { @@ -12,6 +15,21 @@ public enum InteractiveRebaseAction 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; @@ -19,8 +37,55 @@ public class InteractiveRebaseJob public string Message { get; set; } = string.Empty; } - public class InteractiveRebaseJobCollection + 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/IssueTrackerRule.cs b/src/Models/IssueTracker.cs similarity index 68% rename from src/Models/IssueTrackerRule.cs rename to src/Models/IssueTracker.cs index 29487a16a..b424707f6 100644 --- a/src/Models/IssueTrackerRule.cs +++ b/src/Models/IssueTracker.cs @@ -1,12 +1,16 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - +using System.Text.RegularExpressions; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.Models { - public class IssueTrackerRule : ObservableObject + public class IssueTracker : ObservableObject { + public bool IsShared + { + get => _isShared; + set => SetProperty(ref _isShared, value); + } + public string Name { get => _name; @@ -22,12 +26,11 @@ public string RegexString { try { - _regex = null; _regex = new Regex(_regexString, RegexOptions.Multiline); } catch { - // Ignore errors. + _regex = null; } } @@ -46,31 +49,17 @@ public string URLTemplate set => SetProperty(ref _urlTemplate, value); } - public void Matches(List outs, string message) + public void Matches(InlineElementCollector outs, string message) { if (_regex == null || string.IsNullOrEmpty(_urlTemplate)) return; var matches = _regex.Matches(message); - for (var i = 0; i < matches.Count; i++) + foreach (Match match in matches) { - var match = matches[i]; - if (!match.Success) - continue; - var start = match.Index; var len = match.Length; - var intersect = false; - foreach (var exist in outs) - { - if (exist.Intersect(start, len)) - { - intersect = true; - break; - } - } - - if (intersect) + if (outs.Intersect(start, len) != null) continue; var link = _urlTemplate; @@ -81,11 +70,11 @@ public void Matches(List outs, string message) link = link.Replace($"${j}", group.Value); } - var range = new Hyperlink(start, len, link); - outs.Add(range); + outs.Add(new InlineElement(InlineElementType.Link, start, len, link)); } } + private bool _isShared; private string _name; private string _regexString; private string _urlTemplate; diff --git a/src/Models/LFSLock.cs b/src/Models/LFSLock.cs index 0a328cfb2..8d9a4acff 100644 --- a/src/Models/LFSLock.cs +++ b/src/Models/LFSLock.cs @@ -1,9 +1,22 @@ -namespace SourceGit.Models +using System.Text.Json.Serialization; + +namespace SourceGit.Models { + public class LFSLockOwner + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } + public class LFSLock { - public string File { get; set; } = string.Empty; - public string User { get; set; } = string.Empty; - public long ID { get; set; } = 0; + [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 0f281253e..8bc2dda28 100644 --- a/src/Models/LFSObject.cs +++ b/src/Models/LFSObject.cs @@ -1,8 +1,22 @@ -namespace SourceGit.Models +using System.Text.RegularExpressions; + +namespace SourceGit.Models { - public class LFSObject + 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 9d24f491e..5bdb2431b 100644 --- a/src/Models/Locales.cs +++ b/src/Models/Locales.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace SourceGit.Models { @@ -9,13 +9,21 @@ public class Locale 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) diff --git a/src/Models/MergeMode.cs b/src/Models/MergeMode.cs index 15e3f7e9c..0fcf76c8f 100644 --- a/src/Models/MergeMode.cs +++ b/src/Models/MergeMode.cs @@ -1,24 +1,33 @@ namespace SourceGit.Models { - public class MergeMode + 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 = [ - 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-ff --no-commit"), + Default, + FastForward, + NoFastForward, + Squash, + DontCommit, ]; - 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; - } + 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 473947b0f..f033213fb 100644 --- a/src/Models/Notification.cs +++ b/src/Models/Notification.cs @@ -1,8 +1,23 @@ -namespace SourceGit.Models +using System; + +namespace SourceGit.Models { public class Notification { - public bool IsError { get; set; } = false; - public string Message { get; set; } = string.Empty; + public static event Action Raised; + + public string Group { get; set; } + public string Message { get; set; } + public bool IsError { get; set; } + + 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 index e22ef8b33..1820d4d05 100644 --- a/src/Models/Null.cs +++ b/src/Models/Null.cs @@ -1,6 +1,4 @@ namespace SourceGit.Models { - public class Null - { - } + public class Null; } diff --git a/src/Models/NumericSort.cs b/src/Models/NumericSort.cs index ed5002e62..433a921bd 100644 --- a/src/Models/NumericSort.cs +++ b/src/Models/NumericSort.cs @@ -1,61 +1,48 @@ -namespace SourceGit.Models +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; - char[] tmp1 = new char[len1]; - char[] tmp2 = new char[len2]; - while (marker1 < len1 && marker2 < len2) { char c1 = s1[marker1]; char c2 = s2[marker2]; - int loc1 = 0; - int loc2 = 0; bool isDigit1 = char.IsDigit(c1); bool isDigit2 = char.IsDigit(c2); if (isDigit1 != isDigit2) - return c1.CompareTo(c2); + return comparer.Compare(c1.ToString(), c2.ToString()); - do - { - tmp1[loc1] = c1; - loc1++; - marker1++; + int subLen1 = 1; + while (marker1 + subLen1 < len1 && char.IsDigit(s1[marker1 + subLen1]) == isDigit1) + subLen1++; - if (marker1 < len1) - c1 = s1[marker1]; - else - break; - } while (char.IsDigit(c1) == isDigit1); + int subLen2 = 1; + while (marker2 + subLen2 < len2 && char.IsDigit(s2[marker2 + subLen2]) == isDigit2) + subLen2++; - do - { - tmp2[loc2] = c2; - loc2++; - marker2++; + string sub1 = s1.Substring(marker1, subLen1); + string sub2 = s2.Substring(marker2, subLen2); - if (marker2 < len2) - c2 = s2[marker2]; - else - break; - } while (char.IsDigit(c2) == isDigit2); + marker1 += subLen1; + marker2 += subLen2; - string sub1 = new string(tmp1, 0, loc1); - string sub2 = new string(tmp2, 0, loc2); int result; if (isDigit1) - result = loc1 == loc2 ? string.CompareOrdinal(sub1, sub2) : loc1 - loc2; + result = (subLen1 == subLen2) ? string.CompareOrdinal(sub1, sub2) : (subLen1 - subLen2); else - result = string.CompareOrdinal(sub1, sub2); + result = comparer.Compare(sub1, sub2); if (result != 0) return result; diff --git a/src/Models/OpenAI.cs b/src/Models/OpenAI.cs deleted file mode 100644 index c5ca7449e..000000000 --- a/src/Models/OpenAI.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; - -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.Models -{ - public class OpenAIChatMessage - { - [JsonPropertyName("role")] - public string Role - { - get; - set; - } - - [JsonPropertyName("content")] - public string Content - { - get; - set; - } - } - - public class OpenAIChatChoice - { - [JsonPropertyName("index")] - public int Index - { - get; - set; - } - - [JsonPropertyName("message")] - public OpenAIChatMessage Message - { - get; - set; - } - } - - public class OpenAIChatResponse - { - [JsonPropertyName("choices")] - public List Choices - { - get; - set; - } = []; - } - - public class OpenAIChatRequest - { - [JsonPropertyName("model")] - public string Model - { - get; - set; - } - - [JsonPropertyName("messages")] - public List Messages - { - get; - set; - } = []; - - public void AddMessage(string role, string content) - { - Messages.Add(new OpenAIChatMessage { Role = role, Content = content }); - } - } - - public class OpenAIService : ObservableObject - { - public string Name - { - get => _name; - set => SetProperty(ref _name, value); - } - - public string Server - { - get => _server; - set => SetProperty(ref _server, value); - } - - public string ApiKey - { - get => _apiKey; - set => SetProperty(ref _apiKey, value); - } - - public string Model - { - get => _model; - set => SetProperty(ref _model, value); - } - - public string AnalyzeDiffPrompt - { - get => _analyzeDiffPrompt; - set => SetProperty(ref _analyzeDiffPrompt, value); - } - - public string GenerateSubjectPrompt - { - get => _generateSubjectPrompt; - set => SetProperty(ref _generateSubjectPrompt, value); - } - - public OpenAIService() - { - AnalyzeDiffPrompt = """ - You are an expert developer specialist in creating commits. - Provide a super concise one sentence overall changes summary of the user `git diff` output following strictly the next rules: - - Do not use any code snippets, imports, file routes or bullets points. - - Do not mention the route of file that has been change. - - Write clear, concise, and descriptive messages that explain the MAIN GOAL made of the changes. - - Use the present tense and active voice in the message, for example, "Fix bug" instead of "Fixed bug.". - - Use the imperative mood, which gives the message a sense of command, e.g. "Add feature" instead of "Added feature". - - Avoid using general terms like "update" or "change", be specific about what was updated or changed. - - Avoid using terms like "The main goal of", just output directly the summary in plain text - """; - - GenerateSubjectPrompt = """ - You are an expert developer specialist in creating commits messages. - Your only goal is to retrieve a single commit message. - Based on the provided user changes, combine them in ONE SINGLE commit message retrieving the global idea, following strictly the next rules: - - Assign the commit {type} according to the next conditions: - feat: Only when adding a new feature. - fix: When fixing a bug. - docs: When updating documentation. - style: When changing elements styles or design and/or making changes to the code style (formatting, missing semicolons, etc.) without changing the code logic. - test: When adding or updating tests. - chore: When making changes to the build process or auxiliary tools and libraries. - revert: When undoing a previous commit. - refactor: When restructuring code without changing its external behavior, or is any of the other refactor types. - - Do not add any issues numeration, explain your output nor introduce your answer. - - Output directly only one commit message in plain text with the next format: {type}: {commit_message}. - - Be as concise as possible, keep the message under 50 characters. - """; - } - - public OpenAIChatResponse Chat(string prompt, string question, CancellationToken cancellation) - { - var chat = new OpenAIChatRequest() { Model = Model }; - chat.AddMessage("system", prompt); - chat.AddMessage("user", question); - - var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(60) }; - if (!string.IsNullOrEmpty(ApiKey)) - { - if (Server.Contains("openai.azure.com/", StringComparison.Ordinal)) - client.DefaultRequestHeaders.Add("api-key", ApiKey); - else - client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}"); - } - - var req = new StringContent(JsonSerializer.Serialize(chat, JsonCodeGen.Default.OpenAIChatRequest), Encoding.UTF8, "application/json"); - try - { - var task = client.PostAsync(Server, req, cancellation); - task.Wait(cancellation); - - var rsp = task.Result; - if (!rsp.IsSuccessStatusCode) - throw new Exception($"AI service returns error code {rsp.StatusCode}"); - - var reader = rsp.Content.ReadAsStringAsync(cancellation); - reader.Wait(cancellation); - - return JsonSerializer.Deserialize(reader.Result, JsonCodeGen.Default.OpenAIChatResponse); - } - catch - { - if (cancellation.IsCancellationRequested) - return null; - - throw; - } - } - - private string _name; - private string _server; - private string _apiKey; - private string _model; - private string _analyzeDiffPrompt; - private string _generateSubjectPrompt; - } -} 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 dcf30ddce..12681b4b4 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -1,41 +1,47 @@ using System; +using System.IO; using System.Text.RegularExpressions; +using System.Web; namespace SourceGit.Models { public partial class Remote { - [GeneratedRegex(@"^http[s]?://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/[\w\-/~%]+/[\w\-\.%]+(\.git)?$")] + [GeneratedRegex(@"^https?://[^/]+/.+[^/\.]$")] private static partial Regex REG_HTTPS(); - [GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:[\w\-/~%]+/[\w\-\.%]+(\.git)?$")] + + [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]+)?/[\w\-/~]+/[\w\-\.]+(\.git)?$")] + + [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$")] + [GeneratedRegex(@"^git@([\w\.\-]+):([\w\.\-/~%]+/[\w\-\.%]+)\.git$")] private static partial Regex REG_TO_VISIT_URL_CAPTURE(); private static readonly Regex[] URL_FORMATS = [ REG_HTTPS(), + REG_GIT(), REG_SSH1(), REG_SSH2(), ]; public 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; - for (int i = 1; i < URL_FORMATS.Length; i++) - { - if (URL_FORMATS[i].IsMatch(url)) - return true; - } + if (REG_SSH1().IsMatch(url)) + return true; - return false; + return REG_SSH2().IsMatch(url); } public static bool IsValidURL(string url) @@ -49,29 +55,85 @@ public static bool IsValidURL(string url) return true; } - return false; + return url.StartsWith("file://", StringComparison.Ordinal) || + url.StartsWith("./", StringComparison.Ordinal) || + url.StartsWith("../", StringComparison.Ordinal) || + Directory.Exists(url); } public bool TryGetVisitURL(out string url) { url = null; - if (URL.StartsWith("http", StringComparison.Ordinal)) + if (URL.StartsWith("http://", StringComparison.Ordinal) || URL.StartsWith("https://", StringComparison.Ordinal)) { - // Try to remove the user before host and `.git` extension. - var uri = new Uri(URL.EndsWith(".git", StringComparison.Ordinal) ? URL.Substring(0, URL.Length - 4) : URL); + 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.LocalPath}"; + url = $"{uri.Scheme}://{uri.Host}:{uri.Port}{uri.AbsolutePath}"; else - url = $"{uri.Scheme}://{uri.Host}{uri.LocalPath}"; - + url = $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}"; return true; } var match = REG_TO_VISIT_URL_CAPTURE().Match(URL); if (match.Success) { - url = $"https://{match.Groups[1].Value}/{match.Groups[2].Value}"; + 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 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; } diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs index f67961983..506e6ded7 100644 --- a/src/Models/RepositorySettings.cs +++ b/src/Models/RepositorySettings.cs @@ -1,6 +1,10 @@ -using System; +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; @@ -14,450 +18,139 @@ public string DefaultRemote set; } = string.Empty; - public DealWithLocalChanges DealWithLocalChangesOnCheckoutBranch + public int PreferredMergeMode { get; set; - } = DealWithLocalChanges.DoNothing; + } = 0; - public bool EnablePruneOnFetch + public string ConventionalTypesOverride { get; set; - } = false; - - public bool FetchWithoutTags - { - get; - set; - } = false; - - public DealWithLocalChanges DealWithLocalChangesOnPull - { - get; - set; - } = DealWithLocalChanges.DoNothing; - - public bool PreferRebaseInsteadOfMerge - { - get; - set; - } = true; - - public bool FetchWithoutTagsOnPull - { - get; - set; - } = false; - - public bool FetchAllBranchesOnPull - { - get; - set; - } = true; + } = string.Empty; - public bool CheckSubmodulesOnPush + public bool EnableRecursiveWhenAutoUpdatingSubmodules { get; set; } = true; - public bool PushAllTags + public bool AskBeforeAutoUpdatingSubmodules { get; set; } = false; - public DealWithLocalChanges DealWithLocalChangesOnCreateBranch - { - get; - set; - } = DealWithLocalChanges.DoNothing; - - public bool CheckoutBranchOnCreateBranch - { - get; - set; - } = true; - - public AvaloniaList HistoriesFilters + public string PreferredOpenAIService { get; set; - } = new AvaloniaList(); + } = "---"; public AvaloniaList CommitTemplates { get; set; - } = new AvaloniaList(); - - public AvaloniaList CommitMessages - { - get; - set; - } = new AvaloniaList(); - - public AvaloniaList IssueTrackerRules - { - get; - set; - } = new AvaloniaList(); + } = []; public AvaloniaList CustomActions { get; set; - } = new AvaloniaList(); + } = []; - public bool EnableAutoFetch + public static RepositorySettings Get(string gitCommonDir) { - get; - set; - } = false; - - public int AutoFetchInterval - { - get; - set; - } = 10; - - public bool EnableSignOffForCommit - { - get; - set; - } = false; - - public bool IncludeUntrackedWhenStash - { - get; - set; - } = true; - - public bool OnlyStagedWhenStash - { - get; - set; - } = false; + var fileInfo = new FileInfo(Path.Combine(gitCommonDir, "sourcegit.settings")); + var fullpath = fileInfo.FullName; + if (_cache.TryGetValue(fullpath, out var setting)) + return setting; - public bool KeepIndexWhenStash - { - get; - set; - } = false; - - public string PreferedOpenAIService - { - get; - set; - } = "---"; - - public Dictionary CollectHistoriesFilters() - { - var map = new Dictionary(); - foreach (var filter in HistoriesFilters) - map.Add(filter.Pattern, filter.Mode); - return map; - } - - public bool UpdateHistoriesFilter(string pattern, FilterType type, FilterMode mode) - { - // Clear all filters when there's a filter that has different mode. - if (mode != FilterMode.None) + if (!File.Exists(fullpath)) { - var clear = false; - foreach (var filter in HistoriesFilters) - { - if (filter.Mode != mode) - { - clear = true; - break; - } - } - - if (clear) - { - HistoriesFilters.Clear(); - HistoriesFilters.Add(new Filter(pattern, type, mode)); - return true; - } + setting = new(); } else { - for (int i = 0; i < HistoriesFilters.Count; i++) + try { - var filter = HistoriesFilters[i]; - if (filter.Type == type && filter.Pattern.Equals(pattern, StringComparison.Ordinal)) - { - HistoriesFilters.RemoveAt(i); - return true; - } + using var stream = File.OpenRead(fullpath); + setting = JsonSerializer.Deserialize(stream, JsonCodeGen.Default.RepositorySettings); } - - return false; - } - - for (int i = 0; i < HistoriesFilters.Count; i++) - { - var filter = HistoriesFilters[i]; - if (filter.Type != type) - continue; - - if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) - return false; - } - - HistoriesFilters.Add(new Filter(pattern, type, mode)); - return true; - } - - public void RemoveChildrenBranchFilters(string pattern) - { - var dirty = new List(); - var prefix = $"{pattern}/"; - - foreach (var filter in HistoriesFilters) - { - if (filter.Type == FilterType.Tag) - continue; - - if (filter.Pattern.StartsWith(prefix, StringComparison.Ordinal)) - dirty.Add(filter); - } - - foreach (var filter in dirty) - HistoriesFilters.Remove(filter); - } - - public string BuildHistoriesFilter() - { - var excludedBranches = new List(); - var excludedRemotes = new List(); - var excludedTags = new List(); - var includedBranches = new List(); - var includedRemotes = new List(); - var includedTags = new List(); - foreach (var filter in HistoriesFilters) - { - if (filter.Type == FilterType.LocalBranch) + catch { - var name = filter.Pattern.Substring(11); - var b = $"{name.Substring(0, name.Length - 1)}[{name[^1]}]"; - - if (filter.Mode == FilterMode.Included) - includedBranches.Add(b); - else if (filter.Mode == FilterMode.Excluded) - excludedBranches.Add(b); - } - else if (filter.Type == FilterType.LocalBranchFolder) - { - if (filter.Mode == FilterMode.Included) - includedBranches.Add($"{filter.Pattern.Substring(11)}/*"); - else if (filter.Mode == FilterMode.Excluded) - excludedBranches.Add($"{filter.Pattern.Substring(11)}/*"); - } - else if (filter.Type == FilterType.RemoteBranch) - { - var name = filter.Pattern.Substring(13); - var r = $"{name.Substring(0, name.Length - 1)}[{name[^1]}]"; - - if (filter.Mode == FilterMode.Included) - includedRemotes.Add(r); - else if (filter.Mode == FilterMode.Excluded) - excludedRemotes.Add(r); - } - else if (filter.Type == FilterType.RemoteBranchFolder) - { - if (filter.Mode == FilterMode.Included) - includedRemotes.Add($"{filter.Pattern.Substring(13)}/*"); - else if (filter.Mode == FilterMode.Excluded) - excludedRemotes.Add($"{filter.Pattern.Substring(13)}/*"); - } - else if (filter.Type == FilterType.Tag) - { - var name = filter.Pattern; - var t = $"{name.Substring(0, name.Length - 1)}[{name[^1]}]"; - - if (filter.Mode == FilterMode.Included) - includedTags.Add(t); - else if (filter.Mode == FilterMode.Excluded) - excludedTags.Add(t); + setting = new(); } } - bool hasIncluded = includedBranches.Count > 0 || includedRemotes.Count > 0 || includedTags.Count > 0; - bool hasExcluded = excludedBranches.Count > 0 || excludedRemotes.Count > 0 || excludedTags.Count > 0; - - var builder = new StringBuilder(); - if (hasIncluded) + // Serialize setting again to make sure there are no unnecessary whitespaces. + Task.Run(() => { - foreach (var b in includedBranches) - { - builder.Append("--branches="); - builder.Append(b); - builder.Append(' '); - } + var formatted = JsonSerializer.Serialize(setting, JsonCodeGen.Default.RepositorySettings); + setting._orgHash = HashContent(formatted); + }); - foreach (var r in includedRemotes) - { - builder.Append("--remotes="); - builder.Append(r); - builder.Append(' '); - } + setting._file = fullpath; + _cache.Add(fullpath, setting); + return setting; + } - foreach (var t in includedTags) - { - builder.Append("--tags="); - builder.Append(t); - builder.Append(' '); - } - } - else if (hasExcluded) + public void Save() + { + try { - if (excludedBranches.Count > 0) - { - foreach (var b in excludedBranches) - { - builder.Append("--exclude="); - builder.Append(b); - builder.Append(' '); - } - } - - builder.Append("--exclude=HEA[D] --branches "); - - if (excludedRemotes.Count > 0) - { - foreach (var r in excludedRemotes) - { - builder.Append("--exclude="); - builder.Append(r); - builder.Append(' '); - } - } - - builder.Append("--exclude=origin/HEA[D] --remotes "); - - if (excludedTags.Count > 0) + var content = JsonSerializer.Serialize(this, JsonCodeGen.Default.RepositorySettings); + var hash = HashContent(content); + if (!hash.Equals(_orgHash, StringComparison.Ordinal)) { - foreach (var t in excludedTags) - { - builder.Append("--exclude="); - builder.Append(t); - builder.Append(' '); - } + var tmpfile = $"{_file}.tmp"; + File.WriteAllText(tmpfile, content); + File.Move(tmpfile, _file, true); + _orgHash = hash; } - - builder.Append("--tags "); } - - return builder.ToString(); - } - - public void PushCommitMessage(string message) - { - var existIdx = CommitMessages.IndexOf(message); - if (existIdx == 0) - return; - - if (existIdx > 0) + catch { - CommitMessages.Move(existIdx, 0); - return; + // Ignore save errors } - - if (CommitMessages.Count > 9) - CommitMessages.RemoveRange(9, CommitMessages.Count - 9); - - CommitMessages.Insert(0, message); } - public IssueTrackerRule AddNewIssueTracker() - { - var rule = new IssueTrackerRule() - { - Name = "New Issue Tracker", - RegexString = "#(\\d+)", - URLTemplate = "https://xxx/$1", - }; - - IssueTrackerRules.Add(rule); - return rule; - } - - public IssueTrackerRule AddGithubIssueTracker(string repoURL) - { - var rule = new IssueTrackerRule() - { - Name = "Github ISSUE", - RegexString = "#(\\d+)", - URLTemplate = string.IsNullOrEmpty(repoURL) ? "https://github.com/username/repository/issues/$1" : $"{repoURL}/issues/$1", - }; - - IssueTrackerRules.Add(rule); - return rule; - } - - public IssueTrackerRule AddJiraIssueTracker() + public CustomAction AddNewCustomAction() { - var rule = new IssueTrackerRule() - { - Name = "Jira Tracker", - RegexString = "PROJ-(\\d+)", - URLTemplate = "https://jira.yourcompany.com/browse/PROJ-$1", - }; - - IssueTrackerRules.Add(rule); - return rule; + var act = new CustomAction() { Name = "Unnamed Action" }; + CustomActions.Add(act); + return act; } - public IssueTrackerRule AddGitLabIssueTracker(string repoURL) + public void RemoveCustomAction(CustomAction act) { - var rule = new IssueTrackerRule() - { - Name = "GitLab ISSUE", - RegexString = "#(\\d+)", - URLTemplate = string.IsNullOrEmpty(repoURL) ? "https://gitlab.com/username/repository/-/issues/$1" : $"{repoURL}/-/issues/$1", - }; - - IssueTrackerRules.Add(rule); - return rule; + if (act != null) + CustomActions.Remove(act); } - public IssueTrackerRule AddGitLabMergeRequestTracker(string repoURL) + public void MoveCustomActionUp(CustomAction act) { - var rule = new IssueTrackerRule() - { - Name = "GitLab MR", - RegexString = "!(\\d+)", - URLTemplate = string.IsNullOrEmpty(repoURL) ? "https://gitlab.com/username/repository/-/merge_requests/$1" : $"{repoURL}/-/merge_requests/$1", - }; - - IssueTrackerRules.Add(rule); - return rule; + var idx = CustomActions.IndexOf(act); + if (idx > 0) + CustomActions.Move(idx - 1, idx); } - public void RemoveIssueTracker(IssueTrackerRule rule) + public void MoveCustomActionDown(CustomAction act) { - if (rule != null) - IssueTrackerRules.Remove(rule); + var idx = CustomActions.IndexOf(act); + if (idx < CustomActions.Count - 1) + CustomActions.Move(idx + 1, idx); } - public CustomAction AddNewCustomAction() + private static string HashContent(string source) { - var act = new CustomAction() - { - Name = "Unnamed Custom Action", - }; - - CustomActions.Add(act); - return act; + var hash = MD5.HashData(Encoding.Default.GetBytes(source)); + return Convert.ToHexStringLower(hash); } - public void RemoveCustomAction(CustomAction act) - { - if (act != null) - CustomActions.Remove(act); - } + 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 index 827ccaa97..1c84dc8e0 100644 --- a/src/Models/ResetMode.cs +++ b/src/Models/ResetMode.cs @@ -2,7 +2,7 @@ namespace SourceGit.Models { - public class ResetMode + public class ResetMode(string n, string d, string a, string k, IBrush b) { public static readonly ResetMode[] Supported = [ @@ -13,19 +13,10 @@ public class ResetMode new ResetMode("Hard", "Discard all changes", "--hard", "H", Brushes.Red), ]; - public string Name { get; set; } - public string Desc { get; set; } - public string Arg { get; set; } - public string Key { get; set; } - public IBrush Color { get; set; } - - public ResetMode(string n, string d, string a, string k, IBrush b) - { - Name = n; - Desc = d; - Arg = a; - Key = k; - Color = b; - } + 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 f1f5265fa..9a9fe7d20 100644 --- a/src/Models/RevisionFile.cs +++ b/src/Models/RevisionFile.cs @@ -1,4 +1,6 @@ -using Avalonia.Media.Imaging; +using System.Globalization; +using System.IO; +using Avalonia.Media.Imaging; namespace SourceGit.Models { @@ -9,10 +11,17 @@ public class RevisionBinaryFile public class RevisionImageFile { - public Bitmap Image { get; set; } = null; - public long FileSize { get; set; } = 0; - public string ImageType { get; set; } = string.Empty; + 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 @@ -29,6 +38,7 @@ public class RevisionLFSObject public class RevisionSubmodule { public Commit Commit { get; set; } = null; - public string FullMessage { get; set; } = string.Empty; + 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 index 8ab257888..e8e585475 100644 --- a/src/Models/ShellOrTerminal.cs +++ b/src/Models/ShellOrTerminal.cs @@ -11,6 +11,7 @@ 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 { @@ -32,15 +33,21 @@ static ShellOrTerminal() 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") + new ShellOrTerminal("wt", "Windows Terminal", "wt.exe", "-d .") }; } else if (OperatingSystem.IsMacOS()) { Supported = new List() { - new ShellOrTerminal("mac-terminal", "Terminal", ""), - new ShellOrTerminal("iterm2", "iTerm", ""), + 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 @@ -54,17 +61,21 @@ static ShellOrTerminal() new ShellOrTerminal("deepin-terminal", "Deepin Terminal", "deepin-terminal"), new ShellOrTerminal("mate-terminal", "MATE Terminal", "mate-terminal"), new ShellOrTerminal("foot", "Foot", "foot"), - new ShellOrTerminal("wezterm", "WezTerm", "wezterm"), + new ShellOrTerminal("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) + 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 06da763a3..93439a40a 100644 --- a/src/Models/Stash.cs +++ b/src/Models/Stash.cs @@ -1,4 +1,4 @@ -using System; +using System.Collections.Generic; namespace SourceGit.Models { @@ -6,9 +6,10 @@ public class Stash { public string Name { get; set; } = ""; public string SHA { get; set; } = ""; + public List Parents { get; set; } = []; public ulong Time { get; set; } = 0; public string Message { get; set; } = ""; - - public string TimeStr => DateTime.UnixEpoch.AddSeconds(Time).ToLocalTime().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 969d3945a..4ef9927fc 100644 --- a/src/Models/Statistics.cs +++ b/src/Models/Statistics.cs @@ -2,173 +2,245 @@ using System.Collections.Generic; using System.Globalization; -using LiveChartsCore; -using LiveChartsCore.Defaults; -using LiveChartsCore.SkiaSharpView; -using LiveChartsCore.SkiaSharpView.Painting; - -using SkiaSharp; - namespace SourceGit.Models { - public enum StaticsticsMode + public enum StatisticsMode { - All, + All = 0, ThisMonth, ThisWeek, } - public class StaticsticsAuthor(string name, int count) + public class StatisticsAuthor(User user, int count) { - public string Name { get; set; } = name; + public User User { get; set; } = user; public int Count { get; set; } = count; } - public class StaticsticsSample(DateTime time, int count) + public class StatisticsSamples { - public DateTime Time { get; set; } = time; - public int Count { get; set; } = count; + 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 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 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 static readonly string[] s_weekdays = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; + private StatisticsMode _mode; + private Dictionary _all; + private Dictionary _user; } public class StatisticsReport { - public static readonly string[] WEEKDAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; - + public StatisticsMode Mode { get; } public int Total { get; set; } = 0; - public List Authors { get; set; } = new List(); - public List Series { get; set; } = new List(); - public List XAxes { get; set; } = new List(); - public List YAxes { get; set; } = new List(); + public List Authors { get; set; } = new(); - public StatisticsReport(StaticsticsMode mode, DateTime start) + public StatisticsReport(StatisticsMode mode, DateTime start) { - _mode = mode; + Mode = mode; - YAxes = [new Axis() + if (mode == StatisticsMode.ThisWeek) { - TextSize = 10, - MinLimit = 0, - SeparatorsPaint = new SolidColorPaint(new SKColor(0x40808080)) { StrokeThickness = 1 } - }]; + _minSampleTime = start; + _maxSampleTime = start.AddDays(6); - if (mode == StaticsticsMode.ThisWeek) - { for (int i = 0; i < 7; i++) - _mapSamples.Add(start.AddDays(i), 0); - - XAxes.Add(new DateTimeAxis(TimeSpan.FromDays(1), v => WEEKDAYS[(int)v.DayOfWeek]) { TextSize = 10 }); + _all.Add(start.AddDays(i), 0); } - else if (mode == StaticsticsMode.ThisMonth) + 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++) - _mapSamples.Add(start.AddDays(i), 0); - - XAxes.Add(new DateTimeAxis(TimeSpan.FromDays(1), v => $"{v:MM/dd}") { TextSize = 10 }); - } - else - { - XAxes.Add(new DateTimeAxis(TimeSpan.FromDays(30), v => $"{v:yyyy/MM}") { TextSize = 10 }); + _all.Add(start.AddDays(i), 0); } } - public void AddCommit(DateTime time, string author) + public void AddCommit(DateTime time, User author) { Total++; - var normalized = DateTime.MinValue; - if (_mode == StaticsticsMode.ThisWeek || _mode == StaticsticsMode.ThisMonth) + DateTime normalized; + if (Mode == StatisticsMode.ThisWeek || Mode == StatisticsMode.ThisMonth) + { normalized = time.Date; + } else - normalized = new DateTime(time.Year, time.Month, 1).ToLocalTime(); + { + normalized = new DateTime(time.Year, time.Month, 1).ToLocalTime().Date; + + if (normalized < _minSampleTime) + _minSampleTime = normalized; + if (normalized > _maxSampleTime) + _maxSampleTime = normalized; + } - if (_mapSamples.TryGetValue(normalized, out var vs)) - _mapSamples[normalized] = vs + 1; + if (_all.TryGetValue(normalized, out var vs)) + _all[normalized] = vs + 1; else - _mapSamples.Add(normalized, 1); + _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 void Complete() { - var samples = new List(); - foreach (var kv in _mapSamples) - samples.Add(new DateTimePoint(kv.Key, kv.Value)); - - Series.Add( - new ColumnSeries() - { - Values = samples, - Stroke = null, - Fill = null, - Padding = 1, - } - ); - foreach (var kv in _mapUsers) - Authors.Add(new StaticsticsAuthor(kv.Key, kv.Value)); + Authors.Add(new StatisticsAuthor(kv.Key, kv.Value)); + _mapUsers.Clear(); Authors.Sort((l, r) => r.Count - l.Count); - _mapUsers.Clear(); - _mapSamples.Clear(); + foreach (var kv in _all) + { + if (kv.Value > _maxSampleValue) + _maxSampleValue = kv.Value; + } } - public void ChangeColor(uint color) + public StatisticsSamples GetSamples(StatisticsAuthor withUser) { - if (Series is [ColumnSeries series]) - series.Fill = new SolidColorPaint(new SKColor(color)); + var samples = new StatisticsSamples(Mode, _minSampleTime, _maxSampleTime, _all, _maxSampleValue); + if (withUser != null && _mapUserSamples.TryGetValue(withUser.User, out var userSamples)) + samples.WithUser(userSamples); + return samples; } - private StaticsticsMode _mode = StaticsticsMode.All; - private Dictionary _mapUsers = new Dictionary(); - private Dictionary _mapSamples = new Dictionary(); + 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; set; } - public StatisticsReport Month { get; set; } - public StatisticsReport Week { get; set; } + public StatisticsReport All { get; } + public StatisticsReport Month { get; } + public StatisticsReport Week { get; } public Statistics() { - _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(StaticsticsMode.All, DateTime.MinValue); - Month = new StatisticsReport(StaticsticsMode.ThisMonth, _thisMonthStart); - Week = new StatisticsReport(StaticsticsMode.ThisWeek, _thisWeekStart); + 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 time = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + 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); + } + + var time = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().Date; if (time >= _thisWeekStart) - Week.AddCommit(time, author); + Week.AddCommit(time, user); if (time >= _thisMonthStart) - Month.AddCommit(time, author); + Month.AddCommit(time, user); - All.AddCommit(time, author); + All.AddCommit(time, user); } public void Complete() { + _users.Clear(); + All.Complete(); Month.Complete(); Week.Complete(); } - private readonly DateTime _today; 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 index ce00ac023..17080713f 100644 --- a/src/Models/Submodule.cs +++ b/src/Models/Submodule.cs @@ -1,8 +1,21 @@ namespace SourceGit.Models { + public enum SubmoduleStatus + { + Normal = 0, + NotInited, + RevisionChanged, + Unmerged, + Modified, + } + public class Submodule { - public string Path { get; set; } = ""; - public bool IsDirty { get; set; } = false; + 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 2e8f2c8e9..5e3ed7b41 100644 --- a/src/Models/Tag.cs +++ b/src/Models/Tag.cs @@ -1,19 +1,18 @@ -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.Models +namespace SourceGit.Models { - public class Tag : ObservableObject + 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; - - public FilterMode FilterMode - { - get => _filterMode; - set => SetProperty(ref _filterMode, value); - } - - private FilterMode _filterMode = FilterMode.None; } } diff --git a/src/Models/TemplateEngine.cs b/src/Models/TemplateEngine.cs new file mode 100644 index 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 c96d839fe..e49a6d032 100644 --- a/src/Models/TextInlineChange.cs +++ b/src/Models/TextInlineChange.cs @@ -1,31 +1,24 @@ using System.Collections.Generic; +using System.Globalization; namespace SourceGit.Models { - public class TextInlineChange + public class TextInlineChange(int dp, int dc, int ap, int ac) { - public int DeletedStart { get; set; } - public int DeletedCount { get; set; } - public int AddedStart { get; set; } - public int AddedCount { get; set; } + public int DeletedStart { get; set; } = dp; + public int DeletedCount { get; set; } = dc; + public int AddedStart { get; set; } = ap; + public int AddedCount { get; set; } = ac; - class Chunk + private class Chunk(int hash, int start, int size) { - public int Hash; + 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, @@ -34,7 +27,14 @@ 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; @@ -43,14 +43,6 @@ 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) { var hashes = new Dictionary(); @@ -66,7 +58,7 @@ public static List Compare(string oldValue, string newValue) var ret = new List(); var posOld = 0; var posNew = 0; - var last = null as TextInlineChange; + TextInlineChange last = null; do { while (posOld < sizeOld && posNew < sizeNew && !chunksOld[posOld].Modified && !chunksNew[posNew].Modified) @@ -116,22 +108,25 @@ private static List MakeChunks(Dictionary hashes, string tex var start = 0; var size = text.Length; var chunks = new List(); - var delims = new HashSet(" \t+-*/=!,:;.'\"/?|&#@%`<>()[]{}\\".ToCharArray()); + if (size == 0) + return chunks; - for (int i = 0; i < size; i++) + var prev = GetCategory(text[0]); + + for (var i = 1; i < size; i++) { var ch = text[i]; - if (delims.Contains(ch)) + var category = GetCategory(ch); + if (prev != category || category == CharCategory.Other) { - if (start != i) - AddChunk(chunks, hashes, text.Substring(start, i - start), start); - AddChunk(chunks, hashes, text.Substring(i, 1), i); - start = i + 1; + AddChunk(chunks, hashes, text[start..i], start); + start = i; } + prev = category; } if (start < size) - AddChunk(chunks, hashes, text.Substring(start), start); + AddChunk(chunks, hashes, text[start..], start); return chunks; } @@ -204,11 +199,10 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, for (int i = 0; i <= half; i++) { - for (int j = -i; j <= i; j += 2) { var idx = j + half; - int o, n; + int o; if (j == -i || (j != i && forward[idx - 1] < forward[idx + 1])) { o = forward[idx + 1]; @@ -220,7 +214,7 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, rs.State = Edit.DeletedRight; } - n = o - j; + var n = o - j; var startX = o; var startY = n; @@ -258,7 +252,7 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, for (int j = -i; j <= i; j += 2) { var idx = j + half; - int o, n; + int o; if (j == -i || (j != i && reverse[idx + 1] <= reverse[idx - 1])) { o = reverse[idx + 1] - 1; @@ -270,7 +264,7 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, rs.State = Edit.AddedLeft; } - n = o - (j + delta); + var n = o - (j + delta); var endX = o; var endY = n; @@ -312,17 +306,40 @@ private static EditResult CheckModifiedEdit(List chunksOld, int startOld, 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 + 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 index 0eb5e4892..5bb50da69 100644 --- a/src/Models/TextMateHelper.cs +++ b/src/Models/TextMateHelper.cs @@ -21,28 +21,33 @@ public static class GrammarUtility { private static readonly ExtraGrammar[] s_extraGrammars = [ - new ExtraGrammar("source.toml", ".toml", "toml.json"), - new ExtraGrammar("source.kotlin", ".kotlin", "kotlin.json"), - new ExtraGrammar("source.hx", ".hx", "haxe.json"), - new ExtraGrammar("source.hxml", ".hxml", "hxml.json"), + 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 == ".resx" || extension == ".plist" || extension == ".manifest") + else if (extension is ".resx" or ".plist" or ".manifest") extension = ".xml"; else if (extension == ".command") extension = ".sh"; - else if (extension == ".kt" || extension == ".kts") - extension = ".kotlin"; foreach (var grammar in s_extraGrammars) { - if (grammar.Extension.Equals(extension, StringComparison.OrdinalIgnoreCase)) - return grammar.Scope; + foreach (var ext in grammar.Extensions) + { + if (ext.Equals(extension, StringComparison.OrdinalIgnoreCase)) + return grammar.Scope; + } } return reg.GetScopeByExtension(extension); @@ -50,6 +55,12 @@ public static string GetScope(string file, RegistryOptions reg) 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)) @@ -59,7 +70,9 @@ public static IRawGrammar GetGrammar(string scopeName, RegistryOptions reg) try { - return GrammarReader.ReadGrammarSync(new StreamReader(asset)); + var raw = GrammarReader.ReadGrammarSync(new StreamReader(asset)); + s_cachedRawGrammars.Add(scopeName, raw); + return raw; } catch { @@ -68,13 +81,15 @@ public static IRawGrammar GetGrammar(string scopeName, RegistryOptions reg) } } - return reg.GetGrammar(scopeName); + var fallback = reg.GetGrammar(scopeName); + s_cachedRawGrammars.Add(scopeName, fallback); + return fallback; } - private record ExtraGrammar(string Scope, string Extension, string File) + private record ExtraGrammar(string Scope, List Extensions, string File) { public readonly string Scope = Scope; - public readonly string Extension = Extension; + public readonly List Extensions = Extensions; public readonly string File = File; } } @@ -85,11 +100,21 @@ public class RegistryOptionsWrapper(ThemeName defaultTheme) : IRegistryOptions public IRawTheme GetTheme(string scopeName) => _backend.GetTheme(scopeName); public IRawTheme GetDefaultTheme() => _backend.GetDefaultTheme(); - public IRawTheme LoadTheme(ThemeName name) => _backend.LoadTheme(name); public ICollection GetInjections(string scopeName) => _backend.GetInjections(scopeName); public IRawGrammar GetGrammar(string scopeName) => GrammarUtility.GetGrammar(scopeName, _backend); public string GetScope(string filename) => GrammarUtility.GetScope(filename, _backend); + 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); } @@ -119,7 +144,7 @@ public static void SetGrammarByFileName(TextMate.Installation installation, stri if (reg.LastScope != scope) { reg.LastScope = scope; - installation.SetGrammar(reg.GetScope(filePath)); + installation.SetGrammar(scope); GC.Collect(); } } diff --git a/src/Models/ThemeOverrides.cs b/src/Models/ThemeOverrides.cs index ccd9f57e8..c80576dc8 100644 --- a/src/Models/ThemeOverrides.cs +++ b/src/Models/ThemeOverrides.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; - using Avalonia.Media; namespace SourceGit.Models diff --git a/src/Models/User.cs b/src/Models/User.cs index 850bcf2ff..a1b984a04 100644 --- a/src/Models/User.cs +++ b/src/Models/User.cs @@ -1,49 +1,38 @@ -using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; namespace SourceGit.Models { public class User { - public static readonly User Invalid = new 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 User() - { - // Only used by User.Invalid - } - public User(string data) { - var nameEndIdx = data.IndexOf('±', StringComparison.Ordinal); - - Name = nameEndIdx > 0 ? data.Substring(0, nameEndIdx) : string.Empty; - Email = data.Substring(nameEndIdx + 1); - _hash = data.GetHashCode(); - } - - public 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; + var parts = data.Split('±', 2); + if (parts.Length < 2) + { + Email = data; + } + else + { + Name = parts[0]; + Email = parts[1]; + } } - public override int GetHashCode() + public static User FindOrAdd(string data) { - return _hash; + return _caches.GetOrAdd(data, key => new User(key)); } - public static User FindOrAdd(string data) + public override string ToString() { - return _caches.GetOrAdd(data, key => new User(key)); + return $"{Name} <{Email}>"; } private static ConcurrentDictionary _caches = new ConcurrentDictionary(); - private readonly int _hash; } } diff --git a/src/Models/Version.cs b/src/Models/Version.cs deleted file mode 100644 index 35c217782..000000000 --- a/src/Models/Version.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Text.Json.Serialization; - -namespace SourceGit.Models -{ - public class Version - { - [JsonPropertyName("name")] - public string Name { get; set; } - - [JsonPropertyName("tag_name")] - public string TagName { get; set; } - - [JsonPropertyName("body")] - public string Body { get; set; } - - public bool IsNewVersion - { - get - { - try - { - System.Version version = new System.Version(TagName.Substring(1)); - System.Version current = Assembly.GetExecutingAssembly().GetName().Version!; - return current.CompareTo(version) < 0; - } - catch - { - return false; - } - } - } - } - - public class AlreadyUpToDate { } -} diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index 710b307d8..ce18fe4be 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -8,220 +8,357 @@ namespace SourceGit.Models { public class Watcher : IDisposable { - public Watcher(IRepository repo) + public class LockContext : IDisposable { - _repo = repo; + public LockContext(Watcher target) + { + _target = target; + Interlocked.Increment(ref _target._lockCount); + } - _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; - - // If this repository is a worktree repository, just watch the main repository's gitdir. - var gitDirNormalized = _repo.GitDir.Replace("\\", "/"); - var worktreeIdx = gitDirNormalized.IndexOf(".git/worktrees/", StringComparison.Ordinal); - var repoWatchDir = _repo.GitDir; - if (worktreeIdx > 0) - repoWatchDir = _repo.GitDir.Substring(0, worktreeIdx + 4); - - _repoWatcher = new FileSystemWatcher(); - _repoWatcher.Path = repoWatchDir; - _repoWatcher.Filter = "*"; - _repoWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; - _repoWatcher.IncludeSubdirectories = true; - _repoWatcher.Created += OnRepositoryChanged; - _repoWatcher.Renamed += OnRepositoryChanged; - _repoWatcher.Changed += OnRepositoryChanged; - _repoWatcher.Deleted += OnRepositoryChanged; - _repoWatcher.EnableRaisingEvents = true; + public void Dispose() + { + Interlocked.Decrement(ref _target._lockCount); + } - _timer = new Timer(Tick, null, 100, 100); + private Watcher _target; } - public void SetEnabled(bool enabled) + public Watcher(IRepository repo, string fullpath, string gitDir) { - if (enabled) + _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)) { - if (_lockCount > 0) - _lockCount--; + 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 { - _lockCount++; + 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; + + _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 SetSubmodules(List submodules) + public IDisposable Lock() { - lock (_lockSubmodule) - { - _submodules.Clear(); - foreach (var submodule in submodules) - _submodules.Add(submodule.Path); - } + return new LockContext(this); + } + + public void MarkBranchUpdated() + { + Interlocked.Exchange(ref _updateBranch, 0); + Interlocked.Exchange(ref _updateWC, 0); } - public void MarkBranchDirtyManually() + public void MarkTagUpdated() { - _updateBranch = DateTime.Now.ToFileTime() - 1; + Interlocked.Exchange(ref _updateTags, 0); } - public void MarkWorkingCopyDirtyManually() + public void MarkWorkingCopyUpdated() { - _updateWC = DateTime.Now.ToFileTime() - 1; + Interlocked.Exchange(ref _updateWC, 0); + } + + public void MarkStashUpdated() + { + Interlocked.Exchange(ref _updateStashes, 0); + } + + public void MarkSubmodulesUpdated() + { + Interlocked.Exchange(ref _updateSubmodules, 0); } public void Dispose() { - _repoWatcher.EnableRaisingEvents = false; - _repoWatcher.Created -= OnRepositoryChanged; - _repoWatcher.Renamed -= OnRepositoryChanged; - _repoWatcher.Changed -= OnRepositoryChanged; - _repoWatcher.Deleted -= OnRepositoryChanged; - _repoWatcher.Dispose(); - _repoWatcher = null; - - _wcWatcher.EnableRaisingEvents = false; - _wcWatcher.Created -= OnWorkingCopyChanged; - _wcWatcher.Renamed -= OnWorkingCopyChanged; - _wcWatcher.Changed -= OnWorkingCopyChanged; - _wcWatcher.Deleted -= OnWorkingCopyChanged; - _wcWatcher.Dispose(); - _wcWatcher = null; + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + _watchers.Clear(); _timer.Dispose(); _timer = null; } private void Tick(object sender) { - if (_lockCount > 0) + if (Interlocked.Read(ref _lockCount) > 0) return; var now = DateTime.Now.ToFileTime(); - if (_updateBranch > 0 && now > _updateBranch) + var refreshCommits = false; + var refreshSubmodules = false; + var refreshWC = false; + + var oldUpdateBranch = Interlocked.Exchange(ref _updateBranch, -1); + if (oldUpdateBranch > 0) { - _updateBranch = 0; - _updateWC = 0; + if (now > oldUpdateBranch) + { + refreshCommits = true; + refreshSubmodules = _repo.MayHaveSubmodules(); + refreshWC = true; - if (_updateTags > 0) + _repo.RefreshBranches(); + _repo.RefreshWorktrees(); + } + else { - _updateTags = 0; - Task.Run(_repo.RefreshTags); + Interlocked.CompareExchange(ref _updateBranch, oldUpdateBranch, -1); } - - Task.Run(_repo.RefreshBranches); - Task.Run(_repo.RefreshCommits); - Task.Run(_repo.RefreshWorkingCopyChanges); - Task.Run(_repo.RefreshWorktrees); } - if (_updateWC > 0 && now > _updateWC) + if (refreshWC) + { + Interlocked.Exchange(ref _updateWC, -1); + _repo.RefreshWorkingCopyChanges(); + } + else { - _updateWC = 0; - Task.Run(_repo.RefreshWorkingCopyChanges); + 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) + if (refreshSubmodules) { - _updateSubmodules = 0; + 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) + var oldUpdateStashes = Interlocked.Exchange(ref _updateStashes, -1); + if (oldUpdateStashes > 0) { - _updateStashes = 0; - _repo.RefreshStashes(); + if (now > oldUpdateStashes) + _repo.RefreshStashes(); + else + Interlocked.CompareExchange(ref _updateStashes, oldUpdateStashes, -1); } - if (_updateTags > 0 && now > _updateTags) + var oldUpdateTags = Interlocked.Exchange(ref _updateTags, -1); + if (oldUpdateTags > 0) { - _updateTags = 0; - _repo.RefreshTags(); - _repo.RefreshCommits(); + 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.EndsWith(".lock", StringComparison.Ordinal)) + 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 OnGitDirChanged(object o, FileSystemEventArgs e) + { + if (string.IsNullOrEmpty(e.Name)) + return; + + 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; - var name = e.Name.Replace("\\", "/"); - if (name.StartsWith("modules", StringComparison.Ordinal) && name.EndsWith("HEAD", StringComparison.Ordinal)) + 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)) { - _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); - _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + if (_repo.MayHaveSubmodules()) + Interlocked.Exchange(ref _updateSubmodules, DateTime.Now.AddSeconds(1).ToFileTime()); } else if (name.StartsWith("refs/tags", StringComparison.Ordinal)) { - _updateTags = DateTime.Now.AddSeconds(.5).ToFileTime(); + Interlocked.Exchange(ref _updateTags, DateTime.Now.AddSeconds(.5).ToFileTime()); } else if (name.StartsWith("refs/stash", StringComparison.Ordinal)) { - _updateStashes = DateTime.Now.AddSeconds(.5).ToFileTime(); + 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/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal))) { - _updateBranch = DateTime.Now.AddSeconds(.5).ToFileTime(); - - lock (_submodules) - { - if (_submodules.Count > 0) - _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); - } + 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)) { - _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime()); } } - private void OnWorkingCopyChanged(object o, FileSystemEventArgs e) + private void HandleWorkingCopyFileChanged(string name, string fullpath) { - if (string.IsNullOrEmpty(e.Name)) + if (name.StartsWith(".vs/", StringComparison.Ordinal)) return; - var name = e.Name.Replace("\\", "/"); - if (name == ".git" || name.StartsWith(".git/", StringComparison.Ordinal)) + if (name.Equals(".gitmodules", StringComparison.Ordinal)) + { + var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + Interlocked.Exchange(ref _updateSubmodules, desired); + Interlocked.Exchange(ref _updateWC, desired); return; + } - lock (_submodules) + var dir = Directory.Exists(fullpath) ? fullpath : Path.GetDirectoryName(fullpath); + if (IsInSubmodule(dir)) { - foreach (var submodule in _submodules) - { - if (name.StartsWith(submodule, StringComparison.Ordinal)) - { - _updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime(); - return; - } - } + Interlocked.Exchange(ref _updateSubmodules, DateTime.Now.AddSeconds(1).ToFileTime()); + return; } - _updateWC = DateTime.Now.AddSeconds(1).ToFileTime(); + Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime()); + } + + 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 = 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 readonly IRepository _repo; + private readonly string _root; + private List _watchers; + private Timer _timer; - private object _lockSubmodule = new object(); - private List _submodules = new List(); + 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 index f9ba14e4a..27a9415d5 100644 --- a/src/Models/Worktree.cs +++ b/src/Models/Worktree.cs @@ -1,38 +1,12 @@ -using CommunityToolkit.Mvvm.ComponentModel; - namespace SourceGit.Models { - public class Worktree : ObservableObject + 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 => _isLocked; - set => SetProperty(ref _isLocked, value); - } - - public string Name - { - get - { - if (IsDetached) - return $"(deteched HEAD at {Head.Substring(10)})"; - - if (Branch.StartsWith("refs/heads/", System.StringComparison.Ordinal)) - return $"({Branch.Substring(11)})"; - - if (Branch.StartsWith("refs/remotes/", System.StringComparison.Ordinal)) - return $"({Branch.Substring(13)})"; - - return $"({Branch})"; - } - } - - private bool _isLocked = false; + public bool IsLocked { get; set; } = false; } } diff --git a/src/Native/Linux.cs b/src/Native/Linux.cs index 55b7b43b8..a28d88ebe 100644 --- a/src/Native/Linux.cs +++ b/src/Native/Linux.cs @@ -5,6 +5,8 @@ using System.Runtime.Versioning; using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; namespace SourceGit.Native { @@ -13,10 +15,40 @@ internal class Linux : OS.IBackend { public void SetupApp(AppBuilder builder) { - builder.With(new X11PlatformOptions() + builder.With(new X11PlatformOptions() { EnableIme = true }); + } + + public void SetupWindow(Window window) + { + window.BorderThickness = new Thickness(0); + + if (OS.UseSystemWindowFrame) { - EnableIme = true, - }); + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.Default; + window.ExtendClientAreaToDecorationsHint = false; + } + else + { + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; + window.ExtendClientAreaToDecorationsHint = true; + window.Classes.Add("custom_window_frame"); + } + } + + 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; + } + + // Runtime data dir: ~/.sourcegit + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".sourcegit"); } public string FindGitExecutable() @@ -34,37 +66,45 @@ public string FindTerminal(Models.ShellOrTerminal shell) 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.Fleet(FindJetBrainsFleet); - finder.FindJetBrainsFromToolbox(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}/JetBrains/Toolbox"); + finder.Cursor(() => FindExecutable("cursor")); + finder.FindJetBrainsFromToolbox(() => Path.Combine(localAppDataDir, "JetBrains/Toolbox")); finder.SublimeText(() => FindExecutable("subl")); - finder.Zed(() => FindExecutable("zeditor")); - return finder.Founded; + finder.Zed(() => + { + var exec = FindExecutable("zeditor"); + return string.IsNullOrEmpty(exec) ? FindExecutable("zed") : exec; + }); + return finder.Tools; } public void OpenBrowser(string url) { - Process.Start("xdg-open", $"\"{url}\""); + var browser = Environment.GetEnvironmentVariable("BROWSER"); + if (string.IsNullOrEmpty(browser)) + browser = "xdg-open"; + Process.Start(browser, url.Quoted()); } - public void OpenInFileManager(string path, bool select) + public void OpenInFileManager(string path) { if (Directory.Exists(path)) { - Process.Start("xdg-open", $"\"{path}\""); + Process.Start("xdg-open", path.Quoted()); } else { var dir = Path.GetDirectoryName(path); if (Directory.Exists(dir)) - Process.Start("xdg-open", $"\"{dir}\""); + Process.Start("xdg-open", dir.Quoted()); } } - public void OpenTerminal(string workdir) + public void OpenTerminal(string workdir, string args) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var cwd = string.IsNullOrEmpty(workdir) ? home : workdir; @@ -72,9 +112,7 @@ public void OpenTerminal(string workdir) var startInfo = new ProcessStartInfo(); startInfo.WorkingDirectory = cwd; startInfo.FileName = OS.ShellOrTerminal; - - if (OS.ShellOrTerminal.EndsWith("wezterm", StringComparison.OrdinalIgnoreCase)) - startInfo.Arguments = $"start --cwd \"{cwd}\""; + startInfo.Arguments = args; try { @@ -82,19 +120,19 @@ public void OpenTerminal(string workdir) } catch (Exception e) { - App.RaiseException(workdir, $"Failed to start '{OS.ShellOrTerminal}'. Reason: {e.Message}"); + Models.Notification.Send(workdir, $"Failed to start '{OS.ShellOrTerminal}'. Reason: {e.Message}", true); } } public void OpenWithDefaultEditor(string file) { - var proc = Process.Start("xdg-open", $"\"{file}\""); + var proc = Process.Start("xdg-open", file.Quoted()); if (proc != null) { proc.WaitForExit(); if (proc.ExitCode != 0) - App.RaiseException("", $"Failed to open \"{file}\""); + Models.Notification.Send("", $"Failed to open: {file}", true); proc.Close(); } @@ -103,21 +141,16 @@ public void OpenWithDefaultEditor(string file) private string FindExecutable(string filename) { var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - var pathes = pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); - foreach (var path in pathes) + var paths = pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in paths) { var test = Path.Combine(path, filename); if (File.Exists(test)) return test; } - return string.Empty; - } - - private string FindJetBrainsFleet() - { - var path = $"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}/JetBrains/Toolbox/apps/fleet/bin/Fleet"; - return File.Exists(path) ? path : FindExecutable("fleet"); + 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 9660920d6..54a2a8a72 100644 --- a/src/Native/MacOS.cs +++ b/src/Native/MacOS.cs @@ -1,10 +1,14 @@ 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 Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; namespace SourceGit.Native { @@ -18,40 +22,71 @@ public void SetupApp(AppBuilder builder) DisableDefaultApplicationMenuItems = true, }); + // Fix `PATH` env on macOS. + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) + path = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; + else if (!path.Contains("/opt/homebrew/", StringComparison.Ordinal)) + path = "/opt/homebrew/bin:/opt/homebrew/sbin:" + path; + var customPathFile = Path.Combine(OS.DataDir, "PATH"); if (File.Exists(customPathFile)) - OS.CustomPathEnv = File.ReadAllText(customPathFile).Trim(); + { + var env = File.ReadAllText(customPathFile).Trim(); + if (!string.IsNullOrEmpty(env)) + path = env; + } + + Environment.SetEnvironmentVariable("PATH", path); } - public string FindGitExecutable() + public void SetupWindow(Window window) { - return File.Exists("/usr/bin/git") ? "/usr/bin/git" : string.Empty; + window.ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome; + window.ExtendClientAreaToDecorationsHint = true; + window.BorderThickness = new Thickness(0); } - public string FindTerminal(Models.ShellOrTerminal shell) + public string GetDataDir() { - switch (shell.Type) - { - case "mac-terminal": - return "Terminal"; - case "iterm2": - return "iTerm"; - } + 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 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.Fleet(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}/Applications/Fleet.app/Contents/MacOS/Fleet"); - finder.FindJetBrainsFromToolbox(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}/Library/Application Support/JetBrains/Toolbox"); + finder.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.Founded; + return finder.Tools; } public void OpenBrowser(string url) @@ -59,24 +94,195 @@ public void OpenBrowser(string url) Process.Start("open", url); } - public void OpenInFileManager(string path, bool select) + public void OpenInFileManager(string path) { if (Directory.Exists(path)) - Process.Start("open", $"\"{path}\""); + Process.Start("open", path.Quoted()); else if (File.Exists(path)) - Process.Start("open", $"\"{path}\" -R"); + Process.Start("open", $"{path.Quoted()} -R"); } - public void OpenTerminal(string workdir) + 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}\""); + Process.Start("open", $"-a {OS.ShellOrTerminal} {dir.Quoted()}"); } public void OpenWithDefaultEditor(string file) { - Process.Start("open", $"\"{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); + } + + [StructLayout(LayoutKind.Sequential)] + public struct CGSize + { + public double Width; + public double Height; + + 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); + } + + 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 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 b53f81d96..ceaa69c84 100644 --- a/src/Native/OS.cs +++ b/src/Native/OS.cs @@ -1,51 +1,143 @@ 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 static partial class OS { public interface IBackend { void SetupApp(AppBuilder builder); + void SetupWindow(Window window); + string GetDataDir(); string FindGitExecutable(); 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 DataDir { get; private set; } = string.Empty; - public static string GitExecutable { get; set; } = string.Empty; - public static string ShellOrTerminal { get; set; } = string.Empty; - public static List ExternalTools { get; set; } = []; - public static string CustomPathEnv { get; set; } = string.Empty; + public static string DataDir + { + get; + private set; + } = string.Empty; + + public static string GitExecutable + { + get => _gitExecutable; + set + { + if (_gitExecutable != value) + { + _gitExecutable = value; + UpdateGitVersion(); + } + } + } + + public static string GitVersionString + { + get; + private set; + } = string.Empty; + + public static Version GitVersion + { + get; + private set; + } = new Version(0, 0, 0); + + public static 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.IsWindows()) - { _backend = new Windows(); - } else if (OperatingSystem.IsMacOS()) - { _backend = new MacOS(); - } else if (OperatingSystem.IsLinux()) - { _backend = new Linux(); - } else - { - throw new Exception("Platform unsupported!!!"); - } + throw new PlatformNotSupportedException(); + } + + public static void SetupDataDir() + { + DataDir = _backend.GetDataDir(); + if (!Directory.Exists(DataDir)) + Directory.CreateDirectory(DataDir); } public static void SetupApp(AppBuilder builder) @@ -53,21 +145,43 @@ public static void SetupApp(AppBuilder builder) _backend.SetupApp(builder); } - public static void SetupDataDir() + public static void SetupExternalTools() { - var osAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - if (string.IsNullOrEmpty(osAppDataDir)) - DataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sourcegit"); - else - DataDir = Path.Combine(osAppDataDir, "SourceGit"); + ExternalTools = _backend.FindExternalTools(); + } - if (!Directory.Exists(DataDir)) - Directory.CreateDirectory(DataDir); + public static void SetupForWindow(Window window) + { + _backend.SetupWindow(window); } - public static void SetupEnternalTools() + public static void LogException(Exception ex) { - ExternalTools = _backend.FindExternalTools(); + 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() @@ -82,28 +196,78 @@ public static bool TestShellOrTerminal(Models.ShellOrTerminal shell) public static void SetShellOrTerminal(Models.ShellOrTerminal shell) { - if (shell == null) - ShellOrTerminal = string.Empty; + 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 - ShellOrTerminal = _backend.FindTerminal(shell); + { + ExternalMergerExecFile = string.Empty; + ExternalDiffArgs = string.Empty; + ExternalMergeArgs = string.Empty; + } } - public static void OpenInFileManager(string path, bool select = false) + public static void OpenInFileManager(string path) { - _backend.OpenInFileManager(path, select); + _backend.OpenInFileManager(path); } 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)) - App.RaiseException(workdir, $"Terminal is not specified! Please confirm that the correct shell/terminal has been configured."); + Models.Notification.Send(workdir, "Terminal is not specified! Please confirm that the correct shell/terminal has been configured.", true); else - _backend.OpenTerminal(workdir); + _backend.OpenTerminal(workdir, ShellOrTerminalArgs); } public static void OpenWithDefaultEditor(string file) @@ -111,6 +275,115 @@ public static void OpenWithDefaultEditor(string file) _backend.OpenWithDefaultEditor(file); } + public static string GetAbsPath(string root, string sub) + { + var fullpath = Path.Combine(root, sub); + if (OperatingSystem.IsWindows()) + return fullpath.Replace('/', '\\'); + + return fullpath; + } + + 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; + } + + 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; + } + + 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 48fbb2879..2ec98ca40 100644 --- a/src/Native/Windows.cs +++ b/src/Native/Windows.cs @@ -5,30 +5,18 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; +using System.Text.Json; using Avalonia; using Avalonia.Controls; +using Avalonia.Platform; +using Avalonia.Threading; namespace SourceGit.Native { [SupportedOSPlatform("windows")] internal class Windows : OS.IBackend { - [StructLayout(LayoutKind.Sequential)] - internal struct RTL_OSVERSIONINFOEX - { - internal uint dwOSVersionInfoSize; - internal uint dwMajorVersion; - internal uint dwMinorVersion; - internal uint dwBuildNumber; - internal uint dwPlatformId; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] - internal string szCSDVersion; - } - - [DllImport("ntdll")] - private static extern int RtlGetVersion(ref RTL_OSVERSIONINFOEX lpVersionInformation); - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, SetLastError = false)] private static extern bool PathFindOnPath([In, Out] StringBuilder pszFile, [In] string[] ppszOtherDirs); @@ -43,14 +31,27 @@ internal struct RTL_OSVERSIONINFOEX public void SetupApp(AppBuilder builder) { - // Fix drop shadow issue on Windows 10 - RTL_OSVERSIONINFOEX v = new RTL_OSVERSIONINFOEX(); - v.dwOSVersionInfoSize = (uint)Marshal.SizeOf(); - if (RtlGetVersion(ref v) == 0 && (v.dwMajorVersion < 10 || v.dwBuildNumber < 22000)) - { - Window.WindowStateProperty.Changed.AddClassHandler((w, _) => FixWindowFrameOnWin10(w)); - Control.LoadedEvent.AddClassHandler((w, _) => FixWindowFrameOnWin10(w)); - } + // 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() @@ -59,23 +60,17 @@ public string FindGitExecutable() Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryView.Registry64); - var git = reg.OpenSubKey("SOFTWARE\\GitForWindows"); - if (git != null && git.GetValue("InstallPath") is string installPath) - { + var git = reg.OpenSubKey(@"SOFTWARE\GitForWindows"); + if (git?.GetValue("InstallPath") is string installPath) return Path.Combine(installPath, "bin", "git.exe"); - } var builder = new StringBuilder("git.exe", 259); if (!PathFindOnPath(builder, null)) - { return null; - } var exePath = builder.ToString(); if (!string.IsNullOrEmpty(exePath)) - { return exePath; - } return null; } @@ -89,7 +84,7 @@ public string FindTerminal(Models.ShellOrTerminal shell) break; var binDir = Path.GetDirectoryName(OS.GitExecutable)!; - var bash = Path.Combine(binDir, "bash.exe"); + var bash = Path.GetFullPath(Path.Combine(binDir, "..", "git-bash.exe")); if (!File.Exists(bash)) break; @@ -113,7 +108,7 @@ public string FindTerminal(Models.ShellOrTerminal shell) break; case "cmd": - return "C:\\Windows\\System32\\cmd.exe"; + return @"C:\Windows\System32\cmd.exe"; case "wt": var wtFinder = new StringBuilder("wt.exe", 512); if (PathFindOnPath(wtFinder, null)) @@ -127,86 +122,101 @@ public string FindTerminal(Models.ShellOrTerminal shell) public List FindExternalTools() { + var localAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var finder = new Models.ExternalToolsFinder(); finder.VSCode(FindVSCode); finder.VSCodeInsiders(FindVSCodeInsiders); finder.VSCodium(FindVSCodium); - finder.Fleet(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\\Programs\\Fleet\\Fleet.exe"); - finder.FindJetBrainsFromToolbox(() => $"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\\JetBrains\\Toolbox"); + FindVisualStudio(finder); + finder.Cursor(() => Path.Combine(localAppDataDir, @"Programs\Cursor\Cursor.exe")); + finder.FindJetBrainsFromToolbox(() => Path.Combine(localAppDataDir, @"JetBrains\Toolbox")); finder.SublimeText(FindSublimeText); - finder.TryAdd("Visual Studio", "vs", FindVisualStudio, GenerateCommandlineArgsForVisualStudio); - return finder.Founded; + finder.Zed(FindZed); + return finder.Tools; } public void OpenBrowser(string url) { - var info = new ProcessStartInfo("cmd", $"/c start {url}"); + var info = new ProcessStartInfo(url); + info.UseShellExecute = true; info.CreateNoWindow = true; Process.Start(info); } - public void OpenTerminal(string workdir) + public void OpenTerminal(string workdir, string args) { - if (!File.Exists(OS.ShellOrTerminal)) + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var cwd = string.IsNullOrEmpty(workdir) ? home : workdir; + var terminal = OS.ShellOrTerminal; + + if (!File.Exists(terminal)) { - App.RaiseException(workdir, $"Terminal is not specified! Please confirm that the correct shell/terminal has been configured."); + 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.WorkingDirectory = workdir; - startInfo.FileName = OS.ShellOrTerminal; - - // Directly launching `Windows Terminal` need to specify the `-d` parameter - if (OS.ShellOrTerminal.EndsWith("wt.exe", StringComparison.OrdinalIgnoreCase)) - startInfo.Arguments = $"-d \"{workdir}\""; - + startInfo.WorkingDirectory = cwd; + startInfo.FileName = terminal; + startInfo.Arguments = args; Process.Start(startInfo); } - public void OpenInFileManager(string path, bool select) + public void OpenInFileManager(string path) { - string fullpath; if (File.Exists(path)) { - fullpath = new FileInfo(path).FullName; - select = true; - } - else - { - fullpath = new DirectoryInfo(path!).FullName; - fullpath += Path.DirectorySeparatorChar; - } + var pidl = ILCreateFromPathW(new FileInfo(path).FullName); - if (select) - { - OpenFolderAndSelectFile(fullpath); - } - else - { - Process.Start(new ProcessStartInfo(fullpath) + try { - UseShellExecute = true, - CreateNoWindow = true, - }); + 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) { 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); } - private void FixWindowFrameOnWin10(Window w) + #region HELPER_METHODS + private List GenerateVSProjectLaunchOptions(string path) { - if (w.WindowState == WindowState.Maximized || w.WindowState == WindowState.FullScreen) - w.SystemDecorations = SystemDecorations.Full; - else if (w.WindowState == WindowState.Normal) - w.SystemDecorations = SystemDecorations.BorderOnly; + 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() @@ -218,9 +228,7 @@ private string FindVSCode() // 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, @@ -229,9 +237,7 @@ private string FindVSCode() // 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; } @@ -245,9 +251,7 @@ private string FindVSCodeInsiders() // 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, @@ -256,9 +260,7 @@ private string FindVSCodeInsiders() // 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; } @@ -270,11 +272,9 @@ private string FindVSCodium() 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 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, @@ -283,9 +283,7 @@ private string FindVSCodium() // 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; } @@ -315,69 +313,104 @@ private string FindSublimeText() return string.Empty; } - private string FindVisualStudio() + private void FindVisualStudio(Models.ExternalToolsFinder finder) { - var localMachine = Microsoft.Win32.RegistryKey.OpenBaseKey( - Microsoft.Win32.RegistryHive.LocalMachine, - Microsoft.Win32.RegistryView.Registry64); + var vswhere = Environment.ExpandEnvironmentVariables(@"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"); + if (!File.Exists(vswhere)) + return; - // Get default class for VisualStudio.Launcher.sln - the handler for *.sln files - if (localMachine.OpenSubKey(@"SOFTWARE\Classes\VisualStudio.Launcher.sln\CLSID") is Microsoft.Win32.RegistryKey launcher) + 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 { - // Get actual path to the executable - if (launcher.GetValue(string.Empty) is string CLSID && - localMachine.OpenSubKey(@$"SOFTWARE\Classes\CLSID\{CLSID}\LocalServer32") is Microsoft.Win32.RegistryKey devenv && - devenv.GetValue(string.Empty) is string localServer32) + using var proc = Process.Start(startInfo)!; + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode == 0) { - return localServer32!.Trim('\"'); + 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); + } } } - - return string.Empty; + catch + { + // Just ignore. + } } - #endregion - private void OpenFolderAndSelectFile(string folderPath) + private string FindZed() { - var pidl = ILCreateFromPathW(folderPath); + var currentUser = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.CurrentUser, + Microsoft.Win32.RegistryView.Registry64); - try - { - SHOpenFolderAndSelectItems(pidl, 0, 0, 0); - } - finally - { - ILFree(pidl); - } + // 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 + } - private string GenerateCommandlineArgsForVisualStudio(string repo) + [SupportedOSPlatform("windows")] + public static class Win64Utilities + { + [StructLayout(LayoutKind.Sequential)] + internal struct MARGINS { - var sln = FindVSSolutionFile(new DirectoryInfo(repo), 4); - return string.IsNullOrEmpty(sln) ? $"\"{repo}\"" : $"\"{sln}\""; + public int cxLeftWidth; + public int cxRightWidth; + public int cyTopHeight; + public int cyBottomHeight; } - private string FindVSSolutionFile(DirectoryInfo dir, int leftDepth) + [DllImport("dwmapi.dll")] + private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins); + + public static void FixWindowFrame(Window w) { - var files = dir.GetFiles(); - foreach (var f in files) + if (w.WindowState == WindowState.Maximized) { - if (f.Name.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)) - return f.FullName; + 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 (leftDepth <= 0) - return null; + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) + return; - var subDirs = dir.GetDirectories(); - foreach (var subDir in subDirs) + Dispatcher.UIThread.Post(() => { - var first = FindVSSolutionFile(subDir, leftDepth - 1); - if (!string.IsNullOrEmpty(first)) - return first; - } + var platformHandle = w.TryGetPlatformHandle(); + if (platformHandle == null) + return; - return null; + 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/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 index 12acc5383..3f78154d0 100644 --- a/src/Resources/Grammars/haxe.json +++ b/src/Resources/Grammars/haxe.json @@ -1,7 +1,9 @@ { "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" + "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", @@ -2485,4 +2487,4 @@ "name": "variable.other.hx" } } -} \ No newline at end of file +} diff --git a/src/Resources/Grammars/hxml.json b/src/Resources/Grammars/hxml.json index 829c403e4..3be425772 100644 --- a/src/Resources/Grammars/hxml.json +++ b/src/Resources/Grammars/hxml.json @@ -1,7 +1,9 @@ { "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" + "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" @@ -67,4 +69,4 @@ ], "scopeName": "source.hxml", "uuid": "CB1B853A-C4C8-42C3-BA70-1B1605BE51C1" -} \ No newline at end of file +} diff --git a/src/Resources/Grammars/jsp.json b/src/Resources/Grammars/jsp.json new file mode 100644 index 000000000..2fbfd97cc --- /dev/null +++ b/src/Resources/Grammars/jsp.json @@ -0,0 +1,100 @@ +{ + "information_for_contributors": [ + "This file has been copied from https://github.com/samuel-weinhardt/vscode-jsp-lang/blob/0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355/syntaxes/jsp.tmLanguage.json", + "The original file was licensed under the MIT License", + "https://github.com/samuel-weinhardt/vscode-jsp-lang/blob/0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355/LICENSE" + ], + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Jakarta Server Pages", + "fileTypes": ["jsp", "jspf", "tag"], + "scopeName": "text.html.jsp", + "patterns": [ + { "include": "#comment" }, + { "include": "#directive" }, + { "include": "#expression" }, + { "include": "text.html.derivative" } + ], + "injections": { + "L:text.html.jsp -comment -meta.tag.directive.jsp -meta.tag.scriptlet.jsp": { + "patterns": [ + { "include": "#scriptlet" } + ], + "comment": "allow scriptlets anywhere except comments and nested" + }, + "L:meta.attribute (string.quoted.single.html | string.quoted.double.html) -string.template.expression.jsp": { + "patterns": [ + { "include": "#expression" }, + { "include": "text.html.derivative" } + ], + "comment": "allow expressions and tags within HTML attributes (not nested)" + } + }, + "repository": { + "comment": { + "name": "comment.block.jsp", + "begin": "<%--", + "end": "--%>" + }, + "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 index e8f844d00..2857f7171 100644 --- a/src/Resources/Grammars/kotlin.json +++ b/src/Resources/Grammars/kotlin.json @@ -1,6 +1,8 @@ { "information_for_contributors": [ - "This file has been copied from https://github.com/eclipse/buildship/blob/6bb773e7692f913dec27105129ebe388de34e68b/org.eclipse.buildship.kotlindsl.provider/kotlin.tmLanguage.json" + "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", @@ -698,4 +700,4 @@ "name": "variable.language.this.kotlin" } } -} \ No newline at end of file +} diff --git a/src/Resources/Grammars/toml.json b/src/Resources/Grammars/toml.json index 86c2ef878..6be4678fc 100644 --- a/src/Resources/Grammars/toml.json +++ b/src/Resources/Grammars/toml.json @@ -3,7 +3,10 @@ "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." + "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": [ { diff --git a/src/Resources/Grammars/vue.json b/src/Resources/Grammars/vue.json new file mode 100644 index 000000000..fd7e47c54 --- /dev/null +++ b/src/Resources/Grammars/vue.json @@ -0,0 +1,1326 @@ +{ + "information_for_contributors": [ + "This file has been copied from https://github.com/vuejs/language-tools/blob/68d98dc57f8486c2946ae28dc86bf8e91d45da4d/extensions/vscode/syntaxes/vue.tmLanguage.json", + "The original file was licensed under the MIT License", + "https://github.com/samuel-weinhardt/vscode-jsp-lang/blob/0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355/LICENSE" + ], + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Vue", + "scopeName": "source.vue", + "patterns": [ + { + "include": "#vue-comments" + }, + { + "include": "#self-closing-tag" + }, + { + "begin": "(<)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.html.vue" + } + }, + "end": "(>)", + "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 53b2b3d3b..6852b6a51 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -1,27 +1,40 @@ M41 512c0-128 46-241 138-333C271 87 384 41 512 41s241 46 333 138c92 92 138 205 138 333s-46 241-138 333c-92 92-205 138-333 138s-241-46-333-138C87 753 41 640 41 512zm87 0c0 108 36 195 113 271s164 113 271 113c108 0 195-36 271-113s113-164 113-271-36-195-113-271c-77-77-164-113-271-113-108 0-195 36-271 113C164 317 128 404 128 512zm256 148V292l195 113L768 512l-195 113-195 113v-77zm148-113-61 36V440l61 36 61 36-61 36z M304 464a128 128 0 01128-128c71 0 128 57 128 128v224a32 32 0 01-64 0V592h-128v95a32 32 0 01-64 0v-224zm64 1v64h128v-64a64 64 0 00-64-64c-35 0-64 29-64 64zM688 337c18 0 32 14 32 32v319a32 32 0 01-32 32c-18 0-32-14-32-32v-319a32 32 0 0132-32zM84 911l60-143A446 446 0 0164 512C64 265 265 64 512 64s448 201 448 448-201 448-448 448c-54 0-105-9-153-27l-242 22a32 32 0 01-32-44zm133-150-53 126 203-18 13 5c41 15 85 23 131 23 212 0 384-172 384-384S724 128 512 128 128 300 128 512c0 82 26 157 69 220l20 29z - M296 392h64v64h-64zM296 582v160h128V582h-64v-62h-64v62zm80 48v64h-32v-64h32zM360 328h64v64h-64zM296 264h64v64h-64zM360 456h64v64h-64zM360 200h64v64h-64zM855 289 639 73c-6-6-14-9-23-9H192c-18 0-32 14-32 32v832c0 18 14 32 32 32h640c18 0 32-14 32-32V311c0-9-3-17-9-23zM790 326H602V138L790 326zm2 562H232V136h64v64h64v-64h174v216c0 23 19 42 42 42h216v494z + 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 - M832 64H192c-18 0-32 14-32 32v832c0 18 14 32 32 32h640c18 0 32-14 32-32V96c0-18-14-32-32-32zM736 596 624 502 506 596V131h230v318z + 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 - M378 116l265 0 0 47-265 0 0-47ZM888 116 748 116l0 47 124 0c18 0 33 15 33 33l0 93L115 290l0-93c0-18 15-33 33-33l124 0 0-47L132 116c-35 0-64 29-64 64l0 714c0 35 29 64 64 64l757 0c35 0 64-29 64-64l-0-714C952 145 924 116 888 116zM905 337l0 540c0 18-15 33-33 33L148 910c-18 0-33-15-33-33L115 337 905 337zM301 65l47 0 0 170-47 0 0-170ZM673 65l47 0 0 170-47 0 0-170ZM358 548l0 231 53 0L411 459l-35 0-3 4c-18 26-41 49-70 68l-4 3 0 54 13-8C331 569 346 559 358 548zM618 727c-10 6-24 8-42 5-16-3-28-18-35-46l-2-9-48 13 2 8c6 30 18 52 36 65 17 13 36 20 55 21 3 0 7 0 10 0 15 0 28-2 40-7 14-6 27-13 37-23 10-10 18-22 23-37 5-14 8-28 8-42 1-14-1-27-4-39l-0-0c-3-12-8-24-15-36-7-13-19-23-35-30-15-7-31-11-47-11-11-0-23 1-36 5 4-15 8-32 11-52l114 0 0-49L536 464l-1 7c-25 116-32 145-33 150l-3 10 46 5 3-4c8-11 18-18 31-21 13-3 25-3 35-0 10 3 18 9 24 18 7 9 10 20 11 34 1 14-2 26-6 37C636 711 629 720 618 727z + 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 + 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 - 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 + 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 @@ -29,29 +42,33 @@ 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 - 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 + M0 512M1024 512M512 0M512 1024M520 168C291 168 95 311 16 512c79 201 275 344 504 344 229 0 425-143 504-344-79-201-275-344-504-344zm0 573c-126 0-229-103-229-229s103-229 229-229c126 0 229 103 229 229s-103 229-229 229zm0-367c-76 0-137 62-137 137s62 137 137 137S657 588 657 512s-62-137-137-137z M734 128c-33-19-74-8-93 25l-41 70c-28-6-58-9-90-9-294 0-445 298-445 298s82 149 231 236l-31 54c-19 33-8 74 25 93 33 19 74 8 93-25L759 222C778 189 767 147 734 128zM305 512c0-115 93-208 207-208 14 0 27 1 40 4l-37 64c-1 0-2 0-2 0-77 0-140 63-140 140 0 26 7 51 20 71l-37 64C324 611 305 564 305 512zM771 301 700 423c13 27 20 57 20 89 0 110-84 200-192 208l-51 89c12 1 24 2 36 2 292 0 446-298 446-298S895 388 771 301z M826 498 538 250c-11-9-26-1-26 14v496c0 15 16 23 26 14L826 526c8-7 8-21 0-28zm-320 0L218 250c-11-9-26-1-26 14v496c0 15 16 23 26 14L506 526c4-4 6-9 6-14 0-5-2-10-6-14z M1024 896v128H0V704h128v192h768V704h128v192zM576 555 811 320 896 405l-384 384-384-384L213 320 448 555V0h128v555z M959 320H960v640A64 64 0 01896 1024H192A64 64 0 01128 960V64A64 64 0 01192 0H640v321h320L959 320zM320 544c0 17 14 32 32 32h384A32 32 0 00768 544c0-17-14-32-32-32H352A32 32 0 00320 544zm0 128c0 17 14 32 32 32h384a32 32 0 0032-32c0-17-14-32-32-32H352a32 32 0 00-32 32zm0 128c0 17 14 32 32 32h384a32 32 0 0032-32c0-17-14-32-32-32H352a32 32 0 00-32 32z M683 85l213 213v598a42 42 0 01-42 42H170A43 43 0 01128 896V128C128 104 147 85 170 85H683zm-213 384H341v85h128v128h85v-128h128v-85h-128V341h-85v128z M949 727l-217-231a33 33 0 00-48 0 33 33 0 000 48l157 172H389a35 35 0 00-35 35c0 19 16 34 35 34h452l-160 179a34 34 0 005 54c14 10 33 7 45-5l219-237a33 33 0 000-49zM719 196h131c-24-91-95-160-185-185v131c0 27 25 54 54 54zM129 846l1-747s-7-37 36-33h359v52s-7 76 32 133a191 191 0 00146 84h91v126h66v-191H719a126 126 0 01-127-127V0H155c-51 0-91 40-91 91v767c0 51 40 91 91 91h193v-66H155c0-0-26 4-26-36z + 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 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 - m211 611a142 142 0 00-90-4v-190a142 142 0 0090-4v198zm0 262v150h-90v-146a142 142 0 0090-4zm0-723a142 142 0 00-90-4v-146h90zm-51 246a115 115 0 11115-115 115 115 0 01-115 115zm0 461a115 115 0 11115-115 115 115 0 01-115 115zm256-691h563v90h-563zm0 461h563v90h-563zm0-282h422v90h-422zm0 474h422v90h-422z + 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 - M939 94v710L512 998 85 805V94h-64A21 21 0 010 73v-0C0 61 10 51 21 51h981c12 0 21 10 21 21v0c0 12-10 21-21 21h-64zm-536 588L512 624l109 58c6 3 13 4 20 3a32 32 0 0026-37l-21-122 88-87c5-5 8-11 9-18a32 32 0 00-27-37l-122-18-54-111a32 32 0 00-57 0l-54 111-122 18c-7 1-13 4-18 9a33 33 0 001 46l88 87-21 122c-1 7-0 14 3 20a32 32 0 0043 14z - M236 542a32 32 0 109 63l86-12a180 180 0 0022 78l-71 47a32 32 0 1035 53l75-50a176 176 0 00166 40L326 529zM512 16C238 16 16 238 16 512s222 496 496 496 496-222 496-496S786 16 512 16zm0 896c-221 0-400-179-400-400a398 398 0 0186-247l561 561A398 398 0 01512 912zm314-154L690 622a179 179 0 004-29l85 12a32 32 0 109-63l-94-13v-49l94-13a32 32 0 10-9-63l-87 12a180 180 0 00-20-62l71-47A32 32 0 10708 252l-75 50a181 181 0 00-252 10l-115-115A398 398 0 01512 112c221 0 400 179 400 400a398 398 0 01-86 247z - M884 159l-18-18a43 43 0 00-38-12l-235 43a166 166 0 00-101 60L400 349a128 128 0 00-148 47l-120 171a21 21 0 005 29l17 12a128 128 0 00178-32l27-38 124 124-38 27a128 128 0 00-32 178l12 17a21 21 0 0029 5l171-120a128 128 0 0047-148l117-92A166 166 0 00853 431l43-235a43 43 0 00-12-38zm-177 249a64 64 0 110-90 64 64 0 010 90zm-373 312a21 21 0 010 30l-139 139a21 21 0 01-30 0l-30-30a21 21 0 010-30l139-139a21 21 0 0130 0z - M590 74 859 342V876c0 38-31 68-68 68H233c-38 0-68-31-68-68V142c0-38 31-68 68-68h357zm-12 28H233a40 40 0 00-40 38L193 142v734a40 40 0 0038 40L233 916h558a40 40 0 0040-38L831 876V354L578 102zM855 371h-215c-46 0-83-36-84-82l0-2V74h28v213c0 30 24 54 54 55l2 0h215v28zM57 489m28 0 853 0q28 0 28 28l0 284q0 28-28 28l-853 0q-28 0-28-28l0-284q0-28 28-28ZM157 717c15 0 29-6 37-13v-51h-41v22h17v18c-2 2-6 3-10 3-21 0-30-13-30-34 0-21 12-34 28-34 9 0 15 4 20 9l14-17C184 610 172 603 156 603c-29 0-54 21-54 57 0 37 24 56 54 56zM245 711v-108h-34v108h34zm69 0v-86H341V603H262v22h28V711h24zM393 711v-108h-34v108h34zm66 6c15 0 29-6 37-13v-51h-41v22h17v18c-2 2-6 3-10 3-21 0-30-13-30-34 0-21 12-34 28-34 9 0 15 4 20 9l14-17C485 610 474 603 458 603c-29 0-54 21-54 57 0 37 24 56 54 56zm88-6v-36c0-13-2-28-3-40h1l10 24 25 52H603v-108h-23v36c0 13 2 28 3 40h-1l-10-24L548 603H523v108h23zM677 717c30 0 51-22 51-57 0-36-21-56-51-56-30 0-51 20-51 56 0 36 21 57 51 57zm3-23c-16 0-26-12-26-32 0-19 10-31 26-31 16 0 26 11 26 31S696 694 680 694zm93 17v-38h13l21 38H836l-25-43c12-5 19-15 19-31 0-26-20-34-44-34H745v108h27zm16-51H774v-34h15c16 0 25 4 25 16s-9 18-25 18zM922 711v-22h-43v-23h35v-22h-35V625h41V603H853v108h68z + 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 @@ -62,9 +79,9 @@ 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 - M875 117H149C109 117 75 151 75 192v640c0 41 34 75 75 75h725c41 0 75-34 75-75V192c0-41-34-75-75-75zm-725 64h725c6 0 11 4 11 11v288h-747V192c0-6 4-11 11-11zm725 661H149c-6 0-11-4-11-11V544h747V832c0 6-4 11-11 11z + 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 @@ -73,20 +90,29 @@ 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 - M299 811 299 725 384 725 384 811 299 811M469 811 469 725 555 725 555 811 469 811M640 811 640 725 725 725 725 811 640 811M299 640 299 555 384 555 384 640 299 640M469 640 469 555 555 555 555 640 469 640M640 640 640 555 725 555 725 640 640 640M299 469 299 384 384 384 384 469 299 469M469 469 469 384 555 384 555 469 469 469M640 469 640 384 725 384 725 469 640 469M299 299 299 213 384 213 384 299 299 299M469 299 469 213 555 213 555 299 469 299M640 299 640 213 725 213 725 299 640 299Z - M64 363l0 204 265 0L329 460c0-11 6-18 14-20C349 437 355 437 362 441c93 60 226 149 226 149 33 22 34 60 0 82 0 0-133 89-226 149-14 9-32-3-32-18l-1-110L64 693l0 117c0 41 34 75 75 75l746 0c41 0 75-34 75-74L960 364c0-0 0-1 0-1L64 363zM64 214l0 75 650 0-33-80c-16-38-62-69-103-69l-440 0C97 139 64 173 64 214z + 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 - 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 + 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 + 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 - M854 234a171 171 0 00-171 171v51c13-5 28-7 43-7h35a136 136 0 01136 136v93a198 198 0 01-198 198 101 101 0 01-101-101V405a256 256 0 01256-256h21a21 21 0 0121 21v43a21 21 0 01-21 21h-21zM213 456c13-5 28-7 43-7h35a136 136 0 01136 136v93a198 198 0 01-198 198 101 101 0 01-101-101V405a256 256 0 01256-256h21a21 21 0 0121 21v43a21 21 0 01-21 21h-21a171 171 0 00-171 171v51z - m224 154a166 166 0 00-166 166v192a166 166 0 00166 166h64v-76h-64a90 90 0 01-90-90v-192a90 90 0 0190-90h320a90 90 0 0190 90v192a90 90 0 01-90 90h-128v77h128a166 166 0 00166-167v-192a166 166 0 00-166-166h-320zm166 390a90 90 0 0190-90h128v-76h-128a166 166 0 00-166 166v192a166 166 0 00166 166h320a166 166 0 00166-166v-192a166 166 0 00-166-166h-64v77h64a90 90 0 0190 90v192a90 90 0 01-90 90h-320a90 90 0 01-90-90v-192z + 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 @@ -96,23 +122,27 @@ 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 - M576 160H448c-18 0-32-14-32-32s14-32 32-32h128c18 0 32 14 32 32s-14 32-32 32zm243 186 36-36c13-13 13-33 0-45s-33-13-45 0l-33 33C708 233 614 192 512 192c-212 0-384 172-384 384s172 384 384 384 384-172 384-384c0-86-29-166-77-230zM544 894V864c0-18-14-32-32-32s-32 14-32 32v30C329 879 209 759 194 608H224c18 0 32-14 32-32s-14-32-32-32h-30C209 393 329 273 480 258V288c0 18 14 32 32 32s32-14 32-32v-30C695 273 815 393 830 544H800c-18 0-32 14-32 32s14 32 32 32h30C815 759 695 879 544 894zm108-471-160 128c-14 11-16 31-5 45 6 8 16 12 25 12 7 0 14-2 20-7l160-128c14-11 16-31 5-45-11-14-31-16-45-5z M558 545 790 403c24-15 31-47 16-71-15-24-46-31-70-17L507 457 277 315c-24-15-56-7-71 17-15 24-7 56 17 71l232 143V819c0 28 23 51 51 51 28 0 51-23 51-51V545h0zM507 0l443 256v512L507 1024 63 768v-512L507 0z M770 320a41 41 0 00-56-14l-252 153L207 306a41 41 0 10-43 70l255 153 2 296a41 41 0 0082 0l-2-295 255-155a41 41 0 0014-56zM481 935a42 42 0 01-42 0L105 741a42 42 0 01-21-36v-386a42 42 0 0121-36L439 89a42 42 0 0142 0l335 193a42 42 0 0121 36v87h84v-87a126 126 0 00-63-109L523 17a126 126 0 00-126 0L63 210a126 126 0 00-63 109v386a126 126 0 0063 109l335 193a126 126 0 00126 0l94-54-42-72zM1029 700h-126v-125a42 42 0 00-84 0v126h-126a42 42 0 000 84h126v126a42 42 0 1084 0v-126h126a42 42 0 000-84z M416 587c21 0 37 17 37 37v299A37 37 0 01416 960h-299a37 37 0 01-37-37v-299c0-21 17-37 37-37h299zm448 0c21 0 37 17 37 37v299A37 37 0 01864 960h-299a37 37 0 01-37-37v-299c0-21 17-37 37-37h299zM758 91l183 189a37 37 0 010 52l-182 188a37 37 0 01-53 1l-183-189a37 37 0 010-52l182-188a37 37 0 0153-1zM416 139c21 0 37 17 37 37v299A37 37 0 01416 512h-299a37 37 0 01-37-37v-299c0-21 17-37 37-37h299z + 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 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 @@ -120,15 +150,16 @@ M762 1024C876 818 895 504 448 514V768L64 384l384-384v248c535-14 595 472 314 776z M832 464H332V240c0-31 25-56 56-56h248c31 0 56 25 56 56v68c0 4 4 8 8 8h56c4 0 8-4 8-8v-68c0-71-57-128-128-128H388c-71 0-128 57-128 128v224h-68c-18 0-32 14-32 32v384c0 18 14 32 32 32h640c18 0 32-14 32-32V496c0-18-14-32-32-32zM540 701v53c0 4-4 8-8 8h-40c-4 0-8-4-8-8v-53c-12-9-20-23-20-39 0-27 22-48 48-48s48 22 48 48c0 16-8 30-20 39z M170 831l343-342L855 831l105-105-448-448L64 726 170 831z + M667 607c-3-2-7-14-0-38 73-77 118-187 118-290C784 115 668 0 508 0 348 0 236 114 236 278c0 104 45 215 119 292 7 24-2 33-8 35C274 631 0 725 0 854L0 1024l1024 0 0-192C989 714 730 627 667 607L667 607z M880 128A722 722 0 01555 13a77 77 0 00-85 0 719 719 0 01-325 115c-40 4-71 38-71 80v369c0 246 329 446 439 446 110 0 439-200 439-446V207c0-41-31-76-71-80zM465 692a36 36 0 01-53 0L305 579a42 42 0 010-57 36 36 0 0153 0l80 85L678 353a36 36 0 0153 0 42 42 0 01-0 57L465 692z M812 864h-29V654c0-21-11-40-28-52l-133-88 134-89c18-12 28-31 28-52V164h28c18 0 32-14 32-32s-14-32-32-32H212c-18 0-32 14-32 32s14 32 32 32h30v210c0 21 11 40 28 52l133 88-134 89c-18 12-28 31-28 52V864H212c-18 0-32 14-32 32s14 32 32 32h600c18 0 32-14 32-32s-14-32-32-32zM441 566c18-12 28-31 28-52s-11-40-28-52L306 373V164h414v209l-136 90c-18 12-28 31-28 52 0 21 11 40 28 52l135 89V695c-9-7-20-13-32-19-30-15-93-41-176-41-63 0-125 14-175 38-12 6-22 12-31 18v-36l136-90z M0 512M1024 512M512 0M512 1024M762 412v100h-500v-100h-150v200h800v-200h-150z M519 459 222 162a37 37 0 10-52 52l297 297L169 809a37 37 0 1052 52l297-297 297 297a37 37 0 1052-52l-297-297 297-297a37 37 0 10-52-52L519 459z - M1024 565V459H0V565H1024ZM512 0M512 1024 + M0 0M32 512H936v96H32z 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 + 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 - M128 691H6V38h838v160h-64V102H70v525H128zM973 806H154V250h819v557zm-755-64h691V314H218v429zM365 877h448v64h-448z + 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 index 9d9cf6bdb..01902a920 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/CL.png 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 index c73262877..694713cd0 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/DB.png 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 index 2715c1dd3..fc76eb93b 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/DL.png 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 index 417f83371..f456aab92 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/DS.png 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 index 744938fcb..72cea44ca 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/GO.png 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 index e9a3d1c09..73a7b8a94 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/JB.png 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 index 38ae82740..f3e15a637 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/PC.png 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 index e5e2ceaf6..e06fe6618 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/PS.png 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 index 38ae82740..f3e15a637 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/PY.png 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 index 499b2effe..a962becf1 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/QA.png 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 index 225f36528..d2b7c4200 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/QD.png 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 index a2fa3d337..871988c39 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/RD.png 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 index 1adfa654e..5fe13b620 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/RM.png 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 index ee27634a0..5f72b09b9 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/RR.png 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 index 2d8f9c363..5ea6cd56a 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/WRS.png 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 index 09dda1fc9..6b5cb0f2d 100644 Binary files a/src/Resources/Images/ExternalToolIcons/JetBrains/WS.png 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 index c7aaf18b9..dc05f32c3 100644 Binary files a/src/Resources/Images/ExternalToolIcons/beyond_compare.png 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 index 10abb719f..58c49bcc6 100644 Binary files a/src/Resources/Images/ExternalToolIcons/codium.png 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 index 5e9d84f62..552327844 100644 Binary files a/src/Resources/Images/ExternalToolIcons/fleet.png 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 index a05a13226..034f3b9c3 100644 Binary files a/src/Resources/Images/ExternalToolIcons/git.png 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 index 1e2a0bbb2..640aacbee 100644 Binary files a/src/Resources/Images/ExternalToolIcons/kdiff3.png 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 index b9885f154..59cdc7c5d 100644 Binary files a/src/Resources/Images/ExternalToolIcons/meld.png 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 index 010d8f6f0..352fc21d3 100644 Binary files a/src/Resources/Images/ExternalToolIcons/p4merge.png 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 index 6ab3b8cbf..411d77320 100644 Binary files a/src/Resources/Images/ExternalToolIcons/rider.png 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 index 89e4a2861..ca0cdebf0 100644 Binary files a/src/Resources/Images/ExternalToolIcons/sublime_text.png 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 index eb5b1a8a7..10ffe0ef6 100644 Binary files a/src/Resources/Images/ExternalToolIcons/tortoise_merge.png 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 index d79fbcfdd..072ee5cbc 100644 Binary files a/src/Resources/Images/ExternalToolIcons/vs.png 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 index 23b6af54b..9c15c8d72 100644 Binary files a/src/Resources/Images/ExternalToolIcons/vscode.png 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 index 38fe8a1eb..b48ed6679 100644 Binary files a/src/Resources/Images/ExternalToolIcons/vscode_insiders.png 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 index 8f9f53d4c..410776914 100644 Binary files a/src/Resources/Images/ExternalToolIcons/win_merge.png 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 index faccf441b..6c7d72028 100644 Binary files a/src/Resources/Images/ExternalToolIcons/xcode.png 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 index 07c4c50fd..f2b5ceada 100644 Binary files a/src/Resources/Images/ExternalToolIcons/zed.png 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 index efa27dd47..aa1184342 100644 Binary files a/src/Resources/Images/ShellIcons/cmd.png 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 index 0175688f7..6b11e3d39 100644 Binary files a/src/Resources/Images/ShellIcons/custom.png 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 index 78eef1b45..3bc708cd2 100644 Binary files a/src/Resources/Images/ShellIcons/deepin-terminal.png 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 index c5dbd0a50..a5ce6fdf5 100644 Binary files a/src/Resources/Images/ShellIcons/foot.png 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 index 767e0a4ee..e48485cba 100644 Binary files a/src/Resources/Images/ShellIcons/git-bash.png 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 index f9edd2e32..3fa56a816 100644 Binary files a/src/Resources/Images/ShellIcons/gnome-terminal.png 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 index 16fbd3bd8..c9410f56a 100644 Binary files a/src/Resources/Images/ShellIcons/iterm2.png 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 index e1dbcd49c..f9fe06eae 100644 Binary files a/src/Resources/Images/ShellIcons/konsole.png 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 index 99f62683b..19f29475d 100644 Binary files a/src/Resources/Images/ShellIcons/lxterminal.png 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 index 569af7567..62099f3fd 100644 Binary files a/src/Resources/Images/ShellIcons/mac-terminal.png 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 index d48cedfaf..7dc2945ab 100644 Binary files a/src/Resources/Images/ShellIcons/mate-terminal.png 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 index 12edf7571..ebb9e0dab 100644 Binary files a/src/Resources/Images/ShellIcons/pwsh.png 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 index ed7a659f6..9a80820b1 100644 Binary files a/src/Resources/Images/ShellIcons/wezterm.png 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 index f2a874f44..01e48f0b0 100644 Binary files a/src/Resources/Images/ShellIcons/wt.png 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 index 9eda3d005..eed9223e1 100644 Binary files a/src/Resources/Images/ShellIcons/xfce4-terminal.png and b/src/Resources/Images/ShellIcons/xfce4-terminal.png differ diff --git a/src/Resources/Images/github.png b/src/Resources/Images/github.png index 3a7abb16c..54658efa9 100644 Binary files a/src/Resources/Images/github.png 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 index 200d36383..57a344c63 100644 --- a/src/Resources/Locales/de_DE.axaml +++ b/src/Resources/Locales/de_DE.axaml @@ -2,205 +2,293 @@ + Info Über SourceGit - • Erstellt mit - • Grafik gerendert durch - © 2024 sourcegit-scm - • Texteditor von - • Monospace-Schriftarten von - • Quelltext findest du auf - Open Source & freier Git GUI Client + Veröffentlichungsdatum: {0} + Versionshinweise + Open Source & freier Git GUI-Client + Zu ignorierende Datei(en) hinzufügen + Muster: + Speichern in Datei: Worktree hinzufügen - Was auschecken: - Existierender Branch - Neuen Branch erstellen Ordner: - Pfad für diesen Worktree. Relativer Pfad wird unterstützt. - Branch Name: - Optional. Standard ist der Zielordnername. + Pfad für diesen Worktree. Relative Pfade werden unterstützt. + Branch-Name: + Optional. Standard ist der Name des Zielordners. Branch verfolgen: Remote-Branch verfolgen - OpenAI Assistent - Verwende OpenAI, um Commit-Nachrichten zu generieren - Patch - Fehler - Fehler werfen und anwenden des Patches verweigern - Alle Fehler - Ähnlich wie 'Fehler', zeigt aber mehr an - Patch-Datei: + 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 - Keine Warnungen - Keine Warnung vor Leerzeichen am Zeilenende + Ignoriere Leerzeichen-Änderungen Patch anwenden - Warnen - Gibt eine Warnung für ein paar solcher Fehler aus, aber wendet es an Leerzeichen: - Archivieren... + Stash anwenden + Nach dem Anwenden löschen + Änderungen des Index wiederherstellen + Stash: + Archivieren… Speichere Archiv in: - Wähle Archivpfad aus + Wähle einen Archivpfad aus Revision: Archiv erstellen - SourceGit Askpass + SourceGit-Askpass + Passphrase eingeben: ALS UNVERÄNDERT ANGENOMMENE DATEIEN KEINE ALS UNVERÄNDERT ANGENOMMENEN DATEIEN - ENTFERNEN - BINÄRE DATEI NICHT UNTERSTÜTZT!!! + 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 WIRD BEI DIESER DATEI NICHT UNTERSTÜTZT!!! - Auschecken von ${0}$... - Mit Branch vergleichen - Mit HEAD vergleichen - Mit Worktree vergleichen + 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 - Lösche ${0}$... - Lösche alle ausgewählten {0} Branches - Alle Änderungen verwerfen + 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}$ - Merge ${0}$ in ${1}$ hinein... + 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... + Pull ${0}$ in ${1}$ hinein… Push ${0}$ - Rebase ${0}$ auf ${1}$... - Benenne ${0}$ um... - Setze verfolgten Branch - Upstream Verfolgung aufheben - Branch Vergleich - Bytes + 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 diese Revision zurücksetzen Auf Vorgänger-Revision zurücksetzen + Auf diese Revision zurücksetzen Generiere Commit-Nachricht - ANZEIGE MODUS ÄNDERN + Merge (integriert) + Merge (extern) + Datei(en) auf ${0}$ zurücksetzen + ANZEIGEMODUS ÄNDERN Zeige als Datei- und Ordnerliste Zeige als Pfadliste - Zeige als Dateisystembaum + Zeige als Dateisystem-Baumstruktur + Ändere URL des Submoduls + Submodul: + URL: Branch auschecken - Commit auschecken - Warnung: Beim Auschecken eines Commits wird dein HEAD losgelöst (detached) sein! - Commit: - Branch: Lokale Änderungen: - Verwerfen - Nichts tun - Stashen & wieder anwenden + 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 - Hautplinie: - Normalerweise ist es nicht möglich einen Merge zu cherry-picken, da unklar ist welche Seite des Merges die Hauptlinie ist. Diese Option ermöglicht es die Änderungen relativ zum ausgewählten Vorgänger zu wiederholen. + 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 Repositories. Optional. + 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. - Übergeordneter Ordner: - Repository URL: + Übergeordnetes Verzeichnis: + Submodule initialisieren und aktualisieren + Repository-URL: SCHLIESSEN Editor - Diesen Commit cherry-picken - Mehrere cherry-picken Commit auschecken - Mit HEAD vergleichen - Mit Worktree vergleichen - Info kopieren - SHA kopieren + Diesen Commit cherry-picken + Mehrere cherry-picken… + Vergleiche mit HEAD + Vergleiche mit Worktree + Autor + Commit-Nachricht + Committer + SHA + Betreff Benutzerdefinierte Aktion - Interactives Rebase von ${0}$ auf diesen Commit - Rebase von ${0}$ auf diesen Commit - Reset ${0}$ auf diesen Commit + 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 - Umformulieren - Als Patch speichern... - Squash in den Vorgänger - Squash Nachfolger Commits bis hier + Als Patch speichern… ÄNDERUNGEN - Änderungen durchsuchen... + geänderte Datei(en) + Änderungen durchsuchen… DATEIEN - LFS DATEI - Submodule + LFS-DATEI + Dateien durchsuchen… + Submodul INFORMATION AUTOR - GEÄNDERT COMMITTER Prüfe Refs, die diesen Commit enthalten COMMIT ENTHALTEN IN - Zeigt nur die ersten 100 Änderungen. Alle Änderungen im ÄNDERUNGEN Tab. + 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 - Details - Repository Einstellungen - COMMIT TEMPLATE - Template Name: - Template Inhalt: + 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: - ${REPO} - Repository Pfad; ${SHA} - SHA-Wert des selektierten Commits + 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 - Email Adresse - Email Adresse + 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) - Standard Remote - Aktivere --prune beim fetchen - Aktiviere --signoff für Commits + Konventionelle Commit-Typen + Standard-Remote + Bevorzugter Merge-Modus TICKETSYSTEM - Beispiel für Github-Regel hinzufügen + 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 - Beispiel für Gitlab Issue Regel einfügen - Beispiel für Gitlab Merge Request einfügen Neue Regel - Ticketnummer Regex-Ausdruck: + 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. + Verwende bitte $1, $2, um auf Regex-Gruppenwerte zuzugreifen. OPEN AI - Bevorzugter Service: - Der ausgewählte 'Bevorzugte Service' wird nur in diesem Repository gesetzt und verwendet. Wenn keiner gesetzt ist und mehrere Servies verfügbar sind wird ein Kontextmenü zur Auswahl angezeigt. - HTTP Proxy - HTTP Proxy für dieses Repository + 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 - Arbeitsplätze + 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 - Zuletzt geöffnete Tabs beim Starten wiederherstellen + 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: - Gültigkeitsbereich: + Geltungsbereich: Kurzbeschreibung: Typ der Änderung: Kopieren - Kopiere gesamten Text + Gesamten Text kopieren + Ganzen Pfad kopieren Pfad kopieren - Dateinamen kopieren - Branch erstellen... + Branch erstellen… Basierend auf: Erstellten Branch auschecken Lokale Änderungen: - Verwerfen - Nichts tun - Stashen & wieder anwenden Neuer Branch-Name: - Branch-Namen eingeben. + Einen Branch-Namen eingeben Lokalen Branch erstellen - Tag erstellen... + Überschreibe existierenden Branch + Tag erstellen… Neuer Tag auf: Mit GPG signieren Anmerkung: @@ -208,288 +296,358 @@ Tag-Name: Empfohlenes Format: v1.0.0-alpha Nach Erstellung auf alle Remotes pushen - Erstelle neuen Tag + Neuen Tag anlegen Art: Mit Anmerkung Ohne Anmerkung - Halte Strg gedrückt, um direkt auszuführen + 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 löscht gerade einen Remote-Branch!!! - Auch Remote-Branch ${0}$ löschen + Du bist dabei, einen Remote-Branch zu löschen!!! Mehrere Branches löschen - Du versuchst mehrere Branches auf einmal zu löschen. Kontrolliere noch einmal vor dem Fortfahren! + 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: - Bestätige löschen von Gruppe - Bestätige löschen von Repository + 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: + Submodul-Pfad: Tag löschen Tag: - Von Remote Repositories löschen + Von Remote-Repositorys löschen BINÄRER VERGLEICH - NEU - ALT - Kopieren - Dateimodus geändert - Ignoriere Leerzeichenänderungen - LFS OBJEKT ÄNDERUNG - Nächste Änderung - KEINE ÄNDERUNG ODER NUR ZEILEN-ENDE ÄNDERUNGEN - Vorherige Änderung + 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 wechseln - Syntax Hervorhebung + Seiten tauschen + Syntax-Hervorhebung Zeilenumbruch - Öffne in Merge Tool + Öffne in Merge-Tool Alle Zeilen anzeigen Weniger Zeilen anzeigen Mehr Zeilen anzeigen - WÄHLE EINE DATEI AUS UM ÄNDERUNGEN ANZUZEIGEN - Öffne in Merge Tool + 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 inkludieren - Insgesamt {0} Änderungen werden verworfen + 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 - Führe benutzerdefinierte Aktion aus - Name der Aktion: - Fast-Forward (ohne Auschecken) + Ziel: + Dieses Repository Fetch Alle Remotes fetchen + Überschreiben lokaler Refs erzwingen Ohne Tags fetchen Remote: Remote-Änderungen fetchen - Als unverändert annehmen - Verwerfen... - Verwerfe {0} Dateien... - Verwerfe Änderungen in ausgewählten Zeilen - Öffne externes Merge Tool - Als Patch speichern... + Als unverändert betrachten + Benutzerdefinierte Aktion + Verwerfen… + Verwerfe {0} Dateien… + Löse mit ${0}$ + Als Patch speichern… Stagen - {0} Dateien stagen - Änderungen in ausgewählten Zeilen stagen - Stash... - {0} Dateien stashen... - Unstage + Stage {0} Dateien + Stashen… + Stashe {0} Dateien… + Unstagen {0} Dateien unstagen - Änderungen in ausgewählten Zeilen unstagen - "Ihre" verwenden (checkout --theirs) - "Meine" verwenden (checkout --ours) - Datei Historie + Meine verwenden (checkout --ours) + Deren verwenden (checkout --theirs) + Dateiverlauf + ÄNDERUNG INHALT - ÄNDERUNGEN - Git-Flow + Git Flow Development-Branch: Feature: - Feature-Prefix: - FLOW - Finish Feature - FLOW - Finish Hotfix - FLOW - Finish Release + Feature-Präfix: + FLOW – Feature fertigstellen + FLOW – Hotfix fertigstellen + FLOW – Release fertigstellen Ziel: + Squash beim Merge Hotfix: - Hotfix-Prefix: - Git-Flow initialisieren + Hotfix-Präfix: + Git Flow initialisieren Branch behalten Production-Branch: Release: - Release-Prefix: - Feature starten... - FLOW - Feature starten - Hotfix starten... - FLOW - Hotfix starten - Name eingeben - Release starten... - FLOW - Release starten - Versions-Tag-Prefix: + 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... + Verfolgungsmuster hinzufügen… Muster ist ein Dateiname Eigenes Muster: Verfolgungsmuster zu Git LFS hinzufügen Fetch - LFS Objekte fetchen - Führt `git lfs fetch` aus um Git LFS Objekte herunterzuladen. Das aktualisiert nicht die Arbeitskopie. - Installiere Git LFS Hooks + 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 + 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 von lokalem Speicher zu löschen + Führt `git lfs prune` aus, um alte LFS-Dateien aus dem lokalen Speicher zu löschen Pull - LFS Objekte pullen - Führt `git lfs pull` aus um alle Git LFS Dasteien für aktuellen Ref & Checkout herunterzuladen + Führt `git lfs pull` aus, um alle Git-LFS-Dateien für aktuellen Ref & Checkout herunterzuladen + LFS-Objekte pullen Push - LFS Objekte pushen - Pushe große Dateien in der Warteschlange zum Git LFS Endpunkt + Pushe große Dateien in der Warteschlange zum Git-LFS-Endpunkt + LFS-Objekte pushen Remote: - Verfolge alle '{0}' Dateien + Verfolge alle ‚{0}‘ Dateien Verfolge alle *{0} Dateien - Verlauf - Wechsle zwischen horizontalem und vertikalem Layout + VERLAUF AUTOR - AUTOR ZEITPUNKT - GRAPH & COMMIT-NACHRICHT + AUTOR-ZEITPUNKT + COMMIT-ZEITPUNKT + VERLAUF & COMMIT-NACHRICHT SHA - COMMIT ZEITPUNKT {0} COMMITS AUSGEWÄHLT - Halte 'Strg' oder 'Umschalt', um mehrere Commits auszuwählen. + SPALTEN ANZEIGEN + Halte ‚Strg‘ oder ‚Umschalt‘, um mehrere Commits auszuwählen. Halte ⌘ oder ⇧, um mehrere Commits auszuwählen TIPPS: - Tastaturkürzel Referenz + Tastaturkürzel-Referenz GLOBAL - Aktuelles Popup schließen - Aktuellen Tab schließen - Zum vorherigen Tab wechseln - Zum nächsten Tab wechseln - Neuen Tab erstellen - Einstellungen öffnen + 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 - Neuen Branch basierend auf ausgewählten Commit erstellen - Ausgewählte Änderungen verwerfen Fetch, wird direkt ausgeführt - Dashboard Modus (Standard) + 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 - Ausgewählte Änderungen stagen/unstagen - Commit-Suchmodus - Wechsle zu 'Änderungen' - Wechsle zu 'Verlauf' - Wechsle zu 'Stashes' - TEXTEDITOR - Suchpanel schließen - Suche nächste Übereinstimmung - Suche vorherige Übereinstimmung - Öffne Suchpanel + Wechsle zu ‚Änderungen‘ + Wechsle zu ‚Verlauf‘ + Wechsle zu ‚Stashes‘ + Verwerfen Stagen Unstagen - Verwerfen Initialisiere Repository Pfad: - Cherry-Pick wird durchgeführt. Drücke 'Abbrechen' um den originalen HEAD wiederherzustellen. - Merge request wird durchgeführt. Drücke 'Abbrechen' um den originalen HEAD wiederherzustellen. - Rebase wird durchgeführt. Drücke 'Abbrechen' um den originalen HEAD wiederherzustellen. - Revert wird durchgeführt. Drücke 'Abbrechen' um den originalen HEAD wiederherzustellen. + 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 - Ziel Branch: + Lokale Änderungen stashen & wieder anwenden Auf: - In Browser öffnen + 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: - Quell-Branch: - Bewege Repository Knoten + 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: - Git wurde NICHT konfiguriert. Gehe bitte zuerst in die [Einstellungen] und konfiguriere Git. - App-Daten Ordner öffnen - Öffne mit... + 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 Seite erstellen - Lesezeichen - Tab schließen - Andere Tabs schließen - Rechte Tabs schließen + Neue Registerkarte erstellen + Registerkarte schließen + Andere Registerkarten schließen + Registerkarten zur Rechten schließen Kopiere Repository-Pfad - Repositories + Bearbeiten + In Arbeitsumgebung verschieben + Aktualisieren + Repositorys Einfügen - Gerade eben - Vor {0} Minuten - Vor {0} Stunden - Gestern Vor {0} Tagen + Vor 1 Stunde + Vor {0} Stunden + Gerade eben Letzter Monat + Letztes Jahr + Vor {0} Minuten Vor {0} Monaten - Leztes Jahr Vor {0} Jahren - Einstellungen - OPEN AI - Analysierung des Diff Befehl - API Schlüssel - Generiere Nachricht Befehl - Name - Server - Modell - DARSTELLUNG - Standardschriftart - Schriftgröße - Standard - Texteditor - Monospace-Schriftart - Verwende die Monospace-Schriftart nur im Texteditor - Design - Design-Anpassungen - Fixe Tab-Breite in Titelleiste - Verwende nativen Fensterrahmen - DIFF/MERGE TOOL - Installationspfad - Installationspfad zum Diff/Merge Tool - Tool - ALLGEMEIN - Beim Starten nach Updates suchen - Sprache - Commit-Historie - Zeige Autor Zeitpunkt anstatt Commit Zeitpunkt - Längenvorgabe für Commit-Nachrichten - GIT - Aktiviere Auto-CRLF - Clone Standardordner - Benutzer Email - Globale Git Benutzer Email - Installationspfad - Benutzername - Globaler Git Benutzername - Git Version - Diese App setzt Git (>= 2.23.0) voraus - GPG SIGNIERUNG - Commit-Signierung - Tag-Signierung - GPG Format - GPG Installationspfad - Installationspfad zum GPG Programm - Benutzer Signierungsschlüssel - GPG Benutzer Signierungsschlüssel - EINBINDUNGEN - SHELL/TERMINAL - Shell/Terminal - Pfad - Remote löschen + 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 löschen - Worktree Informationen in `$GIT_DIR/worktrees` löschen + Worktrees bereinigen + Worktree-Informationen in `$GIT_COMMON_DIR/worktrees` bereinigen Pull Remote-Branch: - Alle Branches fetchen Lokaler Branch: Lokale Änderungen: - Verwerfen - Nichts tun - Stashen & wieder anwenden - Ohne Tags fetchen Remote: Pull (Fetch & Merge) Rebase anstatt Merge verwenden @@ -497,7 +655,10 @@ Sicherstellen, dass Submodule gepusht wurden Erzwinge Push Lokaler Branch: + NEU Remote: + Revision: + Revision zu Remote-Branch pushen Push Remote-Branch: Remote-Branch verfolgen @@ -506,25 +667,26 @@ 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: - Rebase: - Aktualisieren Remote hinzufügen Remote bearbeiten Name: - Remote Name - Repository URL: - Remote Git Repository URL + Remote-Name + Repository-URL: + Remote Git-Repository-URL URL kopieren - Löschen... - Bearbeiten... + Benutzerdefinierte Aktion + Löschen… + Bearbeiten… Fetch Im Browser öffnen Prune - Bestätige das entfernen des Worktrees + Bestätige das Entfernen des Worktrees Aktiviere `--force` Option Ziel: Branch umbenennen @@ -532,121 +694,183 @@ Eindeutiger Name für diesen Branch Branch: ABBRECHEN - Änderungen automatisch von Remote fetchen... + Änderungen automatisch von Remote fetchen… + Sortieren + Nach Commit-Datum + Nach Name Aufräumen (GC & Prune) - Führt `git gc` auf diesem Repository aus. + Führt `git gc` in diesem Repository aus. Filter aufheben - Repository Einstellungen + Löschen + Repository-Einstellungen WEITER Benutzerdefinierte Aktionen Keine benutzerdefinierten Aktionen - Aktiviere '--reflog' Option + Dashboard + Alle Änderungen verwerfen Öffne im Datei-Browser - Suche Branches/Tags/Submodule + Suche Branches / Tags / Submodule + Sichtbarkeit im Verlauf Aufheben - Im Graph ausblenden - Im Graph filtern + Im Verlauf ausblenden + Im Verlauf filtern + LAYOUT + Horizontal + Vertikal + COMMIT-SORTIERUNG + Commit-Zeitpunkt + Topologisch LOKALE BRANCHES + Mehr Optionen… Zum HEAD wechseln - Aktiviere '--first-parent' Option Erstelle Branch + BENACHRICHTIGUNGEN LÖSCHEN + Als Ordner öffnen Öffne in {0} Öffne in externen Tools - Aktualisiern REMOTES - REMOTE HINZUFÜGEN - KONFLIKTE BEHEBEN + Remote hinzufügen + AUFLÖSEN Commit suchen - Dateiname + Autor + Inhalt Commit-Nachricht + Pfad SHA - Autor & Committer Aktueller Branch - Zeige Tags als Baum + 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 + Submodul hinzufügen + Submodul aktualisieren TAGS - NEUER TAG + Neuer Tag + Nach Erstelldatum + Nach Name + Sortieren Öffne im Terminal + Logs ansehen + Öffne ‚{0}‘ im Browser WORKTREES - WORKTREE HINZUFÜGEN - PRUNE - Git Repository URL + Worktree hinzufügen + Prune + Git-Repository-URL Aktuellen Branch auf Revision zurücksetzen Rücksetzmodus: Verschiebe zu: Aktueller Branch: - Zeige im Datei-Explorer + Branch zurücksetzen (ohne Checkout) + Auf: + Branch: + Zeigen im Datei-Explorer Commit rückgängig machen Commit: - Commit Änderungen rückgängig machen - Commit Nachricht umformulieren - Verwende 'Shift+Enter' um eine neue Zeile einzufügen. 'Enter' ist das Kürzel für den OK Button - Bitte warten... + Commit-Änderungen rückgängig machen + In Bearbeitung. Bitte warten… SPEICHERN - Speichern als... + Speichern als… Patch wurde erfolgreich gespeichert! - Durchsuche Repositories - Hauptverzeichnis: - Suche nach Updates... - Neue Version ist verfügbar: - Suche nach Updates fehlgeschlagen! - Download + 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 - Software Update - Es sind momentan kein Updates verfügbar. - Squash Commits - In: - SSH privater Schlüssel: - Pfad zum privaten SSH Schlüssel + 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 - Inklusive nicht-verfolgter Dateien - Behalte gestagte Dateien + Nicht-verfolgte Dateien einbeziehen Name: - Optional. Name dieses Stashes + Optional. Name des Stashes. + Modus: Nur gestagte Änderungen - Gestagte und unstagte Änderungen der ausgewähleten Datei(en) werden gestasht!!! + Gestagte und ungestagte Änderungen der ausgewählten Datei(en) werden gestasht!!! Lokale Änderungen stashen Anwenden + Kopiere Nachricht Entfernen - Anwenden und entfernen + Als Patch speichern… Stash entfernen Entfernen: - Stashes + STASHES ÄNDERUNGEN STASHES Statistiken - COMMITS - COMMITTER + ÜBERSICHT MONAT WOCHE - COMMITS: AUTOREN: - ÜBERSICHT + COMMITS: SUBMODULE Submodul hinzufügen - Relativen Pfad kopieren + BRANCH + Branch + Relativer Pfad + Deinitialisiere Submodul Untergeordnete Submodule fetchen - Öffne Submodul Repository + Verlauf + Verschieben nach + Öffne Submodul-Repository Relativer Pfad: - Relativer Ordner um dieses Submodul zu speichern. + 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 - Tag-Namen kopieren - Tag-Nachricht kopieren - Lösche ${0}$... - Merge ${0}$ in ${1}$ hinein... - Pushe ${0}$... - URL: + 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 - Initialisiere wenn nötig - Rekursiv + Bei Bedarf initialisieren Submodul: - Verwende `--remote` Option + Auf verfolgten Remote-Branch des Submoduls aktualisieren + URL: + Logs + ALLES LÖSCHEN + Kopieren + Löschen Warnung Willkommensseite Erstelle Gruppe @@ -655,46 +879,58 @@ Lösche DRAG & DROP VON ORDNER UNTERSTÜTZT. BENUTZERDEFINIERTE GRUPPIERUNG UNTERSTÜTZT. Bearbeiten - Bewege in eine andere Gruppe - Öffne alle Repositories + Verschiebe in eine andere Gruppe + Öffne alle Repositorys Öffne Repository Öffne Terminal - Clone Standardordner erneut nach Repositories durchsuchen - Suche Repositories... - Sortieren - Änderungen + Standard Klon-Ordner erneut nach Repositorys durchsuchen + Suche Repositorys… + ÄNDERUNGEN Git Ignore Ignoriere alle *{0} Dateien - Ignoriere *{0} Datein im selben Ordner - Ignoriere Dateien im selben Ordner + Ignoriere *{0} Dateien im selben Ordner + Ignoriere nicht-verfolgte Dateien in diesem Ordner Ignoriere nur diese Datei - Amend + 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 - Template/Historie + Vorlage / Historie Klick-Ereignis auslösen + Commit (Bearbeitung) Alle Änderungen stagen und committen - Leerer Commit erkannt! Fortfahren (--allow-empty)? + 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 - DATEI KONFLIKTE GELÖST - NICHT-VERFOLGTE DATEIEN INKLUDIEREN + 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 TEMPLATES + KEINE COMMIT-VORLAGEN + Ohne Überprüfung + Autor zurücksetzen + Abzeichnen GESTAGED UNSTAGEN ALLES UNSTAGEN - UNSTAGED + UNGESTAGED STAGEN ALLES STAGEN - ALS UNVERÄNDERT ANGENOMMENE ANZEIGEN - Template: ${0}$ - Rechtsklick auf selektierte Dateien und wähle die Konfliktlösungen aus. - ARBEITSPLATZ: - Arbeitsplätze konfigurieren... + 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 55fb04d8d..676bd57a0 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -1,89 +1,129 @@ About About SourceGit - • Build with - • Chart is rendered by - © 2024 sourcegit-scm - • TextEditor from - • Monospace fonts come from - • Source code can be found at + Release Date: {0} + Release Notes Opensource & Free Git GUI Client + Add File(s) To Ignore + Pattern: + Storage File: Add Worktree - What to Checkout: - Existing Branch - Create New Branch 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 - Patch - Error - Raise errors and refuses to apply the patch - Error All - Similar to 'error', but shows more - Patch File: + Use + Hide SourceGit + Hide Others + Show All + 3-Way Merge Select .patch file to apply Ignore whitespace changes - No Warn - Turns off the trailing whitespace warning + Source: + File + Clipboard Apply Patch - Warn - Outputs warnings for a few such errors, but applies 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 - REMOVE + 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 THIS FILE IS NOT SUPPORTED!!! + Blame on Previous Revision + Ignore whitespace changes + BLAME ON THIS FILE IS NOT SUPPORTED!!! Checkout ${0}$... - Compare with Branch + Compare selected 2 branches + Compare with... Compare with HEAD - Compare with Worktree + Compare with ${0}$ Copy Branch Name + Create PR... + Create PR for upstream ${0}$... + Custom Action Delete ${0}$... - Delete selected {0} branches - Discard all changes + Delete selected {0} branches... + Edit description for ${0}$... Fast-Forward to ${0}$ - Fetch ${0}$ into ${1}$... - Git Flow - Finish ${0}$ + Fetch ${0}$ into ${1}$ + Git Flow - Finish ${0}$... + Interactively Rebase ${0}$ on ${1}$ Merge ${0}$ into ${1}$... - Pull ${0}$ + Merge selected {0} branches into current... + Pull ${0}$... Pull ${0}$ into ${1}$... - Push ${0}$ + Push ${0}$... Rebase ${0}$ on ${1}$... Rename ${0}$... - Set Tracking Branch - Unset Upstream - Branch Compare - Bytes + 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 This Revision 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 - Checkout Commit - Warning: By doing a commit checkout, your Head will be detached - Commit: - Branch: Local Changes: - Discard - Do Nothing - Stash & Reapply + 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): @@ -95,87 +135,171 @@ 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 - Cherry-Pick This Commit - Cherry-Pick ... - Checkout Commit + Branches + Branches & Tags + Repository Custom Actions + Revision Files + Checkout Commit... + Cherry-Pick Commit... + Cherry-Pick... Compare with HEAD Compare with Worktree - Copy Info - Copy SHA + Author + Author Time + Message + Committer + Committer Time + SHA + Subject Custom Action - Interactive Rebase ${0}$ to Here - Rebase ${0}$ to Here - Reset ${0}$ to Here - Revert Commit - Reword + 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... - Squash Into Parent - Squash Child Commits to Here CHANGES + changed file(s) Search Changes... + Collapse details FILES LFS File + Search Files... Submodule INFORMATION AUTHOR - CHANGED 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 - Enter commit subject - Description + 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 - Template Name: + Built-in parameters: + + ${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: - ${REPO} - Repository's path; ${SHA} - Selected commit's SHA + Built-in parameters: + + ${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 --prune on fetch - Enable --signoff for commit + Enable `--recursive` when auto-updating submodules + Preferred Merge Mode ISSUE TRACKER - Add Sample Github Rule - Add Sample Jira Rule - Add Sample GitLab Issue Rule - Add Sample GitLab Merge Request Rule + 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 - Prefered Service: - If the 'Prefered 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. + 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: @@ -185,18 +309,17 @@ Type of Change: Copy Copy All Text + Copy as Patch + Copy Full Path Copy Path - Copy File Name Create Branch... Based On: Check out the created branch Local Changes: - Discard - Do Nothing - Stash & Reapply New Branch Name: Enter branch name. Create Local Branch + Overwrite existing branch Create Tag... New Tag At: GPG signing @@ -205,21 +328,34 @@ Tag Name: Recommended format: v1.0.0-alpha Push to all remotes after created - Create New Tag + 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!!! - Also delete remote branch ${0}$ Delete Multiple Branches You are trying to delete multiple branches at one time. Be sure to double-check before taking action! + Delete 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 @@ -228,20 +364,27 @@ Tag: Delete from remote repositories BINARY DIFF - NEW - OLD - Copy - File Mode Changed - Ignore Whitespace Change + 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 @@ -250,53 +393,70 @@ Decrease Number of Visible Lines Increase Number of Visible Lines SELECT FILE TO VIEW CHANGES - Open in Merge Tool + Dir History + Has Local Changes + Mismatched with Upstream + Already Up-To-Date Discard Changes All local changes in working copy. Changes: Include ignored files - Total {0} changes will be discard + 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 - Run Custom Action - Action Name: - Fast-Forward (without checkout) + 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... - Discard Changes in Selected Line(s) - Open External Merge Tool + Resolve Using ${0}$ Save as Patch... Stage Stage {0} files - Stage Changes in Selected Line(s) Stash... Stash {0} files... Unstage Unstage {0} files - Unstage Changes in Selected Line(s) - Use Theirs (checkout --theirs) Use Mine (checkout --ours) + Use Theirs (checkout --theirs) File History - CONTENT CHANGE + CONTENT + File mode change: + Deleted file mode: + Directory + Executable + New file mode: + Normal + Submodule + Symlink + Unknown Git-Flow Development Branch: Feature: Feature Prefix: - FLOW - Finish Feature - FLOW - Finish Hotfix - FLOW - Finish Release + Finish ${0}$ + Finish Feature + Finish Hotfix + Finish Release Target: + Rebase before merging + Squash during merge Hotfix: Hotfix Prefix: Initialize Git-Flow @@ -304,13 +464,15 @@ Production Branch: Release: Release Prefix: + Start at: Start Feature... - FLOW - Start Feature + Start Feature Start Hotfix... - FLOW - Start Hotfix + Start Hotfix + Name: Enter name Start Release... - FLOW - Start Release + Start Release Version Tag Prefix: Git LFS Add Track Pattern... @@ -318,8 +480,8 @@ Custom Pattern: Add Track Pattern to Git LFS Fetch - Fetch LFS Objects 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 @@ -327,166 +489,247 @@ 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 - Pull LFS Objects Run `git lfs pull` to download all Git LFS files for current ref & checkout + Pull LFS Objects Push - Push LFS Objects Push queued large files to the Git LFS endpoint + Push LFS Objects Remote: Track files named '{0}' Track all *{0} files - Histories - Switch Horizontal/Vertical Layout + Select Commit + HISTORY AUTHOR AUTHOR TIME + COMMIT TIME GRAPH & SUBJECT SHA - COMMIT TIME + HIGHLIGHTS IN GRAPH + All + Current Branch Only + Current Branch & Selected Commits + Selected Commits Only SELECTED {0} COMMITS - Holding 'Ctrl' or 'Shift' to select multiple commits. - Holding ⌘ or ⇧ to select multiple 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 - Cancel current popup - Close current page - Go to previous page - Go to next page - Create new page - Open preference dialog + 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 - Creates a new branch based on selected commit - Discard selected changes + 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 - Stage/Unstage selected changes - Commit search mode - Switch to 'Changes' - Switch to 'Histories' - Switch to 'Stashes' - TEXT EDITOR - Close search panel - Find next match - Find previous match - Open search panel + Expand/Collapse details panel in `HISTORY` + Switch to 'LOCAL CHANGES' + Switch to 'HISTORY' + Switch to 'STASHES' + Discard Stage Unstage - Discard Initialize Repository + Do you want to run `git init` command under this path? + Open repository failed. Reason: Path: - Cherry-Pick in progress. Press 'Abort' to restore original HEAD. - Merge request in progress. Press 'Abort' to restore original HEAD. - Rebase in progress. Press 'Abort' to restore original HEAD. - Revert in progress. Press 'Abort' to restore original HEAD. + Cherry-Pick in progress. + Processing commit + Merge in progress. + Merging + Rebase in progress. + Stopped at + Revert in progress. + Reverting commit Interactive Rebase - Target Branch: + Stash & reapply local changes + Bypass the pre-rebase hook On: - Open in Browser + 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 Branch: + 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: - Git has NOT been configured. Please to go [Preference] and configure it first. - Open App Data Dir - Open with... + 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 Page - Bookmark + Create New Tab Close Tab Close Other Tabs Close Tabs to the Right Copy Repository Path + Edit + Move to Workspace + Refresh Repositories Paste - Just now - {0} minutes ago - {0} hours ago - Yesterday {0} days ago + 1 hour ago + {0} hours ago + Just now Last month - {0} months ago Last year + {0} minutes ago + {0} months ago {0} years ago - Preference - AI - Analyze Diff Prompt - API Key - Generate Subject Prompt - Model - Name - Server - APPEARANCE - Default Font - Font Size - Default - Editor - Monospace Font - Only use monospace font in text editor - Theme - Theme Overrides - Use fixed tab width in titlebar - Use native window frame - DIFF/MERGE TOOL - Install Path - Input path for diff/merge tool - Tool - GENERAL - Check for updates on startup - Language - History Commits - Show author time intead of commit time in graph - Subject Guide Length - GIT - Enable Auto CRLF - Default Clone Dir - User Email - Global git user email - Install Path - User Name - Global git user name - Git version - Git (>= 2.23.0) is required by this app - GPG SIGNING - Commit GPG signing - Tag GPG signing - GPG Format - Program Install Path - Input path for installed gpg program - User Signing Key - User's gpg signing key - INTEGRATION - SHELL/TERMINAL - Shell/Terminal - Path + 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_DIR/worktrees` + Prune worktree information in `$GIT_COMMON_DIR/worktrees` Pull - Branch: - Fetch all branches + Remote Branch: Into: Local Changes: - Discard - Do Nothing - Stash & Reapply - Fetch without tags Remote: Pull (Fetch & Merge) Use rebase instead of merge @@ -494,7 +737,10 @@ 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 @@ -503,12 +749,17 @@ 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: - Rebase: - Refresh + 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: @@ -516,8 +767,10 @@ Repository URL: Remote git repository URL Copy URL + Custom Action Delete... Edit... + Enable Auto-Fetch Fetch Open In Browser Prune @@ -530,120 +783,192 @@ Branch: ABORT Auto fetching changes from remotes... - Cleanup(GC & Prune) + 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 - Enable '--reflog' Option + 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 - Enable '--first-parent' Option Create Branch + CLEAR NOTIFICATIONS + Open as Folder Open in {0} Open in External Tools - Refresh REMOTES - ADD REMOTE + Add Remote RESOLVE Search Commit - File + Author + Content Message + Path SHA - Author & Committer 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 + Add Submodule + Update Submodule TAGS - NEW TAG + Create Tag + By Creator Date + By Name + Sort Open in Terminal + View Logs + Visit '{0}' in Browser WORKTREES - ADD WORKTREE - PRUNE + Add Worktree + Prune Git Repository URL Reset Current Branch To Revision Reset Mode: Move To: Current Branch: + Reset Branch (Without Checkout) + Move To: + Branch: Reveal in File Explorer Revert Commit Commit: Commit revert changes - Reword Commit Message - Use 'Shift+Enter' to input a new line. 'Enter' is the hotkey of OK button Running. Please wait... SAVE Save As... Patch has been saved successfully! Scan Repositories Root Dir: + 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. - Squash Commits - Into: + 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 - Keep staged files Message: - Optional. Name of this stash - Only staged changes + 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 - Pop + Save as Patch... Drop Stash Drop: - Stashes + STASHES CHANGES STASHES Statistics - COMMITS - COMMITTER + OVERVIEW MONTH WEEK - COMMITS: AUTHORS: - OVERVIEW + COMMITS: SUBMODULES Add Submodule - Copy Relative Path + BRANCH + Branch + Relative Path + De-initialize Fetch nested submodules - Open Submodule Repository + History + Move To + Open Repository Relative Path: Relative folder to store this module. - Delete Submodule + Delete + Set Branch + Change URL + STATUS + modified + not initialized + revision changed + unmerged + Update + URL + Submodule Change Details + OPEN DETAILS OK - Copy Tag Name - Copy Tag Message + 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}$... - URL: Update Submodules All submodules Initialize as needed - Recursively Submodule: - Use --remote option + Update nested submodules + Update to submodule's remote tracking branch + URL: + Logs + CLEAR ALL + Copy + Delete Warning Welcome Page Create Group @@ -658,40 +983,57 @@ Open Terminal Rescan Repositories in Default Clone Dir Search Repositories... - Sort - Changes + LOCAL CHANGES Git Ignore Ignore all *{0} files Ignore *{0} files in the same folder - Ignore 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/Histories + Template/History Trigger click event + Commit (Edit) Stage all changes and commit - Empty commit detected! Do you want to continue (--allow-empty)? + 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 + VIEW ASSUME UNCHANGED Template: ${0}$ - Right-click the selected file(s), and make your choice to resolve conflicts. 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 index f69fc9340..ac2e7d76f 100644 --- a/src/Resources/Locales/es_ES.axaml +++ b/src/Resources/Locales/es_ES.axaml @@ -2,91 +2,132 @@ + Acerca de Acerca de SourceGit - • Construido con - • El gráfico es renderizado por - © 2024 sourcegit-scm - • Editor de texto de - • Las fuentes monoespaciadas provienen de - • El código fuente se puede encontrar en + 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 - Qué Checkout: - Rama Existente - Crear Nueva Rama 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 - Aplicar Patch - Error - Genera errores y se niega a aplicar el patch - Error Todo - Similar a 'error', pero muestra más - Archivo Patch: + 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 - Sin Advertencia - Desactiva la advertencia de espacios en blanco al final - Aplicar Patch - Advertencia - Genera advertencias para algunos de estos errores, pero aplica + 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 - REMOVER + 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 EN ESTE ARCHIVO NO SOPORTADO! + Blame sobre la Revisión Previa + Ignorar cambios de espacios en blanco + ¡BLAME EN ESTE ARCHIVO NO SOPORTADO! Checkout ${0}$... - Comparar con Rama + Comparar las 2 ramas seleccionadas + Comparar con... Comparar con HEAD - Comparar con Worktree - Copiar Nombre de Rama + Comparar con ${0}$ + Copiar Nombre de la Rama + Crear PR... + Crear PR para upstream ${0}$... + Acción personalizada Eliminar ${0}$... Eliminar {0} ramas seleccionadas - Descartar todos los cambios + 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}$... - Establecer Rama de Seguimiento - Desestablecer Upstream - Comparar Ramas - Bytes + 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 Esta Revisión 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 - Checkout Commit - Advertencia: Al hacer un checkout de commit, tu Head se separará - Commit: - Rama: Cambios Locales: - Descartar - No Hacer Nada - Stash & Reaplicar + 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): @@ -98,89 +139,171 @@ 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 ... - Checkout Commit Comparar con HEAD Comparar con Worktree - Copiar Información - Copiar SHA + Autor + Fecha/Hora del Autor + Mensaje + Committer + Fecha/Hora del Committer + SHA + Asunto Acción personalizada - Rebase Interactivo ${0}$ hasta Aquí - Abrir en el Navegador - Copiar Enlace - Rebase ${0}$ hasta Aquí - Reset ${0}$ hasta Aquí + 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 - Reescribir - Guardar como Patch... - Squash en Parent - Squash Commits Hijos hasta Aquí + Guardar como Parche... CAMBIOS + archivo(s) modificado(s) Buscar Cambios... + Colapsar detalles ARCHIVOS Archivo LFS + Buscar Archivos... Submódulo INFORMACIÓN AUTOR - CAMBIADO 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 - Introducir asunto del commit - Descripción + 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 - Nombre de la Plantilla: + 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: - ${REPO} - Ruta del repositorio; ${SHA} - SHA del commit seleccionado + 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 --prune para fetch - Habilitar --signoff para commit + Habilitar `--recursive` cuando se actualicen automáticamente los submódulos + Modo preferido de Merge SEGUIMIENTO DE INCIDENCIAS - Añadir Regla de Ejemplo para Github - Añadir Regla de Ejemplo para Jira + 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. + Servicio Preferido: + Si el 'Servicio Preferido' está establecido, SourceGit sólo lo usará en este repositorio. De lo contrario, si hay más de un servicio disponible, se mostrará un menú de contexto para elegir uno. Proxy HTTP Proxy HTTP utilizado por este repositorio - Nombre de Usuario - Nombre de usuario para este repositorio + 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: @@ -190,18 +313,17 @@ Tipo de Cambio: Copiar Copiar Todo el Texto + Copiar como Parche + Copiar Ruta Completa Copiar Ruta - Copiar Nombre del Archivo Crear Rama... Basado En: Checkout de la rama creada Cambios Locales: - Descartar - No Hacer Nada - Stash & Reaplicar 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 @@ -216,15 +338,28 @@ 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! - También eliminar la rama remota ${0}$ Eliminar Múltiples Ramas - Estás intentando eliminar múltiples ramas a la vez. ¡Asegúrate de revisar antes de tomar acción! + 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 @@ -233,20 +368,27 @@ Etiqueta: Eliminar de los repositorios remotos DIFERENCIA BINARIA - NUEVO - ANTIGUO - Copiar - Modo de Archivo Cambiado + 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 Patch + 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 @@ -255,53 +397,70 @@ Disminuir Número de Líneas Visibles Aumentar Número de Líneas Visibles SELECCIONA ARCHIVO PARA VER CAMBIOS - Abrir en Herramienta de Merge + 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 - Ejecutar Acción Personalizada - Nombre de la Acción: - Fast-Forward (sin checkout) + Destino: + Este repositorio Fetch Fetch todos los remotos + Utilizar opción '--force' Fetch sin etiquetas Remoto: Fetch Cambios Remotos - Asumir sin cambios + Asumir como sin cambios + Acción Personalizada Descartar... Descartar {0} archivos... - Descartar Cambios en Línea(s) Seleccionada(s) - Abrir Herramienta de Merge Externa - Guardar Como Patch... + Resolver usando ${0}$ + Guardar como Parche... Stage Stage {0} archivos - Stage Cambios en Línea(s) Seleccionada(s) Stash... Stash {0} archivos... Unstage Unstage {0} archivos - Unstage Cambios en Línea(s) Seleccionada(s) - Usar Suyos (checkout --theirs) Usar Míos (checkout --ours) + Usar Suyos (checkout --theirs) Historial de Archivos - CONTENIDO 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 @@ -309,10 +468,12 @@ 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 @@ -323,8 +484,8 @@ Patrón Personalizado: Añadir Patrón de Seguimiento a Git LFS Fetch - Fetch Objetos LFS 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 @@ -332,162 +493,247 @@ 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 - Pull Objetos LFS Ejecuta `git lfs pull` para descargar todos los archivos Git LFS para la referencia actual y hacer checkout + Pull Objetos LFS Push - Push Objetos LFS 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 - Cambiar a Disposición Horizontal/Vertical AUTOR HORA DEL AUTOR - GRÁFICO & ASUNTO + FECHA DE COMMIT + GRÁFICO y ASUNTO SHA - FECHA DE COMMIT + 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 - Cancelar popup actual + Clonar repositorio nuevo Cerrar página actual - Ir a la página anterior Ir a la siguiente página + Ir a la página anterior Crear nueva página - Abrir diálogo de preferencias + 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 - Crea una nueva rama basada en el commit seleccionado - Descartar cambios seleccionados + 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 - Stage/Unstage cambios seleccionados - Modo de búsqueda de commits + Expandir/Colapsar el panel de detalles en el `HISTORIAL`/`HISTORY` Cambiar a 'Cambios' Cambiar a 'Historias' Cambiar a 'Stashes' - EDITOR DE TEXTO - Cerrar panel de búsqueda - Buscar siguiente coincidencia - Buscar coincidencia anterior - Abrir panel de búsqueda + Descartar Stage Unstage - Descartar Inicializar Repositorio + ¿Quieres correr el comando `git init` en esta ruta? + Falló la apertura del repositorio. Razón: Ruta: - Cherry-Pick en progreso. Presiona 'Abort' para restaurar el HEAD original. - Merge en progreso. Presiona 'Abort' para restaurar el HEAD original. - Rebase en progreso. Presiona 'Abort' para restaurar el HEAD original. - Revert en progreso. Presiona 'Abort' para restaurar el HEAD original. + 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 - Rama Objetivo: + 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 Con... + Abrir Archivo + Abrir en Herramienta de Merge + Abrir Repositorio Local + Marcador: + Grupo: + Carpeta: Opcional. Crear Nueva Página - Marcador 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 - Justo ahora - Hace {0} minutos - Hace {0} horas - Ayer Hace {0} días + Hace 1 hora + Hace {0} horas + Justo ahora Último mes - Hace {0} meses Último año + Hace {0} minutos + Hace {0} meses Hace {0} años - Preferencias - Opciones Avanzadas - OPEN AI - Analizar Diff Prompt - Clave API - Generar Subject Prompt - Modelo - Nombre - Servidor - APARIENCIA - Fuente por defecto - Fuente Monospace - Usar solo fuente monospace en el editor de texto - Tema - Sobreescritura de temas - Usar ancho de pestaña fijo en la barra de título - Usar marco de ventana nativo - HERRAMIENTA DIFF/MERGE - Ruta de instalación - Introducir ruta para la herramienta diff/merge - Herramienta - GENERAL - Buscar actualizaciones al iniciar - Idioma - Commits en el historial - Mostrar hora del autor en lugar de la hora del commit en el gráfico - Longitud de la guía del asunto - GIT - Habilitar Auto CRLF - Directorio de clonado por defecto - Email de usuario - Email global del usuario git - Ruta de instalación - Nombre de usuario - Nombre global del usuario git - Versión de Git - Se requiere Git (>= 2.23.0) para esta aplicación - FIRMA GPG - Firma GPG en commit - Firma GPG en etiqueta - Formato GPG - Ruta de instalación del programa - Introducir ruta para el programa gpg instalado - Clave de firma del usuario - Clave de firma gpg del usuario - INTEGRACIÓN - SHELL/TERMINAL - Shell/Terminal - Ruta + 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_DIR/worktrees` + Podar información de worktree en `$GIT_COMMON_DIR/worktrees` Pull - Rama: - Fetch todas las ramas + Rama Remota: En: Cambios Locales: - Descartar - No Hacer Nada - Stash & Reaplicar - Fetch sin etiquetas Remoto: Pull (Fetch & Merge) Usar rebase en lugar de merge @@ -495,7 +741,10 @@ 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 @@ -504,12 +753,17 @@ 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 & reaplicar cambios locales + Stash y volver a aplicar cambios locales + Saltar el hook de pre-rebase En: - Rebase: - Refrescar + 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: @@ -517,8 +771,10 @@ URL del Repositorio: URL del repositorio git remoto Copiar URL + Acción Personalizada Borrar... Editar... + Habilitar Auto-Fetch Fetch Abrir En Navegador Podar (Prune) @@ -531,40 +787,71 @@ 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 - Habilitar Opción '--reflog' + 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 - Habilitar Opción '--first-parent' Crear Rama + LIMPIAR NOTIFICACIONES + Abrir como Carpeta Abrir en {0} Abrir en Herramientas Externas - Refrescar REMOTOS AÑADIR REMOTO RESOLVER Buscar Commit - Archivo + Autor + Contenido Mensaje + Ruta SHA - Autor & Committer 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 @@ -573,75 +860,119 @@ Modo de Reset: Mover a: Rama Actual: + Resetear Rama (Sin hacer Checkout) + Mover A: + Rama: Revelar en el Explorador de Archivos Revertir Commit Commit: Commit revertir cambios - Reescribir Mensaje de Commit - Usa 'Shift+Enter' para introducir una nueva línea. 'Enter' es el atajo del botón OK Ejecutando. Por favor espera... GUARDAR Guardar Como... - ¡El patch se ha guardado exitosamente! + ¡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. - Squash Commits - En: + 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 no rastreados - Mantener archivos staged + Incluir archivos sin rastrear Mensaje: - Opcional. Nombre de este stash + 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 - Pop + Guardar como Parche... Eliminar Stash Eliminar: Stashes CAMBIOS STASHES Estadísticas - COMMITS - COMMITTER + GENERAL MES SEMANA - COMMITS: AUTORES: - GENERAL + COMMITS: SUBMÓDULOS Añadir Submódulo - Copiar Ruta Relativa + 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 - Copiar Nombre de la Etiqueta - Copiar Mensaje de la Etiqueta + 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}$... - Merge ${0}$ en ${1}$... - Push ${0}$... - URL: + 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 - Recursivamente Submódulo: - Usar opción --remote + 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 @@ -656,40 +987,56 @@ Abrir Terminal Reescanear Repositorios en el Directorio de Clonado por Defecto Buscar Repositorios... - Ordenar Cambios Git Ignore Ignorar todos los archivos *{0} Ignorar archivos *{0} en la misma carpeta - Ignorar archivos en la misma carpeta + Ignorar archivos sin rastrear en esta carpeta Ignorar solo este archivo - Enmendar (Amend) - Puedes stagear este archivo ahora. + 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 & PUSH + COMMIT y PUSH Plantilla/Historias Activar evento de clic - Stagear todos los cambios y commit - ¡Commit vacío detectado! ¿Quieres continuar (--allow-empty)? + 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 - INCLUIR ARCHIVOS NO RASTREADOS + 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 ASSUME UNCHANGED + VER ASUMIR COMO SIN CAMBIOS Plantilla: ${0}$ - Haz clic derecho en el(los) archivo(s) seleccionado(s) y elige tu opción para resolver conflictos. 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 index 75cd4d58a..a094fb2ae 100644 --- a/src/Resources/Locales/fr_FR.axaml +++ b/src/Resources/Locales/fr_FR.axaml @@ -2,181 +2,297 @@ + À propos À propos de SourceGit - • Compilé avec - • Le graphique est rendu par - © 2024 sourcegit-scm - • TextEditor de - • Les polices Monospace proviennent de - • Le code source est disponible sur + 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 - What to Checkout: - Créer une nouvelle branche - Branche existante 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 - Appliquer - Erreur - Soulever les erreurs et refuser d'appliquer le patch - Toutes les erreurs - Similaire à 'Erreur', mais plus détaillé - Fichier de patch : + Utiliser + Masquer SourceGit + Masquer les autres + Tout Afficher + Fusion à 3 voies Selectionner le fichier .patch à appliquer Ignorer les changements d'espaces blancs - Pas d'avertissement - Désactiver l'avertissement sur les espaces blancs terminaux Appliquer le patch - Avertissement - Affiche des avertissements pour ce type d'erreurs tout en appliquant 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 - SUPPRIMER + 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 - LE BLÂME SUR CE FICHIER N'EST PAS SUPPORTÉ!!! - Checkout ${0}$... - Comparer avec la branche + 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 le worktree + 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 - Rejeter tous les changements + 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}$... - Définir la branche de suivi - Ne plus suivre la branche distante - Comparer les branches - Octets + 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 - Checkout Branch - Checkout ce commit - Commit : - Avertissement: un checkout vers un commit aboutiera vers un HEAD détaché - Branche : + Changer l'URL du sous-module + Sous-module : + URL : + Récupérer la branche Changements locaux : - Annuler - Ne rien faire - Mettre en stash et réappliquer + 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 : - Cherry Pick + 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 - Changer de commit + 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 - Copier les informations - Copier le SHA + Auteur + Message + Committer + SHA + Sujet Action personnalisée - Rebase interactif de ${0}$ ici - Rebaser ${0}$ ici - Réinitialiser ${0}$ ici + 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 - Reformuler Enregistrer en tant que patch... - Squash dans le parent - Squash les commits enfants ici CHANGEMENTS + fichier(s) modifié(s) Rechercher les changements... + Réduire les détails FICHIERS Fichier LFS + Rechercher des fichiers... Sous-module INFORMATIONS AUTEUR - CHANGÉ 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 - Entrez le message du commit - Description + 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 - Nom de modèle: + 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 : - ${REPO} - Chemin du repository; ${SHA} - SHA du commit sélectionné + 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 - Repository + 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 - Activer --prune pour fetch - Activer --signoff pour commit + Mode de fusion préféré SUIVI DES PROBLÈMES - Ajouter une règle d'exemple Github - Ajouter une règle d'exemple Jira + 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é. + 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 : @@ -186,18 +302,16 @@ Type de Changement : Copier Copier tout le texte - Copier le nom de fichier + Copier le chemin complet Copier le chemin Créer une branche... Basé sur : - Passer à la branche créée + Récupérer la branche créée Changements locaux : - Rejeter - Ne rien faire - Stash & Réappliquer 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 @@ -212,15 +326,26 @@ 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 également la branche distante ${0}$ Supprimer plusieurs branches Vous essayez de supprimer plusieurs branches à la fois. Assurez-vous de revérifier avant de procéder ! + Supprimer 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 @@ -229,20 +354,26 @@ Tag : Supprimer des dépôts distants DIFF BINAIRE - NOUVEAU - ANCIEN - Copier - Mode de fichier changé + Première différence Ignorer les changements d'espaces + 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 @@ -251,45 +382,50 @@ Réduit le nombre de ligne visibles Augmente le nombre de ligne visibles SÉLECTIONNEZ UN FICHIER POUR VOIR LES CHANGEMENTS - Ouvrir dans l'outil de fusion + 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é - Lancer action personnalisée - Nom de l'action : - Fast-Forward (sans checkout) + 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... - Rejeter les changements dans les lignes sélectionnées - Ouvrir l'outil de fusion externe + Résoudre en utilisant ${0}$ Enregistrer en tant que patch... Indexer Indexer {0} fichiers - Indexer les changements dans les lignes sélectionnées Stash... Stash {0} fichiers... Désindexer Désindexer {0} fichiers - Désindexer les changements dans les lignes sélectionnées Utiliser les miennes (checkout --ours) Utiliser les leurs (checkout --theirs) Historique du fichier - CONTENU MODIFICATION + CONTENU Git-Flow Branche de développement : Feature: @@ -298,6 +434,7 @@ FLOW - Terminer Hotfix FLOW - Terminer Release Cible: + Squash lors de la fusion Hotfix: Hotfix Prefix: Initialiser Git-Flow @@ -319,8 +456,8 @@ Pattern personnalisé : Ajouter un pattern de suivi à Git LFS Fetch - Fetch les objets LFS 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 @@ -328,187 +465,267 @@ 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 - Prune + 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 - Lancer `git lfs pull` pour télécharger tous les fichier Git LFS de la référence actuelle & checkout - Push - Push 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 - Basculer entre dispositions Horizontal/Vertical AUTEUR HEURE DE L'AUTEUR + HEURE DE COMMIT GRAPHE & SUJET SHA - HEURE DE COMMIT + 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 - Annuler le popup en cours + Cloner un nouveau dépôt Fermer la page en cours - Aller à la page précédente Aller à la page suivante + Aller à la page précédente Créer une nouvelle page - Ouvrir le dialogue des préférences + 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 basée sur le commit actuel - Rejeter les changements sélectionnés + 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 - Ajouter/Retirer les changements sélectionnés de l'index - Recherche de commit + Afficher/Masquer le panneau de détails du panneau`HISTORIQUE` Basculer vers 'Changements' Basculer vers 'Historique' Basculer vers 'Stashes' - ÉDITEUR DE TEXTE - Fermer le panneau de recherche - Trouver la prochaine correspondance - Trouver la correspondance précédente - Ouvrir le panneau de recherche - Stage - Retirer de l'index 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. Appuyer sur 'Abort' pour restaurer le HEAD d'origine. - Merge request in progress. Appuyer sur 'Abort' pour restaurer le HEAD d'origine. - Rebase in progress. Appuyer sur 'Abort' pour restaurer le HEAD d'origine. - Revert in progress. Appuyer sur 'Abort' pour restaurer le HEAD d'origine. + 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 - Branche cible : + Stash & réappliquer changements locaux + Contourner le hook de pre-rebase Sur : - Ouvrir dans le navigateur + 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: - Branche source : + 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 avec... + Ouvrir un fichier + Ouvrir dans l'outil de fusion + Ouvrir un dépôt local + Signet : + Groupe : + Dossier : Optionnel. Créer un nouvel onglet - Bookmark Fermer l'onglet Fermer les autres onglets Fermer les onglets à droite - Copy Repository Path + Copier le chemin vers le dépôt + Éditer + Se déplacer vers l'espace de travail + Actualiser Dépôts - Paste - A l'instant - il y a {0} minutes - il y a {0} heures - Hier + Coller il y a {0} jours + il y a 1 heure + il y a {0} heures + A l'instant Le mois dernier - il y a {0} mois L'an dernier + il y a {0} minutes + il y a {0} mois il y a {0} ans - Préférences - IA - Analyser Diff Prompt - Clé d'API - Générer le sujet de Prompt - Modèle - Nom - Serveur - APPARENCE - Police par défaut - Taille de police par défaut - Taille de police de l'éditeur - Police monospace - N'utiliser que des polices monospace pour l'éditeur de texte - Thème - Dérogations de thème - Utiliser des onglets de taille fixe dans la barre de titre - Utiliser un cadre de fenêtre natif - OUTIL DIFF/MERGE - Chemin d'installation - Saisir le chemin d'installation de l'outil diff/merge - Outil - GÉNÉRAL - Vérifier les mises à jour au démarrage - Language - Historique de commits - Afficher l'heure de l'auteur au lieu de l'heure de validation dans le graphique - Guide de longueur du sujet - GIT - Activer auto CRLF - Répertoire de clônage par défaut - E-mail utilsateur - E-mail utilsateur global - Chemin d'installation - Nom d'utilisateur - Nom d'utilisateur global - Version de Git - Cette application requière Git (>= 2.23.0) - SIGNATURE GPG - Signature GPG de commit - Signature GPG de tag - Format GPG - Chemin d'installation du programme - Saisir le chemin d'installation vers le programme GPG - Clé de signature de l'utilisateur - Clé de signature GPG de l'utilisateur - INTEGRATION - SHELL/TERMINAL - Shell/Terminal - Chemin - Élaguer une branche distant + 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_DIR/worktrees` + Élaguer les information de worktree dans `$GIT_COMMON_DIR/worktrees` Pull - Branche : - Fetch toutes les branches + Branche distante : Dans : Changements locaux : - Rejeter - Ne rien faire - Stash & Réappliquer - Fetch sans les tags Dépôt distant : Pull (Fetch & Merge) Utiliser rebase au lieu de merge - Push + Pousser Assurez-vous que les submodules ont été poussés - Force push + 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 - Push tous les tags - Push Tag To Remote - Push tous les dépôts distants + 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 : - Rebase : - Rafraîchir Ajouter dépôt distant Modifier dépôt distant Nom : @@ -516,129 +733,202 @@ 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 - Prune + Elaguer Confirmer la suppression du Worktree Activer l'option `--force` Cible : - la branche + Renommer la branche Nouveau nom : Nom unique pour cette branche Branche : ABORT Fetch automatique des changements depuis les dépôts... - Nettoyage(GC & Prune) + 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 - Activer l'option '--reflog' + 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 - Activer l'option '--first-parent' Créer une branche + EFFACER LES NOTIFICATIONS + Ouvrir comme dossier Ouvrir dans {0} Ouvrir dans un outil externe - Rafraîchir DEPOTS DISTANTS AJOUTER DEPOT DISTANT - RESOUDRE + RÉSOUDRE Rechercher un commit - Fichier + Auteur + Contenu Message + Chemin SHA - Auteur & Committer 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 - PRUNE + 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 - Revert le Commit + Annuler le Commit Commit : - Commit les changements du revert - Reformuler le message de commit - Utiliser 'Maj+Entrée' pour insérer une nouvelle ligne. 'Entrée' est la touche pour valider + 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. - Squash Commits - Dans : - SSH Private Key: - Private SSH key store path + 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 - Include untracked files - Garder les fichiers staged + Inclure les fichiers non-suivis Message : - Optionnel. Nom de ce stash - Seulement les changements staged - Les modifications staged et unstaged des fichiers sélectionnés seront stockées!!! + 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 - Extraire + Sauver comme Patch... Effacer le Stash Effacer : Stashes CHANGEMENTS STASHES - Statistics - COMMITS - COMMITTER - MONTH - WEEK - COMMITS: - AUTHORS: + Statistiques APERCU - SUBMODULES - Add Submodule - Copy Relative Path - Fetch nested submodules - Open Submodule Repository - Relative Path: - Relative folder to store this module. - Delete Submodule + 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 - Copy Tag Name - Copier le message du tag - Delete ${0}$... - Fusionner ${0}$ dans ${1}$... - Push ${0}$... - URL : + 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 - Récursivement Sous-module : - Utiliser l'option --remote + 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 @@ -653,40 +943,55 @@ Ouvrir le terminal Réanalyser les repositories dans le dossier de clonage par défaut Chercher des dépôts... - Trier Changements Git Ignore Ignorer tous les *{0} fichiers Ignorer *{0} fichiers dans le même dossier - Ignorer les fichiers dans le même dossier + 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 & PUSH + COMMIT & POUSSER Modèles/Historiques Trigger click event - Stage tous les changements et commit - Un commit vide a été détecté ! Voulez-vous continuer (--allow-empty) ? + 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 + VOIR LES FICHIERS PRÉSUMÉS INCHANGÉS Modèle: ${0}$ - Faites un clique droit sur les fichiers sélectionnés et faites vos choix pour la résoluion des conflits. 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 index a4f203107..8155dc647 100644 --- a/src/Resources/Locales/pt_BR.axaml +++ b/src/Resources/Locales/pt_BR.axaml @@ -2,130 +2,115 @@ - - - • Construído com - • Gráfico desenhado por - © 2024 sourcegit-scm - • Editor de Texto de - • Fontes monoespaçadas de + Sobre Sobre o SourceGit - • Código-fonte pode ser encontrado em + Data de Lançamento + Notas de Lançamento Cliente Git GUI Livre e de Código Aberto - Sobre - Caminho para este worktree. Caminho relativo é suportado. + Adicionar Arquivo(s) ao Ignore + Padrão: + Salvar arquivo: + Adicionar Worktree Localização: - Opcional. O padrão é o nome da pasta de destino. + Caminho para este worktree. Caminho relativo é suportado. Nome do Branch: - Rastreando branch remoto + Opcional. O padrão é o nome da pasta de destino. Rastrear Branch: + Rastreando branch remoto + O que Checar: Criar Novo Branch Branch Existente - O que Checar: - Adicionar Worktree Assietente IA + Modelo + Gerar novamente Utilizar IA para gerar mensagem de commit - Erros levantados e se recusa a aplicar o patch - Erro - Semelhante a 'erro', mas mostra mais - Erro Total + Esconder SourceGit + Mostrar Todos Selecione o arquivo .patch para aplicar - Arquivo de Patch: Ignorar mudanças de espaço em branco - Desativa o aviso de espaço em branco no final - Sem Aviso Aplicar Patch - Emite avisos para alguns erros, mas aplica - Aviso Espaço em Branco: - Patch - Selecione o caminho do arquivo de arquivo + 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 - Arquivar... SourceGit Askpass - NENHUM ARQUIVO CONSIDERADO SEM ALTERAÇÕES - REMOVER + 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 - BLAME NESTE ARQUIVO NÃO É SUPORTADO!!! + Ignorar espaços em branco + BLAME NESSE ARQUIVO NÃO É SUPORTADO!!! Checkout ${0}$... - Comparar com Branch + Comparar ambos os branches selecionados + Comparar com... Comparar com HEAD - Comparar com Worktree Copiar Nome do Branch + Criar PR... + Criar PR para o upstream ${0}$... + Ação personalizada Excluir ${0}$... Excluir {0} branches selecionados - Descartar todas as alterações + 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}$... - Definir Branch de Rastreamento - Remover Upstream - Comparação de Branches - Bytes + 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 - ALTERAR MODO DE EXIBIÇÃO - Commit: - Aviso: Ao fazer o checkout de um commit, seu Head ficará desanexado - Checkout Commit - Descartar - Nada - Stash & Reaplicar + Checkout Branch Alterações Locais: Branch: - Checkout 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. - Cherry-Pick - Você está tentando limpar todas as stashes. Tem certeza que deseja continuar? Limpar Stashes - Argumentos adicionais para clonar o repositório. Opcional. + Você está tentando limpar todas as stashes. Tem certeza que deseja continuar? + Clonar Repositório Remoto Parâmetros Extras: - Nome do repositório. Opcional. + Argumentos adicionais para clonar o repositório. Opcional. Nome Local: + Nome do repositório. Opcional. Pasta Pai: URL do Repositório: - Clonar Repositório Remoto FECHAR Editor Checar Commit @@ -133,24 +118,17 @@ Cherry-Pick ... Comparar com HEAD Comparar com Worktree - Copiar Informações - Copiar SHA + SHA Ação customizada - Rebase Interativo ${0}$ até Aqui - Rebase ${0}$ até Aqui - Resetar ${0}$ até Aqui Reverter Commit - Modificar Mensagem Salvar como Patch... - Mesclar ao Commit Pai - Mesclar commits filhos para este - Buscar Alterações... ALTERAÇÕES + Buscar Alterações... + ARQUIVOS Arquivo LFS Submódulo - ARQUIVOS + INFORMAÇÃO AUTOR - ALTERADO COMMITTER Verificar referências que contenham este commit COMMIT EXISTE EM @@ -159,16 +137,14 @@ PAIS REFERÊNCIAS SHA - INFORMAÇÃO Abrir no navegador - Descrição - Insira o assunto do commit + Comparação + Configurar Repositório + TEMPLATE DE COMMIT Conteúdo do Template: Nome do Template: - TEMPLATE DE COMMIT AÇÃO CUSTOMIZADA Argumentos: - ${REPO} - Caminho do repositório; ${SHA} - SHA do commit selecionado Caminho do executável: Nome: Escopo: @@ -176,32 +152,31 @@ Repositório Endereço de email Endereço de email + GIT Buscar remotos automaticamente Minuto(s) Remoto padrão - Habilita --prune ao buscar - Habilita --signoff para commits - GIT - Adicionar Regra de Exemplo do Github - Adicionar Regra de Exemplo do Jira + 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: - Por favor, use $1, $2 para acessar os valores de grupos do regex. URL de Resultado: - RASTREADOR DE PROBLEMAS + 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 usado por este repositório + 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 - Nome de usuário para este repositório + Proxy HTTP usado por este repositório Nome de Usuário - Configurar Repositório + Nome de usuário para este repositório Workspaces Cor + Nome Restaurar abas ao inicializar Assistente de Conventional Commit Breaking Change: @@ -212,53 +187,47 @@ Tipo de mudança: Copiar Copiar todo o texto - Copiar Nome do Arquivo Copiar Caminho + Criar Branch... Baseado Em: Checar o branch criado - Descartar - Não Fazer Nada - Guardar & Reaplicar Alterações Locais: - Insira o nome do branch. Nome do Novo Branch: + Insira o nome do branch. Criar Branch Local - Criar Branch... + Criar Tag... Nova Tag Em: Assinatura GPG - Opcional. Mensagem da Tag: - Formato recomendado: v1.0.0-alpha + 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 - Tipo: - Criar Tag... Pressione Ctrl para iniciar diretamente Recortar + Descartar + Nada + Stash & Reaplicar + Excluir Branch Branch: Você está prestes a excluir uma branch remota!!! - Também excluir branch remoto ${0}$ - Excluir Branch - Você está tentando excluir vários branches de uma vez. Certifique-se de verificar antes de agir! Excluir Múltiplos Branches - Remoto: + 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 - Caminho do Submódulo: Excluir Submódulo + Caminho do Submódulo: + Excluir Tag Tag: Excluir dos repositórios remotos - Excluir Tag - NOVO - ANTIGO DIFERENÇA BINÁRIA - Copiar - Modo de Arquivo Alterado Ignorar mudanças de espaço em branco MUDANÇA DE OBJETO LFS Próxima Diferença @@ -267,8 +236,8 @@ Salvar como um Patch Exibir símbolos ocultos Diferença Lado a Lado - NOVO SUBMÓDULO + NOVO Trocar Realce de Sintaxe Quebra de Linha @@ -277,45 +246,38 @@ Diminuir Número de Linhas Visíveis Aumentar Número de Linhas Visíveis SELECIONE O ARQUIVO PARA VISUALIZAR AS MUDANÇAS - Abrir na Ferramenta de Mesclagem + Descartar Alterações Todas as alterações locais na cópia de trabalho. Alterações: Incluir arquivos ignorados Um total de {0} alterações será descartado Você não pode desfazer esta ação!!! - Descartar Alterações Favorito: Novo Nome: Alvo: Editar Grupo Selecionado Editar Repositório Selecionado - Executar ação customizada - Nome da ação: - Fast-Forward (sem checkout) + Buscar Buscar todos os remotos Buscar sem tags Remoto: Buscar Alterações Remotas - Buscar Assumir não alterado Descartar... Descartar {0} arquivos... - Descartar Alterações nas Linhas Selecionadas - Abrir Ferramenta de Mesclagem Externa Salvar Como Patch... Preparar Preparar {0} arquivos - Preparar Alterações nas Linhas Selecionadas Stash... Stash {0} arquivos... Desfazer Preparação Desfazer Preparação de {0} arquivos - Desfazer Preparação nas Linhas Selecionadas Usar Meu (checkout --ours) Usar Deles (checkout --theirs) Histórico de Arquivos - CONTEUDO MUDANÇA + CONTEUDO + Git-Flow Branch de Desenvolvimento: Feature: Prefixo da Feature: @@ -338,110 +300,100 @@ Iniciar Release... FLOW - Iniciar Release Prefixo da Tag de Versão: - Git-Flow + Git LFS + Adicionar Padrão de Rastreamento... Padrão é nome do arquivo Padrão Personalizado: Adicionar Padrão de Rastreamento ao Git LFS - Adicionar Padrão de Rastreamento... + Buscar Execute `git lfs fetch` para baixar objetos Git LFS. Isso não atualiza a cópia de trabalho. Buscar Objetos LFS - Buscar Instalar hooks do Git LFS + Exibir bloqueios Sem Arquivos Bloqueados Bloquear Exibir apenas meus bloqueios Bloqueios LFS Desbloquear Forçar Desbloqueio - Exibir bloqueios - Execute `git lfs prune` para excluir arquivos LFS antigos do armazenamento local 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 - Puxar + Enviar Envie arquivos grandes enfileirados para o endpoint Git LFS Enviar Objetos LFS - Enviar Remoto: Rastrear arquivos nomeados '{0}' Rastrear todos os arquivos *{0} - Git LFS - Alternar Layout Horizontal/Vertical + Históricos AUTOR DATA DO AUTOR + HORA DO COMMIT GRÁFICO & ASSUNTO SHA - HORA DO COMMIT SELECIONADO {0} COMMITS + Segure 'Ctrl' ou 'Shift' para selecionar múltiplos commits. Segure ⌘ ou ⇧ para selecionar múltiplos commits. DICAS: - Segure 'Ctrl' ou 'Shift' para selecionar múltiplos commits. - Históricos - Cancelar popup atual + 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 - GLOBAL + Abrir diálogo de preferências + REPOSITÓRIO Commitar mudanças preparadas Commitar e enviar mudanças preparadas Preparar todas as mudanças e commitar - Cria um novo branch partindo do commit selecionado - Descartar mudanças selecionadas Buscar, imediatamente Modo de Dashboard (Padrão) Modo de busca de commits Puxar, imediatamente Enviar, imediatamente Forçar recarregamento deste repositório - Preparar/Despreparar mudanças selecionadas Alternar para 'Mudanças' Alternar para 'Históricos' Alternar para 'Stashes' - REPOSITÓRIO - Fechar painel de busca - Encontrar próxima correspondência - Encontrar correspondência anterior - Abrir painel de busca - EDITOR DE TEXTO - Referência de Atalhos de Teclado Descartar Preparar Despreparar - Caminho: Inicializar Repositório - Cherry-Pick em andamento. Pressione 'Abort' para restaurar o HEAD original. - Merge em andamento. Pressione 'Abort' para restaurar o HEAD original. - Rebase em andamento. Pressione 'Abort' para restaurar o HEAD original. - Revert em andamento. Pressione 'Abort' para restaurar o HEAD original. + Caminho: + Cherry-Pick em andamento. + Merge em andamento. + Rebase em andamento. + Revert em andamento. + Rebase Interativo + Guardar & reaplicar alterações locais Em: Ramo Alvo: - Rebase Interativo Copiar link Abrir no navegador ERRO AVISO + Mesclar Ramo Para: Opção de Mesclagem: - Ramo de Origem: Mover nó do repositório Selecionar nó pai para: - Mesclar Ramo Nome: O Git NÃO foi configurado. Por favor, vá para [Preferências] e configure primeiro. Abrir Pasta de Dados do Aplicativo - Abrir Com... + Abrir na Ferramenta de Mesclagem Opcional. Criar Nova Página - Adicionar aos Favoritos 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 @@ -450,70 +402,65 @@ {0} meses atrás {0} anos atrás Ontem - Prompt para Analisar Diff - Chave da API - Prompt para Gerar Título - Modelo - Nome - Servidor - INTELIGÊNCIA ARTIFICIAL - Fonte Padrão - Fonte Monoespaçada - Usar fonte monoespaçada apenas no editor de texto - Tema - Substituições de Tema - Usar largura fixa de aba na barra de título - Usar moldura de janela nativa - APARÊNCIA - Insira o caminho para a ferramenta de diff/merge - Caminho de Instalação - Ferramenta - FERRAMENTA DE DIFF/MERGE - Verificar atualizações na inicialização - Idioma - Commits do Histórico - Exibir data do autor em vez da data do commit no gráfico - Comprimento do Guia de Assunto - GERAL - Habilitar Auto CRLF - Diretório de Clone Padrão - Email global do usuário git - Email do Usuário - Git (>= 2.23.0) é necessário para este aplicativo - Caminho de Instalação - Nome global do usuário git - Nome do Usuário - Versão do Git - GIT - Assinatura GPG de commit - Formato GPG - Insira o caminho para o programa gpg instalado - Caminho de Instalação do Programa - Assinatura GPG de tag - Chave de assinatura gpg do usuário - Chave de Assinatura do Usuário - ASSINATURA GPG - INTEGRAÇÃO - Caminho - Shell/Terminal - SHELL/TERMINAL - Preferências - Alvo: + 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 - Podar informações de worktree em `$GIT_DIR/worktrees` + Alvo: Podar Worktrees - Branch: - Buscar todos os branches + Podar informações de worktree em `$GIT_COMMON_DIR/worktrees` + Puxar + Branch Remoto: Para: - Descartar - Não Fazer Nada - Guardar & Reaplicar Alterações Locais: - Buscar sem tags Remoto: Puxar (Buscar & Mesclar) Usar rebase em vez de merge - Puxar + Empurrar Certifica de que submodules foram enviadas Forçar push Branch Local: @@ -522,36 +469,33 @@ Branch Remoto: Definir como branch de rastreamento Empurrar todas as tags - Empurrar + Empurrar Tag para o Remoto Empurrar para todos os remotos Remoto: Tag: - Empurrar Tag para o Remoto Sair + Rebase da Branch Atual Guardar & reaplicar alterações locais Em: - Rebase: - Rebase da Branch Atual - Atualizar Adicionar Remoto Editar Remoto - Nome do remoto Nome: - URL do repositório git remoto + 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: - Confirmar Remoção de Worktree - Nome único para este branch + Renomear Branch Novo Nome: + Nome único para este branch Branch: - Renomear Branch ABORTAR Buscando automaticamente mudanças dos remotos... Limpar (GC & Podar) @@ -561,111 +505,101 @@ CONTINUAR Ações customizada Nenhuma ação customizada - Habilitar opção '--reflog' + Descartar todas as alterações Abrir no Navegador de Arquivos Pesquisar Branches/Tags/Submódulos - Habilitar opção '--first-parent' + 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 - Atualizar - ADICIONAR REMOTO REMOTOS - RESOLVER - Arquivo + ADICIONAR REMOTO + Pesquisar Commit + Autor Mensagem SHA - Autor & Committer Branch Atual - Pesquisar Commit Exibir Tags como Árvore Estatísticas + SUBMÓDULOS ADICIONAR SUBMÓDULO ATUALIZAR SUBMÓDULO - SUBMÓDULOS - NOVA TAG TAGS + NOVA TAG Abrir no Terminal + WORKTREES ADICIONAR WORKTREE PODAR - WORKTREES URL do Repositório Git + Resetar Branch Atual para Revisão Modo de Reset: Mover Para: Branch Atual: - Resetar Branch Atual para Revisão Revelar no Explorador de Arquivos + Reverter Commit Commit: Commitar alterações de reversão - Reverter Commit - Use 'Shift+Enter' para inserir uma nova linha. 'Enter' é a tecla de atalho do botão OK - Reescrever Mensagem do Commit Executando. Por favor, aguarde... SALVAR Salvar Como... Patch salvo com sucesso! - Diretório Raiz: 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. - Verificar atualizações... - Squash Commits - Squash commits em: - Caminho para a chave SSH privada + Copiar SHA Chave SSH Privada: + Caminho para a chave SSH privada INICIAR + Stash Incluir arquivos não rastreados - Manter arquivos em stage - Opcional. Nome deste stash 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 - Stash Aplicar Descartar - Pop - Descartar: Descartar Stash + Descartar: + Stashes ALTERAÇÕES STASHES - Stashes - COMMITS - COMMITTER + Estatísticas VISÃO GERAL MÊS SEMANA AUTORES: COMMITS: - Estatísticas + SUBMÓDULOS Adicionar Submódulo - Copiar Caminho Relativo + Caminho Relativo Buscar submódulos aninhados Abrir Repositório do Submódulo - Pasta relativa para armazenar este módulo. Caminho Relativo: + Pasta relativa para armazenar este módulo. Excluir Submódulo - SUBMÓDULOS OK - Copiar Nome da Tag - Copiar mensage da Tag Excluir ${0}$... - Mesclar ${0}$ em ${1}$... Enviar ${0}$... + Atualizar Submódulos Todos os submódulos Inicializar conforme necessário - Recursivamente Submódulo: - Usar opção --remote - Atualizar Submódulos URL: Aviso + Página de Boas-vindas Criar Grupo Raíz Criar Subgrupo Clonar Repositório @@ -678,13 +612,11 @@ Abrir Terminal Reescanear Repositórios no Diretório de Clone Padrão Buscar Repositórios... - Ordenar - Página de Boas-vindas + Alterações + Git Ignore Ignorar todos os arquivos *{0} Ignorar arquivos *{0} na mesma pasta - Ignorar arquivos na mesma pasta Ignorar apenas este arquivo - Git Ignore Corrigir Você pode stagear este arquivo agora. COMMIT @@ -692,27 +624,24 @@ Modelo/Históricos Acionar evento de clique Preparar todas as mudanças e commitar - Commit vazio detectado! Deseja continuar (--allow-empty)? - CONFLITOS DE ARQUIVO RESOLVIDOS CONFLITOS DETECTADOS + CONFLITOS DE ARQUIVO RESOLVIDOS INCLUIR ARQUIVOS NÃO RASTREADOS SEM MENSAGENS DE ENTRADA RECENTES SEM MODELOS DE COMMIT - Clique com o botão direito nos arquivos selecionados e escolha como resolver conflitos. + STAGED UNSTAGE UNSTAGE TODOS - STAGED UNSTAGED STAGE STAGE TODOS - VER SUPOR NÃO ALTERADO + VER SUPOR NÃO ALTERADO Template: ${0}$ - Alterações - Configurar workspaces... Workspaces: + Configurar workspaces... + WORKTREE Copiar Caminho Bloquear Remover Desbloquear - WORKTREE diff --git a/src/Resources/Locales/ru_RU.axaml b/src/Resources/Locales/ru_RU.axaml index 3d03e8503..525796412 100644 --- a/src/Resources/Locales/ru_RU.axaml +++ b/src/Resources/Locales/ru_RU.axaml @@ -2,701 +2,1042 @@ + О программе О SourceGit - • Сборка с - • Диаграмма отображается с помощью - © 2024 sourcegit-scm - • Текстовый редактор от - • Моноширинные шрифты взяты из - • Исходный код можно найти по адресу + Дата выпуска: {0} + Примечания выпуска Бесплатный графический клиент Git с исходным кодом - Добавить рабочее дерево - Что проверить: - Существующую ветку - Создать новую ветку + Добавить файл(ы) к игнорируемым + Шаблон: + Файл хранилища: + Добавить рабочий каталог Расположение: - Путь к этому рабочему дереву. Поддерживается относительный путь. + Путь к рабочему каталогу (поддерживается относительный путь) Имя ветки: - Необязательно. По умолчанию используется имя целевой папки. + Имя целевого каталога по умолчанию (необязательно) Отслеживание ветки: Отслеживание внешней ветки - OpenAI Ассистент - Использовать OpenAI для создания сообщения о фиксации - Исправить - Ошибка - Выдает ошибки и отказывается применять исправление - Все ошибки - Аналогично «ошибке», но показывает больше - Файл исправлений: + Переключиться на: + Создать новую ветку + Ветку из списка + Помощник OpenAI + Модель + ПЕРЕСОЗДАТЬ + Использовать OpenAI для создания сообщения о ревизии + Использовать + Скрыть SourceGit + Скрыть остальные + Показать все + Трехстороннее слияние Выберите файл .patch для применения Игнорировать изменения пробелов - Нет предупреждений - Отключает предупреждение о пробелах в конце - Применить исправление - Предупреждение - Выдает предупреждения о нескольких таких ошибках, но применяет + Источник: + Файл + Буфер обмена + Применить заплатку Пробел: + Отложить + Удалить после применения + Восстановить изменения индекса + Отложенный: Архивировать... Сохранить архив в: Выберите путь к архивному файлу Ревизия: Архив Спросить разрешения SourceGit - ФАЙЛЫ СЧИТАЮТСЯ НЕИЗМЕНЕННЫМИ - НИ ОДИН ФАЙЛ НЕ СЧИТАЕТСЯ НЕИЗМЕНЕННЫМ - УДАЛИТЬ + Введите парольную фразу: + НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ + СПИСОК ПУСТ + Загрузить картинку... + Обновить ДВОИЧНЫЙ ФАЙЛ НЕ ПОДДЕРЖИВАЕТСЯ!!! - Обвинение - ОБВИНЕНИЕ В ЭТОМ ФАЙЛЕ НЕ ПОДДЕРЖИВАЕТСЯ!!! - Проверить ${0}$... - Сравнить в веткой - Сравнить в заголовком - Сравнить в рабочим деревом + Раздвоить + О + Плохая + Хорошая + Пропустить + Раздвоение. Пожалуйста, переключитесь на другой, затем отметьте его как хороший или плохой. + Раздвоение. Отметить текущую ревизию как плохую или переключиться на другую, а затем отметить её как плохую. + Раздвоение. Отметить текущую ревизию хорошей или плохой и переключиться на другой. + Раздвоение. Текущая ГОЛОВА (HEAD) хорошая или плохая? + Расследование + Расследование на предыдущей редакции + Игнорировать изменения пробелов + РАССЛЕДОВАНИЕ В ЭТОМ ФАЙЛЕ НЕ ПОДДЕРЖИВАЕТСЯ!!! + Переключиться на ${0}$... + Сравнить две выбранные ветки + Сравнить с ... + Сравнить с ГОЛОВОЙ + Сравнить с ${0}$ Копировать имя ветки + Создать PR... + Создать PR для основной ветки ${0}$... + Изменить действие Удалить ${0}$... Удалить выбранные {0} ветки - Отклонить все изменения. + Править описание для ${0}$... Перемотать вперёд к ${0}$ Извлечь ${0}$ в ${1}$... - Поток Git - Завершение ${0}$ - Слить ${0}$ в ${1}$... - Забрать ${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 (необязательно) + Закладка: + Группа: Локальное имя: - Имя хранилища. Необязательно. + Имя репозитория (необязательно) Родительский каталог: - Адрес хранилища: + Создать и обновить подмодули + Адрес репозитория: ЗАКРЫТЬ Редактор - Выбрать из списка эту фиксацию - Список выбора ... - Проверить фиксацию - Сравнить в заголовком - Сравнить с рабочим деревом - Копировать информацию - Копировать SHA + Ветки + Ветки и метки + Пользовательские действия репозитория + Ревизия файлов + Переключиться на эту ревизию + Применить эту ревизию (cherry-pick) + Применить несколько ревизий ... + Сравнить c ГОЛОВОЙ (HEAD) + Сравнить с рабочим каталогом + Автор + Автор (время) + Сообщение + Ревизор + Ревизор (время) + SHA + Субъект Пользовательское действие - Интерактивное перемещение ${0}$ сюда - Переместить ${0}$ сюда - Сбросить ${0}$ сюда - Вернуть фиксацию - Переформулировать - Сохранить как исправление... - Втиснуть в родительскую - Втиснуть дочерную фиксацию сюда + Интерактивное перемещение + Бросить... + Редактировать... + Исправить в родительском... + Интерактивное перемещение ${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} - Путь хранилища; ${SHA} - Выбранные фиксации SHA - Исполняемый фалй: + Встроенные параметры: + + ${REPO} Путь репозитория + ${REMOTE} Выбранная удаённая ветка + ${BRANCH} Выбранная ветка, без ${REMOTE} удалённых веток + ${BRANCH_FRIENDLY_NAME} Понятное имя выбранной ветки, содержащую ${REMOTE} удалённые ветки + ${SHA} Хеш выбранной ревизии + ${TAG} Выбранная метка + ${FILE} Выбранный файл, относительно корня репозитория + $1, $2 ... Ввод управляющих значений + Исполняемый файл: + Элементы управления вводом: + Редактор Имя: Диапазон: - Фиксация - Хранилище + Ветка + Ревизия + Файл + Удалённый + Репозиторий + Метка + Ждать для выполения выхода Адрес электронной почты Адрес электронной почты GIT - Автоматическое извлечение внешних хранилищ + Спрашивать перед автоматическим обновлением подмодулей. + Автозагрузка изменений Минут(а/ы) - Разрешить --signoff для фиксации - Удалённое хранилище по-умолчанию - Разрешить --prune при извлечении + Общепринятые типы ревизии + Внешний репозиторий по умолчанию + Включить (--recursive) при автоматическом обновлении подмодулей + Предпочтительный режим слияния ОТСЛЕЖИВАНИЕ ПРОБЛЕМ - Добавить пример правила для Git - Добавить пример правила Jira + Добавить пример правила Azure DevOps + Добавить правило Gerrit ревизии идентификатора изменения + Добавить пример правила для тем в Gitea + Добавить пример правила запроса скачивания из Gitea + Добавить пример правила для Git Добавить пример правила выдачи GitLab Добавить пример правила запроса на слияние в GitLab + Добавить пример правила Jira Новое правило Проблема с регулярным выражением: Имя правила: + Опубликовать это правило в файле .issuetracker Адрес результата: Пожалуйста, используйте $1, $2 для доступа к значениям групп регулярных выражений. ОТКРЫТЬ ИИ - Предпочитаемый сервис: - Если «Предпочитаемый сервис» установлен, SourceGit будет использовать только этот хранилище. В противном случае, если доступно более одной услуги, будет отображено контекстное меню для выбора одной из них. + Предпочтительный сервис: + Если «Предпочтительный сервис» установлен, SourceGit будет использовать только этот репозиторий. В противном случае, если доступно более одной услуги, будет отображено контекстное меню для выбора одной из них. HTTP-прокси - HTTP-прокси, используемый этим хранилищем + HTTP-прокси для репозитория Имя пользователя - Имя пользователя для этого хранилища + Имя пользователя репозитория + Пользовательское редактирование элементами действий + Проверяемое значение: + Если установлено, то данное значение будет использоваться в аргументах командной строки + Описание: + По умолчанию: + Это каталог: + Метка: + Опции: + Используйте разделитель «|» для опций + Строка форматирования: + Не обязательно. Использовать для форматирования выходной строки. Игнорировать пустые входные данные. Пожалуйста, используйте «${VALUE}» для представления входной строки. + Встроенные переменные ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE}, и ${TAG} останутся здесь доступными + Тип: + Используйте понятное имя: Рабочие пространства - Имя Цвет + Имя Восстанавливать вкладки при запуске - Общепринятый помощник по фиксации изменений + ПРОДОЛЖИТЬ + Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty)? + Сформировать всё и зафиксировать ревизию + Сформировать выбранные и зафиксировать + Обнаружена пустая ревизия! Вы хотите продолжить (--allow-empty) или отложить всё, а затем зафиксировать ревизию? + Требуется перезапуск + Вы должны перезапустить приложение после применения изменений. + Общепринятый помощник по ревизии Кардинальные изменения: Закрытая тема: Детали изменений: Область: - Коротнкое описание: + Короткое описание: Тип изменения: Копировать Копировать весь текст + Копировать как исправление + Копировать полный путь Копировать путь - Копировать имя файла Создать ветку... Основан на: - Проверить созданную ветку + Переключиться на созданную ветку Локальные изменения: - Отклонить - Ничего не делать - Отложить и применить повторно Имя новой ветки: - Ввести имя ветки. + Введите имя ветки. Создать локальную ветку + Перезаписать существующую ветку Создать метку... Новая метка у: - Подпись GPG - Сообщение с меткой: + GPG подпись + Сообщение с меткой: Необязательно. Имя метки: Рекомендуемый формат: v1.0.0-alpha - Выложить на все внешние хранилища после создания + Выложить на все внешние репозитории после создания Создать новую метку Вид: - Аннотированный - Лёгкий - Удерживайте Ctrl, чтобы начать сразу + С примечаниями + Простой + Удерживайте Ctrl, чтобы сразу начать Вырезать + Отклонить + Ничего не делать + Отложить и примненить повторно + Удалить подмодуль + Принудительно удалить даже если содержит локальные изменения. + Подмодуль: Удалить ветку + Вы хотите также удалить следующую удалённую ветку? Ветка: - Вы собираетесь удалить удаленную ветку!!! - Также удалите удаленную ветку ${0}$ + Принудительно удалить, даже если содержит неслитые ревизии + Вы собираетесь удалить внешнюю ветку!!! Удаление нескольких веток Вы пытаетесь удалить несколько веток одновременно. Обязательно перепроверьте, прежде чем предпринимать какие-либо действия! - Удалить внешнее хранилище - Внешнее хранилище: + Удалить несколько меток + Удалить их с удалённого + Вы пытаетесь удалить сразу несколько меток. Перепроверьте обязательно перед выполнением! + Удалить внешний репозиторий + Внешний репозиторий: + Путь: Цель: + Все дочерние элементы будут удалены из списка. + Будет удалён из списка. На диске останется. Подтвердите удаление группы - Подтвердите удаление хранилища + Подтвердите удаление репозитория Удалить подмодуль Путь подмодуля: Удалить метку Метка: - Удалить из внешнего хранилища - РАЗНИЦА БИНАРНИКОВ - НОВЫЙ - СТАРЫЙ - Копировать - Режим файла изменён - Игнорировать изменение пробелов - Показывать скрытые символы - ИЗМЕНЕНИЕ ОБЪЕКТА ХБФ - Следующее различие + Удалить из внешнего репозитория + СРАВНЕНИЕ БИНАРНИКОВ + ФАЙЛ ПУСТ + Первое сравнение + Игнорировать изменения пробелов + СМЕСЬ + СРАВНЕНИЕ + РЯДОМ + СМАХИВАНИЕ + Последнее сравнение + ИЗМЕНЕНИЕ ОБЪЕКТА LFS + НОВЫЙ + Следующее сравнение НИКАКИХ ИЗМЕНЕНИЙ ИЛИ МЕНЯЕТСЯ ТОЛЬКО EOL - Предыдущее различие - Сохранить как исправление - Различие бок о бок + СТАРЫЙ + Предыдущее сравнение + Сохранить как заплатку + Показывать скрытые символы + Сравнение рядом ПОДМОДУЛЬ + УДАЛЁН НОВЫЙ + + {0} Незафиксированных изменний Обмен Подсветка синтаксиса Перенос слов в строке Открыть в инструменте слияния - Показывать все линии - Уменьшить количество видимых линий - Увеличить количество видимых линий - ВЫБРАТЬ ФАЙЛ ДЛЯ ПРОСМОТРА ИЗМЕНЕНИЙ - Открыть в инструменте слияния + Показывать все строки + Меньше видимых строк + Больше видимых строк + ВЫБЕРИТЕ ФАЙЛ ДЛЯ ПРОСМОТРА ИЗМЕНЕНИЙ + Каталог историй + Есть локальные изменения + Не соответствует с исходящим потоком + В актуальном состоянии Отклонить изменения Все локальные изменения в рабочей копии. Изменения: Включить игнорируемые файлы - Всего {0} изменений будут отменены + Включить изменённые/удалённые файлы + Включить неотслеживаемые файлы + {0} изменений будут отменены Вы не можете отменить это действие!!! + Править описание ветки + Цель: Закладка: Новое имя: Цель: Редактировать выбранную группу - Редактировать выбранное хранилище - Выполнить пользовательское действие - Имя действия: - Быстрая перемотка вперёд (без проверки) + Редактировать выбранный репозиторий + Цель: + Этот репозиторий Извлечь - Извлечь все внешние хранилища + Извлечь все внешние репозитории + Разрешить опцию (--force) Извлечь без меток - Внешнее хранилище: + Внешний репозиторий: Извлечь внешние изменения - Допустить без изменений + Не отслеживать + Пользовательское действие Отклонить... - Отклонить {0} файлов... - Отклонить изменения в выбранной(ых) строке(ах) - Открыть расширенный инструмент слияния - Сохранить как исправление... - Подготовить - Подготовленные {0} файлы - Подготовленные изменения в выбранной(ых) строке(ах) + Отклонить файлы ({0})... + Взять версию ${0}$ + Сохранить как файл заплатки... + Сформировать + Сформировать файлы ({0}) Отложить... - Отложить {0} файлов... - Снять подготовленный - Неподготовленные {0} файлы - Неподготовленные изменения в выбранной(ых) строке(ах) - Использовать их (checkout --theirs) + Отложить файлы ({0})... + Расформировать + Расформировать файлы ({0}) Использовать мой (checkout --ours) + Использовать их (checkout --theirs) История файлов - СОДЕРЖИМОЕ ИЗМЕНИТЬ - Git-поток + СОДЕРЖИМОЕ + Изменение режима файла: + Режим удалённого файла: + Каталог + Исполняемый + Новый режим файла: + Обычный + Подмодуль + Символическая сскла + Неизвестно + Git-процесс Ветка разработчика: Свойство: Свойство префикса: - ПОТОК - Свойства завершения - ПОТОК - Закончить исправление - ПОТОК - Завершить выпуск + Завершение ${0}$ + ПРОЦЕСС - Свойства завершения + ПРОЦЕСС - Закончить исправление + ПРОЦЕСС - Завершить выпуск Цель: + Перенос перед слиянием + Втиснуть при слиянии Исправление: Префикс исправлений: - Инициализировать Git-поток + Создать Git-процесс Держать ветку Производственная ветка: Выпуск: Префикс выпуска: + Начать с: Свойство запуска... - ПОТОК - Свойство запуска + ПРОЦЕСС - Свойство запуска Запуск исправлений... - ПОТОК - Запуск исправлений + ПРОЦЕСС - Запуск исправлений + Имя: Ввести имя Запуск выпуска... - ПОТОК - Запуск выпуска + ПРОЦЕСС - Запуск выпуска Префикс метки версии: - Git хранилища больших файлов + Git LFS (хранилище больших файлов) Добавить шаблон отслеживания... Шаблон — это имя файла Изменить шаблон: - Добавить шаблон отслеживания в ХБФ Git + Добавить шаблон отслеживания в LFS Git Извлечь - Извлечь объекты ХБФ - Запустить «git lfs fetch», чтобы загрузить объекты ХБФ Git. При этом рабочая копия не обновляется. - Установить перехват ХБФ Git + Запустить (git lfs fetch), чтобы загрузить объекты LFS Git. При этом рабочая копия не обновляется. + Извлечь объекты LFS + Установить перехват LFS Git Показывать блокировки Нет заблокированных файлов Блокировка Показывать только мои блокировки - Блокировки ХБФ + Блокировки LFS Разблокировать + Снять все мои блокировки + Вы уверены, что хотите снять все свои блокировки? Принудительно разблокировать Обрезать - Запустить «git lfs prune», чтобы удалить старые файлы ХБФ из локального хранилища - Забрать - Забрать объекты ХБФ - Запустить «git lfs pull», чтобы загрузить все файлы ХБФ Git для текущей ссылки и проверить + Запустить (git lfs prune), чтобы удалить старые файлы LFS из локального хранилища + Загрузить + Запустить (git lfs pull), чтобы загрузить все файлы LFS Git для текущей ссылки и проверить + Загрузить объекты LFS Выложить - Выложить объекты ХБФ - Отправляйте большие файлы, помещенные в очередь, в конечную точку ХБФ Git + Отправляйте большие файлы, помещенные в очередь, в конечную точку LFS Git + Выложить объекты LFS Внешнее хранилище: Отслеживать файлы с именем «{0}» - Отслеживать все *{0} файлов + Отслеживать все файлы (*{0}) + Выбрать ревизию Истории - Переключение горизонтального/вертикального расположения АВТОР ВРЕМЯ АВТОРА + ВРЕМЯ РЕВИЗИИ ГРАФ И СУБЪЕКТ SHA - ВРЕМЯ ФИКСАЦИИ - ВЫБРАННЫЕ {0} ФИКСАЦИИ - Удерживайте Ctrl или Shift, чтобы выбрать несколько фиксаций. - Удерживайте ⌘ или ⇧, чтобы выбрать несколько фиксаций. + ПОДСВЕТКА В ГРАФЕ + ВСЕ + Только текущая ветка + Текущая ветка и выбранные ревизии + Только выбранные ревизии + ВЫБРАНО РЕВИЗИЙ: {0} + ПОКАЗЫВАТЬ КОЛОНКИ + Удерживайте Ctrl или Shift, чтобы выбрать несколько ревизий. + Удерживайте ⌘ или ⇧, чтобы выбрать несколько ревизий. ПОДСКАЗКИ: - Ссылка на сочетания клавиш - ОБЩЕЕ - Отменить текущее всплывающее окно - Закрыть текущее окно - Перейти на предыдущую страницу - Перейти на следующую страницу - Создать новую страницу - Открыть диалоговое окно настроек - ХРАНИЛИЩЕ - Зафиксировать подготовленные изменения - Зафиксировать и выложить подготовленные изменения - Подготовить все изменения и зафиксировать - Создать новую ветку на основе выбранной ветки - Отклонить выбранные изменения - Извлечение, запускается сразу - Режим доски (по-умолчанию) - Принудительно перезагрузить этот хранилище - Забрать, запускается сразу - Выложить, запускается сразу - Подготовленные/Неподготовленные выбранные изменения - Режим поиска фиксаций + Открыть в отдельном окне + Подробности ревизии + Сравнить ревизию + Справка по сочетаниям клавиш + ГЛОБАЛЬНЫЕ + Клонировать репозиторий + Закрыть текущую вкладку + Перейти на следующую вкладку + Перейти на предыдущую вкладку + Создать новую вкладку + Открыть локальный репозиторий + Открыть диалоговое окно настроек + Показать рабочее пространство в выпадющем меню + Переключиться на вкладку + Масштабирование + РЕПОЗИТОРИЙ + Зафиксировать сформированные изменения + Зафиксировать и выложить сформированные изменения + Сформировать все изменения и зафиксировать + Создать новую ветку + Извлечь (fetch), запускается сразу + Режим доски (по умолчанию) + К дочернему выбранной ревизии + К родительскому выбранной ревизии + Открыть палитру команд + Режим поиска ревизий + Загрузить (pull), запускается сразу + Выложить (push), запускается сразу + Принудительно перечитать репозиторий + Развернуть/Свернуть панель подробностей в «ИСТОРИИ» Переключить на «Изменения» - Переключить на «Истории» + Переключить на «Историю» Переключить на «Отложенные» - ТЕКСТОВЫЙ РЕДАКТОР - Закрыть панель поиска - Найти следующее совпадение - Найти предыдущее совпадение - Открыть панель поиска - Подготовить - Снять из подготовленных Отклонить - Инициализировать хранилище + Сформировать + Расформировать + Создать репозиторий + Вы действительно хотите запуститб команду «git init» по этому пути? + Не удалось открыть репозиторий. Причина: Путь: - Выполняется частичный забор. Нажмите «Отказ» для восстановления заголовка. - Выполняет запрос слияния. Нажмите «Отказ» для восстановления заголовка. - Выполняется перенос. Нажмите «Отказ» для восстановления заголовка. - Выполняется возврат. Нажмите «Отказ» для восстановления заголовка. + Выполняется частичный перенос ревизий (cherry-pick). + Обрабтка ревизии. + Выполняется слияние. + Выполяется. + Выполняется перенос. + Остановлен на + Выполняется отмена ревизии. + Выполняется отмена Интерактивное перемещение - Целевая ветка: + Отложить и применить повторно локальные изменения + Обходной зацеп предварительного перемещения На: - Открыть в браузере + Перетаскивайте для переупорядочивания ревизий + Целевая ветка: Копировать ссылку - ОШИБКА + Открыть в браузере + Команды + ОШИБКА УВЕДОМЛЕНИЕ - Слить ветку + Доступна новая версия + Открыть репозитории + Вкладки + Рабочие места + Влить ветку + Изменить сообщение слияния В: Опции слияния: - Исходная ветка: - Переместить узел хранилища - Выбрать родительский узел для: + Источник: + Тестировать на слияние... + Слияние может быть выполнено без конфликтов + Неизвестная ошибка при тестировании слияния + Слияние вызовет конфликты + Сначала мои, а потом их + Сначала их, а потом мои + ИСПОЛЬЗОВАТЬ ОБА + Все конфликты разрешены + Осталось конфликтов: {0} + MINE + Следующий конфликт + Предыдущий конфликт + РЕЗУЛЬТАТ + СОХРАНИТЬ И СФОРМИРОВАТЬ + ИХ + Конфликты при слиянии + Отменить несохранённые изменения? + ИСПОЛЬЗОВАТЬ МОИ + ИСПОЛЬЗОВАТЬ ИХ + Отменить + Влить несколько веток + Зафиксировать все изменения + Стратегия: + Цели: + Переместить подмодуль + Переместить в: + Подмодуль: + Переместить репозиторий в другую группу + Выбрать группу для: Имя: + Нет Git НЕ был настроен. Пожалуйста, перейдите в [Настройки] и сначала настройте его. - Открыть приложение каталогов данных - Окрыть с... + Открыть + Редактор по умолчанию (Системный) + Открыть каталог данных программы + Открыть файл + Открыть в инструменте слияния + Открыть локальный репозиторий + Закладка: + Группа: + Каталог: Необязательно. - Создать новую страницу - Закладка + Создать новую вкладку Закрыть вкладку Закрыть другие вкладки Закрыть вкладки справа - Копировать путь хранилища - Хранилища + Копировать путь репозитория + Редактировать... + Переместить в рабочее пространство + Обновить + Репозитории Вставить - Сейчас - {0} минут назад - {0} часов назад - Вчера {0} дней назад + 1 час назад + {0} часов назад + Сейчас Последний месяц + В прошлом году + {0} минут назад {0} месяцев назад - В пролому году {0} лет назад - Параметры - ОТКРЫТЬ ИИ - Ключ API - Запрос на анализ различий - Произвести запрос на тему - Модель - Имя: - Сервер - ВИД - Шрифт по-умолчанию - Размер шрифта - По-умолчанию - Редактор - Моноширный шрифт - В текстовом редакторе используется только моноширный шрифт - Тема - Переопределение темы - Использовать фиксированную ширину табуляции в строке заголовка. - Использовать системное окно - ИНСТРУМЕНТ РАЗЛИЧИЙ/СЛИЯНИЯ - Путь установки - Введите путь для инструмента различия/слияния - Инструмент - ГЛАВНЫЙ - Проверить обновления при старте - Язык - История фиксаций - Показывать время автора вместо времени фиксации на графике - Длина темы фиксации - GIT - Включить автозавершение CRLF - Каталог клонирования по-умолчанию - Электроная почта пользователя - Общая электроная почта пользователя git - Путь установки - Имя пользователя - общее имя пользователя git - Версия Git - Git (>= 2.23.0) требуется для этого приложения - ПОДПИСЬ GPG - Фиксация подписи GPG - Метка подписи GPG - Формат GPG - Путь установки программы - Введите путь для установленной программы GPG - Ключ подписи пользователя - Ключ подписи GPG пользователя - ВНЕДРЕНИЕ - ОБОЛОЧКА/ТЕРМИНАЛ - Оболочка/Терминал - Путь - Удалить внешнее хранилище + Вчера + Параметры + ОТКРЫТЬ ИИ + Дополнительная подсказка (Для перечисления ваших требований используйте `-`) + Ключ 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_DIR/worktrees» - Забрать - Ветка: - Извлечь все ветки + Удалить рабочий каталог + Информация об обрезке рабочего каталога в «$GIT_COMMON_DIR/worktrees» + Загрузить + Ветка внешнего репозитория: В: Локальные изменения: - Отклонить - Ничего не делать - Отложить и применить повторно - Забрать без меток - Внешнее хранилище: - Забрать (Получить и слить) + Внешний репозиторий: + Загрузить (Получить и слить) Использовать перемещение вместо слияния Выложить Убедитесь, что подмодули были вставлены Принудительно выложить Локальная ветка: - Внешнее хранилище: - Выложить изменения на внешнее хранилище - Ветка внешнего хранилища: - Установить в качестве ветки отслеживания + НОВЫЙ + Внешний репозиторий: + Ревизия: + Выложить ревизию на удалёную + Выложить изменения на внешний репозиторий + Ветка внешнего репозитория: + Отслеживать ветку Выложить все метки - Выложить метку на внешнее хранилище - Выложить на все внешние хранилища - Внешнее хранилище: + Выложить метку на внешний репозиторий + Выложить на все внешние репозитории + Внешний репозиторий: Метка: + Отправить к НОВОЙ ветке + Введитте имя для новой удалённой ветки: Выйти Перемещение текущей ветки Отложить и применить повторно локальные изменения + Обходной зацеп предварительного перемещения На: - Переместить: - Обновить - Добавить внешнее хранилище - Редактировать внешнее хранилище + Тестировать на перемещение... + Перемещение может быть выполнено без конфликтов + Неизвестная ошибка при тестировании перемещения + Перемещение вызовет конфликты + Добавить внешний репозиторий + Редактировать внешний репозиторий Имя: - Имя внешнего хранилища - Адрес хранилища: - Адрес внешнего хранилища git + Имя внешнего репозитория + Адрес: + Адрес внешнего репозитория git Копировать адрес + Пользовательские действия Удалить... Редактировать... + Включить автоизвлечение Извлечь Открыть в браузере Удалить - Подтвердить удаление рабочего дерева - Включить опцию --force + Подтвердить удаление рабочего каталога + Включить опцию (--force) Цель: Переименовать ветку Новое имя: Уникальное имя для данной ветки Ветка: Отказ - Автоматическое извлечение изменений с внешних хранилищ... + Автоматическое извлечение изменений с внешних репозиторий... + Сортировать + По дате ревизора (исполнителя) + По имени Очистить (Сбор мусора и удаление) - Запустить команду «git gc» для данного хранилища. + Запустить команду (git gc) для данного репозитория. Очистить всё - Настройка этого хранилища + Очистить + Настройка репозитория ПРОДОЛЖИТЬ Изменить действия Не изменять действия - Разрешить опцию --reflog + Панель + Отклонить все изменения. Открыть в файловом менеджере Поиск веток, меток и подмодулей - Не установлен (По-умолчанию) - Скрыть в графе фиксации - Фильтр в графе фиксации - ОТФИЛЬТРОВАНО: + Видимость на графике + Свернуть фильтры + Не установлен (по умолчанию) + Скрыть в графе ревизии + Развернуть фильтры + Фильтр в графе ревизии + фильтры включены + РАСПОЛОЖЕНИЕ + Горизонтально + Вертикально + ЗАПРОС РЕВИЗИЙ + Дата ревизии + Топологически ЛОКАЛЬНЫЕ ВЕТКИ - Навигация по заголовку - Включить опцию --first-parent + Больше опций... + Навигация по ГОЛОВЕ (HEAD) Создать ветку + ОЧИСТКА УВЕДОМЛЕНИЙ + Открыть как каталог Открыть в {0} Открыть в расширенном инструменте - Обновить - ВНЕШНИЕ ХРАНИЛИЩА - ДОБАВИТЬ ВНЕШНЕЕ ХРАНИЛИЩЕ + ВНЕШНИЕ РЕПОЗИТОРИИ + ДОБАВИТЬ ВНЕШНИЙ РЕПОЗИТОРИЙ РАЗРЕШИТЬ - Поиск фиксации - Файл + Поиск ревизии + Автор + Содержимое Сообщение + Путь SHA - Автор и исполнитель Текущая ветка - Показывать метки как дерево - Статистики + Только оформленные ревизии + Показывать только первый родительский + ПОКАЗЫВАТЬ ФЛАГИ + Показывать потерянные ревизии + Показывать подмодули как дерево + Показывать метки как катлог + ПРОПУСТИТЬ + Статистикa ПОДМОДУЛИ ДОБАВИТЬ ПОДМОДУЛЬ ОБНОВИТЬ ПОДМОДУЛЬ МЕТКИ НОВАЯ МЕТКА + По дате создания + По имени + Сортировать Открыть в терминале - РАБОЧИЕ ДЕРЕВЬЯ - ДОБАВИТЬ РАБОЧЕЕ ДЕРЕВО + Просмотр журналов + Посетить '{0}' в браузере + РАБОЧИЕ КАТАЛОГИ + ДОБАВИТЬ РАБОЧИЙ КАТАЛОГ ОБРЕЗАТЬ - Адрес хранилища Git - Сбросить текущую втеку до версии + Адрес репозитория Git + Сбросить текущую ветку до версии Режим сброса: Переместить в: Текущая ветка: + Сброс ветки (без переключения) + Переместить в: + Ветка: Открыть в файловом менеджере - Отменить фиксацию - Фиксация: - Отмена изменений фиксации - Переформулировать сообщение фиксации - Использовать «Shift+Enter» для ввода новой строки. «Enter» - это горячая клавиша кнопки OK + Отменить ревизию + Ревизия: + Отмена ревизии Запуск. Подождите пожалуйста... СОХРАНИТЬ Сохранить как... - Исправление успешно сохранено! - Сканирование хранилищ + Заплатка успешно сохранена! + Обнаружение репозиториев Корневой каталог: - Проверка для обновления... - Доступна новая версия этого программного обеспечения: + Сканировать другой пользовательский каталог + Проверить обновления... + Доступна новая версия программного обеспечения: + Текущая версия: Не удалось проверить наличие обновлений! Загрузка Пропустить эту версию + Дата выпуска новой версии: Обновление ПО - В настоящее время обновления недоступны. - Втиснуть фиксации - В: - Частный ключ SSH: - Путь хранения частного ключа SSH - Только подготовленные изменения - Подготовленные так и подготовленные изменения выбранных файлов будут сохранены!!! + Сейчас нет обновлений. + Установить ветку подмодуля + Подмодуль: + Текущий: + Изменить в: + Не обязательно. Установить по умолчанию, когда пусто. + Отслеживать ветку + Ветка: + Снять основную ветку + Основная ветка: + Копировать SHA + Перейти + Приватный ключ SSH: + Путь хранения приватного ключа SSH ЗАПУСК Отложить Включить неотслеживаемые файлы - Хранить отложенные файлы Сообщение: - Необязательно. Имя этого тайника + Имя тайника (необязательно) + Режим: + Только сформированные изменения + Сформированные так и несформированные изменения выбранных файлов будут сохранены!!! Отложить локальные изменения Принять + Применить изменения + Переключить на новую ветку + Копировать сообщение Отбросить - Применить - Отрбосить тайник + Сохранить как заплатку... + Отбросить тайник Отбросить: Отложенные ИЗМЕНЕНИЯ ОТЛОЖЕННЫЕ - Статистики - ФИКСАЦИИ - ИСПОЛНИТЕЛИ + Статистика + ОБЗОР МЕСЯЦ НЕДЕЛЯ - ФИКСАЦИИ: АВТОРЫ: - ОБЗОР + РЕВИЗИИ: ПОДМОДУЛИ - Добавить подмодули - Копировать относительный путь + Добавить подмодули + ВЕТКА + Ветка + Каталог + Удалить подмодуль Извлечение вложенных подмодулей - Открыть подмодуль хранилища - Относительный путь: - Относительный каталог для хранения подмодуля. + Истории + Переместить в + Открыть подмодуль репозитория + Каталог: + Относительный путь для хранения подмодуля. Удалить подмодуль + Установить ветку + Изменить URL-адрес + СОСТОЯНИЕ + изменён + не создан + ревизия изменена + не слита + Обновить + URL-адрес + Подробности изменения подмодуля + ОТКРЫТЬ ПОДРОБНОСТИ ОК - Копировать имя метки - Копировать сообщение с метки + РАЗМЕТЧИК + ВРЕМЯ + Переключить метку... + Сравнить две ветки + Сравнить с ... + Сравнить с ГОЛОВОЙ + Сообщение + Имя + Разметчик + Копировать имя метки + Пользовательское действие Удалить ${0}$... - Слить ${0}$ в ${1}$... + Удалить выбранные метки ({0})... + Влить ${0}$ в ${1}$... Выложить ${0}$... - Сетевой адрес: Обновление подмодулей Все подмодули - Инициализировать по необходимости - Рекурсивно + Создавать по необходимости Подмодуль: - Использовать опцию --remote + Обновить вложенные подмодули + Обновить до отслеживаемой ветки удалённого подмодуля + Сетевой адрес: + Журналы + ОЧИСТИТЬ ВСЁ + Копировать + Удалить Предупреждение Приветствие Создать группу - Создать подгруппу - Клонировать хранилище - Удалить + Создать подгруппу... + Клонировать репозиторий + Удалить... ПОДДЕРЖИВАЕТСЯ: ПЕРЕТАСКИВАНИЕ КАТАЛОГОВ, ПОЛЬЗОВАТЕЛЬСКАЯ ГРУППИРОВКА. - Редактировать - Перейти в другую группу - Открыть все хранилища - Открыть хранилище + Редактировать... + Переместить в другую группу... + Открыть все репозитории + Открыть репозиторий Открыть терминал - Повторное сканирование хранилищ в каталоге клонирования по-умолчанию - Поиск хранилищ... - Сортировка + Обнаружить репозитории в каталоге клонирования по умолчанию + Найти репозиторий... Изменения Игнорировать Git Игнорировать все *{0} файлы Игнорировать *{0} файлы в том же каталоге - Игнорировать файлы в том же каталоге + Игнорировать неотслеживаемые файлы в этом каталоге Игнорировать только эти файлы + Игнорировать все неотслеживаемые файлы в одном и том же каталоге Изменить - Теперь вы можете подготовитть этот файл. + Теперь вы можете сформировать этот файл. + Очистить историю + Вы действительно хотите очистить всю историю сообщений ревизии? Данное действие нельзя отменить. ЗАФИКСИРОВАТЬ ЗАФИКСИРОВАТЬ и ОТПРАВИТЬ Шаблон/Истории Запустить событие щелчка - Подготовить все изменения и зафиксировать - Обнаружена пустая фиксация! Вы хотите продолжить (--allow-empty)? + Зафиксировать (Редактировать) + Сформировать все изменения и зафиксировать + Вы создаёте ревизию на отсоединённой ГОЛОВЕ. Вы хотите продолжить? + Вы сформировали {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 1ab661be2..d77cd5418 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -2,91 +2,132 @@ + 关于软件 关于本软件 - • 项目依赖于 - • 图表绘制组件来自 - © 2024 sourcegit-scm - • 文本编辑器使用 - • 等宽字体来自于 - • 项目源代码地址 + 发布日期:{0} + 浏览版本更新说明 开源免费的Git客户端 + 新增忽略文件 + 匹配模式 : + 保存位置 : 新增工作树 - 检出分支方式 : - 已有分支 - 创建新分支 工作树路径 : 填写该工作树的路径。支持相对路径。 分支名 : 选填。默认使用目标文件夹名称。 跟踪分支 设置上游跟踪分支 + 检出分支方式 : + 创建新分支 + 已有分支 AI助手 + 模型 + 重新生成 使用AI助手生成提交信息 - 应用补丁(apply) - 错误 - 输出错误,并终止应用补丁 - 更多错误 - 与【错误】级别相似,但输出内容更多 - 补丁文件 : + 应用 + 隐藏 SourceGit + 隐藏其他 + 显示全部 + 尝试三路合并 选择补丁文件 忽略空白符号 - 忽略 - 关闭所有警告 + 补丁来源 : + 文件 + 剪贴板 应用补丁 - 警告 - 应用补丁,输出关于空白符的警告 空白符号处理 : - 存档(archive) ... + 应用贮藏 + 在成功应用后丢弃该贮藏 + 恢复索引中已暂存的变化 + 已选贮藏 : + 存档(archive)... 存档文件路径: 选择存档文件的存放路径 指定的提交: 存档 SourceGit Askpass + 请输入密码 : 不跟踪更改的文件 没有不跟踪更改的文件 - 移除 + 加载本地图片 + 重新加载 二进制文件不支持该操作!!! + 二分定位(bisect) + 终止 + 标记错误 + 标记正确 + 无法判定 + 二分定位进行中。请检出另一个提交并标记其是【正确】还是【错误】 + 二分定位进行中。请标记当前提交为【错误】,或检出另一个提交并标记为【错误】。 + 二分定位进行中。请标记当前的提交是【正确】还是【错误】,然后检出另一个提交。 + 二分定位进行中。当前提交是【正确】还是【错误】? 逐行追溯(blame) - 选中文件不支持该操作!!! + 对当前版本的前一版本执行逐行追溯操作 + 忽略空白符变化 + 选中文件不支持该操作!!! 检出(checkout) ${0}$... - 与其他分支对比 - 与当前HEAD比较 - 与本地工作树比较 + 比较选中的 2 个分支 + 与其他分支或标签比较... + 与当前 HEAD 比较 + 与 ${0}$ 比较 复制分支名 + 创建合并请求... + 为上游分支 ${0}$ 创建合并请求... + 自定义操作 删除 ${0}$... - 删除选中的 {0} 个分支 - 放弃所有更改 - 快进(fast-forward)到 ${0}$ + 删除选中的 {0} 个分支... + 编辑 ${0}$ 的描述... + 快进(fast-forward) 到 ${0}$ 拉取(fetch) ${0}$ 至 ${1}$... - GIT工作流 - 完成 ${0}$ - 合并 ${0}$ 到 ${1}$... - 拉回(pull) ${0}$ + GIT工作流 - 完成 ${0}$... + 交互式变基 ${0}$ 到 ${1}$ + 合并(merge) ${0}$ 到 ${1}$... + 合并(merge) {0} 个分支到当前分支... + 拉回(pull) ${0}$... 拉回(pull) ${0}$ 内容至 ${1}$... - 推送(push)${0}$ - 变基(rebase) ${0}$ 分支至 ${1}$... + 推送(push) ${0}$... + 变基(rebase) ${0}$ 至 ${1}$... 重命名 ${0}$... - 切换上游分支 - 取消追踪 - 分支比较 - 字节 + 重置(reset) ${0}$ 到 ${1}$... + 切换到 ${0}$ (工作树) + 切换上游分支... + 领先 {0} 个提交 + 领先 {0} 个提交,落后 {1} 个提交 + 落后 {0} 个提交 + 不存在 + 远程 + 状态 + 上游分支 + 远程地址 + 工作树 取 消 - 重置文件到该版本 重置文件到上一版本 + 重置文件到该版本 生成提交信息 + 解决冲突(内部工具) + 解决冲突(外部工具) + 重置文件到 ${0}$ 切换变更显示模式 文件名+路径列表模式 全路径列表模式 文件目录树形结构模式 + 修改子模块远程地址 + 子模块 : + 远程地址 : 检出(checkout)分支 - 检出(checkout)提交 - 注意:执行该操作后,当前HEAD会变为游离(detached)状态! - 提交 : - 目标分支 : 未提交更改 : - 丢弃更改 - 不做处理 - 贮藏并自动恢复 + 目标分支 : + 您当前游离的HEAD包含未被任何分支及标签引用的提交!是否继续? + 以下子模块需要更新:{0}是否立即更新? + 检出分支并快进 + 上游分支 : + 从所选贮藏检出分支 + 新分支 : + 所选贮藏 : + 检出提交或标签 + 目标 : + 注意:执行该操作后,当前HEAD会变为游离(detached)状态! 挑选提交 提交信息中追加来源信息 提交列表 : @@ -98,87 +139,171 @@ 克隆远程仓库 额外参数 : 其他克隆参数,选填。 + 书签 : + 自定义分组 : 本地仓库名 : 本地仓库目录的名字,选填。 父级目录 : + 初始化并更新子模块 远程仓库 : 关闭 提交信息编辑器 - 挑选(cherry-pick)此提交 + 分支列表 + 分支 & 标签 + 自定义操作列表 + 文件列表 + 检出此提交... + 挑选(cherry-pick)此提交... 挑选(cherry-pick)... - 检出此提交 与当前HEAD比较 与本地工作树比较 - 复制简要信息 - 复制提交指纹 + 作者 + 修改时间 + 提交信息 + 提交者 + 提交时间 + 提交指纹 + 主题 自定义操作 - 交互式变基(rebase -i) ${0}$ 到此处 - 变基(rebase) ${0}$ 到此处 - 重置(reset) ${0}$ 到此处 - 回滚此提交 - 编辑提交信息 - 另存为补丁 ... - 合并此提交到上一个提交 - 合并之后的提交到此处 + 交互式变基(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}代替仓库路径,${SHA}代替提交哈希 + 内置变量: + + ${REPO} 仓库路径 + ${REMOTE} 选中的远程仓库或选中分支所属的远程仓库 + ${BRANCH} 选中的分支,对于远程分支不包含远程名 + ${BRANCH_FRIENDLY_NAME} 选中的分支,对于远程分支包含远程名 + ${SHA} 选中的提交哈希 + ${TAG} 选中的标签 + ${FILE} 选中的文件 + $1, $2 ... 输入控件中填写的值 可执行文件路径 : + 输入控件 : + 编辑 名称 : 作用目标 : + 选中的分支 选中的提交 + 选中的文件 + 远程仓库 仓库 + 选中的标签 + 等待操作执行完成 电子邮箱 邮箱地址 GIT配置 + 在自动更新子模块前询问用户 启用定时自动拉取远程更新 分钟 + 自定义规范化提交类型 默认远程 - 提交信息追加署名 (--signoff) - 拉取更新时启用修剪(--prune) + 自动更新子模块时启用 '--recursive' 选项 + 默认合并方式 ISSUE追踪 - 新增匹配Github Issue规则 - 新增匹配Jira规则 + 新增匹配Azure DevOps规则 + 新增匹配Gerrit Change-Id规则 + 新增匹配Gitee议题规则 + 新增匹配Gitee合并请求规则 + 新增匹配GitHub Issue规则 新增匹配GitLab议题规则 新增匹配GitLab合并请求规则 + 新增匹配Jira规则 新增自定义规则 匹配ISSUE的正则表达式 : 规则名 : + 写入 .issuetracker 文件共享此规则 为ISSUE生成的URL链接 : 可在URL中使用$1,$2等变量填入正则表达式匹配的内容 AI - 启用特定服务 : - 当【启用特定服务】被设置时,SourceGit将在本仓库中仅使用该服务。否则将弹出可用的AI服务列表供用户选择。 + 启用特定服务 : + 当【启用特定服务】被设置时,SourceGit将在本仓库中仅使用该服务。否则将弹出可用的AI服务列表供用户选择。 HTTP代理 HTTP网络代理 用户名 应用于本仓库的用户名 + 编辑自定义操作输入控件 + 启用时命令行参数 : + 此CheckBox勾选后,该值会被应用于命令行参数 + 描述 : + 默认值 : + 目标是否是目录 : + 名称 : + 选项列表 : + 选项之间请使用英文 '|' 作为分隔符 + 输出内容格式化字串: + 可选。用于格式化输出结果。当用户输入为空时忽略该操作。请使用`${VALUE}`代替用户输入。 + 内置变量 ${REPO}, ${REMOTE}, ${BRANCH}, ${BRANCH_FRIENDLY_NAME}, ${SHA}, ${FILE} 与 ${TAG} 在这里仍然可用 + 类型 : + 输出结果带有远程名: 工作区 颜色 + 名称 启动时恢复打开的仓库 + 确认继续 + 提交未包含变更文件!是否继续(--allow-empty)? + 暂存所有变更并提交 + 仅暂存所选变更并提交 + 提交未包含变更文件!是否继续(--allow-empty)或是自动暂存变更并提交? + 系统提示 + 程序需要重新启动,以便修改生效! 规范化提交信息生成 破坏性更新: 关闭的ISSUE: @@ -188,19 +313,18 @@ 类型: 复制 复制全部文本 + 复制为补丁 + 复制完整路径 复制路径 - 复制文件名 - 新建分支 ... + 新建分支... 新分支基于 : 完成后切换到新分支 未提交更改 : - 丢弃更改 - 不做处理 - 贮藏并自动恢复 新分支名 : 填写分支名称。 创建本地分支 - 新建标签 ... + 允许重置已存在的分支 + 新建标签... 标签位于 : 使用GPG签名 标签描述 : @@ -214,15 +338,28 @@ 轻量标签 按住Ctrl键点击将以默认参数运行 剪切 + 丢弃更改 + 不做处理 + 贮藏并自动恢复 + 取消初始化子模块 + 强制取消,即使包含本地变更 + 子模块 : 删除分支确认 + 是否同时删除以下远程分支? 分支名 : + 强制删除,即使包含未合并的提交 您正在删除远程上的分支,请务必小心!!! - 同时删除远程分支 ${0}$ 删除多个分支 您正在尝试一次性删除多个分支,请务必仔细检查后再执行操作! + 删除多个标签 + 同时在远程仓库中删除 + 您正在尝试一次性删除多个标签,请务必仔细检查后再执行操作! 删除远程确认 远程名 : + 路径 : 目标 : + 所有子节点将被同时从列表中移除。 + 仅从列表中移除,不会删除硬盘中的文件! 删除分组确认 删除仓库确认 删除子模块确认 @@ -231,20 +368,27 @@ 标签名 : 同时删除远程仓库中的此标签 二进制文件 - 当前大小 - 原始大小 - 复制 - 文件权限已变化 + 空文件 + 首个差异 忽略空白符号变化 + 混合对比 + 差异比较 + 分列对比 + 填充对比 + 最后一个差异 LFS对象变更 + 变更后 下一个差异 没有变更或仅有换行符差异 + 变更前 上一个差异 保存为补丁文件 显示隐藏符号 分列对比 子模块 + 删除 新增 + + {0} 项未提交变更 交换比对双方 语法高亮 自动换行 @@ -253,53 +397,70 @@ 减少可见的行数 增加可见的行数 请选择需要对比的文件 - 使用外部比对工具查看 + 目录内容变更历史 + 未提交的本地变更 + 当前分支HEAD与远端不一致 + 已是最新 放弃更改确认 - 所有本地址未提交的修改。 + 所有本仓库未提交的修改。 变更 : 包括所有已忽略的文件 + 包括已修改或删除的文件 + 包括未跟踪的文件 总计{0}项选中更改 本操作不支持回退,请确认后继续!!! + 编辑分支描述 + 目标 : 书签 : 名称 : 目标 : 编辑分组 编辑仓库 - 执行自定义操作 - 自定义操作 : - 快进(fast-forward,无需checkout) + 目标: + 本仓库 拉取(fetch) 拉取所有的远程仓库 + 强制覆盖本地REFs 不拉取远程标签 远程仓库 : 拉取远程仓库内容 不跟踪此文件的更改 + 自定义操作 放弃更改... 放弃 {0} 个文件的更改... - 放弃选中的更改 - 使用外部合并工具打开 + 应用 ${0}$ 另存为补丁... 暂存(add) 暂存(add){0} 个文件 - 暂存选中的更改 贮藏(stash)... 贮藏(stash)选中的 {0} 个文件... 从暂存中移除 从暂存中移除 {0} 个文件 - 从暂存中移除选中的更改 - 使用 THEIRS (checkout --theirs) 使用 MINE (checkout --ours) + 使用 THEIRS (checkout --theirs) 文件历史 - 文件内容 文件变更 + 文件内容 + 文件类型变更: + 删除: + 目录 + 可执行文件 + 新增文件类型: + 普通文件 + 子模块 + 符号链接 + 未知 GIT工作流 开发分支 : 特性分支 : 特性分支名前缀 : + 完成 ${0}$ 结束特性分支 结束修复分支 结束版本分支 目标分支 : + 合并前执行变基操作 + 压缩变更为单一提交后合并分支 修复分支 : 修复分支名前缀 : 初始化GIT工作流 @@ -307,10 +468,12 @@ 发布分支 : 版本分支 : 版本分支名前缀 : + 开始于 : 开始特性分支... 开始特性分支 开始修复分支... 开始修复分支 + 分支名 : 输入分支名 开始版本分支... 开始版本分支 @@ -321,8 +484,8 @@ 规则 : 添加LFS追踪文件规则 拉取LFS对象 (fetch) + 执行`git lfs fetch`命令,下载远程LFS对象,但不会更新工作副本。 拉取LFS对象 - 执行`git lfs prune`命令,下载远程LFS对象,但不会更新工作副本。 启用Git LFS支持 显示LFS对象锁 没有锁定的LFS文件 @@ -330,167 +493,247 @@ 仅显示被我锁定的文件 LFS对象锁状态 解锁 + 解锁所有被我锁定的文件 + 确定要解锁所有被您锁定的文件吗? 强制解锁 精简本地LFS对象存储 运行`git lfs prune`命令,从本地存储中精简当前版本不需要的LFS对象 拉回LFS对象 (pull) - 拉回LFS对象 运行`git lfs pull`命令,下载远程LFS对象并更新工作副本。 + 拉回LFS对象 推送 - 推送LFS对象 将排队的大文件推送到Git LFS远程服务 + 推送LFS对象 远程 : 跟踪名为'{0}'的文件 跟踪所有 *{0} 文件 + 选择前往的提交 历史记录 - 切换横向/纵向显示 作者 修改时间 + 提交时间 路线图与主题 提交指纹 - 提交时间 + 高亮分支 + 全部 + 仅当前分支 + 当前分支和选中的提交 + 仅选中的提交 已选中 {0} 项提交 + 显示列 可以按住 Ctrl 或 Shift 键选择多个提交 可以按住 ⌘ 或 ⇧ 键选择多个提交 小提示: + 以独立窗口中打开 + 提交详情 + 比较 快捷键参考 全局快捷键 - 取消弹出面板 + 克隆远程仓库 关闭当前页面 - 切换到上一个页面 切换到下一个页面 + 切换到上一个页面 新建页面 - 打开偏好设置面板 + 打开本地仓库 + 打开偏好设置面板 + 显示工作区下拉菜单 + 切换显示页面 + 缩放内容 仓库页面快捷键 提交暂存区更改 提交暂存区更改并推送 自动暂存全部变更并提交 - 基于选中提交创建新分支 - 丢弃选中的更改 + 新建分支 拉取 (fetch) 远程变更 切换左边栏为分支/标签等显示模式(默认) + 前往选中提交的子提交 + 前往选中提交的父提交 + 打开快捷命令面板 + 切换左边栏为提交搜索模式 拉回 (pull) 远程变更 推送本地变更到远程 重新加载仓库状态 - 将选中的变更暂存或从暂存列表中移除 - 切换左边栏为提交搜索模式 + 展开/折叠历史提交中的详情面板 显示本地更改 显示历史记录 显示贮藏列表 - 文本编辑器 - 关闭搜索 - 定位到下一个匹配搜索的位置 - 定位到上一个匹配搜索的位置 - 打开搜索 + 丢弃 暂存 移出暂存区 - 丢弃 初始化新仓库 + 是否在该路径下执行 `git init` 命令(初始化仓库)? + 打开本地仓库失败,原因: 路径 : - 挑选(Cherry-Pick)操作进行中。点击【终止】回滚到操作前的状态。 - 合并操作进行中。点击【终止】回滚到操作前的状态。 - 变基(Rebase)操作进行中。点击【终止】回滚到操作前的状态。 - 回滚提交操作进行中。点击【终止】回滚到操作前的状态。 + 挑选(Cherry-Pick)操作进行中。 + 正在处理提交 + 合并操作进行中。 + 正在处理 + 变基(Rebase)操作进行中。 + 当前停止于 + 回滚提交操作进行中。 + 正在回滚提交 交互式变基 - 目标分支 : + 自动贮藏并恢复本地变更 + 绕过 pre-rebase 钩子 起始提交 : - 在浏览器中访问 + 拖拽以便对提交重新排序 + 目标分支 : 复制链接地址 + 在浏览器中访问 + 命令列表 出错了 系统提示 + 检测到新版本 + 打开其他仓库 + 页面列表 + 工作区列表 合并分支 + 编辑合并信息 目标分支 : 合并方式 : - 合并分支 : + 合并目标 : + 检测合并冲突中... + 合并操作不会产生冲突 + 检测合并冲突时出现未知错误 + 合并操作将存在冲突文件 + 先应用 MINE 后 THEIRS + 先应用 THEIRS 后 MINE + 应用全部 + 所有冲突已解决 + {0} 个冲突未解决 + MINE + 下一个冲突 + 上一个冲突 + 合并结果 + 保存并暂存 + THEIRS + 合并冲突 + 放弃所有更改? + 仅应用 MINE + 仅应用 THEIRS + 撤销更改 + 合并(多目标) + 提交变化 + 合并策略 : + 目标列表 : + 移动子模块 + 移动到 : + 子模块 : 调整仓库分组 请选择目标分组: 名称 : + 不用了 GIT尚未配置。请打开【偏好设置】配置GIT路径。 + 打开 + 系统默认编辑器 浏览应用数据目录 - 打开文件... + 打开文件 + 使用外部对比工具查看 + 打开本地仓库 + 书签 : + 分组 : + 仓库位置 : 选填。 新建空白页 - 设置书签 关闭标签页 关闭其他标签页 关闭右侧标签页 复制仓库路径 + 编辑 + 移至工作区 + 刷新 新标签页 粘贴 - 刚刚 - {0}分钟前 - {0}小时前 - 昨天 {0}天前 + 1小时前 + {0}小时前 + 刚刚 上个月 - {0}个月前 一年前 + {0}分钟前 + {0}个月前 {0}年前 - 偏好设置 - AI - Analyze Diff Prompt - API密钥 - Generate Subject Prompt - 模型 - 配置名称 - 服务地址 - 外观配置 - 缺省字体 - 字体大小 - 默认 - 代码编辑器 - 代码字体大小 - 等宽字体 - 仅在文本编辑器中使用等宽字体 - 主题 - 主题自定义 - 主标签使用固定宽度 - 使用系统默认窗体样式 - 对比/合并工具 - 安装路径 - 填写工具可执行文件所在位置 - 工具 - 通用配置 - 启动时检测软件更新 - 显示语言 - 最大历史提交数 - 在提交路线图中显示修改时间而非提交时间 - SUBJECT字数检测 - GIT配置 - 自动换行转换 - 默认克隆路径 - 邮箱 - 默认GIT用户邮箱 - 安装路径 - 用户名 - 默认GIT用户名 - Git 版本 - 本软件要求GIT最低版本为2.23.0 - GPG签名 - 启用提交签名 - 启用标签签名 - 签名格式 - 签名程序位置 - 签名程序所在路径 - 用户签名KEY - 输入签名提交所使用的KEY - 第三方工具集成 - 终端/SHELL - 终端/SHELL - 安装路径 + 昨天 + 偏好设置 + 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_DIR/worktrees`中的无效工作树信息 + 清理在`$GIT_COMMON_DIR/worktrees`中的无效工作树信息 拉回(pull) 拉取分支 : - 拉取远程中的所有分支变更 本地分支 : 未提交更改 : - 丢弃更改 - 不做处理 - 贮藏并自动恢复 - 不拉取远程标签 远程 : 拉回(拉取并合并) 使用变基方式合并分支 @@ -498,7 +741,10 @@ 确保子模块变更已推送 启用强制推送 本地分支 : + 新建 远程仓库 : + 修订 : + 推送指定修订到远程仓库 推送到远程仓库 远程分支 : 跟踪远程分支 @@ -507,12 +753,17 @@ 推送到所有远程仓库 远程仓库 : 标签 : + 推送到新的分支 + 输入新的远端分支名 退出 变基(rebase)操作 自动贮藏并恢复本地变更 + 绕过 pre-rebase 钩子 目标提交 : - 分支 : - 重新加载 + 检测变基冲突中... + 未检测到文件冲突 + 检测变基冲突时出现未知错误 + 检测到冲突文件 添加远程仓库 编辑远程仓库 远程名 : @@ -520,8 +771,10 @@ 仓库地址 : 远程仓库的地址 复制远程地址 - 删除 ... - 编辑 ... + 自定义操作 + 删除... + 编辑... + 启用自动拉取 拉取(fetch)更新 在浏览器中打开 清理远程已删除分支 @@ -534,43 +787,71 @@ 分支 : 终止合并 自动拉取远端变更中... + 排序方式 + 按提交时间 + 按名称 清理本仓库(GC) 本操作将执行`git gc`命令。 清空过滤规则 + 清空 配置本仓库 下一步 自定义操作 自定义操作未设置 - 启用 --reflog 选项 + 主页 + 放弃所有更改 在文件浏览器中打开 快速查找分支/标签/子模块 + 设置在列表中的可见性 + 折叠过滤项列表 不指定 在提交列表中隐藏 + 展开过滤项列表 使用其对提交列表过滤 + 个过滤项已被启用 + 布局方式 + 水平排布 + 竖直排布 + 提交列表排序规则 + 按提交时间 + 按拓扑排序 本地分支 + 更多选项... 定位HEAD - 启用 --first-parent 过滤选项 新建分支 + 清空通知列表 + 本仓库(文件夹) 在 {0} 中打开 使用外部工具打开 - 重新加载 远程列表 添加远程 解决冲突 查找提交 - 文件 + 作者 + 变更内容 提交信息 + 路径 提交指纹 - 作者及提交者 仅在当前分支中查找 + 仅显示分支、标签所引用的提交 + 仅显示合并提交中的第一个提交 + 显示内容 + 显示丢失引用的提交 + 以树型结构展示 以树型结构展示 + 跳过此提交 提交统计 子模块列表 添加子模块 更新子模块 标签列表 新建标签 + 按创建时间 + 按名称 + 排序 在终端中打开 + 查看命令日志 + 访问远程仓库 '{0}' 工作树列表 新增工作树 清理 @@ -579,75 +860,119 @@ 重置模式 : 提交 : 当前分支 : + 重置所选分支(非当前分支) + 重置点 : + 操作分支 : 在文件浏览器中查看 回滚操作确认 目标提交 : 回滚后提交更改 - 编辑提交信息 - 请使用Shift+Enter换行。Enter键已被【确 定】按钮占用。 执行操作中,请耐心等待... 保 存 另存为... 补丁已成功保存! 扫描仓库 根路径 : + 扫描其他自定义路径 检测更新... 检测到软件有版本更新: + 当前版本 : 获取最新版本信息失败! 下 载 忽略此版本 + 新版发布时间 : 软件更新 当前已是最新版本。 - 压缩为单个提交 - 合并入: + 修改子模块追踪分支 + 子模块 : + 当前追踪分支 : + 修改为 : + 可选。当不填写时,恢复默认追踪分支。 + 切换上游分支 + 本地分支 : + 取消追踪 + 上游分支 : + 复制提交指纹 + 跳转到提交 SSH密钥 : SSH密钥文件 开 始 贮藏(stash) 包含未跟踪的文件 - 保留暂存区文件 信息 : - 选填,用于命名此贮藏 + 选填,此贮藏的描述信息 + 模式 : 仅贮藏暂存区的变更 选中文件的所有变更均会被贮藏! 贮藏本地变更 应用(apply) + 应用(apply)选中变更 + 检出新分支 + 复制描述信息 删除(drop) - 应用并删除(pop) + 另存为补丁... 丢弃贮藏确认 丢弃贮藏 : 贮藏列表 查看变更 贮藏列表 提交统计 - 提交次数 - 提交者 + 总览 本月 本周 - 提交次数: 贡献者人数: - 总览 + 提交次数: 子模块 添加子模块 - 复制路径 + 跟踪分支 + 跟踪分支 + 相对路径 + 取消初始化 拉取子孙模块 + 变更历史 + 移动 打开仓库 相对仓库路径 : 本地存放的相对路径。 - 删除子模块 + 删除 + 修改跟踪分支 + 修改远程地址 + 状态 + 未提交修改 + 未初始化 + SHA变更 + 未解决冲突 + 更新 + 仓库 + 子模块变更详情 + 查看变更详情 确 定 - 复制标签名 - 复制标签信息 + 创建者 + 创建时间 + 检出标签... + 比较 2 个标签 + 与其他分支或标签比较... + 与当前 HEAD 比较 + 标签信息 + 标签名 + 创建者 + 复制标签名 + 自定义操作 删除 ${0}$... + 删除选中 {0} 个标签... 合并 ${0}$ 到 ${1}$... 推送 ${0}$... - 仓库地址 : 更新子模块 更新所有子模块 - 启用 '--init' - 启用 '--recursive' + 如未初始化子模块,先初始化 子模块 : - 启用 '--remote' + 递归更新子孙模块 + 更新到子模块自己的远程踪分支 + 仓库地址 : + 日志列表 + 清空日志 + 复制 + 删除 警告 起始页 新建分组 @@ -662,40 +987,57 @@ 打开终端 重新扫描默认克隆路径下的仓库 快速查找仓库... - 排序 本地更改 添加至 .gitignore 忽略列表 忽略所有 *{0} 文件 忽略同目录下所有 *{0} 文件 - 忽略同目录下所有文件 + 忽略该目录下的新文件 忽略本文件 - 修补(--amend) + 忽略同目录下所有新文件 + 修补 现在您已可将其加入暂存区中 + 清空历史提交信息 + 您确定要清空所有的历史提交信息记录吗(执行操作后无法撤回)? 提交 提交并推送 历史输入/模板 触发点击事件 + 提交(修改原始提交) 自动暂存所有变更并提交 - 提交未包含变更文件!是否继续(--allow-empty)? + 您正在向一个游离的 HEAD 提交变更,是否继续提交? + 当前有 {0} 个文件在暂存区中,但仅显示了 {1} 个文件({2} 个文件被过滤掉了),是否继续提交? 检测到冲突 + 解决冲突 + 使用外部工具解决冲突 + 打开合并工具解决冲突 文件冲突已解决 + 使用 MINE + 使用 THEIRS + 查找变更... 显示未跟踪文件 没有提交信息记录 没有可应用的提交信息模板 + 跳过GIT钩子 + 重置提交者 + 署名 已暂存 从暂存区移除选中 从暂存区移除所有 未暂存 暂存选中 暂存所有 - 查看忽略变更文件 + 查看忽略变更文件 模板:${0}$ - 请选中冲突文件,打开右键菜单,选择合适的解决方式 工作区: 配置工作区... 本地工作树 + 連結分支 复制工作树路径 + 最新提交 锁定工作树 + 打开工作树 + 位置 移除工作树 解除工作树锁定 + 好的 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 5452fef63..b07c82996 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -2,183 +2,308 @@ + 關於 關於 SourceGit - • 專案依賴於 - • 圖表繪製元件來自 - © 2024 sourcegit-scm - • 文字編輯器使用 - • 等寬字型來自於 - • 專案原始碼網址 + 發行日期: {0} + 版本說明 開源免費的 Git 客戶端 - 新增工作區 - 簽出分支方式: - 已有分支 - 建立新分支 - 工作區路徑: - 填寫該工作區的路徑。支援相對路徑。 + 新增忽略檔案 + 比對模式: + 儲存路徑: + 新增工作樹 + 工作樹路徑: + 填寫該工作樹的路徑。支援相對路徑。 分支名稱: 選填。預設使用目標資料夾名稱。 追蹤分支 設定遠端追蹤分支 + 簽出分支方式: + 建立新分支 + 已有分支 AI 助理 + 模型 + 重新產生 使用 AI 產生提交訊息 - 套用修補檔 (apply patch) - 錯誤 - 輸出錯誤,並中止套用修補檔 - 更多錯誤 - 與 [錯誤] 級別相似,但輸出更多內容 - 修補檔: + 套用 + 隱藏 SourceGit + 隱藏其他 + 顯示全部 + 嘗試三向合併 選擇修補檔 忽略空白符號 - 忽略 - 關閉所有警告 + 修補檔來源: + 檔案 + 剪貼簿 套用修補檔 - 警告 - 套用修補檔,輸出關於空白字元的警告 空白字元處理: + 套用擱置變更 + 套用擱置變更後刪除 + 還原索引中已暫存的變更 (--index) + 已選擇擱置變更: 封存 (archive)... 封存檔案路徑: 選擇封存檔案的儲存路徑 指定的提交: 封存 SourceGit Askpass + 輸入您的密碼: 不追蹤變更的檔案 沒有不追蹤變更的檔案 - 移除 + 載入本機圖片... + 重新載入 二進位檔案不支援該操作! + 二分搜尋 (bisect) + 中止 + 標記為錯誤 + 標記為良好 + 無法確認 + 二分搜尋進行中。請簽出另一個提交,然後標記該提交為「良好」或「錯誤」。 + 二分搜尋進行中。請標記目前的提交為「錯誤」,或簽出另一個提交並標記為「錯誤」。 + 二分搜尋進行中。請標記目前的提交為「良好」或「錯誤」,然後簽出另一個提交。 + 二分搜尋進行中。目前的提交是「良好」還是「錯誤」? 逐行溯源 (blame) - 所選擇的檔案不支援該操作! + 對上一個版本執行逐行溯源 + 忽略空白符號變化 + 所選擇的檔案不支援該操作! 簽出 (checkout) ${0}$... - 與其他分支比較 + 比較所選的 2 個分支 + 與其他分支或標籤比較... 與目前 HEAD 比較 - 與本機工作區比較 + 與 ${0}$ 比較 複製分支名稱 + 建立拉取請求... + 為上游分支 ${0}$ 建立拉取請求... + 自訂動作 刪除 ${0}$... - 刪除所選的 {0} 個分支 - 捨棄所有變更 + 刪除所選的 {0} 個分支... + 編輯 ${0}$ 的描述... 快轉 (fast-forward) 到 ${0}$ 提取 (fetch) ${0}$ 到 ${1}$... - Git 工作流 - 完成 ${0}$ + Git 工作流 - 完成 ${0}$... + 互動式重定基底 ${0}$ 至 ${1}$ 合併 ${0}$ 到 ${1}$... - 拉取 (pull) ${0}$ + 合併 {0} 個分支到目前分支... + 拉取 (pull) ${0}$... 拉取 (pull) ${0}$ 內容至 ${1}$... - 推送 (push) ${0}$ + 推送 (push) ${0}$... 重定基底 (rebase) ${0}$ 分支至 ${1}$... 重新命名 ${0}$... - 切換上游分支 - 取消設定上游分支 - 分支比較 - 位元組 + 重設 ${0}$ 至 ${1}$... + 切換到 ${0}$ (工作樹) + 切換上游分支... + 領先 {0} 次提交 + 領先 {0} 次提交,落後 {1} 次提交 + 落後 {0} 次提交 + 無效 + 遠端 + 狀態 + 上游分支 + 遠端網址 + 工作樹 取 消 - 重設檔案為此版本 重設檔案到上一版本 + 重設檔案為此版本 產生提交訊息 + 解決衝突 (內建工具) + 解決衝突 (外部工具) + 重設檔案到 ${0}$ 切換變更顯示模式 檔案名稱 + 路徑列表模式 全路徑列表模式 檔案目錄樹狀結構模式 + 修改子模組的遠端網址 + 子模組: + 遠端網址: 簽出 (checkout) 分支 - 簽出 (checkout) 提交 - 注意: 執行該操作後,目前 HEAD 會變為分離 (detached) 狀態! - 提交: - 目標分支: 未提交變更: - 捨棄變更 - 不做處理 - 擱置變更並自動復原 + 目標分支: + 目前的 HEAD 包含未連結至任何分支或標籤的提交! 您是否要繼續? + 以下子模組需要更新: {0},您要立即更新嗎? + 簽出分支並快轉 + 上游分支: + 從所選擱置簽出分支 + 新分支: + 所選擱置: + 簽出提交或標籤 + 目標: + 注意: 執行該操作後,目前 HEAD 會變為分離 (detached) 狀態! 揀選提交 提交資訊中追加來源資訊 提交列表: 提交變更 對比的父提交: - 通常您不能對一個合併進行揀選 (cherry-pick),因為您不知道合併的哪一邊應該被視為主線。這個選項指定了作為主線的父提交,允許揀選相對於該提交的修改。 + 通常您無法揀選 (cherry-pick) 合併提交,因為無法判斷哪個父提交應視為主線。此選項可指定作為主線的父提交。 捨棄擱置變更確認 您正在捨棄所有的擱置變更,一經操作便無法復原,是否繼續? 複製 (clone) 遠端存放庫 額外參數: 其他複製參數,選填。 + 書籤: + 群組: 本機存放庫名稱: 本機存放庫目錄的名稱,選填。 - 父級目錄: + 上層目錄: + 初始化並更新子模組 遠端存放庫: 關閉 提交訊息編輯器 - 揀選 (cherry-pick) 此提交 + 分支列表 + 分支 & 標籤 + 自訂動作 + 檔案列表 + 簽出 (checkout) 此提交... + 揀選 (cherry-pick) 此提交... 揀選 (cherry-pick)... - 簽出 (checkout) 此提交 與目前 HEAD 比較 - 與本機工作區比較 - 複製摘要資訊 - 複製提交編號 + 與本機工作樹比較 + 作者 + 修改時間 + 提交訊息 + 提交者 + 提交時間 + 提交編號 + 標題 自訂動作 - 互動式重定基底 (rebase -i) ${0}$ 到此處 - 重定基底 (rebase) ${0}$ 到此處 - 重設 (reset) ${0}$ 到此處 - 復原此提交 - 編輯提交訊息 + 互動式重定基底 (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} 表示存放庫路徑、${SHA} 表示所選的提交編號 + 內建參數: + + ${REPO} 存放庫路徑 + ${REMOTE} 所選的遠端存放庫或所選分支的遠端 + ${BRANCH} 所選的分支。遠端分支不包含遠端名稱 + ${BRANCH_FRIENDLY_NAME} 所選的分支。遠端分支會包含遠端名稱 + ${SHA} 所選的提交編號 + ${TAG} 所選的標籤 + ${FILE} 所選的檔案,相對於存放庫根目錄的路徑 + $1, $2 ... 輸入控制項中的值 可執行檔案路徑: + 輸入控制項: + 編輯 名稱: 執行範圍: + 選取的分支 選取的提交 + 選取的檔案 + 遠端存放庫 存放庫 + 選取的標籤 + 等待自訂動作執行結束 電子郵件 電子郵件地址 Git 設定 + 在自動更新子模組之前先詢問 啟用定時自動提取 (fetch) 遠端更新 分鐘 + 自訂約定式提交類型 預設遠端存放庫 - 提交訊息追加署名 (--signoff) - 拉取變更時進行清理 (--prune) + 自動更新子模組時啟用「--recursive」選項 + 預設合併模式 Issue 追蹤 - 新增符合 GitHub Issue 規則 - 新增符合 Jira 規則 + 新增符合 Azure DevOps 規則 + 新增符合 Gerrit Change-Id 規則 + 新增符合 Gitee 議題規則 + 新增符合 Gitee 合併請求規則 + 新增符合 GitHub Issue 規則 新增符合 GitLab 議題規則 新增符合 GitLab 合併請求規則 + 新增符合 Jira 規則 新增自訂規則 符合 Issue 的正規表達式: 規則名稱: + 寫入 .issuetracker 檔案以共用此規則 為 Issue 產生的網址連結: 可在網址中使用 $1、$2 等變數填入正規表達式相符的內容 AI - 偏好服務: - 設定 [偏好服務] 後,SourceGit 將於此存放庫中使用該服務,否則會顯示 AI 服務列表供使用者選擇。 + 偏好服務: + 設定 [偏好服務] 後,SourceGit 將於此存放庫中使用該服務,否則會顯示 AI 服務列表供使用者選擇。 HTTP 代理 HTTP 網路代理 使用者名稱 用於本存放庫的使用者名稱 + 編輯自訂動作輸入控制項 + 啟用時的指令參數: + 勾選核取方塊後,此值將用於指令參數中 + 描述: + 預設值: + 目標路徑是否為資料夾: + 名稱: + 選項列表: + 請使用英文「|」符號分隔選項 + 字串格式化: + 選填。用於格式化輸出字串。若輸入為空則會忽略此設定。請使用 `${VALUE}` 來表示輸入字串。 + 內建變數 ${REPO}、${REMOTE}、${BRANCH}、${BRANCH_FRIENDLY_NAME}、${SHA}、${FILE} 及 ${TAG} 在此處仍可使用 + 類型: + 輸出包含遠端的名稱: 工作區 顏色 + 名稱 啟動時還原上次開啟的存放庫 + 確認繼續 + 未包含任何檔案變更! 您是否仍要提交 (--allow-empty)? + 暫存全部變更並提交 + 僅暫存所選變更並提交 + 未包含任何檔案變更! 您是否仍要提交 (--allow-empty) 或自動暫存變更並提交? + 系統提示 + 您需要重新啟動此應用程式才能套用變更! 產生約定式提交訊息 破壞性變更: 關閉的 Issue: @@ -188,18 +313,17 @@ 類型: 複製 複製全部內容 + 複製為修補檔 + 複製完整路徑 複製路徑 - 複製檔案名稱 新增分支... 新分支基於: 完成後切換到新分支 未提交變更: - 捨棄變更 - 不做處理 - 擱置變更並自動復原 新分支名稱: 輸入分支名稱。 建立本機分支 + 允許覆寫現有分支 新增標籤... 標籤位於: 使用 GPG 簽章 @@ -214,15 +338,28 @@ 輕量標籤 按住 Ctrl 鍵將直接以預設參數執行 剪下 + 捨棄變更 + 不做處理 + 擱置變更並自動復原 + 取消初始化子模組 + 強制取消,即使包含本機變更 + 子模組: 刪除分支確認 + 您是否也想刪除以下遠端分支? 分支名稱: + 強制刪除,即使包含未合併的提交 您正在刪除遠端上的分支,請務必小心! - 同時刪除遠端分支 ${0}$ 刪除多個分支 您正在嘗試一次性刪除多個分支,請務必仔細檢查後再刪除! + 刪除多個標籤 + 同時刪除遠端存放庫中的這些標籤 + 您正在嘗試一次性刪除多個標籤,請務必仔細檢查後再刪除! 刪除遠端確認 遠端名稱: + 路徑: 目標: + 所有子節點都會從清單中移除。 + 只會從清單中移除,而不會刪除磁碟中的檔案! 刪除群組確認 刪除存放庫確認 刪除子模組確認 @@ -231,20 +368,27 @@ 標籤名稱: 同時刪除遠端存放庫中的此標籤 二進位檔案 - 目前大小 - 原始大小 - 複製 - 檔案權限已變更 + 空檔案 + 第一個差異 忽略空白符號變化 + 混合對比 + 差異對比 + 並排對比 + 滑桿對比 + 最後一個差異 LFS 物件變更 + 變更後 下一個差異 沒有變更或僅有換行字元差異 + 變更前 上一個差異 - 另存為修補檔 + 另存為修補檔 (patch) 顯示隱藏符號 並排對比 子模組 + 已刪除 新增 + + {0} 項未提交變更 交換比對雙方 語法上色 自動換行 @@ -253,53 +397,70 @@ 減少可見的行數 增加可見的行數 請選擇需要對比的檔案 - 使用外部比對工具檢視 + 目錄內容變更歷史 + 未提交的本機變更 + 目前分支 HEAD 與上游不相符 + 已更新至最新 捨棄變更 所有本機未提交的變更。 變更: 包括所有已忽略的檔案 + 包括已變更或刪除的檔案 + 包含未追蹤檔案 將捨棄總計 {0} 項已選取的變更 您無法復原此操作,請確認後再繼續! + 編輯分支的描述 + 目標: 書籤: 名稱: 目標: 編輯群組 編輯存放庫 - 執行自訂動作 - 自訂動作: - 快進 (fast-forward,無需 checkout) + 目標: + 本存放庫 提取 (fetch) 提取所有的遠端存放庫 + 強制覆寫本機 REFs 不提取遠端標籤 遠端存放庫: 提取遠端存放庫內容 不追蹤此檔案的變更 + 自訂動作 捨棄變更... 捨棄已選的 {0} 個檔案變更... - 捨棄選取的變更 - 使用外部合併工具開啟 + 使用 ${0}$ 另存為修補檔 (patch)... 暫存 (add) 暫存 (add) 已選的 {0} 個檔案 - 暫存選取的變更 擱置變更 (stash)... 擱置變更 (stash) 所選的 {0} 個檔案... 取消暫存 從暫存中移除 {0} 個檔案 - 取消暫存選取的變更 - 使用對方版本 (checkout --theirs) - 使用我方版本 (checkout --ours) + 使用我方版本 (ours) + 使用對方版本 (theirs) 檔案歷史 - 檔案内容 檔案變更 + 檔案內容 + 檔案類型變更: + 刪除檔案類型: + 目錄 + 可執行檔案 + 新增檔案類型: + 一般檔案 + 子模組 + 符號連結 + 未知 Git 工作流 開發分支: 功能分支: 功能分支前置詞: + 完成 ${0}$ 完成功能分支 完成修復分支 完成發行分支 目標分支: + 合併前重定基底 + 壓縮為單一提交後合併 修復分支: 修復分支前置詞: 初始化 Git 工作流 @@ -307,10 +468,12 @@ 發行分支: 版本分支: 發行分支前置詞: + 開始於: 開始功能分支... 開始功能分支 開始修復分支... 開始修復分支 + 分支名稱: 輸入分支名稱 開始發行分支... 開始發行分支 @@ -321,8 +484,8 @@ 規則: 加入 LFS 追蹤檔案規則 提取 (fetch) - 提取 LFS 物件 執行 `git lfs fetch` 以下載遠端 LFS 物件,但不會更新工作副本。 + 提取 LFS 物件 啟用 Git LFS 支援 顯示 LFS 物件鎖 沒有鎖定的 LFS 物件 @@ -330,166 +493,247 @@ 僅顯示被我鎖定的檔案 LFS 物件鎖 解鎖 + 解鎖所有由我鎖定的檔案 + 您確定要解鎖所有由您自己鎖定的檔案嗎? 強制解鎖 清理 (prune) 執行 `git lfs prune` 以從本機中清理目前版本不需要的 LFS 物件 拉取 (pull) - 拉取 LFS 物件 執行 `git lfs pull` 以下載遠端 LFS 物件並更新工作副本。 + 拉取 LFS 物件 推送 (push) - 推送 LFS 物件 將大型檔案推送到 Git LFS 遠端服務 + 推送 LFS 物件 遠端存放庫: 追蹤名稱為「{0}」的檔案 追蹤所有 *{0} 檔案 + 選取要前往的提交 歷史記錄 - 切換橫向/縱向顯示 作者 修改時間 + 提交時間 路線圖與訊息標題 提交編號 - 提交時間 + 上色分支 + 全部 + 僅目前分支 + 目前分支和選取的提交 + 僅選取的提交 已選取 {0} 項提交 + 顯示欄位 可以按住 Ctrl 或 Shift 鍵選擇多個提交 可以按住 ⌘ 或 ⇧ 鍵選擇多個提交 小提示: + 在新視窗中開啟 + 提交詳細資訊 + 比較 快速鍵參考 全域快速鍵 - 取消彈出面板 + 複製 (clone) 遠端存放庫 關閉目前頁面 - 切換到上一個頁面 切換到下一個頁面 + 切換到上一個頁面 新增頁面 - 開啟偏好設定面板 + 開啟本機存放庫 + 開啟偏好設定面板 + 顯示工作區的下拉式選單 + 切換目前頁面 + 放大/縮小內容 存放庫頁面快速鍵 提交暫存區變更 提交暫存區變更並推送 自動暫存全部變更並提交 - 根據選取的提交建立新的分支 - 捨棄選取的變更 + 新增分支 提取 (fetch) 遠端的變更 切換左邊欄為分支/標籤等顯示模式 (預設) + 前往所選提交的子提交 + 前往所選提交的父提交 + 開啟命令面板 + 切換左邊欄為歷史搜尋模式 拉取 (pull) 遠端的變更 - 推送 (push) 本地變更到遠端存放庫 + 推送 (push) 本機變更到遠端存放庫 強制重新載入存放庫 - 暫存或取消暫存選取的變更 - 切換左邊欄為歷史搜尋模式 + 在歷史頁面中展開/收合詳細面板 顯示本機變更 顯示歷史記錄 顯示擱置變更列表 - 文字編輯器快速鍵 - 關閉搜尋面板 - 前往下一個搜尋相符的位置 - 前往上一個搜尋相符的位置 - 開啟搜尋面板 + 捨棄 暫存 取消暫存 - 捨棄 初始化存放庫 + 您是否要在該路徑執行 `git init` 以初始化存放庫? + 無法在指定路徑開啟本機存放庫。原因: 路徑: - 揀選 (cherry-pick) 操作進行中。點選 [中止] 復原到操作前的狀態。 - 合併操作進行中。點選 [中止] 復原到操作前的狀態。 - 重定基底 (rebase) 操作進行中。點選 [中止] 復原到操作前的狀態。 - 復原提交操作進行中。點選 [中止] 復原到操作前的狀態。 + 揀選 (cherry-pick) 操作進行中。 + 正在處理提交 + 合併操作進行中。 + 正在處理 + 重定基底 (rebase) 操作進行中。 + 目前停止於 + 復原提交操作進行中。 + 正在復原提交 互動式重定基底 - 目標分支: + 自動擱置變更並復原本機變更 + 繞過 pre-rebase hook 起始提交: - 在瀏覽器中存取網址 - 複製網址 + 拖曳以重新排序提交 + 目標分支: + 複製連結 + 在瀏覽器中開啟連結 + 命令列表 發生錯誤 系統提示 + 有新版本可用 + 開啟存放庫 + 頁面列表 + 工作區列表 合併分支 + 編輯合併訊息 目標分支: 合併方式: - 合併分支: + 合併來源: + 正在偵測合併衝突... + 合併操作不會產生衝突 + 偵測合併衝突時發生未知錯誤 + 合併操作將產生檔案衝突 + 先套用我方版本 (ours),再套用對方版本 (theirs) + 先套用對方版本 (theirs),再套用我方版本 (ours) + 套用兩者 + 所有衝突已經解決 + {0} 個衝突尚未解決 + 我方版本 (ours) + 下一個衝突 + 上一個衝突 + 合併結果 + 儲存並暫存 + 對方版本 (theirs) + 解決衝突 + 捨棄未儲存的變更? + 僅套用我方版本 (ours) + 僅套用對方版本 (theirs) + 復原變更 + 合併 (多個來源) + 提交變更 + 合併策略: + 目標列表: + 移動子模組 + 移動到: + 子模組: 調整存放庫分組 請選擇目標分組: 名稱: + 尚未設定 Git。請開啟 [偏好設定] 以設定 Git 路徑。 + 開啟 + 系統預設編輯器 瀏覽程式資料目錄 - 開啟檔案... + 開啟檔案 + 使用外部比對工具檢視 + 開啟本機存放庫 + 書籤: + 群組: + 存放庫位置: 選填。 新增分頁 - 設定書籤 關閉分頁 關閉其他分頁 關閉右側分頁 複製存放庫路徑 + 編輯 + 移至工作區 + 重新整理 新分頁 貼上 - 剛剛 - {0} 分鐘前 - {0} 小時前 - 昨天 {0} 天前 + 1 小時前 + {0} 小時前 + 剛剛 上個月 - {0} 個月前 一年前 + {0} 分鐘前 + {0} 個月前 {0} 年前 - 偏好設定 - AI - 伺服器 - API 金鑰 - 模型 - 名稱 - 分析變更差異提示詞 - 產生提交訊息提示詞 - 外觀設定 - 預設字型 - 字型大小 - 預設 - 程式碼 - 等寬字型 - 僅在文字編輯器中使用等寬字型 - 佈景主題 - 自訂主題 - 使用固定寬度的分頁標籤 - 使用系統原生預設視窗樣式 - 對比/合併工具 - 安裝路徑 - 填寫可執行檔案所在路徑 - 工具 - 一般設定 - 啟動時檢查軟體更新 - 顯示語言 - 最大歷史提交數 - 在提交路線圖中顯示修改時間而非提交時間 - 提交標題字數偵測 - Git 設定 - 自動換行轉換 - 預設複製 (clone) 路徑 - 電子郵件 - 預設 Git 使用者電子郵件 - 安裝路徑 - 使用者名稱 - 預設 Git 使用者名稱 - Git 版本 - 本軟體要求 Git 最低版本為 2.23.0 - GPG 簽章 - 啟用提交簽章 - 啟用標籤簽章 - GPG 簽章格式 - 可執行檔案路徑 - 填寫 gpg.exe 所在路徑 - 使用者簽章金鑰 - 填寫簽章提交所使用的金鑰 - 第三方工具整合 - 終端機/Shell - 終端機/Shell - 安裝路徑 + 昨天 + 偏好設定 + 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_DIR/worktrees` 中的無效工作區資訊 + 清理工作樹 + 清理在 `$GIT_COMMON_DIR/worktrees` 中的無效工作樹資訊 拉取 (pull) 拉取分支: - 拉取遠端中的所有分支 本機分支: 未提交變更: - 捨棄變更 - 不做處理 - 擱置變更並自動復原 - 不拉取遠端標籤 遠端: 拉取 (提取並合併) 使用重定基底 (rebase) 合併分支 @@ -497,7 +741,10 @@ 確保已推送子模組 啟用強制推送 本機分支: + 新增 遠端存放庫: + 修訂: + 推送修訂到遠端存放庫 推送到遠端存放庫 遠端分支: 追蹤遠端分支 @@ -506,12 +753,17 @@ 推送到所有遠端存放庫 遠端存放庫: 標籤: + 推送到新的分支 + 輸入新的遠端分支名稱: 結束 重定基底 (rebase) 操作 自動擱置變更並復原本機變更 + 繞過 pre-rebase hook 目標提交: - 分支: - 重新載入 + 正在偵測重定基底衝突... + 重定基底操作不會產生衝突 + 偵測重定基底衝突時發生未知錯誤 + 重定基底操作將產生檔案衝突 新增遠端存放庫 編輯遠端存放庫 遠端名稱: @@ -519,134 +771,208 @@ 存放庫網址: 遠端存放庫的網址 複製遠端網址 + 自訂動作 刪除... 編輯... + 啟用定時自動提取 提取 (fetch) 更新 - 在瀏覽器中存取網址 + 在瀏覽器中開啟網址 清理遠端已刪除分支 - 刪除工作區操作確認 + 刪除工作樹操作確認 啟用 [--force] 選項 - 目標工作區: + 目標工作樹: 分支重新命名 新名稱: 新的分支名稱不能與現有分支名稱相同 分支: 中止 - 自動提取遠端變更中... + 正在自動提取遠端變更... + 排序 + 依建立時間 + 依名稱遞增排序 清理本存放庫 (GC) - 本操作將執行 `git gc` 命令。 + 本操作將執行 `git gc` 指令。 清空篩選規則 + 清空 設定本存放庫 下一步 自訂動作 沒有自訂的動作 - 啟用 [--reflog] 選項 + 首頁 + 捨棄所有變更 在檔案瀏覽器中開啟 快速搜尋分支/標籤/子模組 - 不指定 - 在提交清單中隱藏 - 使用其來篩選提交清單 + 篩選以顯示或隱藏 + 摺疊篩選器 + 取消指定 + 在提交列表中隱藏 + 展開篩選器 + 以其篩選提交列表 + 個篩選規則已啟用 + 版面配置 + 橫向顯示 + 縱向顯示 + 提交顯示順序 + 依時間排序 + 依拓撲排序 本機分支 + 更多選項... 回到 HEAD - 啟用 [--first-parent] 選項 新增分支 + 清除所有通知 + 此存放庫 (資料夾) 在 {0} 中開啟 使用外部工具開啟 - 重新載入 遠端列表 新增遠端 解決衝突 搜尋提交 - 檔案 + 作者 + 變更內容 提交訊息 + 路徑 提交編號 - 作者及提交者 僅搜尋目前分支 - 以樹型結構展示 + 只顯示分支和標籤引用的提交 + 只顯示合併提交的第一父提交 + 顯示內容 + 顯示失去參照的提交 + 以樹狀結構顯示 + 以樹狀結構顯示 + 跳過此提交 提交統計 子模組列表 新增子模組 更新子模組 標籤列表 新增標籤 + 依建立時間 + 依名稱 + 排序 在終端機中開啟 - 工作區列表 - 新增工作區 + 檢視 Git 指令記錄 + 檢視遠端存放庫 '{0}' + 工作樹列表 + 新增工作樹 清理 遠端存放庫網址 重設目前分支到指定版本 重設模式: 移至提交: 目前分支: + 重設選取的分支 (非目前分支) + 重設位置: + 選取分支: 在檔案瀏覽器中檢視 復原操作確認 目標提交: 復原後提交變更 - 編輯提交訊息 - 請使用 Shift + Enter 換行。Enter 鍵已被 [確定] 按鈕佔用。 - 執行操作中,請耐心等待... + 正在執行操作,請耐心等待... 儲存 另存新檔... 修補檔已成功儲存! 掃描存放庫 頂層目錄: + 掃描其他自訂目錄 檢查更新... 軟體有版本更新: + 目前版本: 取得最新版本資訊失敗! 下載 忽略此版本 + 新版本發行日期: 軟體更新 目前已是最新版本。 - 壓縮為單個提交 - 合併入: + 設定子模組的追蹤分支 + 子模組: + 目前追蹤分支: + 變更為: + 選填。留空時設定為預設值。 + 切換上游分支 + 本機分支: + 取消設定上游分支 + 上游分支: + 複製提交編號 + 前往此提交 SSH 金鑰: SSH 金鑰檔案 開 始 擱置變更 (stash) 包含未追蹤的檔案 - 保留已暫存的變更 擱置變更訊息: - 選填,用於命名此擱置變更 + 選填,用於描述此擱置變更 + 操作模式: 僅擱置已暫存的變更 已選取的檔案中的變更均會被擱置! 擱置本機變更 套用 (apply) + 套用 (apply) 所選變更 + 簽出分支 + 複製描述訊息 刪除 (drop) - 套用並刪除 (pop) + 另存為修補檔 (patch)... 捨棄擱置變更確認 捨棄擱置變更: 擱置變更 檢視變更 擱置變更列表 提交統計 - 提交次數 - 提交者 + 總覽 本月 本週 - 提交次數: 貢獻者人數: - 總覽 + 提交次數: 子模組 新增子模組 - 複製路徑 + 追蹤分支 + 追蹤分支 + 相對路徑 + 取消初始化 提取子模組 + 變更歷史 + 移動 開啟存放庫 相對存放庫路徑: 本機存放的相對路徑。 - 刪除子模組 + 刪除 + 變更追蹤分支 + 變更遠端網址 + 狀態 + 未提交變更 + 未初始化 + SHA 變更 + 未解決的衝突 + 更新 + 存放庫 + 子模組變更詳細資訊 + 開啟變更詳細資訊 確 定 - 複製標籤名稱 - 複製標籤訊息 + 建立者 + 建立時間 + 簽出標籤... + 比較 2 個標籤 + 與其他分支或標籤比較... + 與目前 HEAD 比較 + 標籤訊息 + 標籤名稱 + 建立者 + 複製標籤名稱 + 自訂動作 刪除 ${0}$... + 刪除所選的 {0} 個標籤... 合併 ${0}$ 到 ${1}$... 推送 ${0}$... - 存放庫網址: 更新子模組 更新所有子模組 - 啟用 [--init] 選項 - 啟用 [--recursive] 選項 + 如果子模組尚未初始化,則將其初始化 子模組: - 啟用 [--remote] 選項 + 遞迴更新子模組 + 更新至子模組的遠端追蹤分支 + 存放庫網址: + 記錄 + 清除所有記錄 + 複製 + 刪除 警告 起始頁 新增群組 @@ -656,45 +982,62 @@ 支援拖放以新增目錄與自訂群組。 編輯 調整存放庫分組 - 開啟所有包含存放庫 + 開啟所有存放庫 開啟本機存放庫 開啟終端機 - 快速搜尋存放庫... 重新掃描預設複製 (clone) 目錄下的存放庫 - 排序 + 快速搜尋存放庫... 本機變更 加入至 .gitignore 忽略清單 忽略所有 *{0} 檔案 忽略同路徑下所有 *{0} 檔案 - 忽略同路徑下所有檔案 + 忽略本路徑下的新增檔案 忽略本檔案 - 修補 (--amend) + 忽略同路徑下所有新增檔案 + 修補 現在您已可將其加入暫存區中 + 清除提交訊息歷史 + 您確定要清除所有提交訊息記錄嗎 (執行後無法復原)? 提 交 提交並推送 歷史輸入/範本 觸發點擊事件 + 提交 (修改原始提交) 自動暫存全部變更並提交 - 未包含任何檔案變更! 您是否仍要提交 (--allow-empty)? - 檢測到衝突 + HEAD 目前處於分離狀態。您確定要繼續提交變更嗎? + 您已暫存 {0} 個檔案,但只顯示 {1} 個檔案 ({2} 個檔案被篩選器隱藏)。您確定要繼續提交嗎? + 偵測到衝突 + 解決衝突 + 使用外部工具解決衝突 + 使用外部合併工具開啟 檔案衝突已解決 + 使用我方版本 (ours) + 使用對方版本 (theirs) + 搜尋變更... 顯示未追蹤檔案 沒有提交訊息記錄 沒有可套用的提交訊息範本 + 繞過 Hooks 檢查 + 重設作者 + 署名 已暫存 取消暫存選取的檔案 取消暫存所有檔案 未暫存 暫存選取的檔案 暫存所有檔案 - 檢視不追蹤變更的檔案 + 檢視不追蹤變更的檔案 範本: ${0}$ - 請選擇發生衝突的檔案,開啟右鍵選單,選擇合適的解決方式 - 工作區: + 工作區: 設定工作區... - 本機工作區 - 複製工作區路徑 - 鎖定工作區 - 移除工作區 - 解除鎖定工作區 + 本機工作樹 + 分支 + 複製工作樹路徑 + 最新提交 + 鎖定工作樹 + 開啟工作樹 + 位置 + 移除工作樹 + 解除鎖定工作樹 + diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml index 829705497..27878b5cc 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -1,44 +1,55 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - - - + + @@ -504,110 +697,18 @@ - + - - - - - - - - - - - + @@ -830,9 +940,6 @@ - @@ -857,6 +964,20 @@ + + + + + + + + + + + + + + + - + + + + + + - - - - + - - - - - - - + + + + + + - - - - - - - + + + + + + diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml index 6326023aa..414316380 100644 --- a/src/Resources/Themes.axaml +++ b/src/Resources/Themes.axaml @@ -3,21 +3,24 @@ #FFF0F5F9 - #00000000 + #FF999999 #FFCFDEEA #FFF0F5F9 - #FFF8F8F8 + #FFF4F8FB + White #FFFAFAFA #FFB0CEE8 #FF1F1F1F - DarkGreen #FF836C2E - #FFFFFFFF + #FFFFFFFF + #400078D7 + #40FF8C00 #FFCFCFCF #FF898989 #FFCFCFCF #FFF8F8F8 White + #FF898989 #FF1F1F1F #FF6F6F6F #10000000 @@ -25,53 +28,67 @@ #80FF9797 #A7E1A7 #F19B9D + DarkCyan #0000EE + #FFE4E4E4 + Black + #FFF0F5F9 #FF252525 - #FF444444 + #FF606060 #FF1F1F1F - #FF2C2C2C + #FF2F2F2F #FF2B2B2B + #FF393939 #FF1C1C1C #FF8F8F8F - #FFDDDDDD - #84c88a + #FFE0E0E0 #FFFAFAD2 - #FF252525 + #FF252525 + #400078D7 + #40FF8C00 #FF181818 #FF7C7C7C #FF404040 #FF303030 #FF333333 - #FFDDDDDD - #40F1F1F1 + #FF4F4F4F + #FFDFDFDF + #FF9F9F9F #3C000000 #C03A5C3F #C0633F3E #A0308D3C #A09F4247 + DarkCyan #4DAAFC + #FF383838 + #FFF0F0F0 + #FF272727 - + + - - + + + + @@ -81,9 +98,12 @@ + + + + fonts:Inter#Inter - fonts:SourceGit#JetBrains Mono - fonts:SourceGit#JetBrains Mono + fonts:SourceGit#JetBrains Mono NL diff --git a/src/SourceGit.csproj b/src/SourceGit.csproj index a5a97df95..fc8fe02d1 100644 --- a/src/SourceGit.csproj +++ b/src/SourceGit.csproj @@ -1,31 +1,42 @@ - + WinExe - net9.0 - true + net10.0 App.manifest App.ico $([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)\\..\\VERSION")) - false true true + false SourceGit OpenSource GIT client sourcegit-scm - Copyright © 2024 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 + + + + + + @@ -37,17 +48,20 @@ - - - - - - - - - - - + + + + + + + + + + + + + + @@ -55,7 +69,15 @@ - - - + + + + + + + + + + + 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 d64245725..f5182ef9f 100644 --- a/src/ViewModels/AddRemote.cs +++ b/src/ViewModels/AddRemote.cs @@ -44,10 +44,15 @@ public string SSHKey set => SetProperty(ref _sshkey, value, true); } + public bool FetchWithoutTags + { + get; + set; + } = false; + public AddRemote(Repository repo) { _repo = repo; - View = new Views.AddRemote() { DataContext = this }; } public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) @@ -88,28 +93,34 @@ public static ValidationResult ValidateSSHKey(string sshkey, ValidationContext c return ValidationResult.Success; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding remote ..."; - return Task.Run(() => + var log = _repo.CreateLog("Add Remote"); + Use(log); + + var succ = await new Commands.Remote(_repo.FullPath) + .Use(log) + .AddAsync(_name, _url); + + if (succ) { - var succ = new Commands.Remote(_repo.FullPath).Add(_name, _url); - if (succ) - { - SetProgressDescription("Fetching from added remote ..."); - new Commands.Config(_repo.FullPath).Set($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); - new Commands.Fetch(_repo.FullPath, _name, false, false, SetProgressDescription).Exec(); - } - CallUIThread(() => - { - _repo.MarkFetched(); - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - }); - return 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 readonly Repository _repo = null; diff --git a/src/ViewModels/AddSubmodule.cs b/src/ViewModels/AddSubmodule.cs index 657075a9e..e9554ea34 100644 --- a/src/ViewModels/AddSubmodule.cs +++ b/src/ViewModels/AddSubmodule.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; @@ -14,12 +15,10 @@ public string Url set => SetProperty(ref _url, value, true); } - [Required(ErrorMessage = "Reletive path is required!!!")] - [CustomValidation(typeof(AddSubmodule), nameof(ValidateRelativePath))] public string RelativePath { get => _relativePath; - set => SetProperty(ref _relativePath, value, true); + set => SetProperty(ref _relativePath, value); } public bool Recursive @@ -31,42 +30,41 @@ public bool Recursive 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"); + return ValidationResult.Success; } - public static ValidationResult ValidateRelativePath(string path, ValidationContext ctx) + public override async Task Sure() { - if (Path.Exists(path)) - { - return new ValidationResult("Give path is exists already!"); - } + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Adding submodule..."; + + var log = _repo.CreateLog("Add Submodule"); + Use(log); - if (Path.IsPathRooted(path)) + var relativePath = _relativePath; + if (string.IsNullOrEmpty(relativePath)) { - return new ValidationResult("Path must be relative to this repository!"); + if (_url.EndsWith("/.git", StringComparison.Ordinal)) + relativePath = Path.GetFileName(Path.GetDirectoryName(_url)); + else if (_url.EndsWith(".git", StringComparison.Ordinal)) + relativePath = Path.GetFileNameWithoutExtension(_url); + else + relativePath = Path.GetFileName(_url); } - return ValidationResult.Success; - } + var succ = await new Commands.Submodule(_repo.FullPath) + .Use(log) + .AddAsync(_url, relativePath, Recursive); - public override Task Sure() - { - _repo.SetWatcherEnabled(false); - ProgressDescription = "Adding submodule..."; - - 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 readonly Repository _repo = null; 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 index cf7360294..8f8aea2c4 100644 --- a/src/ViewModels/AddWorktree.cs +++ b/src/ViewModels/AddWorktree.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; @@ -12,7 +13,7 @@ public class AddWorktree : Popup public string Path { get => _path; - set => SetProperty(ref _path, value); + set => SetProperty(ref _path, value, true); } public bool CreateNewBranch @@ -23,69 +24,76 @@ public bool CreateNewBranch if (SetProperty(ref _createNewBranch, value, true)) { if (value) - SelectedBranch = string.Empty; + SelectedBranch = null; else - SelectedBranch = LocalBranches.Count > 0 ? LocalBranches[0] : string.Empty; + SelectedBranch = LocalBranches.Count > 0 ? LocalBranches[0] : null; } } } - public List LocalBranches + public List LocalBranches { get; private set; } - public List RemoteBranches + public Models.Branch SelectedBranch { - get; - private set; + get => _selectedBranch; + set => SetProperty(ref _selectedBranch, value); } - public string SelectedBranch + public string NewBranchName { - get => _selectedBranch; - set => SetProperty(ref _selectedBranch, value); + get => _newBranchName; + set => SetProperty(ref _newBranchName, value); } public bool SetTrackingBranch { get => _setTrackingBranch; - set => SetProperty(ref _setTrackingBranch, value); + set + { + if (SetProperty(ref _setTrackingBranch, value)) + AutoSelectTrackingBranch(); + } } - public string SelectedTrackingBranch + public List RemoteBranches { get; - set; + 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(); + LocalBranches = new List(); + RemoteBranches = new List(); foreach (var branch in repo.Branches) { if (branch.IsLocal) - LocalBranches.Add(branch.Name); + { + if (!branch.IsCurrent && !branch.HasWorktree) + LocalBranches.Add(branch); + } else - RemoteBranches.Add(branch.FriendlyName); + { + RemoteBranches.Add(branch); + } } - - if (RemoteBranches.Count > 0) - SelectedTrackingBranch = RemoteBranches[0]; - else - SelectedTrackingBranch = string.Empty; - - View = new Views.AddWorktree() { DataContext = this }; } public static ValidationResult ValidateWorktreePath(string path, ValidationContext ctx) { - var creator = ctx.ObjectInstance as AddWorktree; - if (creator == null) + if (ctx.ObjectInstance is not AddWorktree creator) return new ValidationResult("Missing runtime context to create branch!"); if (string.IsNullOrEmpty(path)) @@ -107,26 +115,70 @@ public static ValidationResult ValidateWorktreePath(string path, ValidationConte return ValidationResult.Success; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding worktree ..."; - var branchName = _selectedBranch; - var tracking = _setTrackingBranch ? SelectedTrackingBranch : string.Empty; + var branchName = GetBranchName(false); + var tracking = (_setTrackingBranch && _selectedTrackingBranch != null) ? _selectedTrackingBranch.FriendlyName : string.Empty; + var log = _repo.CreateLog("Add Worktree"); - return Task.Run(() => + 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 succ = new Commands.Worktree(_repo.FullPath).Add(_path, branchName, _createNewBranch, tracking, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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 string _selectedBranch = string.Empty; + 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 095677615..14ecc0730 100644 --- a/src/ViewModels/Apply.cs +++ b/src/ViewModels/Apply.cs @@ -1,13 +1,14 @@ -using System.Collections.Generic; +using System; using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; +using Avalonia.Input.Platform; + namespace SourceGit.ViewModels { public class Apply : Popup { - [Required(ErrorMessage = "Patch file is required!!!")] [CustomValidation(typeof(Apply), nameof(ValidatePatchFile))] public string PatchFile { @@ -21,58 +22,97 @@ public bool IgnoreWhiteSpace set => SetProperty(ref _ignoreWhiteSpace, value); } - public List WhiteSpaceModes + public Models.ApplyWhiteSpaceMode SelectedWhiteSpaceMode { get; - private set; + set; } - public Models.ApplyWhiteSpaceMode SelectedWhiteSpaceMode + public bool FromClipboard + { + get => _fromClipboard; + set => SetProperty(ref _fromClipboard, value); + } + + public bool ThreeWayMerge { get; set; } + public IClipboard Clipboard + { + get; + set; + } = null; + 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]; - - View = new Views.Apply() { DataContext = this }; + SelectedWhiteSpaceMode = Models.ApplyWhiteSpaceMode.Supported[0]; } - public static ValidationResult ValidatePatchFile(string file, ValidationContext _) + public static ValidationResult ValidatePatchFile(string file, ValidationContext ctx) { - if (File.Exists(file)) - { + 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() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Apply patch..."; - return Task.Run(() => + var finalPatchFile = _patchFile; + if (_fromClipboard) { - var succ = new Commands.Apply(_repo.FullPath, _patchFile, _ignoreWhiteSpace, SelectedWhiteSpaceMode.Arg, null).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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 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 366a6179e..7f5840eec 100644 --- a/src/ViewModels/Archive.cs +++ b/src/ViewModels/Archive.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; @@ -23,46 +24,43 @@ 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) { _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) { _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() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + 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); - return succ; - }); + var succ = await new Commands.Archive(_repo.FullPath, _revision, _saveFile) + .Use(log) + .ExecAsync(); + + log.Complete(); + + if (succ) + _repo.SendNotification($"Save archive to : {_saveFile}"); + return succ; } private readonly Repository _repo = null; diff --git a/src/ViewModels/AssumeUnchangedManager.cs b/src/ViewModels/AssumeUnchangedManager.cs index 0da16552b..8cd4a1663 100644 --- a/src/ViewModels/AssumeUnchangedManager.cs +++ b/src/ViewModels/AssumeUnchangedManager.cs @@ -1,5 +1,4 @@ using System.Threading.Tasks; - using Avalonia.Collections; using Avalonia.Threading; @@ -9,30 +8,35 @@ public class AssumeUnchangedManager { public AvaloniaList Files { get; private set; } - public AssumeUnchangedManager(string repo) + public AssumeUnchangedManager(Repository repo) { _repo = repo; Files = new AvaloniaList(); - Task.Run(() => + Task.Run(async () => { - var collect = new Commands.AssumeUnchanged(_repo).View(); - Dispatcher.UIThread.Invoke(() => - { - Files.AddRange(collect); - }); + var collect = await new Commands.QueryAssumeUnchangedFiles(_repo.FullPath) + .GetResultAsync() + .ConfigureAwait(false); + Dispatcher.UIThread.Post(() => Files.AddRange(collect)); }); } - public void Remove(string file) + public async Task RemoveAsync(string file) { if (!string.IsNullOrEmpty(file)) { - new Commands.AssumeUnchanged(_repo).Remove(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 readonly string _repo; + private readonly Repository _repo; } } diff --git a/src/ViewModels/Blame.cs b/src/ViewModels/Blame.cs index 26d77b232..be05fccd4 100644 --- a/src/ViewModels/Blame.cs +++ b/src/ViewModels/Blame.cs @@ -1,53 +1,229 @@ -using System.Threading.Tasks; - +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 Title + public string File { - get; - private set; + get => _file; + private set => SetProperty(ref _file, value); } - public bool IsBinary + public bool IgnoreWhitespace { - get => _data != null && _data.IsBinary; + get => _ignoreWhitespace; + set + { + if (SetProperty(ref _ignoreWhitespace, value)) + SetBlameData(_navigationHistory[0]); + } + } + + public Models.Commit Revision + { + get => _revision; + private set => SetProperty(ref _revision, value); + } + + public Models.Commit PrevRevision + { + get => _prevRevision; + private set => SetProperty(ref _prevRevision, value); } 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 Blame(string repo, string file, string revision) + public bool CanBack { + get => _navigationActiveIndex > 0; + } + + public bool CanForward + { + get => _navigationActiveIndex < _navigationHistory.Count - 1; + } + + public Blame(string repo, string file, Models.Commit commit) + { + var sha = commit.SHA.Substring(0, 10); _repo = repo; + _navigationHistory.Add(new RevisionInfo(file, sha)); + SetBlameData(_navigationHistory[0]); + } + + 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; - Title = $"{file} @ {revision.Substring(0, 10)}"; - Task.Run(() => + _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 }) { - var result = new Commands.Blame(repo, file, revision).Result(); - Dispatcher.UIThread.Invoke(() => + foreach (var page in pages) { - Data = result; - OnPropertyChanged(nameof(IsBinary)); + 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) + private class RevisionInfo { - var repo = App.FindOpenedRepository(_repo); - repo?.NavigateToCommit(commitSHA); + 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 readonly string _repo; + 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/BranchCompare.cs b/src/ViewModels/BranchCompare.cs deleted file mode 100644 index 1d19a2295..000000000 --- a/src/ViewModels/BranchCompare.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; - -using Avalonia.Controls; -using Avalonia.Threading; - -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.ViewModels -{ - public class BranchCompare : ObservableObject - { - public Models.Branch Base - { - get => _based; - private set => SetProperty(ref _based, value); - } - - public Models.Branch To - { - get => _to; - private set => SetProperty(ref _to, value); - } - - public Models.Commit BaseHead - { - get => _baseHead; - private set => SetProperty(ref _baseHead, value); - } - - public Models.Commit ToHead - { - get => _toHead; - private set => SetProperty(ref _toHead, value); - } - - public List VisibleChanges - { - get => _visibleChanges; - private set => SetProperty(ref _visibleChanges, value); - } - - public List SelectedChanges - { - get => _selectedChanges; - set - { - if (SetProperty(ref _selectedChanges, value)) - { - if (value != null && value.Count == 1) - DiffContext = new DiffContext(_repo, new Models.DiffOption(_based.Head, _to.Head, value[0]), _diffContext); - else - DiffContext = null; - } - } - } - - public string SearchFilter - { - get => _searchFilter; - set - { - if (SetProperty(ref _searchFilter, value)) - { - RefreshVisible(); - } - } - } - - public DiffContext DiffContext - { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); - } - - public BranchCompare(string repo, Models.Branch baseBranch, Models.Branch toBranch) - { - _repo = repo; - _based = baseBranch; - _to = toBranch; - - Refresh(); - } - - public void NavigateTo(string commitSHA) - { - var repo = App.FindOpenedRepository(_repo); - repo?.NavigateToCommit(commitSHA); - } - - public void Swap() - { - (Base, To) = (_to, _based); - SelectedChanges = []; - - if (_baseHead != null) - (BaseHead, ToHead) = (_toHead, _baseHead); - - Refresh(); - } - - public void ClearSearchFilter() - { - SearchFilter = string.Empty; - } - - public ContextMenu CreateChangeContextMenu() - { - if (_selectedChanges == null || _selectedChanges.Count != 1) - return null; - - var change = _selectedChanges[0]; - var menu = new ContextMenu(); - - var diffWithMerger = new MenuItem(); - diffWithMerger.Header = App.Text("DiffWithMerger"); - diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - diffWithMerger.Click += (_, ev) => - { - var toolType = Preference.Instance.ExternalMergeToolType; - var toolPath = Preference.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption(_based.Head, _to.Head, change); - - Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, toolType, toolPath, opt)); - ev.Handled = true; - }; - menu.Items.Add(diffWithMerger); - - if (change.Index != Models.ChangeState.Deleted) - { - var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(full); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(full, true); - ev.Handled = true; - }; - menu.Items.Add(explore); - } - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Click += (_, ev) => - { - App.CopyText(change.Path); - ev.Handled = true; - }; - menu.Items.Add(copyPath); - - var copyFileName = new MenuItem(); - copyFileName.Header = App.Text("CopyFileName"); - copyFileName.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFileName.Click += (_, e) => - { - App.CopyText(Path.GetFileName(change.Path)); - e.Handled = true; - }; - menu.Items.Add(copyFileName); - - return menu; - } - - private void Refresh() - { - Task.Run(() => - { - if (_baseHead == null) - { - var baseHead = new Commands.QuerySingleCommit(_repo, _based.Head).Result(); - var toHead = new Commands.QuerySingleCommit(_repo, _to.Head).Result(); - Dispatcher.UIThread.Invoke(() => - { - BaseHead = baseHead; - ToHead = toHead; - }); - } - - _changes = new Commands.CompareRevisions(_repo, _based.Head, _to.Head).Result(); - - var visible = _changes; - if (!string.IsNullOrWhiteSpace(_searchFilter)) - { - visible = new List(); - foreach (var c in _changes) - { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } - } - - Dispatcher.UIThread.Invoke(() => VisibleChanges = visible); - }); - } - - private void RefreshVisible() - { - if (_changes == null) - return; - - if (string.IsNullOrEmpty(_searchFilter)) - { - VisibleChanges = _changes; - } - else - { - var visible = new List(); - foreach (var c in _changes) - { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } - - VisibleChanges = visible; - } - } - - private string _repo; - private Models.Branch _based = null; - private Models.Branch _to = null; - private Models.Commit _baseHead = null; - private Models.Commit _toHead = null; - private List _changes = null; - private List _visibleChanges = null; - private List _selectedChanges = null; - private string _searchFilter = string.Empty; - private DiffContext _diffContext = null; - } -} diff --git a/src/ViewModels/BranchTreeNode.cs b/src/ViewModels/BranchTreeNode.cs index 025632c12..53ac1df9b 100644 --- a/src/ViewModels/BranchTreeNode.cs +++ b/src/ViewModels/BranchTreeNode.cs @@ -1,9 +1,6 @@ using System; using System.Collections.Generic; - using Avalonia; -using Avalonia.Media; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -13,9 +10,11 @@ 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 { @@ -40,14 +39,19 @@ public bool IsBranch get => Backend is Models.Branch; } - public FontWeight NameFontWeight + public bool IsCurrent + { + get => Backend is Models.Branch { IsCurrent: true }; + } + + public bool ShowUpstreamGoneTip { - get => Backend is Models.Branch { IsCurrent: true } ? FontWeight.Bold : FontWeight.Regular; + get => Backend is Models.Branch { IsUpstreamGone: true }; } - public string Tooltip + public string BranchesCount { - get => Backend is Models.Branch b ? b.FriendlyName : null; + get => Counter > 0 ? $"({Counter})" : string.Empty; } private Models.FilterMode _filterMode = Models.FilterMode.None; @@ -56,13 +60,27 @@ public string Tooltip public class Builder { - public List Locals => _locals; - public List Remotes => _remotes; + 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}"; @@ -72,49 +90,54 @@ public void Run(List branches, List remotes, bool Path = path, Backend = remote, IsExpanded = bForceExpanded || _expanded.Contains(path), + TimeToSort = fakeRemoteTime, }; + fakeRemoteTime--; folders.Add(path, node); - _remotes.Add(node); + Remotes.Add(node); } foreach (var branch in branches) { if (branch.IsLocal) { - MakeBranchNode(branch, _locals, folders, "refs/heads", bForceExpanded); + MakeBranchNode(branch, Locals, folders, "refs/heads", bForceExpanded); + continue; } - else + + var rk = $"refs/remotes/{branch.Remote}"; + if (folders.TryGetValue(rk, out var remote)) { - var remote = _remotes.Find(x => x.Name == branch.Remote); - if (remote != null) - MakeBranchNode(branch, remote.Children, folders, $"refs/remotes/{remote.Name}", bForceExpanded); + remote.Counter++; + MakeBranchNode(branch, remote.Children, folders, rk, bForceExpanded); } } - folders.Clear(); - SortNodes(_locals); - SortNodes(_remotes); - } - - public void CollectExpandedNodes(List nodes) - { - foreach (var node in nodes) + foreach (var path in _expanded) { - if (node.Backend is Models.Branch) - continue; + if (!folders.ContainsKey(path)) + InvalidExpandedNodes.Add(path); + } - if (node.IsExpanded) - _expanded.Add(node.Path); + folders.Clear(); - CollectExpandedNodes(node.Children); - } + if (_localSortMode == Models.BranchSortMode.Name) + SortNodesByName(Locals); + else + SortNodesByTime(Locals); + + if (_remoteSortMode == Models.BranchSortMode.Name) + SortNodesByName(Remotes); + else + SortNodesByTime(Remotes); } private void MakeBranchNode(Models.Branch branch, List roots, Dictionary folders, string prefix, bool bForceExpanded) { + var time = branch.CommitterDate; var fullpath = $"{prefix}/{branch.Name}"; - var sepIdx = branch.Name.IndexOf('/', StringComparison.Ordinal); + var sepIdx = branch.Name.IndexOf('/'); if (sepIdx == -1 || branch.IsDetachedHead) { roots.Add(new BranchTreeNode() @@ -123,11 +146,12 @@ private void MakeBranchNode(Models.Branch branch, List roots, Di Path = fullpath, Backend = branch, IsExpanded = false, + TimeToSort = time, }); return; } - var lastFolder = null as BranchTreeNode; + BranchTreeNode lastFolder = null; var start = 0; while (sepIdx != -1) @@ -137,6 +161,8 @@ private void MakeBranchNode(Models.Branch branch, List roots, Di 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)); } @@ -147,6 +173,8 @@ private void MakeBranchNode(Models.Branch branch, List roots, Di Name = name, Path = folder, IsExpanded = bForceExpanded || branch.IsCurrent || _expanded.Contains(folder), + TimeToSort = time, + Counter = 1, }; roots.Add(lastFolder); folders.Add(folder, lastFolder); @@ -158,6 +186,8 @@ private void MakeBranchNode(Models.Branch branch, List roots, Di Name = name, Path = folder, IsExpanded = bForceExpanded || branch.IsCurrent || _expanded.Contains(folder), + TimeToSort = time, + Counter = 1, }; lastFolder.Children.Add(cur); folders.Add(folder, cur); @@ -174,10 +204,11 @@ private void MakeBranchNode(Models.Branch branch, List roots, Di Path = fullpath, Backend = branch, IsExpanded = false, + TimeToSort = time, }); } - private void SortNodes(List nodes) + private void SortNodesByName(List nodes) { nodes.Sort((l, r) => { @@ -191,11 +222,38 @@ private void SortNodes(List nodes) }); foreach (var node in nodes) - SortNodes(node.Children); + 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 List _locals = new List(); - private readonly List _remotes = new List(); + private readonly Models.BranchSortMode _localSortMode; + private readonly Models.BranchSortMode _remoteSortMode; private readonly HashSet _expanded = new HashSet(); } } 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 index 245c1dfe1..c35f4fc1f 100644 --- a/src/ViewModels/ChangeTreeNode.cs +++ b/src/ViewModels/ChangeTreeNode.cs @@ -1,6 +1,5 @@ -using System; using System.Collections.Generic; - +using System.IO; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -8,6 +7,7 @@ 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(); @@ -17,43 +17,52 @@ public bool IsFolder get => Change == null; } + public bool ShowConflictMarker + { + get => Change is { IsConflicted: true }; + } + + public string ConflictMarker + { + get => Change?.ConflictMarker ?? string.Empty; + } + public bool IsExpanded { get => _isExpanded; set => SetProperty(ref _isExpanded, value); } - public ChangeTreeNode(Models.Change c, int depth) + public ChangeTreeNode(Models.Change c) { FullPath = c.Path; - Depth = depth; + DisplayName = Path.GetFileName(c.Path); Change = c; IsExpanded = false; } - public ChangeTreeNode(string path, bool isExpanded, int depth) + public ChangeTreeNode(string path, bool isExpanded) { FullPath = path; - Depth = depth; + DisplayName = Path.GetFileName(path); IsExpanded = isExpanded; } - public static List Build(IList changes, HashSet folded) + 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('/', StringComparison.Ordinal); + var sepIdx = c.Path.IndexOf('/'); if (sepIdx == -1) { - nodes.Add(new ChangeTreeNode(c, 0)); + nodes.Add(new ChangeTreeNode(c)); } else { ChangeTreeNode lastFolder = null; - int depth = 0; while (sepIdx != -1) { @@ -64,27 +73,32 @@ public static List Build(IList changes, HashSet collection, ChangeTreeNode collection.Add(subFolder); } - private static void Sort(List nodes) + 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) - Sort(node.Children); + SortAndSetDepth(node.Children, depth + 1); } nodes.Sort((l, r) => { if (l.IsFolder == r.IsFolder) - return Models.NumericSort.Compare(l.FullPath, r.FullPath); + return Models.NumericSort.Compare(l.DisplayName, r.DisplayName); return l.IsFolder ? -1 : 1; }); } diff --git a/src/ViewModels/Checkout.cs b/src/ViewModels/Checkout.cs index 713c260f6..3c47d47a3 100644 --- a/src/ViewModels/Checkout.cs +++ b/src/ViewModels/Checkout.cs @@ -4,77 +4,112 @@ namespace SourceGit.ViewModels { public class Checkout : Popup { - public string Branch + public string BranchName { - get; + get => _branch.Name; + } + + public bool HasLocalChanges + { + get => _repo.LocalChangesCount > 0; } - public Models.DealWithLocalChanges PreAction + public Models.DealWithLocalChanges DealWithLocalChanges { - get => _repo.Settings.DealWithLocalChangesOnCheckoutBranch; - set => _repo.Settings.DealWithLocalChangesOnCheckoutBranch = value; + get; + 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() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Checkout '{Branch}' ..."; + 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; - return Task.Run(() => + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) { - var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); - var needPopStash = false; + 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) { - if (PreAction == Models.DealWithLocalChanges.StashAndReaply) - { - SetProgressDescription("Stash local changes ..."); - var succ = new Commands.Stash(_repo.FullPath).Push("CHECKOUT_AUTO_STASH"); - if (!succ) - { - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return false; - } - - needPopStash = true; - } - else if (PreAction == Models.DealWithLocalChanges.Discard) + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync("CHECKOUT_AUTO_STASH", false); + if (!succ) { - SetProgressDescription("Discard local changes ..."); - Commands.Discard.All(_repo.FullPath, false); + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return false; } + + needPopStash = true; } - SetProgressDescription("Checkout branch ..."); - var rs = new Commands.Checkout(_repo.FullPath).Branch(Branch, SetProgressDescription); + 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 (needPopStash) - { - SetProgressDescription("Re-apply local changes..."); - rs = new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); - } + if (succ) + { + await _repo.AutoUpdateSubmodulesAsync(log); - CallUIThread(() => - { - var b = _repo.Branches.Find(x => x.IsLocal && x.Name == Branch); - if (b != null && _repo.HistoriesFilterMode == Models.FilterMode.Included) - _repo.Settings.UpdateHistoriesFilter(b.FullName, Models.FilterType.LocalBranch, Models.FilterMode.Included); + if (needPopStash) + await new Commands.Stash(_repo.FullPath) + .Use(log) + .PopAsync("stash@{0}"); - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - }); + _repo.RefreshAfterCheckoutBranch(_branch); + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } - return rs; - }); + log.Complete(); + return succ; } 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/CheckoutCommit.cs b/src/ViewModels/CheckoutCommit.cs deleted file mode 100644 index ddc0a0c66..000000000 --- a/src/ViewModels/CheckoutCommit.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceGit.ViewModels -{ - public class CheckoutCommit : Popup - { - public Models.Commit Commit - { - get; - } - - public bool AutoStash - { - get => _autoStash; - set => SetProperty(ref _autoStash, value); - } - - public CheckoutCommit(Repository repo, Models.Commit commit) - { - _repo = repo; - Commit = commit; - View = new Views.CheckoutCommit() { DataContext = this }; - } - - public override Task Sure() - { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Checkout Commit '{Commit.SHA}' ..."; - - return Task.Run(() => - { - var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); - var needPopStash = false; - if (changes > 0) - { - if (AutoStash) - { - SetProgressDescription("Stash local changes ..."); - var succ = new Commands.Stash(_repo.FullPath).Push("CHECKOUT_AUTO_STASH"); - if (!succ) - { - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return false; - } - - needPopStash = true; - } - else - { - SetProgressDescription("Discard local changes ..."); - Commands.Discard.All(_repo.FullPath, false); - } - } - - SetProgressDescription("Checkout commit ..."); - var rs = new Commands.Checkout(_repo.FullPath).Commit(Commit.SHA, SetProgressDescription); - - if (needPopStash) - { - SetProgressDescription("Re-apply local changes..."); - rs = new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); - } - - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return rs; - }); - } - - private readonly Repository _repo = null; - private bool _autoStash = true; - } -} 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 dde436626..55ec83c99 100644 --- a/src/ViewModels/CherryPick.cs +++ b/src/ViewModels/CherryPick.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Text; using System.Threading.Tasks; @@ -51,7 +52,6 @@ public CherryPick(Repository repo, List targets) MainlineForMergeCommit = 0; AppendSourceToMessage = true; AutoCommit = true; - View = new Views.CherryPick() { DataContext = this }; } public CherryPick(Repository repo, Models.Commit merge, List parents) @@ -63,43 +63,55 @@ public CherryPick(Repository repo, Models.Commit merge, List pare MainlineForMergeCommit = 0; AppendSourceToMessage = true; AutoCommit = true; - View = new Views.CherryPick() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Cherry-Pick commit(s) ..."; + using var lockWatcher = _repo.LockWatcher(); + _repo.ClearCommitMessage(); + ProgressDescription = "Cherry-Pick commit(s) ..."; - return Task.Run(() => + var log = _repo.CreateLog("Cherry-Pick"); + Use(log); + + if (IsMergeCommit) { - var succ = false; - if (IsMergeCommit) - { - succ = new Commands.CherryPick( - _repo.FullPath, - Targets[0].SHA, - !AutoCommit, - AppendSourceToMessage, - $"-m {MainlineForMergeCommit + 1}").Exec(); - } - else + 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(); - for (int i = Targets.Count - 1; i >= 0; i--) - builder.Append($"{Targets[i].SHA} "); - - succ = new Commands.CherryPick( - _repo.FullPath, - builder.ToString(), - !AutoCommit, - AppendSourceToMessage, - string.Empty).Exec(); + 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()); } + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + log.Complete(); + return true; } private readonly Repository _repo = null; diff --git a/src/ViewModels/Cleanup.cs b/src/ViewModels/Cleanup.cs index 7efcf73e8..974f11cf3 100644 --- a/src/ViewModels/Cleanup.cs +++ b/src/ViewModels/Cleanup.cs @@ -7,20 +7,22 @@ public class Cleanup : Popup public Cleanup(Repository repo) { _repo = repo; - View = new Views.Cleanup() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Cleanup (GC & prune) ..."; - return Task.Run(() => - { - new Commands.GC(_repo.FullPath, SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + var log = _repo.CreateLog("Cleanup (GC & prune)"); + Use(log); + + await new Commands.GC(_repo.FullPath) + .Use(log) + .ExecAsync(); + + log.Complete(); + return true; } private readonly Repository _repo = null; diff --git a/src/ViewModels/ClearStashes.cs b/src/ViewModels/ClearStashes.cs index dad4059a2..0c5092d12 100644 --- a/src/ViewModels/ClearStashes.cs +++ b/src/ViewModels/ClearStashes.cs @@ -7,20 +7,23 @@ public class ClearStashes : Popup public ClearStashes(Repository repo) { _repo = repo; - View = new Views.ClearStashes() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + 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 readonly Repository _repo = null; diff --git a/src/ViewModels/Clone.cs b/src/ViewModels/Clone.cs index cc253d3c2..e62937cbc 100644 --- a/src/ViewModels/Clone.cs +++ b/src/ViewModels/Clone.cs @@ -1,10 +1,9 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Threading.Tasks; -using Avalonia.Threading; - namespace SourceGit.ViewModels { public class Clone : Popup @@ -47,31 +46,48 @@ public string Local set => SetProperty(ref _local, value); } + 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() + public bool InitAndUpdateSubmodules { - View = new Views.Clone() { DataContext = this }; + get; + set; + } = true; - Task.Run(async () => - { - try - { - var text = await App.GetClipboardTextAsync(); - if (Models.Remote.IsValidURL(text)) - { - Dispatcher.UIThread.Invoke(() => Remote = text); - } - } - catch - { - // ignore - } - }); + 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 _) @@ -88,72 +104,100 @@ public static ValidationResult ValidateParentFolder(string folder, ValidationCon return ValidationResult.Success; } - public override Task Sure() + public override async Task Sure() { ProgressDescription = "Clone ..."; - return Task.Run(() => + var log = new CommandLog("Clone"); + Use(log); + + 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 cmd = new Commands.Clone(HostPageId, _parentFolder, _remote, _local, _useSSH ? _sshKey : "", _extraArgs, SetProgressDescription); - if (!cmd.Exec()) - return false; + 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); - var path = _parentFolder; - if (!string.IsNullOrEmpty(_local)) - { - path = Path.GetFullPath(Path.Combine(path, _local)); - } - else - { - var name = Path.GetFileName(_remote)!; - if (name.EndsWith(".git", StringComparison.Ordinal)) - name = name.Substring(0, name.Length - 4); - path = Path.GetFullPath(Path.Combine(path, name)); - } + path = Path.GetFullPath(Path.Combine(path, name)); + } - if (!Directory.Exists(path)) - { - CallUIThread(() => - { - App.RaiseException(HostPageId, $"Folder '{path}' can NOT be found"); - }); - return false; - } + 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 (_useSSH && !string.IsNullOrEmpty(_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) { - var config = new Commands.Config(path); - config.Set("remote.origin.sshkey", _sshKey); + page = one; + break; } + } - CallUIThread(() => + Welcome.Instance.Refresh(); + launcher.OpenRepositoryInTab(node, page); + return true; + } + + private void CollectGroups(List outs, List collections) + { + foreach (var node in collections) + { + if (!node.IsRepository) { - var normalizedPath = path.Replace("\\", "/"); - var node = Preference.Instance.FindOrAddNodeByRepositoryPath(normalizedPath, null, true); - var launcher = App.GetLauncer(); - var page = null as LauncherPage; - foreach (var one in launcher.Pages) - { - if (one.GetId() == HostPageId) - { - page = one; - break; - } - } - - Welcome.Instance.Refresh(); - launcher.OpenRepositoryInTab(node, page); - }); - - return true; - }); + outs.Add(node); + CollectGroups(outs, node.SubNodes); + } + } } + 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 7ef8ce85e..b23465774 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -1,32 +1,51 @@ -using System; +using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; -using Avalonia.Collections; -using Avalonia.Controls; -using Avalonia.Media.Imaging; -using Avalonia.Platform.Storage; +using Avalonia; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { + public class CommitDetailSharedData + { + public int ActiveTabIndex + { + get; + set; + } + + public CommitDetailSharedData() + { + ActiveTabIndex = Preferences.Instance.ShowChangesInCommitDetailByDefault ? 1 : 0; + } + } + public partial class CommitDetail : ObservableObject { - public DiffContext DiffContext + public Repository Repository { - get => _diffContext; - private set => SetProperty(ref _diffContext, value); + get => _repo; } - public int ActivePageIndex + public int ActiveTabIndex { - get => _activePageIndex; - set => SetProperty(ref _activePageIndex, value); + 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 @@ -34,12 +53,15 @@ public Models.Commit Commit get => _commit; set { + if (_commit != null && value != null && _commit.SHA.Equals(value.SHA, StringComparison.Ordinal)) + return; + if (SetProperty(ref _commit, value)) Refresh(); } } - public string FullMessage + public Models.CommitFullMessage FullMessage { get => _fullMessage; private set => SetProperty(ref _fullMessage, value); @@ -51,6 +73,12 @@ public Models.CommitSignInfo SignInfo private set => SetProperty(ref _signInfo, value); } + public List WebLinks + { + get; + private set; + } + public List Changes { get => _changes; @@ -70,7 +98,7 @@ public List SelectedChanges { if (SetProperty(ref _selectedChanges, value)) { - if (value == null || value.Count != 1) + if (ActiveTabIndex != 1 || value is not { Count: 1 }) DiffContext = null; else DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_commit, value[0]), _diffContext); @@ -78,80 +106,75 @@ public List SelectedChanges } } + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + public string SearchChangeFilter { get => _searchChangeFilter; set { if (SetProperty(ref _searchChangeFilter, value)) - { RefreshVisibleChanges(); - } } } + public string ViewRevisionFilePath + { + get => _viewRevisionFilePath; + private set => SetProperty(ref _viewRevisionFilePath, value); + } + public object ViewRevisionFileContent { get => _viewRevisionFileContent; - set => SetProperty(ref _viewRevisionFileContent, value); + private set => SetProperty(ref _viewRevisionFileContent, value); } - public AvaloniaList WebLinks + public string RevisionFileSearchFilter { - get; - private set; - } = new AvaloniaList(); + get => _revisionFileSearchFilter; + set + { + if (SetProperty(ref _revisionFileSearchFilter, value)) + RefreshRevisionSearchSuggestion(); + } + } - public AvaloniaList IssueTrackerRules + public List RevisionFileSearchSuggestion { - get => _repo.Settings?.IssueTrackerRules; + get => _revisionFileSearchSuggestion; + private set => SetProperty(ref _revisionFileSearchSuggestion, value); } - public CommitDetail(Repository repo) + public bool CanOpenRevisionFileWithDefaultEditor { - _repo = repo; + get => _canOpenRevisionFileWithDefaultEditor; + private set => SetProperty(ref _canOpenRevisionFileWithDefaultEditor, value); + } - foreach (var remote in repo.Remotes) - { - if (remote.TryGetVisitURL(out var url)) - { - var trimmedUrl = url; - if (url.EndsWith(".git")) - trimmedUrl = url.Substring(0, url.Length - 4); - - if (url.StartsWith("https://github.com/", StringComparison.Ordinal)) - WebLinks.Add(new Models.CommitLink() { Name = $"Github ({trimmedUrl.Substring(19)})", URLPrefix = $"{url}/commit/" }); - else if (url.StartsWith("https://gitlab.", StringComparison.Ordinal)) - WebLinks.Add(new Models.CommitLink() { Name = $"GitLab ({trimmedUrl.Substring(trimmedUrl.Substring(15).IndexOf('/') + 16)})", URLPrefix = $"{url}/-/commit/" }); - else if (url.StartsWith("https://gitee.com/", StringComparison.Ordinal)) - WebLinks.Add(new Models.CommitLink() { Name = $"Gitee ({trimmedUrl.Substring(18)})", URLPrefix = $"{url}/commit/" }); - else if (url.StartsWith("https://bitbucket.org/", StringComparison.Ordinal)) - WebLinks.Add(new Models.CommitLink() { Name = $"BitBucket ({trimmedUrl.Substring(22)})", URLPrefix = $"{url}/commits/" }); - else if (url.StartsWith("https://codeberg.org/", StringComparison.Ordinal)) - WebLinks.Add(new Models.CommitLink() { Name = $"Codeberg ({trimmedUrl.Substring(21)})", URLPrefix = $"{url}/commit/" }); - else if (url.StartsWith("https://gitea.org/", StringComparison.Ordinal)) - WebLinks.Add(new Models.CommitLink() { Name = $"Gitea ({trimmedUrl.Substring(18)})", URLPrefix = $"{url}/commit/" }); - else if (url.StartsWith("https://git.sr.ht/", StringComparison.Ordinal)) - WebLinks.Add(new Models.CommitLink() { Name = $"sourcehut ({trimmedUrl.Substring(18)})", URLPrefix = $"{url}/commit/" }); - } - } + public Vector ScrollOffset + { + get => _scrollOffset; + set => SetProperty(ref _scrollOffset, value); + } + + public CommitDetail(Repository repo, CommitDetailSharedData sharedData) + { + _repo = repo; + _sharedData = sharedData ?? new CommitDetailSharedData(); + WebLinks = Models.CommitLink.Get(repo.Remotes); } - public void Cleanup() + public CommitDetail Clone() { - _repo = null; - _commit = null; - if (_changes != null) - _changes.Clear(); - if (_visibleChanges != null) - _visibleChanges.Clear(); - if (_selectedChanges != null) - _selectedChanges.Clear(); - _signInfo = null; - _searchChangeFilter = null; - _diffContext = null; - _viewRevisionFileContent = null; - _cancelToken = null; + var cloned = new CommitDetail(_repo, null); + cloned.ActiveTabIndex = ActiveTabIndex; + cloned.Commit = _commit; + return cloned; } public void NavigateTo(string commitSHA) @@ -159,9 +182,11 @@ public void NavigateTo(string commitSHA) _repo?.NavigateToCommit(commitSHA); } - public List GetRefsContainsThisCommit() + public async Task> GetRefsContainsThisCommitAsync() { - return new Commands.QueryRefsContainsCommit(_repo.FullPath, _commit.SHA).Result(); + return await new Commands.QueryRefsContainsCommit(_repo.FullPath, _commit.SHA) + .GetResultAsync() + .ConfigureAwait(false); } public void ClearSearchChangeFilter() @@ -169,377 +194,330 @@ public void ClearSearchChangeFilter() SearchChangeFilter = string.Empty; } - public Models.Commit GetParent(string sha) + public void ClearRevisionFileSearchFilter() { - return new Commands.QuerySingleCommit(_repo.FullPath, sha).Result(); + RevisionFileSearchFilter = string.Empty; } - public List GetRevisionFilesUnderFolder(string parentFolder) + public void CancelRevisionFileSuggestions() { - return new Commands.QueryRevisionObjects(_repo.FullPath, _commit.SHA, parentFolder).Result(); + RevisionFileSearchSuggestion = null; } - public void ViewRevisionFile(Models.Object file) + public async Task GetCommitAsync(string sha) { - if (file == null) - { - ViewRevisionFileContent = null; - return; - } - - switch (file.Type) - { - case Models.ObjectType.Blob: - Task.Run(() => - { - var isBinary = new Commands.IsBinary(_repo.FullPath, _commit.SHA, file.Path).Result(); - if (isBinary) - { - var ext = Path.GetExtension(file.Path); - if (IMG_EXTS.Contains(ext)) - { - var stream = Commands.QueryFileContent.Run(_repo.FullPath, _commit.SHA, file.Path); - var fileSize = stream.Length; - var bitmap = fileSize > 0 ? new Bitmap(stream) : null; - var imageType = ext!.Substring(1).ToUpper(CultureInfo.CurrentCulture); - var image = new Models.RevisionImageFile() { Image = bitmap, FileSize = fileSize, ImageType = imageType }; - Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = image); - } - else - { - var size = new Commands.QueryFileSize(_repo.FullPath, file.Path, _commit.SHA).Result(); - var binary = new Models.RevisionBinaryFile() { Size = size }; - Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = binary); - } + return await new Commands.QuerySingleCommit(_repo.FullPath, sha) + .GetResultAsync() + .ConfigureAwait(false); + } - return; - } + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo.FullPath, path); + } - var contentStream = Commands.QueryFileContent.Run(_repo.FullPath, _commit.SHA, file.Path); - var content = new StreamReader(contentStream).ReadToEnd(); - var matchLFS = REG_LFS_FORMAT().Match(content); - if (matchLFS.Success) - { - var obj = new Models.RevisionLFSObject() { Object = new Models.LFSObject() }; - obj.Object.Oid = matchLFS.Groups[1].Value; - obj.Object.Size = long.Parse(matchLFS.Groups[2].Value); - Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = obj); - } - else - { - var txt = new Models.RevisionTextFile() { FileName = file.Path, Content = content }; - Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = txt); - } - }); - break; - case Models.ObjectType.Commit: - Task.Run(() => - { - var submoduleRoot = Path.Combine(_repo.FullPath, file.Path); - var commit = new Commands.QuerySingleCommit(submoduleRoot, file.SHA).Result(); - if (commit != null) - { - var body = new Commands.QueryCommitFullMessage(submoduleRoot, file.SHA).Result(); - var submodule = new Models.RevisionSubmodule() { Commit = commit, FullMessage = body }; - Dispatcher.UIThread.Invoke(() => ViewRevisionFileContent = submodule); - } - else - { - Dispatcher.UIThread.Invoke(() => - { - ViewRevisionFileContent = new Models.RevisionSubmodule() - { - Commit = new Models.Commit() { SHA = file.SHA }, - FullMessage = string.Empty, - }; - }); - } - }); - break; - default: - ViewRevisionFileContent = null; - break; - } + public void OpenChangeInMergeTool(Models.Change c) + { + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(_commit, c)).Open(); } - public ContextMenu CreateChangeContextMenu(Models.Change change) + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) { - var diffWithMerger = new MenuItem(); - diffWithMerger.Header = App.Text("DiffWithMerger"); - diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - diffWithMerger.Click += (_, ev) => - { - var toolType = Preference.Instance.ExternalMergeToolType; - var toolPath = Preference.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption(_commit, change); + if (_commit == null) + return; - Task.Run(() => Commands.MergeTool.OpenForDiff(_repo.FullPath, toolType, toolPath, opt)); - ev.Handled = true; - }; + var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync( + _repo.FullPath, + changes, + _commit.FirstParentToCompare, + _commit.SHA, + saveTo); - var fullPath = Path.Combine(_repo.FullPath, change.Path); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(fullPath); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(fullPath, true); - ev.Handled = true; - }; + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); + } - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => + public async Task ResetToThisRevisionAsync(string path) + { + var c = _changes?.Find(x => x.Path.Equals(path, StringComparison.Ordinal)); + if (c != null) { - var window = new Views.FileHistories() { DataContext = new FileHistories(_repo, change.Path, _commit.SHA) }; - window.Show(); - ev.Handled = true; - }; + await ResetToThisRevisionAsync(c); + return; + } - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.IsEnabled = change.Index != Models.ChangeState.Deleted; - blame.Click += (_, ev) => - { - var window = new Views.Blame() { DataContext = new Blame(_repo.FullPath, change.Path, _commit.SHA) }; - window.Show(); - 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(); + } - var menu = new ContextMenu(); - menu.Items.Add(diffWithMerger); - menu.Items.Add(explore); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(new MenuItem { Header = "-" }); - - var resetToThisRevision = new MenuItem(); - resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); - resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToThisRevision.Click += (_, ev) => - { - new Commands.Checkout(_repo.FullPath).FileWithRevision(change.Path, $"{_commit.SHA}"); - ev.Handled = true; - }; + public async Task ResetToThisRevisionAsync(Models.Change change) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); - var resetToFirstParent = new MenuItem(); - resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); - resetToFirstParent.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToFirstParent.IsEnabled = _commit.Parents.Count > 0; - resetToFirstParent.Click += (_, ev) => + 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) { - if (change.Index == Models.ChangeState.Renamed) - new Commands.Checkout(_repo.FullPath).FileWithRevision(change.OriginalPath, $"{_commit.SHA}~1"); + var old = Native.OS.GetAbsPath(_repo.FullPath, change.OriginalPath); + if (File.Exists(old)) + await new Commands.Remove(_repo.FullPath, [change.OriginalPath]) + .Use(log) + .ExecAsync(); - new Commands.Checkout(_repo.FullPath).FileWithRevision(change.Path, $"{_commit.SHA}~1"); - ev.Handled = true; - }; + 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); + } - menu.Items.Add(resetToThisRevision); - menu.Items.Add(resetToFirstParent); - menu.Items.Add(new MenuItem { Header = "-" }); + log.Complete(); + } - if (File.Exists(Path.Combine(fullPath))) - TryToAddContextMenuItemsForGitLFS(menu, change.Path); + public async Task ResetToParentRevisionAsync(Models.Change change) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}~1'"); - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Click += (_, ev) => + if (change.Index == Models.ChangeState.Added) { - App.CopyText(change.Path); - ev.Handled = true; - }; + 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(); - var copyFileName = new MenuItem(); - copyFileName.Header = App.Text("CopyFileName"); - copyFileName.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFileName.Click += (_, e) => + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.OriginalPath, $"{_commit.SHA}~1"); + } + else { - App.CopyText(Path.GetFileName(change.Path)); - e.Handled = true; - }; + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .FileWithRevisionAsync(change.Path, $"{_commit.SHA}~1"); + } - menu.Items.Add(copyPath); - menu.Items.Add(copyFileName); - return menu; + log.Complete(); } - public ContextMenu CreateRevisionFileContextMenu(Models.Object file) + public async Task ResetMultipleToThisRevisionAsync(List changes) { - var menu = new ContextMenu(); - var fullPath = Path.Combine(_repo.FullPath, file.Path); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(fullPath); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(fullPath, file.Type == Models.ObjectType.Blob); - ev.Handled = true; - }; + var checkouts = new List(); + var removes = new List(); - var openWith = new MenuItem(); - openWith.Header = App.Text("OpenWith"); - openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWith.Click += (_, ev) => + foreach (var c in changes) { - var fileName = Path.GetFileNameWithoutExtension(fullPath) ?? ""; - var fileExt = Path.GetExtension(fullPath) ?? ""; - var tmpFile = Path.Combine(Path.GetTempPath(), $"{fileName}~{_commit.SHA.Substring(0, 10)}{fileExt}"); - Commands.SaveRevisionFile.Run(_repo.FullPath, _commit.SHA, file.Path, tmpFile); - Native.OS.OpenWithDefaultEditor(tmpFile); - ev.Handled = true; - }; - - var saveAs = new MenuItem(); - saveAs.Header = App.Text("SaveAs"); - saveAs.Icon = App.CreateMenuIcon("Icons.Save"); - saveAs.IsEnabled = file.Type == Models.ObjectType.Blob; - saveAs.Click += async (_, ev) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - try + if (c.Index == Models.ChangeState.Deleted) { - var selected = await storageProvider.OpenFolderPickerAsync(options); - if (selected.Count == 1) - { - var saveTo = Path.Combine(selected[0].Path.LocalPath, Path.GetFileName(file.Path)); - Commands.SaveRevisionFile.Run(_repo.FullPath, _commit.SHA, file.Path, saveTo); - } + 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); + + checkouts.Add(c.Path); } - catch (Exception e) + else { - App.RaiseException(_repo.FullPath, $"Failed to save file: {e.Message}"); + checkouts.Add(c.Path); } + } - ev.Handled = true; - }; + var log = _repo.CreateLog($"Reset Files to '{_commit.SHA}'"); - menu.Items.Add(explore); - menu.Items.Add(openWith); - menu.Items.Add(saveAs); - menu.Items.Add(new MenuItem() { Header = "-" }); + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); - 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, _commit.SHA) }; - window.Show(); - ev.Handled = true; - }; + if (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, _commit.SHA); - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.IsEnabled = file.Type == Models.ObjectType.Blob; - blame.Click += (_, ev) => - { - var window = new Views.Blame() { DataContext = new Blame(_repo.FullPath, file.Path, _commit.SHA) }; - window.Show(); - ev.Handled = true; - }; + log.Complete(); + } - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(new MenuItem() { Header = "-" }); + public async Task ResetMultipleToParentRevisionAsync(List changes) + { + var checkouts = new List(); + var removes = new List(); - var resetToThisRevision = new MenuItem(); - resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); - resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToThisRevision.IsEnabled = File.Exists(fullPath); - resetToThisRevision.Click += (_, ev) => + foreach (var c in changes) { - new Commands.Checkout(_repo.FullPath).FileWithRevision(file.Path, $"{_commit.SHA}"); - ev.Handled = true; - }; + 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); - var resetToFirstParent = new MenuItem(); - resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); - resetToFirstParent.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - var fileInChanges = _changes.Find(x => x.Path == file.Path); - var fileIndex = fileInChanges?.Index; - resetToFirstParent.IsEnabled = _commit.Parents.Count > 0 && fileIndex != Models.ChangeState.Renamed; - resetToFirstParent.Click += (_, ev) => - { - new Commands.Checkout(_repo.FullPath).FileWithRevision(file.Path, $"{_commit.SHA}~1"); - ev.Handled = true; - }; + checkouts.Add(c.OriginalPath); + } + else + { + checkouts.Add(c.Path); + } + } - menu.Items.Add(resetToThisRevision); - menu.Items.Add(resetToFirstParent); - menu.Items.Add(new MenuItem() { Header = "-" }); + var log = _repo.CreateLog($"Reset Files to '{_commit.SHA}~1'"); - if (File.Exists(Path.Combine(fullPath))) - TryToAddContextMenuItemsForGitLFS(menu, file.Path); + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); - 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 (checkouts.Count > 0) + await new Commands.Checkout(_repo.FullPath) + .Use(log) + .MultipleFilesWithRevisionAsync(checkouts, $"{_commit.SHA}~1"); + + log.Complete(); + } + + 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; - var copyFileName = new MenuItem(); - copyFileName.Header = App.Text("CopyFileName"); - copyFileName.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFileName.Click += (_, e) => + switch (obj.Type) { - App.CopyText(Path.GetFileName(file.Path)); - e.Handled = true; - }; + 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}"); - menu.Items.Add(copyPath); - menu.Items.Add(copyFileName); - return menu; + 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() { - _changes = null; - FullMessage = string.Empty; + _requestingRevisionFiles = false; + _revisionFiles = null; + SignInfo = null; - Changes = []; - VisibleChanges = null; - SelectedChanges = 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(); - Task.Run(() => + _cancellationSource = new CancellationTokenSource(); + var token = _cancellationSource.Token; + + Task.Run(async () => { - var fullMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA).Result(); - Dispatcher.UIThread.Invoke(() => FullMessage = fullMessage); - }); + var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA) + .GetResultAsync() + .ConfigureAwait(false); + var inlines = await ParseInlinesInMessageAsync(message).ConfigureAwait(false); - Task.Run(() => + if (!token.IsCancellationRequested) + Dispatcher.UIThread.Post(() => + { + FullMessage = new Models.CommitFullMessage + { + Message = message, + Inlines = inlines + }; + }); + }, token); + + Task.Run(async () => { - var signInfo = new Commands.QueryCommitSignInfo(_repo.FullPath, _commit.SHA, !_repo.HasAllowedSignersFile).Result(); - Dispatcher.UIThread.Invoke(() => SignInfo = signInfo); - }); + var signInfo = await new Commands.QueryCommitSignInfo(_repo.FullPath, _commit.SHA, !_repo.HasAllowedSignersFile) + .GetResultAsync() + .ConfigureAwait(false); - if (_cancelToken != null) - _cancelToken.Requested = true; + if (!token.IsCancellationRequested) + Dispatcher.UIThread.Post(() => SignInfo = signInfo); + }, token); - _cancelToken = new Commands.Command.CancelToken(); - Task.Run(() => + Task.Run(async () => { - var parent = _commit.Parents.Count == 0 ? "4b825dc642cb6eb9a060e54bf8d69288fbee4904" : _commit.Parents[0]; - var cmdChanges = new Commands.CompareRevisions(_repo.FullPath, parent, _commit.SHA) { Cancel = _cancelToken }; - var changes = cmdChanges.Result(); + var changes = await new Commands.CompareRevisions(_repo.FullPath, _commit.FirstParentToCompare, _commit.SHA) + .WithCancellation(token) + .ReadAsync() + .ConfigureAwait(false); + var visible = changes; if (!string.IsNullOrWhiteSpace(_searchChangeFilter)) { @@ -551,22 +529,75 @@ private void Refresh() } } - if (!cmdChanges.Cancel.Requested) + if (!token.IsCancellationRequested) { Dispatcher.UIThread.Post(() => { Changes = changes; VisibleChanges = visible; + + if (visible.Count == 0) + SelectedChanges = null; + else + SelectedChanges = [VisibleChanges[0]]; }); } - }); + }, token); } - private void RefreshVisibleChanges() + private async Task ParseInlinesInMessageAsync(string message) { - if (_changes == null) - return; + 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)); + } + + 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; @@ -584,109 +615,140 @@ private void RefreshVisibleChanges() } } - private void TryToAddContextMenuItemsForGitLFS(ContextMenu menu, string path) + private void RefreshRevisionSearchSuggestion() { - var lfsEnabled = new Commands.LFS(_repo.FullPath).IsEnabled(); - if (!lfsEnabled) - return; - - var lfs = new MenuItem(); - lfs.Header = App.Text("GitLFS"); - lfs.Icon = App.CreateMenuIcon("Icons.LFS"); - - var lfsLock = new MenuItem(); - lfsLock.Header = App.Text("GitLFS.Locks.Lock"); - lfsLock.Icon = App.CreateMenuIcon("Icons.Lock"); - lfsLock.IsEnabled = _repo.Remotes.Count > 0; - if (_repo.Remotes.Count == 1) + if (!string.IsNullOrEmpty(_revisionFileSearchFilter)) { - lfsLock.Click += async (_, e) => + if (_revisionFiles == null) { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(_repo.Remotes[0].Name, path)); - if (succ) - App.SendNotification(_repo.FullPath, $"Lock file \"{path}\" successfully!"); + if (_requestingRevisionFiles) + return; + + var sha = Commit.SHA; + _requestingRevisionFiles = true; - e.Handled = true; - }; + 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 { - foreach (var remote in _repo.Remotes) - { - var remoteName = remote.Name; - var lockRemote = new MenuItem(); - lockRemote.Header = remoteName; - lockRemote.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(remoteName, path)); - if (succ) - App.SendNotification(_repo.FullPath, $"Lock file \"{path}\" successfully!"); + RevisionFileSearchSuggestion = null; + GC.Collect(); + } + } - e.Handled = true; - }; - lfsLock.Items.Add(lockRemote); - } + 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; } - lfs.Items.Add(lfsLock); - var lfsUnlock = new MenuItem(); - lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); - lfsUnlock.Icon = App.CreateMenuIcon("Icons.Unlock"); - lfsUnlock.IsEnabled = _repo.Remotes.Count > 0; - if (_repo.Remotes.Count == 1) + RevisionFileSearchSuggestion = suggestion; + } + + private async Task SetViewingBlobAsync(Models.Object file) + { + var isBinary = await new Commands.IsBinary(_repo.FullPath, _commit.SHA, file.Path).GetResultAsync(); + if (isBinary) { - lfsUnlock.Click += async (_, e) => + 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 succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(_repo.Remotes[0].Name, path, false)); - if (succ) - App.SendNotification(_repo.FullPath, $"Unlock file \"{path}\" successfully!"); + var size = await new Commands.QueryFileSize(_repo.FullPath, file.Path, _commit.SHA).GetResultAsync(); + ViewRevisionFileContent = new Models.RevisionBinaryFile() { Size = size }; + } + + return; + } - e.Handled = true; - }; + 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 { - foreach (var remote in _repo.Remotes) - { - var remoteName = remote.Name; - var unlockRemote = new MenuItem(); - unlockRemote.Header = remoteName; - unlockRemote.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(remoteName, path, false)); - if (succ) - App.SendNotification(_repo.FullPath, $"Unlock file \"{path}\" successfully!"); - - e.Handled = true; - }; - lfsUnlock.Items.Add(unlockRemote); - } + ViewRevisionFileContent = new Models.RevisionTextFile() { FileName = file.Path, Content = content }; } - lfs.Items.Add(lfsUnlock); + } - menu.Items.Add(lfs); - menu.Items.Add(new MenuItem() { Header = "-" }); + 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(@"^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_LFS_FORMAT(); + [GeneratedRegex(@"\b(https?://|ftp://)[\w\d\._/\-~%@()+:?&=#!]*[\w\d/]")] + private static partial Regex REG_URL_FORMAT(); - private static readonly HashSet IMG_EXTS = new HashSet() - { - ".ico", ".bmp", ".jpg", ".png", ".jpeg", ".webp" - }; + [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 int _activePageIndex = 0; + private CommitDetailSharedData _sharedData = null; private Models.Commit _commit = null; - private string _fullMessage = string.Empty; + private Models.CommitFullMessage _fullMessage = null; private Models.CommitSignInfo _signInfo = null; - private List _changes = null; - private List _visibleChanges = null; + private List _changes = []; + private List _visibleChanges = []; private List _selectedChanges = null; private string _searchChangeFilter = string.Empty; private DiffContext _diffContext = null; + 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 index 23b0a1aeb..638caf5df 100644 --- a/src/ViewModels/ConfigureWorkspace.cs +++ b/src/ViewModels/ConfigureWorkspace.cs @@ -9,7 +9,6 @@ public class ConfigureWorkspace : ObservableObject public AvaloniaList Workspaces { get; - private set; } public Workspace Selected @@ -18,7 +17,7 @@ public Workspace Selected set { if (SetProperty(ref _selected, value)) - CanDeleteSelected = value != null && !value.IsActive; + CanDeleteSelected = value is { IsActive: false }; } } @@ -30,14 +29,13 @@ public bool CanDeleteSelected public ConfigureWorkspace() { - Workspaces = new AvaloniaList(); - Workspaces.AddRange(Preference.Instance.Workspaces); + Workspaces = new(Preferences.Instance.Workspaces); } public void Add() { var workspace = new Workspace() { Name = $"Unnamed {DateTime.Now:yyyy-MM-dd HH:mm:ss}" }; - Preference.Instance.Workspaces.Add(workspace); + Preferences.Instance.Workspaces.Add(workspace); Workspaces.Add(workspace); Selected = workspace; } @@ -47,10 +45,40 @@ public void Delete() if (_selected == null || _selected.IsActive) return; - Preference.Instance.Workspaces.Remove(_selected); + 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/ConfirmCommitWithoutFiles.cs b/src/ViewModels/ConfirmCommitWithoutFiles.cs deleted file mode 100644 index 3249fba82..000000000 --- a/src/ViewModels/ConfirmCommitWithoutFiles.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace SourceGit.ViewModels -{ - public class ConfirmCommitWithoutFiles - { - public ConfirmCommitWithoutFiles(WorkingCopy wc, bool autoPush) - { - _wc = wc; - _autoPush = autoPush; - } - - public void Continue() - { - _wc.CommitWithoutFiles(_autoPush); - } - - private readonly WorkingCopy _wc; - private bool _autoPush; - } -} 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 index 731c48aeb..99c57d819 100644 --- a/src/ViewModels/ConventionalCommitMessageBuilder.cs +++ b/src/ViewModels/ConventionalCommitMessageBuilder.cs @@ -1,4 +1,6 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Text; @@ -8,11 +10,21 @@ 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 Type + public Models.ConventionalCommitType SelectedType { - get => _type; - set => SetProperty(ref _type, value, true); + get => _selectedType; + set + { + if (SetProperty(ref _selectedType, value, true) && value is { PrefillShortDesc: { Length: > 0 } }) + Description = value.PrefillShortDesc; + } } public string Scope @@ -46,9 +58,11 @@ public string ClosedIssue set => SetProperty(ref _closedIssue, value); } - public ConventionalCommitMessageBuilder(WorkingCopy wc) + public ConventionalCommitMessageBuilder(string conventionalTypesOverride, Action onApply) { - _wc = wc; + Types = Models.ConventionalCommitType.Load(conventionalTypesOverride); + SelectedType = Types.Count > 0 ? Types[0] : null; + _onApply = onApply; } [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode")] @@ -62,7 +76,7 @@ public bool Apply() return false; var builder = new StringBuilder(); - builder.Append(_type.Type); + builder.Append(_selectedType.Type); if (!string.IsNullOrEmpty(_scope)) { @@ -71,25 +85,24 @@ public bool Apply() builder.Append(")"); } - if (string.IsNullOrEmpty(_breakingChanges)) - builder.Append(": "); - else - builder.Append("!: "); + if (!string.IsNullOrEmpty(_breakingChanges)) + builder.Append("!"); + builder.Append(": "); builder.Append(_description); - builder.Append("\n\n"); + builder.AppendLine().AppendLine(); if (!string.IsNullOrEmpty(_detail)) { builder.Append(_detail); - builder.Append("\n\n"); + builder.AppendLine().AppendLine(); } if (!string.IsNullOrEmpty(_breakingChanges)) { builder.Append("BREAKING CHANGE: "); builder.Append(_breakingChanges); - builder.Append("\n\n"); + builder.AppendLine().AppendLine(); } if (!string.IsNullOrEmpty(_closedIssue)) @@ -98,12 +111,12 @@ public bool Apply() builder.Append(_closedIssue); } - _wc.CommitMessage = builder.ToString(); + _onApply?.Invoke(builder.ToString()); return true; } - private WorkingCopy _wc = null; - private Models.ConventionalCommitType _type = Models.ConventionalCommitType.Supported[0]; + private Action _onApply = null; + private Models.ConventionalCommitType _selectedType = null; private string _scope = string.Empty; private string _description = string.Empty; private string _detail = string.Empty; diff --git a/src/ViewModels/CreateBranch.cs b/src/ViewModels/CreateBranch.cs index 653d2db89..f180c94e9 100644 --- a/src/ViewModels/CreateBranch.cs +++ b/src/ViewModels/CreateBranch.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; namespace SourceGit.ViewModels @@ -6,7 +7,7 @@ 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 { @@ -19,124 +20,236 @@ public object BasedOn get; } - public Models.DealWithLocalChanges PreAction + public bool HasLocalChanges { - get => _repo.Settings.DealWithLocalChangesOnCreateBranch; - set => _repo.Settings.DealWithLocalChangesOnCreateBranch = value; + get => _repo.LocalChangesCount > 0; + } + + public Models.DealWithLocalChanges DealWithLocalChanges + { + get; + set; } public bool CheckoutAfterCreated { - get => _repo.Settings.CheckoutBranchOnCreateBranch; - set => _repo.Settings.CheckoutBranchOnCreateBranch = value; + get => _repo.UIStates.CheckoutBranchOnCreateBranch; + set + { + if (_repo.UIStates.CheckoutBranchOnCreateBranch != value) + { + _repo.UIStates.CheckoutBranchOnCreateBranch = value; + OnPropertyChanged(); + UpdateOverrideTip(); + } + } + } + + public bool IsBareRepository + { + get => _repo.IsBare; + } + + 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.IsDetachedHead ? branch.Head : branch.FullName; + _baseOnRevision = branch.Head; + _committerDate = branch.CommitterDate; + _head = branch.Head; - if (!branch.IsLocal && repo.Branches.Find(x => x.IsLocal && x.Name == branch.Name) == null) - { + 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) { _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) { _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!"); - - foreach (var b in creator._repo.Branches) + if (ctx.ObjectInstance is CreateBranch creator) { - if (b.FriendlyName == name) - return new ValidationResult("A branch with same name already exists!"); + 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!"); + } + } + + return ValidationResult.Success; } - return ValidationResult.Success; + return new ValidationResult("Missing runtime context to create branch!"); } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - return Task.Run(() => + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog($"Create Branch '{_name}'"); + Use(log); + + if (CheckoutAfterCreated) { - if (CheckoutAfterCreated) + if (_repo.CurrentBranch is { IsDetachedHead: true } && !_repo.CurrentBranch.Head.Equals(_baseOnRevision, StringComparison.Ordinal)) { - var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); - var needPopStash = false; + 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; + } + } + } + + 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) { - if (PreAction == Models.DealWithLocalChanges.StashAndReaply) + succ = await new Commands.Stash(_repo.FullPath) + .Use(log) + .PushAsync("CREATE_BRANCH_AUTO_STASH", false); + if (!succ) { - SetProgressDescription("Stash local changes"); - var succ = new Commands.Stash(_repo.FullPath).Push("CREATE_BRANCH_AUTO_STASH"); - if (!succ) - { - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return false; - } - - needPopStash = true; + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return false; } - else if (PreAction == Models.DealWithLocalChanges.Discard) - { - SetProgressDescription("Discard local changes..."); - Commands.Discard.All(_repo.FullPath, false); - } - } - - SetProgressDescription($"Create new branch '{_name}'"); - new Commands.Checkout(_repo.FullPath).Branch(_name, _baseOnRevision, SetProgressDescription); - if (needPopStash) - { - SetProgressDescription("Re-apply local changes..."); - new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); + needPopStash = true; } + + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(_name, _baseOnRevision, false, _allowOverwrite); } else { - SetProgressDescription($"Create new branch '{_name}'"); - Commands.Branch.Create(_repo.FullPath, _name, _baseOnRevision); + succ = await new Commands.Checkout(_repo.FullPath) + .Use(log) + .BranchAsync(_name, _baseOnRevision, true, _allowOverwrite); } - CallUIThread(() => + if (succ) { - if (CheckoutAfterCreated && _repo.HistoriesFilterMode == Models.FilterMode.Included) - _repo.Settings.UpdateHistoriesFilter($"refs/heads/{_name}", Models.FilterType.LocalBranch, Models.FilterMode.Included); - - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - }); - return true; - }); + 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; + } + + private void UpdateOverrideTip() + { + OverrideTip = CheckoutAfterCreated ? "-B in `git checkout`" : "-f in `git branch`"; } private readonly Repository _repo = null; - private string _name = null; private readonly string _baseOnRevision = null; + private readonly ulong _committerDate = 0; + private readonly string _head = string.Empty; + private string _name = null; + private string _overrideTip = "-B"; + private bool _allowOverwrite = false; } } diff --git a/src/ViewModels/CreateGroup.cs b/src/ViewModels/CreateGroup.cs index cb94a0f60..eb8ef2b5c 100644 --- a/src/ViewModels/CreateGroup.cs +++ b/src/ViewModels/CreateGroup.cs @@ -16,12 +16,11 @@ public string Name public CreateGroup(RepositoryNode parent) { _parent = parent; - View = new Views.CreateGroup() { DataContext = this }; } public override Task Sure() { - Preference.Instance.AddNode(new RepositoryNode() + Preferences.Instance.AddNode(new RepositoryNode() { Id = Guid.NewGuid().ToString(), Name = _name, @@ -30,7 +29,7 @@ public override Task Sure() }, _parent, true); Welcome.Instance.Refresh(); - return null; + return Task.FromResult(true); } private readonly RepositoryNode _parent = null; diff --git a/src/ViewModels/CreateTag.cs b/src/ViewModels/CreateTag.cs index af9dcf99d..ff729aab9 100644 --- a/src/ViewModels/CreateTag.cs +++ b/src/ViewModels/CreateTag.cs @@ -13,7 +13,7 @@ public object BasedOn } [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 { @@ -29,8 +29,15 @@ public string Message public bool Annotated { - get => _annotated; - set => SetProperty(ref _annotated, value); + get => _repo.UIStates.CreateAnnotatedTag; + set + { + if (_repo.UIStates.CreateAnnotatedTag != value) + { + _repo.UIStates.CreateAnnotatedTag = value; + OnPropertyChanged(); + } + } } public bool SignTag @@ -39,11 +46,11 @@ public bool SignTag set; } = false; - public bool PushToAllRemotes + public bool PushToRemotes { - get; - set; - } = true; + get => _repo.UIStates.PushToRemoteWhenCreateTag; + set => _repo.UIStates.PushToRemoteWhenCreateTag = value; + } public CreateTag(Repository repo, Models.Branch branch) { @@ -52,7 +59,6 @@ public CreateTag(Repository repo, Models.Branch branch) BasedOn = branch; SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); - View = new Views.CreateTag() { DataContext = this }; } public CreateTag(Repository repo, Models.Commit commit) @@ -62,13 +68,11 @@ public CreateTag(Repository repo, Models.Commit commit) BasedOn = commit; SignTag = new Commands.Config(repo.FullPath).Get("tag.gpgsign").Equals("true", StringComparison.OrdinalIgnoreCase); - View = new Views.CreateTag() { DataContext = this }; } public static ValidationResult ValidateTagName(string name, ValidationContext ctx) { - var creator = ctx.ObjectInstance as CreateTag; - if (creator != null) + if (ctx.ObjectInstance is CreateTag creator) { var found = creator._repo.Tags.Find(x => x.Name == name); if (found != null) @@ -77,36 +81,38 @@ public static ValidationResult ValidateTagName(string name, ValidationContext ct return ValidationResult.Success; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Create tag..."; - return Task.Run(() => - { - bool succ; - if (_annotated) - succ = Commands.Tag.Add(_repo.FullPath, _tagName, _basedOn, Message, SignTag); - else - succ = Commands.Tag.Add(_repo.FullPath, _tagName, _basedOn); + var remotes = PushToRemotes ? _repo.Remotes : null; + var log = _repo.CreateLog("Create Tag"); + Use(log); - if (succ && PushToAllRemotes) - { - foreach (var remote in _repo.Remotes) - { - SetProgressDescription($"Pushing tag to remote {remote.Name} ..."); - new Commands.Push(_repo.FullPath, remote.Name, _tagName, false).Exec(); - } - } + 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(); + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + log.Complete(); + return succ; } private readonly Repository _repo = null; private string _tagName = string.Empty; - private bool _annotated = true; private readonly string _basedOn; } } diff --git a/src/ViewModels/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 e7136a0dd..ed8e8ff0b 100644 --- a/src/ViewModels/DeleteBranch.cs +++ b/src/ViewModels/DeleteBranch.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System; +using System.Text; +using System.Threading.Tasks; namespace SourceGit.ViewModels { @@ -7,74 +9,89 @@ public class DeleteBranch : Popup public Models.Branch Target { get; - private set; } - public Models.Branch TrackingRemoteBranch + public bool Force { get; - private set; - } - - public object DeleteTrackingRemoteTip - { - get; - private set; - } - - public bool AlsoDeleteTrackingRemote - { - get => _alsoDeleteTrackingRemote; - set => SetProperty(ref _alsoDeleteTrackingRemote, value); + set; } public DeleteBranch(Repository repo, Models.Branch branch) { _repo = repo; Target = branch; - - if (branch.IsLocal && !string.IsNullOrEmpty(branch.Upstream)) - { - TrackingRemoteBranch = repo.Branches.Find(x => x.FullName == branch.Upstream); - if (TrackingRemoteBranch != null) - DeleteTrackingRemoteTip = new Views.NameHighlightedTextBlock("DeleteBranch.WithTrackingRemote", TrackingRemoteBranch.FriendlyName); - } - - View = new Views.DeleteBranch() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting branch..."; - return Task.Run(() => + var log = _repo.CreateLog("Delete Branch"); + Use(log); + + var succ = false; + if (Target.IsLocal) { - if (Target.IsLocal) + succ = await new Commands.Branch(_repo.FullPath, Target.Name) + .Use(log) + .DeleteLocalAsync(Force); + + if (succ) { - Commands.Branch.DeleteLocal(_repo.FullPath, Target.Name); + _repo.UIStates.RemoveHistoryFilter(Target.FullName, Models.FilterType.LocalBranch); - if (_alsoDeleteTrackingRemote && TrackingRemoteBranch != null) + 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)) { - SetProgressDescription("Deleting remote-tracking branch..."); - Commands.Branch.DeleteRemote(_repo.FullPath, TrackingRemoteBranch.Remote, TrackingRemoteBranch.Name); + 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 - { - Commands.Branch.DeleteRemote(_repo.FullPath, Target.Remote, Target.Name); - } + } + else + { + succ = await DeleteRemoteBranchAsync(Target, log); + if (succ) + _repo.UIStates.RemoveHistoryFilter(Target.FullName, Models.FilterType.RemoteBranch); + } - CallUIThread(() => - { - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - }); - return true; - }); + 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); + + 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 readonly Repository _repo = null; - private bool _alsoDeleteTrackingRemote = false; } } diff --git a/src/ViewModels/DeleteMultipleBranches.cs b/src/ViewModels/DeleteMultipleBranches.cs index 6048ec684..eae8192dc 100644 --- a/src/ViewModels/DeleteMultipleBranches.cs +++ b/src/ViewModels/DeleteMultipleBranches.cs @@ -10,41 +10,53 @@ public List Targets get; } + public bool Force + { + get; + set; + } = false; + public DeleteMultipleBranches(Repository repo, List branches, bool isLocal) { _repo = repo; _isLocal = isLocal; Targets = branches; - View = new Views.DeleteMultipleBranches() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting multiple branches..."; - return Task.Run(() => + var log = _repo.CreateLog("Delete Multiple Branches"); + Use(log); + + if (_isLocal) { - if (_isLocal) - { - foreach (var target in Targets) - { - SetProgressDescription($"Deleting local branch : {target.Name}"); - Commands.Branch.DeleteLocal(_repo.FullPath, target.Name); - } - } - else + foreach (var target in Targets) + await new Commands.Branch(_repo.FullPath, target.Name) + .Use(log) + .DeleteLocalAsync(Force); + } + else + { + foreach (var target in Targets) { - foreach (var target in Targets) - { - SetProgressDescription($"Deleting remote branch : {target.FriendlyName}"); - Commands.Branch.DeleteRemote(_repo.FullPath, target.Remote, target.Name); - } + 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); } + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + log.Complete(); + _repo.MarkBranchesDirtyManually(); + return true; } private Repository _repo = null; 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 e1fba02de..ceef24588 100644 --- a/src/ViewModels/DeleteRemote.cs +++ b/src/ViewModels/DeleteRemote.cs @@ -14,24 +14,23 @@ public DeleteRemote(Repository repo, Models.Remote remote) { _repo = repo; Remote = remote; - View = new Views.DeleteRemote() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Deleting remote ..."; - return Task.Run(() => - { - var succ = new Commands.Remote(_repo.FullPath).Delete(Remote.Name); - CallUIThread(() => - { - _repo.MarkBranchesDirtyManually(); - _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 readonly Repository _repo = null; diff --git a/src/ViewModels/DeleteRepositoryNode.cs b/src/ViewModels/DeleteRepositoryNode.cs index 7cd0df5c2..59ef60ea3 100644 --- a/src/ViewModels/DeleteRepositoryNode.cs +++ b/src/ViewModels/DeleteRepositoryNode.cs @@ -6,23 +6,19 @@ public class DeleteRepositoryNode : Popup { public RepositoryNode Node { - get => _node; - set => SetProperty(ref _node, value); + get; } public DeleteRepositoryNode(RepositoryNode node) { - _node = node; - View = new Views.DeleteRepositoryNode() { DataContext = this }; + Node = node; } public override Task Sure() { - Preference.Instance.RemoveNode(_node, true); + Preferences.Instance.RemoveNode(Node, true); Welcome.Instance.Refresh(); - return null; + return Task.FromResult(true); } - - private RepositoryNode _node = null; } } diff --git a/src/ViewModels/DeleteSubmodule.cs b/src/ViewModels/DeleteSubmodule.cs index 459b40266..998777421 100644 --- a/src/ViewModels/DeleteSubmodule.cs +++ b/src/ViewModels/DeleteSubmodule.cs @@ -4,7 +4,6 @@ namespace SourceGit.ViewModels { public class DeleteSubmodule : Popup { - public string Submodule { get; @@ -15,20 +14,22 @@ public DeleteSubmodule(Repository repo, string submodule) { _repo = repo; Submodule = submodule; - View = new Views.DeleteSubmodule() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + 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 readonly Repository _repo = null; diff --git a/src/ViewModels/DeleteTag.cs b/src/ViewModels/DeleteTag.cs index 7d631a386..6dc6ebb9c 100644 --- a/src/ViewModels/DeleteTag.cs +++ b/src/ViewModels/DeleteTag.cs @@ -10,32 +10,43 @@ public Models.Tag Target private set; } - public bool ShouldPushToRemote + public bool PushToRemotes { - get; - set; + get => _repo.UIStates.PushToRemoteWhenDeleteTag; + set => _repo.UIStates.PushToRemoteWhenDeleteTag = value; } public DeleteTag(Repository repo, Models.Tag tag) { _repo = repo; Target = tag; - ShouldPushToRemote = true; - View = new Views.DeleteTag() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Deleting tag '{Target.Name}' ..."; - return Task.Run(() => + 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) { - var remotes = ShouldPushToRemote ? _repo.Remotes : null; - var succ = Commands.Tag.Delete(_repo.FullPath, Target.Name, remotes); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return 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 readonly Repository _repo = null; diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs index 87b1a6def..27eb93b8d 100644 --- a/src/ViewModels/DiffContext.cs +++ b/src/ViewModels/DiffContext.cs @@ -1,11 +1,7 @@ using System; -using System.Collections.Generic; using System.IO; using System.Threading.Tasks; - -using Avalonia.Media.Imaging; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -14,23 +10,19 @@ public class DiffContext : ObservableObject { public string Title { - get => _title; + get; } - public bool IgnoreWhitespace + public int OldMode { - get => _ignoreWhitespace; - set - { - if (SetProperty(ref _ignoreWhitespace, value)) - LoadDiffContent(); - } + get => _oldMode; + private set => SetProperty(ref _oldMode, value); } - public string FileModeChange + public int NewMode { - get => _fileModeChange; - private set => SetProperty(ref _fileModeChange, value); + get => _newMode; + private set => SetProperty(ref _newMode, value); } public bool IsTextDiff @@ -39,6 +31,12 @@ public bool IsTextDiff private set => SetProperty(ref _isTextDiff, value); } + public bool IsIgnoreWhitespaceVisible + { + get => _isIgnoreWhitespaceVisible; + private set => SetProperty(ref _isIgnoreWhitespaceVisible, value); + } + public object Content { get => _content; @@ -59,150 +57,121 @@ public DiffContext(string repo, Models.DiffOption option, DiffContext previous = if (previous != null) { _isTextDiff = previous._isTextDiff; + _isIgnoreWhitespaceVisible = previous._isIgnoreWhitespaceVisible; _content = previous._content; + _oldMode = previous._oldMode; + _newMode = previous._newMode; _unifiedLines = previous._unifiedLines; - _ignoreWhitespace = previous._ignoreWhitespace; _info = previous._info; } if (string.IsNullOrEmpty(_option.OrgPath) || _option.OrgPath == "/dev/null") - _title = _option.Path; + Title = _option.Path; else - _title = $"{_option.OrgPath} → {_option.Path}"; - - LoadDiffContent(); - } + Title = $"{_option.OrgPath} → {_option.Path}"; - public void ToggleFullTextDiff() - { - Preference.Instance.UseFullTextDiff = !Preference.Instance.UseFullTextDiff; - LoadDiffContent(); + LoadContent(); } public void IncrUnified() { UnifiedLines = _unifiedLines + 1; - LoadDiffContent(); + LoadContent(); } public void DecrUnified() { UnifiedLines = Math.Max(4, _unifiedLines - 1); - LoadDiffContent(); + LoadContent(); } public void OpenExternalMergeTool() { - var toolType = Preference.Instance.ExternalMergeToolType; - var toolPath = Preference.Instance.ExternalMergeToolPath; - Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, toolType, toolPath, _option)); + new Commands.DiffTool(_repo, _option).Open(); } - private void LoadDiffContent() + 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(); + } + } + + private void LoadContent() { if (_option.Path.EndsWith('/')) { - Content = null; + OldMode = 0; + NewMode = 160000; IsTextDiff = false; + IsIgnoreWhitespaceVisible = false; + Content = null; + _info = null; return; } - Task.Run(() => + Task.Run(async () => { - // NOTE: Here we override the UnifiedLines value (if UseFullTextDiff is on). - // There is no way to tell a git-diff to use "ALL lines of context", - // so instead we set a very high number for the "lines of context" parameter. - var numLines = Preference.Instance.UseFullTextDiff ? 999999999 : _unifiedLines; - var latest = new Commands.Diff(_repo, _option, numLines, _ignoreWhitespace).Result(); - var info = new Info(_option, numLines, _ignoreWhitespace, latest); + var pref = Preferences.Instance; + var numLines = pref.UseFullTextDiff ? _entireFileLines : _unifiedLines; + var ignoreWhitespace = pref.IgnoreWhitespaceChangesInDiff; + var ignoreCRAtEOL = pref.IgnoreCRAtEOLInDiff; + + var latest = await new Commands.Diff(_repo, _option, numLines, ignoreWhitespace, ignoreCRAtEOL) + .ReadAsync() + .ConfigureAwait(false); + + var info = new Info(_option, numLines, ignoreWhitespace, latest); if (_info != null && info.IsSame(_info)) return; _info = info; - var rs = null as object; - if (latest.TextDiff != null) + object rs = null; + if (latest.TextDiff is { } textDiff) { - var count = latest.TextDiff.Lines.Count; - var isSubmodule = false; - if (count <= 3) - { - var submoduleDiff = new Models.SubmoduleDiff(); - var submoduleRoot = $"{_repo}/{_option.Path}".Replace("\\", "/"); - isSubmodule = true; - for (int i = 1; i < count; i++) - { - var line = latest.TextDiff.Lines[i]; - if (!line.Content.StartsWith("Subproject commit ", StringComparison.Ordinal)) - { - isSubmodule = false; - break; - } - - var sha = line.Content.Substring(18); - if (line.Type == Models.TextDiffLineType.Added) - submoduleDiff.New = QuerySubmoduleRevision(submoduleRoot, sha); - else if (line.Type == Models.TextDiffLineType.Deleted) - submoduleDiff.Old = QuerySubmoduleRevision(submoduleRoot, sha); - } - - if (isSubmodule) - rs = submoduleDiff; - } - - if (!isSubmodule) - { - latest.TextDiff.File = _option.Path; - rs = latest.TextDiff; - } + 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 oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; - var ext = Path.GetExtension(_option.Path); - - if (IMG_EXTS.Contains(ext)) - { - var imgDiff = new Models.ImageDiff(); - if (_option.Revisions.Count == 2) - { - (imgDiff.Old, imgDiff.OldFileSize) = BitmapFromRevisionFile(_repo, _option.Revisions[0], oldPath); - (imgDiff.New, imgDiff.NewFileSize) = BitmapFromRevisionFile(_repo, _option.Revisions[1], _option.Path); - } - else - { - if (!oldPath.Equals("/dev/null", StringComparison.Ordinal)) - (imgDiff.Old, imgDiff.OldFileSize) = BitmapFromRevisionFile(_repo, "HEAD", oldPath); - - var fullPath = Path.Combine(_repo, _option.Path); - if (File.Exists(fullPath)) - { - imgDiff.New = new Bitmap(fullPath); - imgDiff.NewFileSize = new FileInfo(fullPath).Length; - } - } - rs = imgDiff; - } + var imgDecoder = ImageSource.GetDecoder(_option.Path); + if (imgDecoder != Models.ImageDecoder.None) + rs = await CreateImageDiffAsync(imgDecoder).ConfigureAwait(false); else - { - var binaryDiff = new Models.BinaryDiff(); - if (_option.Revisions.Count == 2) - { - binaryDiff.OldSize = new Commands.QueryFileSize(_repo, oldPath, _option.Revisions[0]).Result(); - binaryDiff.NewSize = new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).Result(); - } - else - { - var fullPath = Path.Combine(_repo, _option.Path); - binaryDiff.OldSize = new Commands.QueryFileSize(_repo, oldPath, "HEAD").Result(); - binaryDiff.NewSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; - } - rs = binaryDiff; - } + rs = await CreateBinaryDiffAsync().ConfigureAwait(false); } - else if (latest.IsLFS) + else if (latest.IsSubmoduleChange) { - rs = latest.LFSDiff; + rs = await CreateSubmoduleDiffAsync(latest.OldHash, latest.NewHash).ConfigureAwait(false); + } + else if (IsEmptyFileHash(latest.OldHash) || IsEmptyFileHash(latest.NewHash)) + { + rs = new Models.EmptyFile(); } else { @@ -211,51 +180,153 @@ private void LoadDiffContent() Dispatcher.UIThread.Post(() => { - if (_content is Models.TextDiff old && rs is Models.TextDiff cur && old.File == cur.File) - cur.ScrollOffset = old.ScrollOffset; + OldMode = latest.OldMode; + NewMode = latest.NewMode; - FileModeChange = latest.FileModeChange; - Content = rs; - IsTextDiff = rs is Models.TextDiff; + if (rs is Models.TextDiff cur) + { + IsTextDiff = true; + 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; + IsIgnoreWhitespaceVisible = (rs is Models.NoOrEOLChange); + Content = rs; + } }); }); } - private (Bitmap, long) BitmapFromRevisionFile(string repo, string revision, string file) + private async Task CreateImageDiffAsync(Models.ImageDecoder imgDecoder) { - var stream = Commands.QueryFileContent.Run(repo, revision, file); - var size = stream.Length; - return size > 0 ? (new Bitmap(stream), size) : (null, size); + var oldPath = string.IsNullOrEmpty(_option.OrgPath) ? _option.Path : _option.OrgPath; + var imgDiff = new Models.ImageDiff(); + var fullPath = Path.Combine(_repo, _option.Path); + + 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 Models.RevisionSubmodule QuerySubmoduleRevision(string repo, string sha) + private async Task CreateBinaryDiffAsync() { - var commit = new Commands.QuerySingleCommit(repo, sha).Result(); - if (commit != null) + 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) { - var body = new Commands.QueryCommitFullMessage(repo, sha).Result(); - return new Models.RevisionSubmodule() { Commit = commit, FullMessage = body }; + 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; + } + + private async Task CreateSubmoduleDiffAsync(string oldRevision, string newRevision) + { + var submoduleDiff = new Models.SubmoduleDiff(); + var submoduleRoot = $"{_repo}/{_option.Path}".Replace('\\', '/').TrimEnd('/'); + submoduleDiff.FullPath = submoduleRoot; - return new Models.RevisionSubmodule() + 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++) { - Commit = new Models.Commit() { SHA = sha }, - FullMessage = string.Empty, - }; + if (hash[i] != '0') + return true; + } + + return false; } - private static readonly HashSet IMG_EXTS = new HashSet() + private bool IsEmptyFileHash(string hash) { - ".ico", ".bmp", ".jpg", ".png", ".jpeg", ".webp" - }; + 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; set; } - public int UnifiedLines { get; set; } - public bool IgnoreWhitespace { get; set; } - public string OldHash { get; set; } - public string NewHash { get; set; } + 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) { @@ -276,13 +347,14 @@ public bool IsSame(Info other) } } + private readonly int _entireFileLines = 999999999; private readonly string _repo; private readonly Models.DiffOption _option = null; - private string _title; - private string _fileModeChange = string.Empty; + private int _oldMode = 0; + private int _newMode = 0; private int _unifiedLines = 4; private bool _isTextDiff = false; - private bool _ignoreWhitespace = false; + private bool _isIgnoreWhitespaceVisible = true; private object _content = null; 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 e5b413dbe..0a37b56ba 100644 --- a/src/ViewModels/Discard.cs +++ b/src/ViewModels/Discard.cs @@ -5,6 +5,18 @@ namespace SourceGit.ViewModels { public class DiscardAllMode { + public bool IncludeModified + { + get; + set; + } = true; + + public bool IncludeUntracked + { + get; + set; + } = false; + public bool IncludeIgnored { get; @@ -40,9 +52,7 @@ public object Mode public Discard(Repository repo) { _repo = repo; - Mode = new DiscardAllMode(); - View = new Views.Discard { DataContext = this }; } public Discard(Repository repo, List changes) @@ -56,30 +66,29 @@ public Discard(Repository repo, List changes) Mode = new DiscardSingleFile() { Path = _changes[0].Path }; else Mode = new DiscardMultipleFiles() { Count = _changes.Count }; - - View = new Views.Discard() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = _changes == null ? "Discard all local changes ..." : $"Discard total {_changes.Count} changes ..."; - return Task.Run(() => - { - if (Mode is DiscardAllMode all) - Commands.Discard.All(_repo.FullPath, all.IncludeIgnored); - else - Commands.Discard.Changes(_repo.FullPath, _changes); + var log = _repo.CreateLog("Discard Changes"); + Use(log); - CallUIThread(() => - { - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); - }); + 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); + } - return true; - }); + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + return true; } private readonly Repository _repo = null; diff --git a/src/ViewModels/DropStash.cs b/src/ViewModels/DropStash.cs index ea6ff2770..7d4136ab9 100644 --- a/src/ViewModels/DropStash.cs +++ b/src/ViewModels/DropStash.cs @@ -4,26 +4,31 @@ namespace SourceGit.ViewModels { public class DropStash : Popup { - public Models.Stash Stash { get; private set; } + 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 readonly 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 912c7991b..84b950e2a 100644 --- a/src/ViewModels/EditRemote.cs +++ b/src/ViewModels/EditRemote.cs @@ -53,11 +53,7 @@ public EditRemote(Repository repo, Models.Remote remote) _useSSH = Models.Remote.IsSSH(remote.URL); if (_useSSH) - { - SSHKey = new Commands.Config(repo.FullPath).Get($"remote.{remote.Name}.sshkey"); - } - - View = new Views.EditRemote() { DataContext = this }; + _sshkey = new Commands.Config(repo.FullPath).Get($"remote.{remote.Name}.sshkey"); } public static ValidationResult ValidateRemoteName(string name, ValidationContext ctx) @@ -102,37 +98,31 @@ public static ValidationResult ValidateSSHKey(string sshkey, ValidationContext c return ValidationResult.Success; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = $"Editing remote '{_remote.Name}' ..."; - return Task.Run(() => + if (_remote.Name != _name) { - if (_remote.Name != _name) - { - var succ = new Commands.Remote(_repo.FullPath).Rename(_remote.Name, _name); - if (succ) - _remote.Name = _name; - } - - if (_remote.URL != _url) - { - var succ = new Commands.Remote(_repo.FullPath).SetURL(_name, _url, false); - if (succ) - _remote.URL = _url; - } + var succ = await new Commands.Remote(_repo.FullPath).RenameAsync(_remote.Name, _name); + if (succ) + _remote.Name = _name; + } - var pushURL = new Commands.Remote(_repo.FullPath).GetURL(_name, true); - if (pushURL != _url) - new Commands.Remote(_repo.FullPath).SetURL(_name, _url, true); + if (_remote.URL != _url) + { + var succ = await new Commands.Remote(_repo.FullPath).SetURLAsync(_name, _url, false); + if (succ) + _remote.URL = _url; + } - SetProgressDescription("Post processing ..."); - new Commands.Config(_repo.FullPath).Set($"remote.{_name}.sshkey", _useSSH ? SSHKey : null); + 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 readonly Repository _repo = null; diff --git a/src/ViewModels/EditRepositoryNode.cs b/src/ViewModels/EditRepositoryNode.cs index c4ae2335c..40402ef67 100644 --- a/src/ViewModels/EditRepositoryNode.cs +++ b/src/ViewModels/EditRepositoryNode.cs @@ -5,10 +5,14 @@ namespace SourceGit.ViewModels { public class EditRepositoryNode : Popup { - public string Id + public string Target { - get => _id; - set => SetProperty(ref _id, value); + get; + } + + public bool IsRepository + { + get; } [Required(ErrorMessage = "Name is required!")] @@ -24,21 +28,14 @@ public int Bookmark set => SetProperty(ref _bookmark, value); } - public bool IsRepository - { - get => _isRepository; - set => SetProperty(ref _isRepository, value); - } - public EditRepositoryNode(RepositoryNode node) { _node = node; - _id = node.Id; _name = node.Name; - _isRepository = node.IsRepository; _bookmark = node.Bookmark; - View = new Views.EditRepositoryNode() { DataContext = this }; + Target = node.IsRepository ? node.Id : node.Name; + IsRepository = node.IsRepository; } public override Task Sure() @@ -49,17 +46,15 @@ public override Task Sure() if (needSort) { - Preference.Instance.SortByRenamedNode(_node); + Preferences.Instance.SortByRenamedNode(_node); Welcome.Instance.Refresh(); } - return null; + return Task.FromResult(true); } private RepositoryNode _node = null; - private string _id = null; private string _name = null; - private bool _isRepository = false; private int _bookmark = 0; } } diff --git a/src/ViewModels/ExecuteCustomAction.cs b/src/ViewModels/ExecuteCustomAction.cs index 920b9f438..1c01fb820 100644 --- a/src/ViewModels/ExecuteCustomAction.cs +++ b/src/ViewModels/ExecuteCustomAction.cs @@ -1,40 +1,335 @@ -using System.Threading.Tasks; +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; - private set; } - public ExecuteCustomAction(Repository repo, Models.CustomAction action, Models.Commit commit) + public object Target { - _repo = repo; - _args = action.Arguments.Replace("${REPO}", _repo.FullPath); - if (commit != null) - _args = _args.Replace("${SHA}", commit.SHA); + get; + } + public List ControlParameters + { + get; + } = []; + + public ExecuteCustomAction(Repository repo, Models.CustomAction action, object scopeTarget) + { + _repo = repo; CustomAction = action; - View = new Views.ExecuteCustomAction() { DataContext = this }; + 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 Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Run custom action ..."; - return Task.Run(() => + 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) { - Commands.ExecuteCustomAction.Run(_repo.FullPath, CustomAction.Executable, _args, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + _repo.SendNotification(e.Message, true); + } } private readonly Repository _repo = null; - private string _args = string.Empty; } } 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 df4b4d73b..000000000 --- a/src/ViewModels/FastForwardWithoutCheckout.cs +++ /dev/null @@ -1,42 +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.UpdateRef(_repo.FullPath, Local.FullName, To.FullName, SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); - } - - private readonly Repository _repo = null; - } -} diff --git a/src/ViewModels/Fetch.cs b/src/ViewModels/Fetch.cs index 7f54680d7..3236a6421 100644 --- a/src/ViewModels/Fetch.cs +++ b/src/ViewModels/Fetch.cs @@ -10,10 +10,19 @@ public List Remotes get => _repo.Remotes; } + 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 @@ -24,51 +33,81 @@ public Models.Remote SelectedRemote public bool NoTags { - get => _repo.Settings.FetchWithoutTags; - set => _repo.Settings.FetchWithoutTags = value; + get => _repo.UIStates.FetchWithoutTags; + set => _repo.UIStates.FetchWithoutTags = value; } - public Fetch(Repository repo, Models.Remote preferedRemote = null) + public bool Force + { + get => _repo.UIStates.EnableForceOnFetch; + set => _repo.UIStates.EnableForceOnFetch = value; + } + + public Fetch(Repository repo, Models.Remote preferredRemote = null) { _repo = repo; - _fetchAllRemotes = preferedRemote == null; - SelectedRemote = preferedRemote != null ? preferedRemote : _repo.Remotes[0]; - View = new Views.Fetch() { DataContext = this }; + 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 Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + 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); - var notags = _repo.Settings.FetchWithoutTags; - var prune = _repo.Settings.EnablePruneOnFetch; - return Task.Run(() => + if (FetchAllRemotes) { - if (FetchAllRemotes) - { - foreach (var remote in _repo.Remotes) - { - SetProgressDescription($"Fetching remote: {remote.Name}"); - new Commands.Fetch(_repo.FullPath, remote.Name, notags, prune, SetProgressDescription).Exec(); - } - } - else - { - SetProgressDescription($"Fetching remote: {SelectedRemote.Name}"); - new Commands.Fetch(_repo.FullPath, SelectedRemote.Name, notags, prune, SetProgressDescription).Exec(); - } + 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(); - CallUIThread(() => + if (navigateToUpstreamHEAD) + { + var upstream = _repo.CurrentBranch?.Upstream; + if (!string.IsNullOrEmpty(upstream)) { - _repo.MarkFetched(); - _repo.SetWatcherEnabled(true); - }); + var upstreamHead = await new Commands.QueryRevisionByRefName(_repo.FullPath, upstream.Substring(13)).GetResultAsync(); + _repo.NavigateToCommit(upstreamHead, true); + } + } - return true; - }); + _repo.MarkFetched(); + return true; } private readonly Repository _repo = null; - private bool _fetchAllRemotes; + private bool _fetchAllRemotes = false; } } diff --git a/src/ViewModels/FetchInto.cs b/src/ViewModels/FetchInto.cs index a52b1cd6e..bcf461203 100644 --- a/src/ViewModels/FetchInto.cs +++ b/src/ViewModels/FetchInto.cs @@ -7,13 +7,11 @@ public class FetchInto : Popup public Models.Branch Local { get; - private set; } public Models.Branch Upstream { get; - private set; } public FetchInto(Repository repo, Models.Branch local, Models.Branch upstream) @@ -21,20 +19,29 @@ public FetchInto(Repository repo, Models.Branch local, Models.Branch upstream) _repo = repo; Local = local; Upstream = upstream; - View = new Views.FetchInto() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Fast-Forward ..."; - return Task.Run(() => + 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) { - new Commands.Fetch(_repo.FullPath, Local, Upstream, SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + 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 52ed6b01a..374396f1f 100644 --- a/src/ViewModels/FileHistories.cs +++ b/src/ViewModels/FileHistories.cs @@ -1,52 +1,255 @@ -using System.Collections.Generic; -using System.Globalization; +using System; +using System.Collections.Generic; using System.IO; -using System.Text.RegularExpressions; using System.Threading.Tasks; - -using Avalonia.Media.Imaging; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class FileHistoriesRevisionFile(string path, object content) + 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 partial class FileHistories : ObservableObject + public class FileHistoriesSingleRevisionViewMode { - public bool IsLoading + public bool IsDiff { - get => _isLoading; - private set => SetProperty(ref _isLoading, value); + get; + set; + } = true; + } + + public class FileHistoriesSingleRevision : ObservableObject + { + public bool IsDiffMode + { + get => _viewMode.IsDiff; + set + { + if (_viewMode.IsDiff != value) + { + _viewMode.IsDiff = value; + RefreshViewContent(); + } + } } - public List Commits + public object ViewContent { - get => _commits; - set => SetProperty(ref _commits, value); + get => _viewContent; + set => SetProperty(ref _viewContent, value); } - public Models.Commit SelectedCommit + public FileHistoriesSingleRevision(string repo, Models.FileVersion revision, FileHistoriesSingleRevisionViewMode viewMode) { - get => _selectedCommit; - set + _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); + } + + private void RefreshViewContent() + { + if (_viewMode.IsDiff) { - if (SetProperty(ref _selectedCommit, value)) - RefreshViewContent(); + 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 bool IsViewContent + public void Swap() { - get => _isViewContent; + (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 _isViewContent, value)) + if (SetProperty(ref _selectedRevisions, value)) RefreshViewContent(); } } @@ -57,147 +260,86 @@ public object ViewContent private set => SetProperty(ref _viewContent, value); } - public FileHistories(Repository repo, string file, string commit = null) + 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(() => + Task.Run(async () => { - var commits = new Commands.QueryCommits(_repo.FullPath, $"-n 10000 {commit} -- \"{file}\"", false).Result(); - Dispatcher.UIThread.Invoke(() => + 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(Models.Commit commit) + public void NavigateToCommit(Models.FileVersion revision) { - _repo.NavigateToCommit(commit.SHA); + 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 void ResetToSelectedRevision() + public string GetCommitFullMessage(Models.FileVersion revision) { - new Commands.Checkout(_repo.FullPath).FileWithRevision(_file, $"{_selectedCommit.SHA}"); + 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() { - if (_selectedCommit == null) + var count = _selectedRevisions?.Count ?? 0; + if (count == 0) { ViewContent = null; - return; } - - if (_isViewContent) - SetViewContentAsRevisionFile(); - else - SetViewContentAsDiff(); - } - - private void SetViewContentAsRevisionFile() - { - var objs = new Commands.QueryRevisionObjects(_repo.FullPath, _selectedCommit.SHA, _file).Result(); - if (objs.Count == 0) + else if (count == 1) { - ViewContent = new FileHistoriesRevisionFile(_file, null); - return; + if (_viewContent is FileHistoriesSingleRevision single) + single.SetRevision(_selectedRevisions[0]); + else + ViewContent = new FileHistoriesSingleRevision(_repo, _selectedRevisions[0], _viewMode); } - - var obj = objs[0]; - switch (obj.Type) + else if (count == 2) { - case Models.ObjectType.Blob: - Task.Run(() => - { - var isBinary = new Commands.IsBinary(_repo.FullPath, _selectedCommit.SHA, _file).Result(); - if (isBinary) - { - var ext = Path.GetExtension(_file); - if (IMG_EXTS.Contains(ext)) - { - var stream = Commands.QueryFileContent.Run(_repo.FullPath, _selectedCommit.SHA, _file); - var fileSize = stream.Length; - var bitmap = fileSize > 0 ? new Bitmap(stream) : null; - var imageType = Path.GetExtension(_file).TrimStart('.').ToUpper(CultureInfo.CurrentCulture); - var image = new Models.RevisionImageFile() { Image = bitmap, FileSize = fileSize, ImageType = imageType }; - Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, image)); - } - else - { - var size = new Commands.QueryFileSize(_repo.FullPath, _file, _selectedCommit.SHA).Result(); - var binaryFile = new Models.RevisionBinaryFile() { Size = size }; - Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, binaryFile)); - } - - return; - } - - var contentStream = Commands.QueryFileContent.Run(_repo.FullPath, _selectedCommit.SHA, _file); - var content = new StreamReader(contentStream).ReadToEnd(); - var matchLFS = REG_LFS_FORMAT().Match(content); - if (matchLFS.Success) - { - var lfs = new Models.RevisionLFSObject() { Object = new Models.LFSObject() }; - lfs.Object.Oid = matchLFS.Groups[1].Value; - lfs.Object.Size = long.Parse(matchLFS.Groups[2].Value); - Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, lfs)); - } - else - { - var txt = new Models.RevisionTextFile() { FileName = obj.Path, Content = content }; - Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, txt)); - } - }); - break; - case Models.ObjectType.Commit: - Task.Run(() => - { - var submoduleRoot = Path.Combine(_repo.FullPath, _file); - var commit = new Commands.QuerySingleCommit(submoduleRoot, obj.SHA).Result(); - if (commit != null) - { - var message = new Commands.QueryCommitFullMessage(submoduleRoot, obj.SHA).Result(); - var module = new Models.RevisionSubmodule() { Commit = commit, FullMessage = message }; - Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, module)); - } - else - { - var module = new Models.RevisionSubmodule() { Commit = new Models.Commit() { SHA = obj.SHA }, FullMessage = "" }; - Dispatcher.UIThread.Invoke(() => ViewContent = new FileHistoriesRevisionFile(_file, module)); - } - }); - break; - default: - ViewContent = new FileHistoriesRevisionFile(_file, null); - break; + ViewContent = new FileHistoriesCompareRevisions(_repo, _selectedRevisions[0], _selectedRevisions[1]); + } + else + { + ViewContent = _selectedRevisions.Count; } } - private void SetViewContentAsDiff() - { - var option = new Models.DiffOption(_selectedCommit, _file); - ViewContent = new DiffContext(_repo.FullPath, option, _viewContent as DiffContext); - } - - [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_LFS_FORMAT(); - - private static readonly HashSet IMG_EXTS = new HashSet() - { - ".ico", ".bmp", ".jpg", ".png", ".jpeg", ".webp" - }; - - private readonly Repository _repo = null; - private readonly string _file = null; + private readonly string _repo = null; private bool _isLoading = true; - private List _commits = null; - private Models.Commit _selectedCommit = null; - private bool _isViewContent = false; + 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/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 d278dc77e..373e5398b 100644 --- a/src/ViewModels/GitFlowFinish.cs +++ b/src/ViewModels/GitFlowFinish.cs @@ -5,14 +5,27 @@ namespace SourceGit.ViewModels public class GitFlowFinish : Popup { public Models.Branch Branch + { + get; + } + + public Models.GitFlowBranchType Type + { + get; + private set; + } + + public bool RebaseBeforeMerging { get; set; - } = null; + } = false; - public bool IsFeature => _type == "feature"; - public bool IsRelease => _type == "release"; - public bool IsHotfix => _type == "hotfix"; + public bool Squash + { + get; + set; + } = false; public bool KeepBranch { @@ -20,31 +33,31 @@ public bool KeepBranch set; } = false; - public GitFlowFinish(Repository repo, Models.Branch branch, string type, string prefix) + public GitFlowFinish(Repository repo, Models.Branch branch, Models.GitFlowBranchType type) { _repo = repo; - _type = type; - _prefix = prefix; - Branch = branch; - View = new Views.GitFlowFinish() { DataContext = this }; + Type = type; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - return Task.Run(() => - { - var name = Branch.Name.StartsWith(_prefix) ? Branch.Name.Substring(_prefix.Length) : Branch.Name; - SetProgressDescription($"Git Flow - finishing {_type} {name} ..."); - var succ = Commands.GitFlow.Finish(_repo.FullPath, _type, name, KeepBranch); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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 readonly Repository _repo; - private readonly string _type; - private readonly string _prefix; } } diff --git a/src/ViewModels/GitFlowStart.cs b/src/ViewModels/GitFlowStart.cs index ec1343d4f..2fe9c0784 100644 --- a/src/ViewModels/GitFlowStart.cs +++ b/src/ViewModels/GitFlowStart.cs @@ -1,10 +1,36 @@ -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 { + 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!")] [CustomValidation(typeof(GitFlowStart), nameof(ValidateBranchName))] @@ -14,29 +40,34 @@ public string Name set => SetProperty(ref _name, value, true); } - public string Prefix + public GitFlowStart(Repository repo, Models.GitFlowBranchType type) { - get => _prefix; - } + _repo = repo; - public bool IsFeature => _type == "feature"; - public bool IsRelease => _type == "release"; - public bool IsHotfix => _type == "hotfix"; + Type = type; + Prefix = _repo.GitFlow.GetPrefix(type); + LocalBranches = new List(); - public GitFlowStart(Repository repo, string type) - { - _repo = repo; - _type = type; - _prefix = Commands.GitFlow.GetPrefix(repo.FullPath, type); + 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}"; + var check = $"{starter.Prefix}{name}"; foreach (var b in starter._repo.Branches) { if (b.FriendlyName == check) @@ -47,21 +78,24 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext return ValidationResult.Success; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - return Task.Run(() => - { - SetProgressDescription($"Git Flow - starting {_type} {_name} ..."); - var succ = Commands.GitFlow.Start(_repo.FullPath, _type, _name); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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 readonly Repository _repo; - private readonly string _type; - private readonly string _prefix; private string _name = null; + private Models.Branch _startPoint = null; } } diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 55b047136..15cf15748 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -1,40 +1,83 @@ using System; -using System.Collections; using System.Collections.Generic; -using System.Text; +using System.IO; using System.Threading.Tasks; +using Avalonia.Collections; using Avalonia.Controls; -using Avalonia.Platform.Storage; - +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { public class Histories : ObservableObject { - public Repository Repo - { - get => _repo; - } - public bool IsLoading { get => _isLoading; set => SetProperty(ref _isLoading, value); } + public bool IsAuthorColumnVisible + { + get => _repo.UIStates.IsAuthorColumnVisibleInHistory; + set + { + if (_repo.UIStates.IsAuthorColumnVisibleInHistory != value) + { + _repo.UIStates.IsAuthorColumnVisibleInHistory = value; + OnPropertyChanged(); + } + } + } + + 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 List Commits { get => _commits; set { - var lastSelected = AutoSelectedCommit; + GenerateGraph(value, true); if (SetProperty(ref _commits, value)) - { - if (value.Count > 0 && lastSelected != null) - AutoSelectedCommit = value.Find(x => x.SHA == lastSelected.SHA); - } + PostCommitsChanged(); } } @@ -44,22 +87,54 @@ public Models.CommitGraph Graph set => SetProperty(ref _graph, value); } - public Models.Commit AutoSelectedCommit + public Models.CommitGraphHighlighting GraphHighlighting { - get => _autoSelectedCommit; - set => SetProperty(ref _autoSelectedCommit, value); + get => _repo.UIStates.GraphHighlighting; + set + { + if (_repo.UIStates.GraphHighlighting != value) + { + _repo.UIStates.GraphHighlighting = value; + GenerateGraph(_commits); + } + } } - public long NavigationId + public List SelectedCommits { - get => _navigationId; - private set => SetProperty(ref _navigationId, value); + get => _selectedCommits; + set + { + var oldCount = _selectedCommits.Count; + if (SetProperty(ref _selectedCommits, value) && oldCount + value.Count > 0) + PostSelectedCommitsChanged(); + } } public object DetailContext { get => _detailContext; - set => SetProperty(ref _detailContext, value); + set + { + if (SetProperty(ref _detailContext, value)) + OnPropertyChanged(nameof(IsOpenAsStandaloneVisible)); + } + } + + public Models.Bisect Bisect + { + get => _bisect; + private set => SetProperty(ref _bisect, value); + } + + public Models.Branch CurrentBranch + { + get => _repo.CurrentBranch; + } + + public AvaloniaList IssueTrackers + { + get => _repo.IssueTrackers; } public GridLength LeftArea @@ -82,871 +157,388 @@ public GridLength TopArea public GridLength BottomArea { - get => _bottomArea; - set => SetProperty(ref _bottomArea, value); + get => _isCollapseDetails ? new GridLength(28, GridUnitType.Pixel) : _bottomArea; + set + { + if (!Preferences.Instance.UseTwoColumnsLayoutInHistories && !_isCollapseDetails) + SetProperty(ref _bottomArea, value); + } } - public Histories(Repository repo) + public double AuthorColumnWidth { - _repo = repo; + get => _repo.UIStates.AuthorColumnWidth; + set => _repo.UIStates.AuthorColumnWidth = value; } - public void Cleanup() + public bool IsOpenAsStandaloneVisible { - Commits = new List(); - - _repo = null; - _graph = null; - _autoSelectedCommit = null; - - if (_detailContext is CommitDetail cd) - { - cd.Cleanup(); - } - else if (_detailContext is RevisionCompare rc) - { - rc.Cleanup(); - } - - _detailContext = null; + get => DetailContext is CommitDetail or RevisionCompare; } - public void NavigateTo(string commitSHA) + public bool IsCollapseDetails { - var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); - if (commit == null) - { - AutoSelectedCommit = null; - commit = new Commands.QuerySingleCommit(_repo.FullPath, commitSHA).Result(); - } - else - { - AutoSelectedCommit = commit; - NavigationId = _navigationId + 1; - } - - if (commit != null) + get => _isCollapseDetails; + set { - if (_detailContext is CommitDetail detail) + if (!Preferences.Instance.UseTwoColumnsLayoutInHistories && SetProperty(ref _isCollapseDetails, value)) { - detail.Commit = commit; - } - else - { - var commitDetail = new CommitDetail(_repo); - commitDetail.Commit = commit; - DetailContext = commitDetail; + OnPropertyChanged(nameof(TopArea)); + OnPropertyChanged(nameof(BottomArea)); } } - else - { - DetailContext = null; - } } - public void Select(IList commits) + public Histories(Repository repo) { - if (commits.Count == 0) - { - _repo.SearchResultSelectedCommit = null; - DetailContext = null; - } - else if (commits.Count == 1) - { - var commit = (commits[0] as Models.Commit)!; - if (_repo.SearchResultSelectedCommit == null || _repo.SearchResultSelectedCommit.SHA != commit.SHA) - _repo.SearchResultSelectedCommit = _repo.SearchedCommits.Find(x => x.SHA == commit.SHA); - - AutoSelectedCommit = commit; - NavigationId = _navigationId + 1; - - if (_detailContext is CommitDetail detail) - { - detail.Commit = commit; - } - else - { - var commitDetail = new CommitDetail(_repo); - commitDetail.Commit = commit; - DetailContext = commitDetail; - } - } - else if (commits.Count == 2) - { - _repo.SearchResultSelectedCommit = null; + _repo = repo; + _commitDetailSharedData = new CommitDetailSharedData(); + } - var end = commits[0] as Models.Commit; - var start = commits[1] as Models.Commit; - DetailContext = new RevisionCompare(_repo.FullPath, start, end); - } - else - { - _repo.SearchResultSelectedCommit = null; - DetailContext = commits.Count; - } + public void NotifyCurrentBranchChanged() + { + OnPropertyChanged(nameof(CurrentBranch)); } - public void DoubleTapped(Models.Commit commit) + public Models.BisectState UpdateBisectInfo() { - if (commit == null || commit.IsCurrentHead) - return; + var test = Path.Combine(_repo.GitDir, "BISECT_START"); + if (!File.Exists(test)) + { + Bisect = null; + return Models.BisectState.None; + } - var firstRemoteBranch = null as Models.Branch; - foreach (var d in commit.Decorators) + 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)) { - if (d.Type == Models.DecoratorType.LocalBranchHead) - { - var b = _repo.Branches.Find(x => x.FriendlyName == d.Name); - if (b != null) - { - _repo.CheckoutBranch(b); - return; - } - } - else if (d.Type == Models.DecoratorType.RemoteBranchHead && firstRemoteBranch == null) + var files = new DirectoryInfo(dir).GetFiles(); + foreach (var file in files) { - firstRemoteBranch = _repo.Branches.Find(x => x.FriendlyName == d.Name); + 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); } } - if (PopupHost.CanCreatePopup()) - { - if (firstRemoteBranch != null) - PopupHost.ShowPopup(new CreateBranch(_repo, firstRemoteBranch)); - else - PopupHost.ShowPopup(new CheckoutCommit(_repo, commit)); - } - } + Bisect = info; - public ContextMenu MakeContextMenu(ListBox list) - { - var current = _repo.CurrentBranch; - if (current == null || list.SelectedItems == null) - return null; + if (info.Bads.Count == 0) + return Models.BisectState.WaitingForFirstBad; - if (list.SelectedItems.Count > 1) - { - var selected = new List(); - var canCherryPick = true; - foreach (var item in list.SelectedItems) - { - if (item is Models.Commit c) - { - selected.Add(c); + if (markedHead) + return Models.BisectState.WaitingForCheckoutAnother; - if (c.IsMerged || c.Parents.Count > 1) - canCherryPick = false; - } - } + if (info.Goods.Count == 0) + return Models.BisectState.WaitingForFirstGood; - var multipleMenu = new ContextMenu(); + return Models.BisectState.WaitingForMark; + } - if (canCherryPick) - { - var cherryPickMultiple = new MenuItem(); - cherryPickMultiple.Header = App.Text("CommitCM.CherryPickMultiple"); - cherryPickMultiple.Icon = App.CreateMenuIcon("Icons.CherryPick"); - cherryPickMultiple.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CherryPick(_repo, selected)); - e.Handled = true; - }; - multipleMenu.Items.Add(cherryPickMultiple); - multipleMenu.Items.Add(new MenuItem() { Header = "-" }); - } + 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); - var saveToPatchMultiple = new MenuItem(); - saveToPatchMultiple.Icon = App.CreateMenuIcon("Icons.Diff"); - saveToPatchMultiple.Header = App.Text("CommitCM.SaveAsPatch"); - saveToPatchMultiple.Click += async (_, e) => + Dispatcher.UIThread.Post(() => { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; + _ignoreSelectionChange = true; + SelectedCommits = []; - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - try + if (_detailContext is CommitDetail detail) { - var picker = await storageProvider.OpenFolderPickerAsync(options); - if (picker.Count == 1) - { - var saveTo = $"{picker[0].Path.LocalPath}/patches"; - var succ = false; - foreach (var c in selected) - { - succ = await Task.Run(() => new Commands.FormatPatch(_repo.FullPath, c.SHA, saveTo).Exec()); - if (!succ) - break; - } - - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + detail.Commit = c; } - catch (Exception exception) + else { - App.RaiseException(_repo.FullPath, $"Failed to save as patch: {exception.Message}"); + var commitDetail = new CommitDetail(_repo, _commitDetailSharedData); + commitDetail.Commit = c; + DetailContext = commitDetail; } - e.Handled = true; - }; - multipleMenu.Items.Add(saveToPatchMultiple); - multipleMenu.Items.Add(new MenuItem() { Header = "-" }); + _ignoreSelectionChange = false; + }); + }); + } - var copyMultipleSHAs = new MenuItem(); - copyMultipleSHAs.Header = App.Text("CommitCM.CopySHA"); - copyMultipleSHAs.Icon = App.CreateMenuIcon("Icons.Copy"); - copyMultipleSHAs.Click += (_, e) => - { - var builder = new StringBuilder(); - foreach (var c in selected) - builder.AppendLine(c.SHA); - - App.CopyText(builder.ToString()); - e.Handled = true; - }; - multipleMenu.Items.Add(copyMultipleSHAs); - - var copyMultipleInfo = new MenuItem(); - copyMultipleInfo.Header = App.Text("CommitCM.CopyInfo"); - copyMultipleInfo.Icon = App.CreateMenuIcon("Icons.Copy"); - copyMultipleInfo.Click += (_, e) => - { - var builder = new StringBuilder(); - foreach (var c in selected) - builder.AppendLine($"{c.SHA.Substring(0, 10)} - {c.Subject}"); + public async Task GetCommitAsync(string sha) + { + return await new Commands.QuerySingleCommit(_repo.FullPath, sha) + .GetResultAsync() + .ConfigureAwait(false); + } - App.CopyText(builder.ToString()); - e.Handled = true; - }; - multipleMenu.Items.Add(copyMultipleInfo); + public void CheckoutCommitDetached(Models.Commit c) + { + if (!c.IsCurrentHead && _repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutDetached(_repo, c)); + } - return multipleMenu; - } + public async Task CheckoutBranchByDecoratorAsync(Models.Decorator decorator) + { + if (decorator == null) + return false; - var commit = (list.SelectedItem as Models.Commit)!; - var menu = new ContextMenu(); - var tags = new List(); + if (decorator.Type == Models.DecoratorType.CurrentBranchHead || + decorator.Type == Models.DecoratorType.CurrentCommitHead) + return true; - if (commit.HasDecorators) + if (decorator.Type == Models.DecoratorType.LocalBranchHead) { - foreach (var d in commit.Decorators) - { - if (d.Type == Models.DecoratorType.CurrentBranchHead) - { - FillCurrentBranchMenu(menu, current); - } - else if (d.Type == Models.DecoratorType.LocalBranchHead) - { - var b = _repo.Branches.Find(x => x.IsLocal && d.Name == x.Name); - FillOtherLocalBranchMenu(menu, b, current, commit.IsMerged); - } - else if (d.Type == Models.DecoratorType.RemoteBranchHead) - { - var b = _repo.Branches.Find(x => !x.IsLocal && d.Name == x.FriendlyName); - FillRemoteBranchMenu(menu, b, current, commit.IsMerged); - } - else if (d.Type == Models.DecoratorType.Tag) - { - var t = _repo.Tags.Find(x => x.Name == d.Name); - if (t != null) - tags.Add(t); - } - } + var b = _repo.Branches.Find(x => x.Name == decorator.Name); + if (b == null) + return false; - if (menu.Items.Count > 0) - menu.Items.Add(new MenuItem() { Header = "-" }); + await _repo.CheckoutBranchAsync(b); + return true; } - if (tags.Count > 0) + if (decorator.Type == Models.DecoratorType.RemoteBranchHead) { - foreach (var tag in tags) - FillTagMenu(menu, tag, current, commit.IsMerged); - menu.Items.Add(new MenuItem() { Header = "-" }); - } + var rb = _repo.Branches.Find(x => x.FriendlyName == decorator.Name); + if (rb == null) + return false; - 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 += (_, e) => + var lb = _repo.Branches.Find(x => x.IsLocal && x.Upstream == rb.FullName); + if (lb == null || lb.Ahead.Count > 0) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Reset(_repo, current, commit)); - e.Handled = true; - }; - menu.Items.Add(reset); - - var squash = new MenuItem(); - squash.Header = App.Text("CommitCM.SquashCommitsSinceThis"); - squash.Icon = App.CreateMenuIcon("Icons.SquashIntoParent"); - squash.IsVisible = commit.IsMerged; - squash.Click += (_, e) => + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CreateBranch(_repo, rb)); + } + else if (lb.Behind.Count > 0) { - if (_repo.LocalChangesCount > 0) - { - App.RaiseException(_repo.FullPath, "You have local changes. Please run stash or discard first."); - return; - } - - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Squash(_repo, commit, commit.SHA)); - - e.Handled = true; - }; - menu.Items.Add(squash); - } - else - { - var reword = new MenuItem(); - reword.Header = App.Text("CommitCM.Reword"); - reword.Icon = App.CreateMenuIcon("Icons.Edit"); - reword.Click += (_, e) => + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutAndFastForward(_repo, lb, rb)); + } + else if (!lb.IsCurrent) { - if (_repo.LocalChangesCount > 0) - { - App.RaiseException(_repo.FullPath, "You have local changes. Please run stash or discard first."); - return; - } + await _repo.CheckoutBranchAsync(lb); + } - 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 += (_, e) => - { - if (_repo.LocalChangesCount > 0) - { - App.RaiseException(_repo.FullPath, "You have local changes. Please run stash or discard first."); - return; - } + return true; + } - 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, parent, commit.SHA)); - } + return false; + } - e.Handled = true; - }; - menu.Items.Add(squash); - } + public async Task CheckoutBranchByCommitAsync(Models.Commit commit) + { + if (commit.IsCurrentHead) + return; - if (!commit.IsMerged) + Models.Branch firstRemoteBranch = null; + foreach (var d in commit.Decorators) { - var rebase = new MenuItem(); - rebase.Header = new Views.NameHighlightedTextBlock("CommitCM.Rebase", current.Name); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (_, 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 += (_, e) => + if (d.Type == Models.DecoratorType.LocalBranchHead) { - if (PopupHost.CanCreatePopup()) - { - if (commit.Parents.Count <= 1) - { - PopupHost.ShowPopup(new CherryPick(_repo, [commit])); - } - else - { - var parents = new List(); - foreach (var sha in commit.Parents) - { - var parent = _commits.Find(x => x.SHA == sha); - if (parent == null) - parent = new Commands.QuerySingleCommit(_repo.FullPath, sha).Result(); - - if (parent != null) - parents.Add(parent); - } - - PopupHost.ShowPopup(new CherryPick(_repo, commit, parents)); - } - } + var b = _repo.Branches.Find(x => x.Name == d.Name); + if (b == null) + continue; - e.Handled = true; - }; - menu.Items.Add(cherryPick); - } - else - { - var revert = new MenuItem(); - revert.Header = App.Text("CommitCM.Revert"); - revert.Icon = App.CreateMenuIcon("Icons.Undo"); - revert.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Revert(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(revert); - - var interactiveRebase = new MenuItem(); - interactiveRebase.Header = new Views.NameHighlightedTextBlock("CommitCM.InteractiveRebase", current.Name); - interactiveRebase.Icon = App.CreateMenuIcon("Icons.InteractiveRebase"); - interactiveRebase.IsVisible = current.Head != commit.SHA; - interactiveRebase.Click += (_, e) => + await _repo.CheckoutBranchAsync(b); + return; + } + + if (d.Type == Models.DecoratorType.RemoteBranchHead) { - if (_repo.LocalChangesCount > 0) + 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) { - App.RaiseException(_repo.FullPath, "You have local changes. Please run stash or discard first."); + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new CheckoutAndFastForward(_repo, lb, rb)); return; } - App.OpenDialog(new Views.InteractiveRebase() - { - DataContext = new InteractiveRebase(_repo, current, commit) - }); - - e.Handled = true; - }; - menu.Items.Add(interactiveRebase); + firstRemoteBranch ??= rb; + } } - if (current.Head != commit.SHA) + if (_repo.CanCreatePopup()) { - var checkoutCommit = new MenuItem(); - checkoutCommit.Header = App.Text("CommitCM.Checkout"); - checkoutCommit.Icon = App.CreateMenuIcon("Icons.Detached"); - checkoutCommit.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CheckoutCommit(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(checkoutCommit); + 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 = "-" }); - - if (current.Head != commit.SHA) + public async Task CherryPickAsync(Models.Commit commit) + { + if (_repo.CanCreatePopup()) { - var compareWithHead = new MenuItem(); - compareWithHead.Header = App.Text("CommitCM.CompareWithHead"); - compareWithHead.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithHead.Click += (_, e) => + if (commit.Parents.Count <= 1) { - var head = _commits.Find(x => x.SHA == current.Head); - if (head == null) - { - _repo.SearchResultSelectedCommit = null; - head = new Commands.QuerySingleCommit(_repo.FullPath, current.Head).Result(); - if (head != null) - DetailContext = new RevisionCompare(_repo.FullPath, commit, head); - } - else - { - list.SelectedItems.Add(head); - } - - e.Handled = true; - }; - menu.Items.Add(compareWithHead); - - if (_repo.LocalChangesCount > 0) - { - var compareWithWorktree = new MenuItem(); - compareWithWorktree.Header = App.Text("CommitCM.CompareWithWorktree"); - compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithWorktree.Click += (_, e) => - { - DetailContext = new RevisionCompare(_repo.FullPath, commit, null); - e.Handled = true; - }; - menu.Items.Add(compareWithWorktree); + _repo.ShowPopup(new CherryPick(_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 += (_, 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 += (_, 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 storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FolderPickerOpenOptions() { AllowMultiple = false }; - try + else { - var selected = await storageProvider.OpenFolderPickerAsync(options); - if (selected.Count == 1) + var parents = new List(); + foreach (var sha in commit.Parents) { - var succ = new Commands.FormatPatch(_repo.FullPath, commit.SHA, selected[0].Path.LocalPath).Exec(); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - } - catch (Exception exception) - { - App.RaiseException(_repo.FullPath, $"Failed to save as patch: {exception.Message}"); - } + var parent = _commits.Find(x => x.SHA.Equals(sha, StringComparison.Ordinal)); + if (parent == null) + parent = await new Commands.QuerySingleCommit(_repo.FullPath, sha).GetResultAsync(); - e.Handled = true; - }; - menu.Items.Add(saveToPatch); + if (parent != null) + parents.Add(parent); + } - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Archive(_repo, commit)); - e.Handled = true; - }; - menu.Items.Add(archive); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var actions = new List(); - foreach (var action in _repo.Settings.CustomActions) - { - if (action.Scope == Models.CustomActionScope.Commit) - actions.Add(action); + _repo.ShowPopup(new CherryPick(_repo, commit, parents)); + } } - if (actions.Count > 0) - { - var custom = new MenuItem(); - custom.Header = App.Text("CommitCM.CustomAction"); - custom.Icon = App.CreateMenuIcon("Icons.Action"); - - foreach (var action in actions) - { - var dup = action; - var item = new MenuItem(); - item.Icon = App.CreateMenuIcon("Icons.Action"); - item.Header = dup.Name; - item.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new ExecuteCustomAction(_repo, dup, commit)); + } - e.Handled = true; - }; + public async Task GetCommitFullMessageAsync(Models.Commit commit) + { + return await new Commands.QueryCommitFullMessage(_repo.FullPath, commit.SHA) + .GetResultAsync() + .ConfigureAwait(false); + } - custom.Items.Add(item); - } + 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); - menu.Items.Add(custom); - menu.Items.Add(new MenuItem() { Header = "-" }); + return null; } - var copySHA = new MenuItem(); - copySHA.Header = App.Text("CommitCM.CopySHA"); - copySHA.Icon = App.CreateMenuIcon("Icons.Copy"); - copySHA.Click += (_, e) => - { - App.CopyText(commit.SHA); - e.Handled = true; - }; - menu.Items.Add(copySHA); - - var copyInfo = new MenuItem(); - copyInfo.Header = App.Text("CommitCM.CopyInfo"); - copyInfo.Icon = App.CreateMenuIcon("Icons.Copy"); - copyInfo.Click += (_, e) => - { - App.CopyText($"{commit.SHA.Substring(0, 10)} - {commit.Subject}"); - e.Handled = true; - }; - menu.Items.Add(copyInfo); + return head; + } - return menu; + public void CompareWithWorktree(Models.Commit commit) + { + DetailContext = new RevisionCompare(_repo, commit, null); } - private void FillCurrentBranchMenu(ContextMenu menu, Models.Branch current) + private void PostCommitsChanged() { - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = current.Name; + if (_selectedCommits.Count == 0) + return; - if (!string.IsNullOrEmpty(current.Upstream)) + if (_commits.Count == 0 || _selectedCommits.Count > 20) { - 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 = current.TrackStatus.Ahead.Count == 0; - fastForward.Click += (_, 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 += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Pull(_repo, null)); - e.Handled = true; - }; - submenu.Items.Add(pull); + SelectedCommits = []; + return; } - 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 += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Push(_repo, current)); - e.Handled = true; - }; - submenu.Items.Add(push); - submenu.Items.Add(new MenuItem() { Header = "-" }); - - var detect = Commands.GitFlow.DetectType(_repo.FullPath, _repo.Branches, current.Name); - if (detect.IsGitFlowBranch) + var set = new HashSet(); + foreach (var c in _selectedCommits) + set.Add(c.SHA); + + var selected = new List(); + foreach (var c in _commits) { - var finish = new MenuItem(); - finish.Header = new Views.NameHighlightedTextBlock("BranchCM.Finish", current.Name); - finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); - finish.Click += (_, e) => + if (set.Contains(c.SHA)) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new GitFlowFinish(_repo, current, detect.Type, detect.Prefix)); - e.Handled = true; - }; - submenu.Items.Add(finish); - submenu.Items.Add(new MenuItem() { Header = "-" }); + selected.Add(c); + set.Remove(c.SHA); + if (set.Count == 0) + break; + } } - var rename = new MenuItem(); - rename.Header = new Views.NameHighlightedTextBlock("BranchCM.Rename", current.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new RenameBranch(_repo, current)); - e.Handled = true; - }; - submenu.Items.Add(rename); - - menu.Items.Add(submenu); + SelectedCommits = selected; } - private void FillOtherLocalBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) + private void PostSelectedCommitsChanged() { - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = branch.Name; + if (_ignoreSelectionChange) + return; - var checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", branch.Name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - _repo.CheckoutBranch(branch); - e.Handled = true; - }; - submenu.Items.Add(checkout); - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", branch.Name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.IsEnabled = !merged; - merge.Click += (_, e) => + if (_selectedCommits.Count == 0) { - 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 detect = Commands.GitFlow.DetectType(_repo.FullPath, _repo.Branches, branch.Name); - if (detect.IsGitFlowBranch) - { - var finish = new MenuItem(); - finish.Header = new Views.NameHighlightedTextBlock("BranchCM.Finish", branch.Name); - finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); - finish.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new GitFlowFinish(_repo, branch, detect.Type, detect.Prefix)); - e.Handled = true; - }; - submenu.Items.Add(finish); - submenu.Items.Add(new MenuItem() { Header = "-" }); + _repo.SearchCommitContext.Selected = null; + DetailContext = new Models.Null(); } - - var rename = new MenuItem(); - rename.Header = new Views.NameHighlightedTextBlock("BranchCM.Rename", branch.Name); - rename.Icon = App.CreateMenuIcon("Icons.Rename"); - rename.Click += (_, 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 += (_, e) => + else if (_selectedCommits.Count == 1) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(delete); + 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)); - menu.Items.Add(submenu); - } - - private void FillRemoteBranchMenu(ContextMenu menu, Models.Branch branch, Models.Branch current, bool merged) - { - var name = branch.FriendlyName; - - var submenu = new MenuItem(); - submenu.Icon = App.CreateMenuIcon("Icons.Branch"); - submenu.Header = name; - - var checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - _repo.CheckoutBranch(branch); - e.Handled = true; - }; - submenu.Items.Add(checkout); - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.IsEnabled = !merged; - merge.Click += (_, e) => + if (_detailContext is CommitDetail detail) + detail.Commit = c; + else + DetailContext = new CommitDetail(_repo, _commitDetailSharedData) { Commit = c }; + } + else if (_selectedCommits.Count == 2) { - 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 += (_, e) => + _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 { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteBranch(_repo, branch)); - e.Handled = true; - }; - submenu.Items.Add(delete); + _repo.SearchCommitContext.Selected = null; + DetailContext = new Models.Count(_selectedCommits.Count); + } - menu.Items.Add(submenu); + if (_repo.UIStates.GraphHighlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) + GenerateGraph(_commits); } - private void FillTagMenu(ContextMenu menu, Models.Tag tag, Models.Branch current, bool merged) + private void GenerateGraph(List commits, bool commitsChanged = false) { - var submenu = new MenuItem(); - submenu.Header = tag.Name; - submenu.Icon = App.CreateMenuIcon("Icons.Tag"); - submenu.MinWidth = 200; + var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); + var highlighting = _repo.UIStates.GraphHighlighting; + var extraHeads = new HashSet(); - 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 += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new PushTag(_repo, tag)); - e.Handled = true; - }; - submenu.Items.Add(push); - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("TagCM.Merge", tag.Name, current.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.IsEnabled = !merged; - merge.Click += (_, e) => + if (highlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Merge(_repo, tag.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("TagCM.Delete", tag.Name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteTag(_repo, tag)); - e.Handled = true; - }; - submenu.Items.Add(delete); + foreach (var c in _selectedCommits) + extraHeads.Add(c.SHA); + } - menu.Items.Add(submenu); + Graph = Models.CommitGraph.Generate(commits, commitsChanged, firstParentOnly, highlighting, extraHeads); } private Repository _repo = null; + 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 long _navigationId = 0; - private object _detailContext = null; - - private GridLength _leftArea = new GridLength(1, GridUnitType.Star); - private GridLength _rightArea = new GridLength(1, GridUnitType.Star); - private GridLength _topArea = new GridLength(1, GridUnitType.Star); - private GridLength _bottomArea = new GridLength(1, GridUnitType.Star); + 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 index f7b85032e..97699eeab 100644 --- a/src/ViewModels/InProgressContexts.cs +++ b/src/ViewModels/InProgressContexts.cs @@ -1,100 +1,255 @@ using System.IO; +using System.Threading.Tasks; namespace SourceGit.ViewModels { public abstract class InProgressContext { - public string Repository + public string Name { get; - set; + protected set; } - public string Cmd + public async Task ContinueAsync(CommandLog log) { - get; - set; + if (_continueCmd != null) + await _continueCmd.Use(log).ExecAsync(); } - public InProgressContext(string repo, string cmd) + public async Task SkipAsync(CommandLog log) { - Repository = repo; - Cmd = cmd; + if (_skipCmd != null) + await _skipCmd.Use(log).ExecAsync(); } - public bool Abort() + public async Task AbortAsync(CommandLog log) { - return new Commands.Command() - { - WorkingDirectory = Repository, - Context = Repository, - Args = $"{Cmd} --abort", - }.Exec(); + if (_abortCmd != null) + await _abortCmd.Use(log).ExecAsync(); + + OnAborted(); } - public virtual bool Continue() + protected virtual void OnAborted() { - return new Commands.Command() - { - WorkingDirectory = Repository, - Context = Repository, - Editor = Commands.Command.EditorType.None, - Args = $"{Cmd} --continue", - }.Exec(); } + + protected Commands.Command _continueCmd = null; + protected Commands.Command _skipCmd = null; + protected Commands.Command _abortCmd = null; } public class CherryPickInProgress : InProgressContext { - public CherryPickInProgress(string repo) : base(repo, "cherry-pick") { } + 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 RebaseInProgress(Repository repo) : base(repo.FullPath, "rebase") + public string HeadName { - _gitDir = repo.GitDir; + get; + } + + public string BaseName + { + get; + } + + public Models.Commit StoppedAt + { + get; + } + + public Models.Commit Onto + { + get; } - public override bool Continue() + public RebaseInProgress(Repository repo) { - var succ = new Commands.Command() + _gitDir = repo.GitDir; + Name = "Rebase"; + + _continueCmd = new Commands.Command { - WorkingDirectory = Repository, - Context = Repository, + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, Editor = Commands.Command.EditorType.RebaseEditor, - Args = $"rebase --continue", - }.Exec(); + Args = "-c core.commentChar=\"^\" -c core.commentString=\"±\" rebase --continue", + }; + + _skipCmd = new Commands.Command + { + WorkingDirectory = repo.FullPath, + Context = repo.FullPath, + Args = "rebase --skip", + }; - if (succ) + _abortCmd = new Commands.Command { - var jobsFile = Path.Combine(_gitDir, "sourcegit_rebase_jobs.json"); - var rebaseMergeHead = Path.Combine(_gitDir, "REBASE_HEAD"); - var rebaseMergeFolder = Path.Combine(_gitDir, "rebase-merge"); - var rebaseApplyFolder = Path.Combine(_gitDir, "rebase-apply"); - if (File.Exists(jobsFile)) - File.Delete(jobsFile); - if (File.Exists(rebaseMergeHead)) - File.Delete(rebaseMergeHead); - if (Directory.Exists(rebaseMergeFolder)) - Directory.Delete(rebaseMergeFolder); - if (Directory.Exists(rebaseApplyFolder)) - Directory.Delete(rebaseApplyFolder); - } - - return succ; - } - - private string _gitDir; + 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 RevertInProgress(string repo) : base(repo, "revert") { } + 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 MergeInProgress(string repo) : base(repo, "merge") { } + 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 913127a19..882d3d647 100644 --- a/src/ViewModels/Init.cs +++ b/src/ViewModels/Init.cs @@ -16,37 +16,44 @@ public string Reason private set; } - public Init(string path, RepositoryNode parent, string reason) + public Init(string pageId, string path, RepositoryNode parent, int bookmark, string reason) { + _pageId = pageId; _targetPath = path; _parentNode = parent; + _bookmark = bookmark; - Reason = string.IsNullOrEmpty(reason) ? "Invalid repository detected!" : reason; - View = new Views.Init() { DataContext = this }; + Reason = string.IsNullOrEmpty(reason) ? "unknown error" : reason; + Reason = Reason.Trim(); } - public override Task Sure() + public override async Task Sure() { ProgressDescription = $"Initialize git repository at: '{_targetPath}'"; - return Task.Run(() => + var log = new CommandLog("Initialize"); + Use(log); + + var succ = await new Commands.Init(_pageId, _targetPath) + .Use(log) + .ExecAsync(); + + log.Complete(); + + if (succ) { - var succ = new Commands.Init(HostPageId, _targetPath).Exec(); - if (!succ) - return false; - - CallUIThread(() => - { - var normalizedPath = _targetPath.Replace("\\", "/"); - Preference.Instance.FindOrAddNodeByRepositoryPath(normalizedPath, _parentNode, true); - Welcome.Instance.Refresh(); - }); - - return true; - }); + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(_targetPath, _parentNode, true); + node.Bookmark = _bookmark; + await node.UpdateStatusAsync(false, null); + + Welcome.Instance.Refresh(); + } + return succ; } + private readonly string _pageId = null; private string _targetPath = null; - private RepositoryNode _parentNode = null; + private readonly RepositoryNode _parentNode = null; + private readonly int _bookmark = 0; } } diff --git a/src/ViewModels/InitGitFlow.cs b/src/ViewModels/InitGitFlow.cs index ed5ff3cef..f00ad01dd 100644 --- a/src/ViewModels/InitGitFlow.cs +++ b/src/ViewModels/InitGitFlow.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -8,18 +9,18 @@ namespace SourceGit.ViewModels public partial class InitGitFlow : Popup { [GeneratedRegex(@"^[\w\-/\.]+$")] - private static partial Regex TAG_PREFIX(); + 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 + public string Production { - get => _master; - set => SetProperty(ref _master, value, true); + 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 @@ -30,7 +31,7 @@ public string Develop [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); @@ -71,23 +72,21 @@ public InitGitFlow(Repository repo) } if (localBranches.Contains("master")) - _master = "master"; + _production = "master"; else if (localBranches.Contains("main")) - _master = "main"; + _production = "main"; else if (localBranches.Count > 0) - _master = localBranches[0]; + _production = localBranches[0]; else - _master = "master"; - - View = new Views.InitGitFlow() { DataContext = this }; + _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!"); + if (initializer._production == initializer._develop) + return new ValidationResult("Develop branch has the same name with production branch!"); } return ValidationResult.Success; @@ -95,27 +94,71 @@ public static ValidationResult ValidateBaseBranch(string _, ValidationContext ct public static ValidationResult ValidateTagPrefix(string tagPrefix, ValidationContext ctx) { - if (!string.IsNullOrWhiteSpace(tagPrefix) && !TAG_PREFIX().IsMatch(tagPrefix)) + if (!string.IsNullOrWhiteSpace(tagPrefix) && !REG_TAG_PREFIX().IsMatch(tagPrefix)) return new ValidationResult("Bad tag prefix format!"); return ValidationResult.Success; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Init git-flow ..."; - return Task.Run(() => + 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) { - var succ = Commands.GitFlow.Init(_repo.FullPath, _repo.Branches, _master, _develop, _featurePrefix, _releasePrefix, _hotfixPrefix, _tagPrefix); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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; + } + + return succ; } private readonly Repository _repo; - private string _master; + 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 index 355671c7e..a8f013fe4 100644 --- a/src/ViewModels/InteractiveRebase.cs +++ b/src/ViewModels/InteractiveRebase.cs @@ -1,9 +1,10 @@ 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; @@ -11,18 +12,31 @@ 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; - private set; } public Models.InteractiveRebaseAction Action { get => _action; - private set => SetProperty(ref _action, value); + set => SetProperty(ref _action, value); + } + + public Models.InteractiveRebasePendingType PendingType + { + get => _pendingType; + set => SetProperty(ref _pendingType, value); } public string Subject @@ -39,29 +53,57 @@ public string FullMessage if (SetProperty(ref _fullMessage, value)) { var normalized = value.ReplaceLineEndings("\n"); - var idx = normalized.IndexOf("\n\n", StringComparison.Ordinal); - if (idx > 0) - Subject = normalized.Substring(0, idx).ReplaceLineEndings(" "); - else - Subject = value.ReplaceLineEndings(" "); + var parts = normalized.Split("\n\n", 2); + Subject = parts[0].ReplaceLineEndings(" "); } } } - public InteractiveRebaseItem(Models.Commit c, string message) + public string OriginalFullMessage { - Commit = c; - FullMessage = message; + get; + set; } - public void SetAction(object param) + public bool CanSquashOrFixup { - Action = (Models.InteractiveRebaseAction)param; + 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 @@ -75,7 +117,28 @@ public Models.Branch Current public Models.Commit On { get; - private set; + } + + 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 @@ -87,106 +150,391 @@ public bool IsLoading public AvaloniaList Items { get; - private set; - } = new AvaloniaList(); + } = []; - public InteractiveRebaseItem SelectedItem + public InteractiveRebaseItem PreSelected { - get => _selectedItem; - set - { - if (SetProperty(ref _selectedItem, value)) - DetailContext.Commit = value != null ? value.Commit : null; - } + get => _preSelected; + private set => SetProperty(ref _preSelected, value); } - public CommitDetail DetailContext + public object Detail { - get; - private set; + get => _detail; + private set => SetProperty(ref _detail, value); } - public InteractiveRebase(Repository repo, Models.Branch current, Models.Commit on) + public InteractiveRebase(Repository repo, Models.Commit on, InteractiveRebasePrefill prefill = null) { - var repoPath = repo.FullPath; _repo = repo; - - Current = current; + _commitDetail = new CommitDetail(repo, null); + Current = repo.CurrentBranch; On = on; IsLoading = true; - DetailContext = new CommitDetail(repo); - Task.Run(() => + Task.Run(async () => { - var commits = new Commands.QueryCommitsWithFullMessage(repoPath, $"{on.SHA}...HEAD").Result(); + 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; - foreach (var c in commits) - list.Add(new InteractiveRebaseItem(c.Commit, c.Message)); + 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.Invoke(() => + Dispatcher.UIThread.Post(() => { Items.AddRange(list); + UpdateItems(); + PreSelected = selected; IsLoading = false; }); }); } - public void MoveItemUp(InteractiveRebaseItem item) + public void SelectCommits(List items) { - var idx = Items.IndexOf(item); - if (idx > 0) + if (items.Count == 0) + { + Detail = null; + } + else if (items.Count == 1) + { + _commitDetail.Commit = items[0].Commit; + Detail = _commitDetail; + } + else { - var prev = Items[idx - 1]; - Items.RemoveAt(idx - 1); - Items.Insert(idx, prev); - SelectedItem = item; + Detail = new Models.Count(items.Count); } } - public void MoveItemDown(InteractiveRebaseItem item) + public void ChangeAction(List selected, Models.InteractiveRebaseAction action) { - var idx = Items.IndexOf(item); - if (idx < Items.Count - 1) + if (action == Models.InteractiveRebaseAction.Squash || action == Models.InteractiveRebaseAction.Fixup) + { + foreach (var item in selected) + { + if (item.CanSquashOrFixup) + item.Action = action; + } + } + else { - var next = Items[idx + 1]; - Items.RemoveAt(idx + 1); - Items.Insert(idx, next); - SelectedItem = item; + foreach (var item in selected) + item.Action = action; } + + UpdateItems(); } - public Task Start() + public void Move(List commits, int index) { - _repo.SetWatcherEnabled(false); + 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(); - var saveFile = Path.Combine(_repo.GitDir, "sourcegit_rebase_jobs.json"); + 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]; - collection.Jobs.Add(new Models.InteractiveRebaseJob() + var job = new Models.InteractiveRebaseJob() { SHA = item.Commit.SHA, Action = item.Action, - Message = item.FullMessage, - }); + }; + + 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; } - File.WriteAllText(saveFile, JsonSerializer.Serialize(collection, JsonCodeGen.Default.InteractiveRebaseJobCollection)); - return Task.Run(() => + await using (var stream = File.Create(saveFile)) { - var succ = new Commands.InteractiveRebase(_repo.FullPath, On.SHA).Exec(); - if (succ) - File.Delete(saveFile); + await JsonSerializer.SerializeAsync(stream, collection, JsonCodeGen.Default.InteractiveRebaseJobCollection); + } - Dispatcher.UIThread.Invoke(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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 _selectedItem = null; + private InteractiveRebaseItem _preSelected = null; + private object _detail = null; + private CommitDetail _commitDetail = null; } } diff --git a/src/ViewModels/LFSFetch.cs b/src/ViewModels/LFSFetch.cs index a3fb1f084..4a869abb4 100644 --- a/src/ViewModels/LFSFetch.cs +++ b/src/ViewModels/LFSFetch.cs @@ -17,19 +17,22 @@ public LFSFetch(Repository repo) { _repo = repo; SelectedRemote = _repo.Remotes[0]; - View = new Views.LFSFetch() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Fetching LFS objects from remote ..."; - return Task.Run(() => - { - new Commands.LFS(_repo.FullPath).Fetch(SelectedRemote.Name, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + 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 index e462a069b..d048398eb 100644 --- a/src/ViewModels/LFSLocks.cs +++ b/src/ViewModels/LFSLocks.cs @@ -1,8 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; - using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -11,9 +10,9 @@ public class LFSLocks : ObservableObject { public bool HasValidUserName { - get; - private set; - } = false; + get => _hasValidUsername; + private set => SetProperty(ref _hasValidUsername, value); + } public bool IsLoading { @@ -37,45 +36,69 @@ public List VisibleLocks private set => SetProperty(ref _visibleLocks, value); } - public LFSLocks(string repo, string remote) + public LFSLocks(Repository repo, string remote) { _repo = repo; _remote = remote; - _userName = new Commands.Config(repo).Get("user.name"); - - HasValidUserName = !string.IsNullOrEmpty(_userName); - Task.Run(() => + Task.Run(async () => { - _cachedLocks = new Commands.LFS(_repo).Locks(_remote); - Dispatcher.UIThread.Invoke(() => + _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 void Unlock(Models.LFSLock lfsLock, bool force) + public async Task UnlockAsync(Models.LFSLock lfsLock, bool force) { if (_isLoading) return; IsLoading = true; - Task.Run(() => + + var succ = await _repo.UnlockLFSFileAsync(_remote, lfsLock.Path, force, false); + if (succ) { - var succ = new Commands.LFS(_repo).Unlock(_remote, lfsLock.ID, force); - Dispatcher.UIThread.Invoke(() => - { - if (succ) - { - _cachedLocks.Remove(lfsLock); - UpdateVisibleLocks(); - } + _cachedLocks.Remove(lfsLock); + UpdateVisibleLocks(); + } - IsLoading = false; - }); - }); + 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() @@ -84,14 +107,13 @@ private void UpdateVisibleLocks() if (!_showOnlyMyLocks) { - foreach (var lfsLock in _cachedLocks) - visible.Add(lfsLock); + visible.AddRange(_cachedLocks); } else { foreach (var lfsLock in _cachedLocks) { - if (lfsLock.User == _userName) + if (lfsLock.Owner.Name.Equals(_userName, StringComparison.Ordinal)) visible.Add(lfsLock); } } @@ -99,12 +121,13 @@ private void UpdateVisibleLocks() VisibleLocks = visible; } - private string _repo; + 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 index 3475cb814..1353bc0d1 100644 --- a/src/ViewModels/LFSPrune.cs +++ b/src/ViewModels/LFSPrune.cs @@ -7,20 +7,22 @@ public class LFSPrune : Popup public LFSPrune(Repository repo) { _repo = repo; - View = new Views.LFSPrune() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "LFS prune ..."; - return Task.Run(() => - { - new Commands.LFS(_repo.FullPath).Prune(SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + 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 index b949ddc2f..8b4b1081e 100644 --- a/src/ViewModels/LFSPull.cs +++ b/src/ViewModels/LFSPull.cs @@ -17,19 +17,22 @@ public LFSPull(Repository repo) { _repo = repo; SelectedRemote = _repo.Remotes[0]; - View = new Views.LFSPull() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Pull LFS objects from remote ..."; - return Task.Run(() => - { - new Commands.LFS(_repo.FullPath).Pull(SelectedRemote.Name, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + 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 index 5ea06757f..e5c28783f 100644 --- a/src/ViewModels/LFSPush.cs +++ b/src/ViewModels/LFSPush.cs @@ -17,19 +17,22 @@ public LFSPush(Repository repo) { _repo = repo; SelectedRemote = _repo.Remotes[0]; - View = new Views.LFSPush() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Push LFS objects to remote ..."; - return Task.Run(() => - { - new Commands.LFS(_repo.FullPath).Push(SelectedRemote.Name, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + 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 index 777e2d22d..d9a98a24d 100644 --- a/src/ViewModels/LFSTrackCustomPattern.cs +++ b/src/ViewModels/LFSTrackCustomPattern.cs @@ -21,20 +21,22 @@ public bool IsFilename public LFSTrackCustomPattern(Repository repo) { _repo = repo; - View = new Views.LFSTrackCustomPattern() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Adding custom LFS tracking pattern ..."; - return Task.Run(() => - { - var succ = new Commands.LFS(_repo.FullPath).Track(_pattern, IsFilename); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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; diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs index 8e4f7775a..7b17290a7 100644 --- a/src/ViewModels/Launcher.cs +++ b/src/ViewModels/Launcher.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.IO; +using System.Text; using Avalonia.Collections; -using Avalonia.Controls; -using Avalonia.Input; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -11,6 +11,12 @@ namespace SourceGit.ViewModels { public class Launcher : ObservableObject { + public string Title + { + get => _title; + private set => SetProperty(ref _title, value); + } + public AvaloniaList Pages { get; @@ -29,97 +35,127 @@ public LauncherPage ActivePage set { if (SetProperty(ref _activePage, value)) - { - PopupHost.Active = value; - - if (!_ignoreIndexChange && value is { Data: Repository repo }) - ActiveWorkspace.ActiveIdx = ActiveWorkspace.Repositories.IndexOf(repo.FullPath); - } + PostActivePageChanged(); } } + 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(); - var pref = Preference.Instance; - if (string.IsNullOrEmpty(startupRepo)) - { - ActiveWorkspace = pref.GetActiveWorkspace(); - - var repos = ActiveWorkspace.Repositories.ToArray(); - foreach (var repo in repos) - { - var node = pref.FindNode(repo); - if (node == null) - { - node = new RepositoryNode() - { - Id = repo, - Name = Path.GetFileName(repo), - Bookmark = 0, - IsRepository = true, - }; - } + 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) - { + if (activeIdx > 0 && activeIdx < Pages.Count) ActivePage = Pages[activeIdx]; - } else - { ActivePage = Pages[0]; - ActiveWorkspace.ActiveIdx = 0; - } } - else - { - ActiveWorkspace = new Workspace() { Name = "Unnamed" }; - foreach (var w in pref.Workspaces) - w.IsActive = false; + PostActivePageChanged(); + } - var test = new Commands.QueryRepositoryRootPath(startupRepo).ReadToEnd(); - if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) + public bool TryOpenRepositoryFromPath(string repo) + { + if (!string.IsNullOrEmpty(repo) && Directory.Exists(repo)) + { + var isBare = new Commands.IsBareRepository(repo).GetResult(); + if (isBare) { - Pages[0].Notifications.Add(new Models.Notification - { - IsError = true, - Message = $"Given path: '{startupRepo}' is NOT a valid repository!" - }); + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(repo, null, false); + Welcome.Instance.Refresh(); + OpenRepositoryInTab(node, null); + return true; } - else + + var test = new Commands.QueryRepositoryRootPath(repo).GetResult(); + if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) { - var normalized = test.StdOut.Trim().Replace("\\", "/"); - var node = pref.FindOrAddNodeByRepositoryPath(normalized, null, false); + 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; } } + return false; + } + + public void CloseAll() + { + _ignoreIndexChange = true; + + foreach (var one in Pages) + CloseRepositoryInTab(one, false); + _ignoreIndexChange = false; } - public void Quit(double width, double height) + public void SwitchWorkspace(Workspace to) { - var pref = Preference.Instance; - pref.Layout.LauncherWidth = width; - pref.Layout.LauncherHeight = height; - pref.Save(); + 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() @@ -136,17 +172,16 @@ public void MoveTab(LauncherPage from, LauncherPage to) var fromIdx = Pages.IndexOf(from); var toIdx = Pages.IndexOf(to); Pages.Move(fromIdx, toIdx); - ActivePage = from; - ActiveWorkspace.Repositories.Clear(); + _activeWorkspace.Repositories.Clear(); foreach (var p in Pages) { if (p.Data is Repository r) - ActiveWorkspace.Repositories.Add(r.FullPath); + _activeWorkspace.Repositories.Add(r.FullPath); } - ActiveWorkspace.ActiveIdx = ActiveWorkspace.Repositories.IndexOf(from.Node.Id); _ignoreIndexChange = false; + ActivePage = from; } public void GotoNextTab() @@ -176,16 +211,20 @@ public void CloseTab(LauncherPage page) var last = Pages[0]; if (last.Data is Repository repo) { - ActiveWorkspace.Repositories.Clear(); - ActiveWorkspace.ActiveIdx = 0; + _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 @@ -193,27 +232,18 @@ public void CloseTab(LauncherPage page) App.Quit(0); } - _ignoreIndexChange = false; return; } - if (page == null) - page = _activePage; + 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); - } - else - { - CloseRepositoryInTab(page); - Pages.RemoveAt(removeIdx); - } + CloseRepositoryInTab(page); + Pages.RemoveAt(removeIdx); GC.Collect(); } @@ -232,9 +262,9 @@ public void CloseOtherTabs() } Pages = new AvaloniaList { ActivePage }; - ActiveWorkspace.ActiveIdx = 0; OnPropertyChanged(nameof(Pages)); + _activeWorkspace.ActiveIdx = 0; _ignoreIndexChange = false; GC.Collect(); } @@ -254,6 +284,21 @@ public void CloseRightTabs() GC.Collect(); } + 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) @@ -265,39 +310,46 @@ public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) } } - if (!Path.Exists(node.Id)) + if (!Directory.Exists(node.Id)) { - var ctx = page == null ? ActivePage.Node.Id : page.Node.Id; - App.RaiseException(ctx, "Repository does NOT exists any more. Please remove it."); + ActivePage.Notifications.Add(new Models.Notification + { + Group = node.Id, + Message = "Repository does NOT exist any more. Please remove it.", + IsError = true, + }); return; } - var gitDir = new Commands.QueryGitDir(node.Id).Result(); + var isBare = new Commands.IsBareRepository(node.Id).GetResult(); + var gitDir = isBare ? node.Id : GetRepositoryGitDir(node.Id); if (string.IsNullOrEmpty(gitDir)) { - var ctx = page == null ? ActivePage.Node.Id : page.Node.Id; - App.RaiseException(ctx, "Given path is not a valid git repository!"); + ActivePage.Notifications.Add(new Models.Notification + { + Group = node.Id, + Message = "Given path is not a valid git repository!", + IsError = true, + }); return; } - var repo = new Repository() - { - FullPath = node.Id, - GitDir = gitDir, - }; + if (node.IsUnmanaged) + node.LoadMinimalInfo(gitDir); + var repo = new Repository(isBare, node.Id, gitDir); repo.Open(); if (page == null) { - if (ActivePage == null || ActivePage.Node.IsRepository) + if (_activePage == null || _activePage.Node.IsRepository) { page = new LauncherPage(node, repo); Pages.Add(page); } else { - page = ActivePage; + page = _activePage; page.Node = node; page.Data = repo; } @@ -308,213 +360,75 @@ public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) page.Data = repo; } - ActivePage = page; - - ActiveWorkspace.Repositories.Clear(); + _activeWorkspace.Repositories.Clear(); foreach (var p in Pages) { if (p.Data is Repository r) - ActiveWorkspace.Repositories.Add(r.FullPath); + _activeWorkspace.Repositories.Add(r.FullPath); } - if (!_ignoreIndexChange) - ActiveWorkspace.ActiveIdx = ActiveWorkspace.Repositories.IndexOf(node.Id); + if (_activePage == page) + PostActivePageChanged(); + else + ActivePage = page; } - public void DispatchNotification(string pageId, string message, bool isError) + private void DispatchNotification(Models.Notification notification) { - var notification = new Models.Notification() + if (!Dispatcher.UIThread.CheckAccess()) { - IsError = isError, - Message = message, - }; + 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("\\", "/"); - if (id == pageId) + var id = page.Node.Id.Replace('\\', '/').TrimEnd('/'); + if (id.Equals(notification.Group, StringComparison.OrdinalIgnoreCase)) { page.Notifications.Add(notification); return; } } - if (_activePage != null) - _activePage.Notifications.Add(notification); + _activePage?.Notifications.Add(notification); } - public ContextMenu CreateContextForWorkspace() + private string GetRepositoryGitDir(string repo) { - var pref = Preference.Instance; - var menu = new ContextMenu(); - - for (var i = 0; i < pref.Workspaces.Count; i++) - { - var workspace = pref.Workspaces[i]; - - var icon = App.CreateMenuIcon(workspace.IsActive ? "Icons.Check" : "Icons.Workspace"); - icon.Fill = workspace.Brush; - - var item = new MenuItem(); - item.Header = workspace.Name; - item.Icon = icon; - item.Click += (_, e) => - { - if (!workspace.IsActive) - SwitchWorkspace(workspace); - - e.Handled = true; - }; - - menu.Items.Add(item); - } - - menu.Items.Add(new MenuItem() { Header = "-" }); - - var configure = new MenuItem(); - configure.Header = App.Text("Workspace.Configure"); - configure.Click += (_, e) => + var fullpath = Path.Combine(repo, ".git"); + if (Directory.Exists(fullpath)) { - App.OpenDialog(new Views.ConfigureWorkspace() { DataContext = new ConfigureWorkspace() }); - e.Handled = true; - }; - menu.Items.Add(configure); - - return menu; - } + if (Directory.Exists(Path.Combine(fullpath, "refs")) && + Directory.Exists(Path.Combine(fullpath, "objects")) && + File.Exists(Path.Combine(fullpath, "HEAD"))) + return fullpath; - public ContextMenu CreateContextForPageTab(LauncherPage page) - { - if (page == null) return null; - - var menu = new ContextMenu(); - var close = new MenuItem(); - close.Header = App.Text("PageTabBar.Tab.Close"); - close.InputGesture = KeyGesture.Parse(OperatingSystem.IsMacOS() ? "⌘+W" : "Ctrl+W"); - close.Click += (_, e) => - { - CloseTab(page); - e.Handled = true; - }; - menu.Items.Add(close); - - var closeOthers = new MenuItem(); - closeOthers.Header = App.Text("PageTabBar.Tab.CloseOther"); - closeOthers.Click += (_, e) => - { - CloseOtherTabs(); - e.Handled = true; - }; - menu.Items.Add(closeOthers); - - var closeRight = new MenuItem(); - closeRight.Header = App.Text("PageTabBar.Tab.CloseRight"); - closeRight.Click += (_, e) => - { - CloseRightTabs(); - e.Handled = true; - }; - menu.Items.Add(closeRight); - - if (page.Node.IsRepository) - { - var bookmark = new MenuItem(); - bookmark.Header = App.Text("PageTabBar.Tab.Bookmark"); - bookmark.Icon = App.CreateMenuIcon("Icons.Bookmark"); - - for (int i = 0; i < Models.Bookmarks.Supported.Count; i++) - { - var icon = App.CreateMenuIcon("Icons.Bookmark"); - - if (i != 0) - icon.Fill = Models.Bookmarks.Brushes[i]; - - var dupIdx = i; - var setter = new MenuItem(); - setter.Header = icon; - setter.Click += (_, e) => - { - page.Node.Bookmark = dupIdx; - e.Handled = true; - }; - bookmark.Items.Add(setter); - } - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(bookmark); - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("PageTabBar.Tab.CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Click += (_, e) => - { - page.CopyPath(); - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copyPath); } - return menu; - } - - private void SwitchWorkspace(Workspace to) - { - foreach (var one in Pages) + if (File.Exists(fullpath)) { - if (one.IsInProgress()) - { - App.RaiseException(null, "You have unfinished task(s) in opened pages. Please wait!!!"); - return; - } - } - - _ignoreIndexChange = true; - - var pref = Preference.Instance; - foreach (var w in pref.Workspaces) - w.IsActive = false; - - ActiveWorkspace = to; - to.IsActive = true; + var redirect = File.ReadAllText(fullpath).Trim(); + if (redirect.StartsWith("gitdir: ", StringComparison.Ordinal)) + redirect = redirect.Substring(8); - foreach (var one in Pages) - CloseRepositoryInTab(one, false); + if (!Path.IsPathRooted(redirect)) + redirect = Path.GetFullPath(Path.Combine(repo, redirect)); - Pages.Clear(); - AddNewTab(); + if (Directory.Exists(redirect)) + return redirect; - var repos = to.Repositories.ToArray(); - foreach (var repo in repos) - { - var node = pref.FindNode(repo); - if (node == null) - { - node = new RepositoryNode() - { - Id = repo, - Name = Path.GetFileName(repo), - Bookmark = 0, - IsRepository = true, - }; - } - - OpenRepositoryInTab(node, null); - } - - var activeIdx = to.ActiveIdx; - if (activeIdx >= 0 && activeIdx < Pages.Count) - { - ActivePage = Pages[activeIdx]; - } - else - { - ActivePage = Pages[0]; - to.ActiveIdx = 0; + return null; } - _ignoreIndexChange = false; - GC.Collect(); + return new Commands.QueryGitDir(repo).GetResult(); } private void CloseRepositoryInTab(LauncherPage page, bool removeFromWorkspace = true) @@ -522,16 +436,43 @@ private void CloseRepositoryInTab(LauncherPage page, bool removeFromWorkspace = if (page.Data is Repository repo) { if (removeFromWorkspace) - ActiveWorkspace.Repositories.Remove(repo.FullPath); + _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 Workspace _activeWorkspace = null; - private LauncherPage _activePage = null; - private bool _ignoreIndexChange = false; + 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 92114f5e6..db5e0241b 100644 --- a/src/ViewModels/LauncherPage.cs +++ b/src/ViewModels/LauncherPage.cs @@ -1,10 +1,11 @@ using System; - +using System.Threading.Tasks; using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class LauncherPage : PopupHost + public class LauncherPage : ObservableObject { public RepositoryNode Node { @@ -18,6 +19,18 @@ public object Data set => SetProperty(ref _data, value); } + 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; @@ -39,26 +52,71 @@ public LauncherPage(RepositoryNode node, Repository repo) _data = repo; } - public override string GetId() + public void ClearNotifications() { - return _node.Id; + Notifications.Clear(); } - public override bool IsInProgress() + public void ChangeDirtyState(Models.DirtyState flag, bool remove) { - if (_data is Repository { IsAutoFetching: true }) - return true; + var state = _dirtyState; + if (remove) + { + if (state.HasFlag(flag)) + state -= flag; + } + else + { + state |= flag; + } - return base.IsInProgress(); + DirtyState = state; } - public void CopyPath() + public bool CanCreatePopup() { - if (_node.IsRepository) - App.CopyText(_node.Id); + return _popup is not { InProgress: true }; + } + + 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 index d5f7ec2bd..f993e242e 100644 --- a/src/ViewModels/LayoutInfo.cs +++ b/src/ViewModels/LayoutInfo.cs @@ -1,5 +1,4 @@ using Avalonia.Controls; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -18,6 +17,18 @@ public double LauncherHeight set; } = 720; + public int LauncherPositionX + { + get; + set; + } = int.MinValue; + + public int LauncherPositionY + { + get; + set; + } = int.MinValue; + public WindowState LauncherWindowState { get; @@ -30,12 +41,6 @@ public GridLength RepositorySidebarWidth set => SetProperty(ref _repositorySidebarWidth, value); } - public GridLength HistoriesAuthorColumnWidth - { - get => _historiesAuthorColumnWidth; - set => SetProperty(ref _historiesAuthorColumnWidth, value); - } - public GridLength WorkingCopyLeftWidth { get => _workingCopyLeftWidth; @@ -61,7 +66,6 @@ public GridLength CommitDetailFilesLeftWidth } private GridLength _repositorySidebarWidth = new GridLength(250, GridUnitType.Pixel); - private GridLength _historiesAuthorColumnWidth = new GridLength(120, GridUnitType.Pixel); private GridLength _workingCopyLeftWidth = new GridLength(300, GridUnitType.Pixel); private GridLength _stashesLeftWidth = new GridLength(300, GridUnitType.Pixel); private GridLength _commitDetailChangesLeftWidth = new GridLength(256, GridUnitType.Pixel); diff --git a/src/ViewModels/Merge.cs b/src/ViewModels/Merge.cs index b76301019..35f9567f6 100644 --- a/src/ViewModels/Merge.cs +++ b/src/ViewModels/Merge.cs @@ -1,11 +1,21 @@ -using System; +using System.IO; using System.Threading.Tasks; +using Avalonia.Threading; namespace SourceGit.ViewModels { + public enum MergeTestingState + { + Disabled = 0, + Testing, + WillCauseConflicts, + UnknownError, + NoConflicts, + } + public class Merge : Popup { - public string Source + public object Source { get; } @@ -15,49 +25,155 @@ public string Into get; } - public Models.MergeMode SelectedMode + 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 bool Edit { get; set; + } = false; + + public MergeTestingState TestingState + { + get => _testingState; + private set => SetProperty(ref _testingState, value); } - public Merge(Repository repo, string source, string into) + public Merge(Repository repo, Models.Branch source, string into, bool forceFastForward) { _repo = repo; + _sourceName = source.FriendlyName; + Source = source; Into = into; - SelectedMode = AutoSelectMergeMode(); - View = new Views.Merge() { DataContext = this }; + 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 override Task Sure() + public Merge(Repository repo, Models.Tag source, string into) { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Merging '{Source}' into '{Into}' ..."; + _repo = repo; + _sourceName = source.Name; - return Task.Run(() => + Source = source; + Into = into; + 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 succ = new Commands.Merge(_repo.FullPath, Source, SelectedMode.Arg, SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return 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"); - if (string.IsNullOrEmpty(config)) - return Models.MergeMode.Supported[0]; - if (config.Equals("--no-ff", StringComparison.Ordinal)) - return Models.MergeMode.Supported[1]; - if (config.Equals("--squash", StringComparison.Ordinal)) - return Models.MergeMode.Supported[2]; - if (config.Equals("--no-commit", StringComparison.Ordinal) || config.Equals("--no-ff --no-commit", StringComparison.Ordinal)) - return Models.MergeMode.Supported[3]; + 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, + }; + + 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]; + } - return Models.MergeMode.Supported[0]; + 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); + + Dispatcher.UIThread.Post(() => TestingState = exitCode switch + { + 0 => MergeTestingState.NoConflicts, + 1 => MergeTestingState.WillCauseConflicts, + _ => MergeTestingState.UnknownError, + }); + }); } 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 index 766d2e78e..716274295 100644 --- a/src/ViewModels/MoveRepositoryNode.cs +++ b/src/ViewModels/MoveRepositoryNode.cs @@ -9,13 +9,11 @@ public class MoveRepositoryNode : Popup public RepositoryNode Target { get; - private set; } = null; public List Rows { get; - private set; } = []; public RepositoryNode Selected @@ -33,21 +31,20 @@ public MoveRepositoryNode(RepositoryNode target) Depth = 0, Id = Guid.NewGuid().ToString() }); - MakeRows(Preference.Instance.RepositoryNodes, 1); - - View = new Views.MoveRepositoryNode() { DataContext = this }; + MakeRows(Preferences.Instance.RepositoryNodes, 1); + Selected = Rows[0]; } public override Task Sure() { if (_selected != null) { - var node = Preference.Instance.FindNode(_selected.Id); - Preference.Instance.MoveNode(Target, node, true); + var node = Preferences.Instance.FindNode(_selected.Id); + Preferences.Instance.MoveNode(Target, node, true); Welcome.Instance.Refresh(); } - return null; + return Task.FromResult(true); } private void MakeRows(List collection, int depth) 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 1b4ff9a89..9d800c50c 100644 --- a/src/ViewModels/Popup.cs +++ b/src/ViewModels/Popup.cs @@ -1,27 +1,11 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; - -using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class Popup : ObservableValidator + public class Popup : ObservableValidator, Models.ICommandLogReceiver { - public string HostPageId - { - get; - set; - } - - public object View - { - get; - set; - } - public bool InProgress { get => _inProgress; @@ -43,22 +27,36 @@ public bool Check() return !HasErrors; } - public virtual Task Sure() + public void OnReceiveCommandLog(string data) { - return null; + var desc = data.Trim(); + if (!string.IsNullOrEmpty(desc)) + ProgressDescription = desc; } - protected void CallUIThread(Action action) + public void Cleanup() { - Dispatcher.UIThread.Invoke(action); + _log?.Unsubscribe(this); + } + + public virtual bool CanStartDirectly() + { + return true; + } + + public virtual Task Sure() + { + return null; } - protected void SetProgressDescription(string description) + protected void Use(CommandLog log) { - CallUIThread(() => ProgressDescription = description); + _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 01d1158c0..000000000 --- a/src/ViewModels/PopupHost.cs +++ /dev/null @@ -1,83 +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?.IsInProgress() != true; - } - - public static void ShowPopup(Popup popup) - { - popup.HostPageId = Active.GetId(); - Active.Popup = popup; - } - - public static void ShowAndStartPopup(Popup popup) - { - var dumpPage = Active; - popup.HostPageId = dumpPage.GetId(); - dumpPage.Popup = popup; - dumpPage.ProcessPopup(); - } - - public virtual string GetId() - { - return string.Empty; - } - - public virtual bool IsInProgress() - { - return _popup is { InProgress: true }; - } - - 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; - _popup.InProgress = false; - if (finished) - Popup = null; - } - else - { - _popup.InProgress = false; - 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/Preferences.cs similarity index 58% rename from src/ViewModels/Preference.cs rename to src/ViewModels/Preferences.cs index 68065df1d..25bbb431f 100644 --- a/src/ViewModels/Preference.cs +++ b/src/ViewModels/Preferences.cs @@ -3,27 +3,28 @@ 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 Preference : ObservableObject + public class Preferences : ObservableObject { [JsonIgnore] - public static Preference Instance + public static Preferences Instance { get { if (_instance != null) return _instance; - _isLoading = true; _instance = Load(); - _isLoading = false; + _instance._isLoading = false; _instance.PrepareGit(); _instance.PrepareShellOrTerminal(); + _instance.PrepareExternalDiffMergeTool(); _instance.PrepareWorkspaces(); return _instance; @@ -66,7 +67,7 @@ public string DefaultFontFamily set { if (SetProperty(ref _defaultFontFamily, value) && !_isLoading) - App.SetFonts(_defaultFontFamily, _monospaceFontFamily, _onlyUseMonoFontInEditor); + App.SetFonts(value, _monospaceFontFamily); } } @@ -76,24 +77,14 @@ public string MonospaceFontFamily set { if (SetProperty(ref _monospaceFontFamily, value) && !_isLoading) - App.SetFonts(_defaultFontFamily, _monospaceFontFamily, _onlyUseMonoFontInEditor); - } - } - - public bool OnlyUseMonoFontInEditor - { - get => _onlyUseMonoFontInEditor; - set - { - if (SetProperty(ref _onlyUseMonoFontInEditor, value) && !_isLoading) - App.SetFonts(_defaultFontFamily, _monospaceFontFamily, _onlyUseMonoFontInEditor); + App.SetFonts(_defaultFontFamily, value); } } public bool UseSystemWindowFrame { - get => _useSystemWindowFrame; - set => SetProperty(ref _useSystemWindowFrame, value); + get => Native.OS.UseSystemWindowFrame; + set => Native.OS.UseSystemWindowFrame = value; } public double DefaultFontSize @@ -108,12 +99,36 @@ public double 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; @@ -126,22 +141,56 @@ public int 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 Check4UpdatesOnStartup + public bool UseAutoHideScrollBars { - get => _check4UpdatesOnStartup; - set => SetProperty(ref _check4UpdatesOnStartup, value); + get => _useAutoHideScrollBars; + set => SetProperty(ref _useAutoHideScrollBars, value); } - public bool ShowAuthorTimeInGraph + public bool UseGitHubStyleAvatar { - get => _showAuthorTimeInGraph; - set => SetProperty(ref _showAuthorTimeInGraph, value); + get => _useGitHubStyleAvatar; + set => SetProperty(ref _useGitHubStyleAvatar, value); + } + + public bool Check4UpdatesOnStartup + { + get => _check4UpdatesOnStartup; + set => SetProperty(ref _check4UpdatesOnStartup, value); } public string IgnoreUpdateTag @@ -150,10 +199,16 @@ public string IgnoreUpdateTag set => SetProperty(ref _ignoreUpdateTag, value); } - public bool ShowTagsAsTree + public bool ShowTagsInGraph { - get => _showTagsAsTree; - set => SetProperty(ref _showTagsAsTree, value); + get => _showTagsInGraph; + set => SetProperty(ref _showTagsInGraph, value); + } + + public bool UseCompactBranchNamesInGraph + { + get => _useCompactBranchNamesInGraph; + set => SetProperty(ref _useCompactBranchNamesInGraph, value); } public bool UseTwoColumnsLayoutInHistories @@ -180,6 +235,36 @@ public bool 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; @@ -198,6 +283,24 @@ public bool 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; @@ -216,6 +319,12 @@ public Models.ChangeViewMode CommitChangeViewMode set => SetProperty(ref _commitChangeViewMode, value); } + public Models.ChangeViewMode StashChangeViewMode + { + get => _stashChangeViewMode; + set => SetProperty(ref _stashChangeViewMode, value); + } + public string GitInstallPath { get => Native.OS.GitExecutable; @@ -235,12 +344,26 @@ public string GitDefaultCloneDir set => SetProperty(ref _gitDefaultCloneDir, value); } - public int ShellOrTerminal + 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 => _shellOrTerminal; + get => _shellOrTerminalType; set { - if (SetProperty(ref _shellOrTerminal, value)) + if (SetProperty(ref _shellOrTerminalType, value) && !_isLoading) { if (value >= 0 && value < Models.ShellOrTerminal.Supported.Count) Native.OS.SetShellOrTerminal(Models.ShellOrTerminal.Supported[value]); @@ -248,6 +371,7 @@ public int ShellOrTerminal Native.OS.SetShellOrTerminal(null); OnPropertyChanged(nameof(ShellOrTerminalPath)); + OnPropertyChanged(nameof(ShellOrTerminalArgs)); } } } @@ -265,33 +389,77 @@ public string ShellOrTerminalPath } } + public string ShellOrTerminalArgs + { + get => Native.OS.ShellOrTerminalArgs; + set + { + if (value != Native.OS.ShellOrTerminalArgs) + { + Native.OS.ShellOrTerminalArgs = value; + OnPropertyChanged(); + } + } + } + public int ExternalMergeToolType { - get => _externalMergeToolType; + get => Native.OS.ExternalMergerType; set { - var changed = SetProperty(ref _externalMergeToolType, value); - if (changed && !OperatingSystem.IsWindows() && value > 0 && value < Models.ExternalMerger.Supported.Count) + if (Native.OS.ExternalMergerType != value) { - var tool = Models.ExternalMerger.Supported[value]; - if (File.Exists(tool.Exec)) - ExternalMergeToolPath = tool.Exec; - else - ExternalMergeToolPath = string.Empty; + Native.OS.ExternalMergerType = value; + OnPropertyChanged(); + + if (!_isLoading) + { + Native.OS.AutoSelectExternalMergeToolExecFile(); + OnPropertyChanged(nameof(ExternalMergeToolPath)); + OnPropertyChanged(nameof(ExternalMergeToolDiffArgs)); + OnPropertyChanged(nameof(ExternalMergeToolMergeArgs)); + } } } } public string ExternalMergeToolPath { - get => _externalMergeToolPath; - set => SetProperty(ref _externalMergeToolPath, value); + 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 uint StatisticsSampleColor + public string ExternalMergeToolMergeArgs { - get => _statisticsSampleColor; - set => SetProperty(ref _statisticsSampleColor, value); + get => Native.OS.ExternalMergeArgs; + set + { + if (!Native.OS.ExternalMergeArgs.Equals(value, StringComparison.Ordinal)) + { + Native.OS.ExternalMergeArgs = value; + OnPropertyChanged(); + } + } } public List RepositoryNodes @@ -306,7 +474,13 @@ public List Workspaces set; } = []; - public AvaloniaList OpenAIServices + public AvaloniaList CustomActions + { + get; + set; + } = []; + + public AvaloniaList OpenAIServices { get; set; @@ -318,6 +492,11 @@ public double LastCheckUpdateTime set => SetProperty(ref _lastCheckUpdateTime, value); } + public void SetCanModify() + { + _isReadonly = false; + } + public bool IsGitConfigured() { var path = GitInstallPath; @@ -356,16 +535,21 @@ public void AddNode(RepositoryNode node, RepositoryNode to, bool save) { var collection = to == null ? RepositoryNodes : to.SubNodes; collection.Add(node); - collection.Sort((l, r) => + 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 string.Compare(l.Name, r.Name, StringComparison.Ordinal); + return Models.NumericSort.Compare(l.Name, r.Name); }); - - if (save) - Save(); } public RepositoryNode FindNode(string id) @@ -373,24 +557,26 @@ public RepositoryNode FindNode(string id) return FindNodeRecursive(id, RepositoryNodes); } - public RepositoryNode FindOrAddNodeByRepositoryPath(string repo, RepositoryNode parent, bool shouldMoveNode) + public RepositoryNode FindOrAddNodeByRepositoryPath(string repo, RepositoryNode parent, bool shouldMoveNode, bool save = true) { - var node = FindNodeRecursive(repo, RepositoryNodes); + var normalized = repo.Replace('\\', '/').TrimEnd('/'); + + var node = FindNodeRecursive(normalized, RepositoryNodes); if (node == null) { node = new RepositoryNode() { - Id = repo, - Name = Path.GetFileName(repo), + Id = normalized, + Name = Path.GetFileName(normalized), Bookmark = 0, IsRepository = true, }; - AddNode(node, parent, true); + AddNode(node, parent, save); } else if (shouldMoveNode) { - MoveNode(node, parent, true); + MoveNode(node, parent, save); } return node; @@ -421,47 +607,60 @@ public void RemoveNode(RepositoryNode node, bool save) public void SortByRenamedNode(RepositoryNode node) { var container = FindNodeContainer(node, RepositoryNodes); - container?.Sort((l, r) => - { - if (l.IsRepository != r.IsRepository) - return l.IsRepository ? 1 : -1; - - return string.Compare(l.Name, r.Name, StringComparison.Ordinal); - }); - + SortNodes(container); Save(); } public void AutoRemoveInvalidNode() { - var changed = RemoveInvalidRepositoriesRecursive(RepositoryNodes); - if (changed) - Save(); + RemoveInvalidRepositoriesRecursive(RepositoryNodes); + } + + public void UpdateAvailableAIModels() + { + Task.Run(() => + { + foreach (var service in OpenAIServices) + { + try + { + service.FetchAvailableModels(); + } + catch + { + // Ignore errors. + } + } + }); } public void Save() { - if (_isLoading) + if (_isLoading || _isReadonly) return; - var file = Path.Combine(Native.OS.DataDir, "preference.json"); - var data = JsonSerializer.Serialize(this, JsonCodeGen.Default.Preference); - File.WriteAllText(file, data); + 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 Preference Load() + private static Preferences Load() { var path = Path.Combine(Native.OS.DataDir, "preference.json"); if (!File.Exists(path)) - return new Preference(); + return new Preferences(); try { - return JsonSerializer.Deserialize(File.ReadAllText(path), JsonCodeGen.Default.Preference); + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize(stream, JsonCodeGen.Default.Preferences); } catch { - return new Preference(); + return new Preferences(); } } @@ -474,7 +673,7 @@ private void PrepareGit() private void PrepareShellOrTerminal() { - if (_shellOrTerminal >= 0) + if (_shellOrTerminalType >= 0) return; for (int i = 0; i < Models.ShellOrTerminal.Supported.Count; i++) @@ -482,12 +681,25 @@ private void PrepareShellOrTerminal() var shell = Models.ShellOrTerminal.Supported[i]; if (Native.OS.TestShellOrTerminal(shell)) { - ShellOrTerminal = i; + 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) @@ -574,48 +786,52 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) return changed; } - private static Preference _instance = null; - private static bool _isLoading = false; + private static Preferences _instance = null; + private bool _isLoading = true; + private bool _isReadonly = true; private string _locale = "en_US"; private string _theme = "Default"; private string _themeOverrides = string.Empty; private string _defaultFontFamily = string.Empty; private string _monospaceFontFamily = string.Empty; - private bool _onlyUseMonoFontInEditor = false; - private bool _useSystemWindowFrame = false; private double _defaultFontSize = 13; private double _editorFontSize = 13; - private LayoutInfo _layout = new LayoutInfo(); + 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 _showAuthorTimeInGraph = false; + 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 _showTagsAsTree = false; + 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 _shellOrTerminal = -1; - private int _externalMergeToolType = 0; - private string _externalMergeToolPath = string.Empty; - - private uint _statisticsSampleColor = 0xFF00FF00; + private int _shellOrTerminalType = -1; } } diff --git a/src/ViewModels/PruneRemote.cs b/src/ViewModels/PruneRemote.cs index 128357615..cba2213cc 100644 --- a/src/ViewModels/PruneRemote.cs +++ b/src/ViewModels/PruneRemote.cs @@ -7,27 +7,28 @@ public class PruneRemote : Popup public Models.Remote Remote { get; - private set; } public PruneRemote(Repository repo, Models.Remote remote) { _repo = repo; Remote = remote; - View = new Views.PruneRemote() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + 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 readonly Repository _repo = null; diff --git a/src/ViewModels/PruneWorktrees.cs b/src/ViewModels/PruneWorktrees.cs index 754ea4cbb..561168362 100644 --- a/src/ViewModels/PruneWorktrees.cs +++ b/src/ViewModels/PruneWorktrees.cs @@ -7,22 +7,24 @@ public class PruneWorktrees : Popup public PruneWorktrees(Repository repo) { _repo = repo; - View = new Views.PruneWorktrees() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); ProgressDescription = "Prune worktrees ..."; - return Task.Run(() => - { - new Commands.Worktree(_repo.FullPath).Prune(SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; - }); + var log = _repo.CreateLog("Prune Worktrees"); + Use(log); + + await new Commands.Worktree(_repo.FullPath) + .Use(log) + .PruneAsync(); + + log.Complete(); + return true; } - private Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/Pull.cs b/src/ViewModels/Pull.cs index 6c4934498..367bca40d 100644 --- a/src/ViewModels/Pull.cs +++ b/src/ViewModels/Pull.cs @@ -7,7 +7,7 @@ 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 { @@ -35,37 +35,34 @@ public List RemoteBranches public Models.Branch SelectedBranch { get => _selectedBranch; - set => SetProperty(ref _selectedBranch, value); + set => SetProperty(ref _selectedBranch, value, true); } - public Models.DealWithLocalChanges PreAction + public bool HasLocalChanges { - get => _repo.Settings.DealWithLocalChangesOnPull; - set => _repo.Settings.DealWithLocalChangesOnPull = value; + get => _repo.LocalChangesCount > 0; } - public bool UseRebase - { - get => _repo.Settings.PreferRebaseInsteadOfMerge; - set => _repo.Settings.PreferRebaseInsteadOfMerge = value; - } - - public bool FetchAllBranches + public Models.DealWithLocalChanges DealWithLocalChanges { - get => _repo.Settings.FetchAllBranchesOnPull; - set => _repo.Settings.FetchAllBranchesOnPull = value; + get; + set; } - public bool NoTags + public bool UseRebase { - get => _repo.Settings.FetchWithoutTagsOnPull; - set => _repo.Settings.FetchWithoutTagsOnPull = value; + get => _repo.UIStates.PreferRebaseInsteadOfMerge; + set => _repo.UIStates.PreferRebaseInsteadOfMerge = value; } public Pull(Repository repo, Models.Branch specifiedRemoteBranch) { _repo = repo; - _current = repo.CurrentBranch; + Current = repo.CurrentBranch; + + DealWithLocalChanges = Preferences.Instance.UseStashAndReapplyByDefault ? + Models.DealWithLocalChanges.StashAndReapply : + Models.DealWithLocalChanges.DoNothing; if (specifiedRemoteBranch != null) { @@ -84,20 +81,20 @@ public Pull(Repository repo, Models.Branch specifiedRemoteBranch) } else { - var autoSelectedRemote = null as Models.Remote; - if (!string.IsNullOrEmpty(_current.Upstream)) + Models.Remote autoSelectedRemote = null; + if (!string.IsNullOrEmpty(Current.Upstream)) { - var remoteNameEndIdx = _current.Upstream.IndexOf('/', 13); + var remoteNameEndIdx = Current.Upstream.IndexOf('/', 13); if (remoteNameEndIdx > 0) { - var remoteName = _current.Upstream.Substring(13, remoteNameEndIdx - 13); + var remoteName = Current.Upstream.Substring(13, remoteNameEndIdx - 13); autoSelectedRemote = _repo.Remotes.Find(x => x.Name == remoteName); } } if (autoSelectedRemote == null) { - var remote = null as Models.Remote; + 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]; @@ -110,92 +107,63 @@ public Pull(Repository repo, Models.Branch specifiedRemoteBranch) PostRemoteSelected(); HasSpecifiedRemoteBranch = false; } - - View = new Views.Pull() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); - return Task.Run(() => + var log = _repo.CreateLog("Pull"); + Use(log); + + var changes = await new Commands.CountLocalChanges(_repo.FullPath, false).GetResultAsync(); + var needPopStash = false; + if (changes > 0) { - var changes = new Commands.CountLocalChangesWithoutUntracked(_repo.FullPath).Result(); - var needPopStash = false; - if (changes > 0) + if (DealWithLocalChanges == Models.DealWithLocalChanges.DoNothing) { - if (PreAction == Models.DealWithLocalChanges.StashAndReaply) - { - SetProgressDescription("Stash local changes..."); - var succ = new Commands.Stash(_repo.FullPath).Push("PULL_AUTO_STASH"); - if (!succ) - { - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return false; - } - - needPopStash = true; - } - else if (PreAction == Models.DealWithLocalChanges.Discard) - { - SetProgressDescription("Discard local changes ..."); - Commands.Discard.All(_repo.FullPath, false); - } + // Do nothing, just let the pull command fail and show the error to user } - - bool rs; - if (FetchAllBranches) + else if (DealWithLocalChanges == Models.DealWithLocalChanges.StashAndReapply) { - SetProgressDescription($"Fetching remote: {_selectedRemote.Name}..."); - rs = new Commands.Fetch( - _repo.FullPath, - _selectedRemote.Name, - NoTags, - _repo.Settings.EnablePruneOnFetch, - SetProgressDescription).Exec(); - - if (!rs) + var succ = await new Commands.Stash(_repo.FullPath).Use(log).PushAsync("PULL_AUTO_STASH", false); + if (!succ) { - CallUIThread(() => _repo.SetWatcherEnabled(true)); + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); return false; } - _repo.MarkFetched(); - - // Use merge/rebase instead of pull as fetch is done manually. - if (UseRebase) - { - SetProgressDescription($"Rebase {_current.Name} on {_selectedBranch.FriendlyName} ..."); - rs = new Commands.Rebase(_repo.FullPath, _selectedBranch.FriendlyName, false).Exec(); - } - else - { - SetProgressDescription($"Merge {_selectedBranch.FriendlyName} into {_current.Name} ..."); - rs = new Commands.Merge(_repo.FullPath, _selectedBranch.FriendlyName, "", SetProgressDescription).Exec(); - } + needPopStash = true; } else { - SetProgressDescription($"Pull {_selectedRemote.Name}/{_selectedBranch.Name}..."); - rs = new Commands.Pull( - _repo.FullPath, - _selectedRemote.Name, - _selectedBranch.Name, - UseRebase, - NoTags, - _repo.Settings.EnablePruneOnFetch, - SetProgressDescription).Exec(); + await Commands.Discard.AllAsync(_repo.FullPath, true, false, false, log); } + } - if (rs && needPopStash) - { - SetProgressDescription("Re-apply local changes..."); - rs = new Commands.Stash(_repo.FullPath).Pop("stash@{0}"); - } + 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}"); + } + + log.Complete(); + + if (_repo.SelectedViewIndex == 0) + { + var head = await new Commands.QueryRevisionByRefName(_repo.FullPath, "HEAD").GetResultAsync(); + _repo.NavigateToCommit(head, true); + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return rs; - }); + return rs; } private void PostRemoteSelected() @@ -211,12 +179,12 @@ private void PostRemoteSelected() RemoteBranches = branches; var autoSelectedBranch = false; - if (!string.IsNullOrEmpty(_current.Upstream) && - _current.Upstream.StartsWith($"refs/remotes/{remoteName}/", System.StringComparison.Ordinal)) + if (!string.IsNullOrEmpty(Current.Upstream) && + Current.Upstream.StartsWith($"refs/remotes/{remoteName}/", System.StringComparison.Ordinal)) { foreach (var branch in branches) { - if (_current.Upstream == branch.FullName) + if (Current.Upstream == branch.FullName) { SelectedBranch = branch; autoSelectedBranch = true; @@ -229,7 +197,7 @@ private void PostRemoteSelected() { foreach (var branch in branches) { - if (_current.Name == branch.Name) + if (Current.Name == branch.Name) { SelectedBranch = branch; autoSelectedBranch = true; @@ -243,7 +211,6 @@ private void PostRemoteSelected() } private readonly Repository _repo = null; - private readonly Models.Branch _current = null; private Models.Remote _selectedRemote = null; private List _remoteBranches = null; private Models.Branch _selectedBranch = null; diff --git a/src/ViewModels/Push.cs b/src/ViewModels/Push.cs index 004ae7b64..b4d9029a9 100644 --- a/src/ViewModels/Push.cs +++ b/src/ViewModels/Push.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; @@ -18,7 +19,7 @@ public Models.Branch SelectedLocalBranch get => _selectedLocalBranch; set { - if (SetProperty(ref _selectedLocalBranch, value)) + if (SetProperty(ref _selectedLocalBranch, value, true)) AutoSelectBranchByRemote(); } } @@ -39,7 +40,7 @@ public Models.Remote SelectedRemote get => _selectedRemote; set { - if (SetProperty(ref _selectedRemote, value)) + if (SetProperty(ref _selectedRemote, value, true)) AutoSelectBranchByRemote(); } } @@ -56,8 +57,8 @@ public Models.Branch SelectedRemoteBranch get => _selectedRemoteBranch; set { - if (SetProperty(ref _selectedRemoteBranch, value)) - IsSetTrackOptionVisible = value != null && _selectedLocalBranch.Upstream != value.FullName; + if (SetProperty(ref _selectedRemoteBranch, value, true)) + IsSetTrackOptionVisible = value != null && (value.Head == null || _selectedLocalBranch.Upstream != value.FullName); } } @@ -69,9 +70,9 @@ public bool IsSetTrackOptionVisible public bool Tracking { - get; - set; - } = true; + get => _tracking; + set => SetProperty(ref _tracking, value); + } public bool IsCheckSubmodulesVisible { @@ -86,8 +87,8 @@ public bool CheckSubmodules public bool PushAllTags { - get => _repo.Settings.PushAllTags; - set => _repo.Settings.PushAllTags = value; + get => _repo.UIStates.PushAllTags; + set => _repo.UIStates.PushAllTags = value; } public bool ForcePush @@ -102,7 +103,7 @@ public Push(Repository repo, Models.Branch localBranch) // Gather all local branches and find current branch. LocalBranches = new List(); - var current = null as Models.Branch; + Models.Branch current = null; foreach (var branch in _repo.Branches) { if (branch.IsLocal) @@ -114,6 +115,9 @@ public Push(Repository repo, Models.Branch localBranch) // Set default selected local branch. if (localBranch != null) { + if (LocalBranches.Count == 0) + LocalBranches.Add(localBranch); + _selectedLocalBranch = localBranch; HasSpecifiedLocalBranch = true; } @@ -124,8 +128,10 @@ public Push(Repository repo, Models.Branch localBranch) } // Find preferred remote if selected local branch has upstream. - if (!string.IsNullOrEmpty(_selectedLocalBranch?.Upstream)) + if (!string.IsNullOrEmpty(_selectedLocalBranch?.Upstream) && !_selectedLocalBranch.IsUpstreamGone) { + _tracking = false; + foreach (var branch in repo.Branches) { if (!branch.IsLocal && _selectedLocalBranch.Upstream == branch.FullName) @@ -135,11 +141,15 @@ public Push(Repository repo, Models.Branch localBranch) } } } + else + { + _tracking = true; + } // Set default remote to the first if it has not been set. if (_selectedRemote == null) { - var remote = null as Models.Remote; + Models.Remote remote = null; if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) remote = repo.Remotes.Find(x => x.Name == _repo.Settings.DefaultRemote); @@ -148,41 +158,68 @@ public Push(Repository repo, Models.Branch localBranch) // 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; + } - View = new Views.Push() { DataContext = this }; + var fake = new Models.Branch() + { + Name = name, + Remote = _selectedRemote.Name, + }; + var collection = new List(); + collection.AddRange(_remoteBranches); + collection.Add(fake); + RemoteBranches = collection; + SelectedRemoteBranch = fake; } - public override Task Sure() + public override bool CanStartDirectly() { - _repo.SetWatcherEnabled(false); + return !string.IsNullOrEmpty(_selectedRemoteBranch?.Head); + } + + public override async Task Sure() + { + using var lockWatcher = _repo.LockWatcher(); 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, - _repo.Submodules.Count > 0 && CheckSubmodules, - _isSetTrackOptionVisible && Tracking, - ForcePush, - SetProgressDescription).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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) + if (!branch.IsLocal && _selectedRemote.Name.Equals(branch.Remote, StringComparison.Ordinal)) branches.Add(branch); } @@ -191,7 +228,7 @@ private void AutoSelectBranchByRemote() { foreach (var branch in branches) { - if (_selectedLocalBranch.Upstream == branch.FullName) + if (_selectedLocalBranch.Upstream.Equals(branch.FullName, StringComparison.Ordinal)) { RemoteBranches = branches; SelectedRemoteBranch = branch; @@ -203,7 +240,7 @@ private void AutoSelectBranchByRemote() // Try to find a remote branch with the same name of selected local branch. foreach (var branch in branches) { - if (_selectedLocalBranch.Name == branch.Name) + if (_selectedLocalBranch.Name.Equals(branch.Name, StringComparison.Ordinal)) { RemoteBranches = branches; SelectedRemoteBranch = branch; @@ -228,5 +265,6 @@ private void AutoSelectBranchByRemote() 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 54673fbe7..d59548254 100644 --- a/src/ViewModels/PushTag.cs +++ b/src/ViewModels/PushTag.cs @@ -8,7 +8,6 @@ public class PushTag : Popup public Models.Tag Target { get; - private set; } public List Remotes @@ -33,36 +32,38 @@ 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() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Pushing tag ..."; + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = "Pushing tag ..."; - return Task.Run(() => + var log = _repo.CreateLog("Push Tag"); + Use(log); + + var succ = true; + var tag = $"refs/tags/{Target.Name}"; + if (_pushAllRemotes) { - bool succ = true; - if (_pushAllRemotes) - { - foreach (var remote in _repo.Remotes) - { - SetProgressDescription($"Pushing tag to remote {remote.Name} ..."); - succ = new Commands.Push(_repo.FullPath, remote.Name, Target.Name, false).Exec(); - if (!succ) - break; - } - } - else + foreach (var remote in _repo.Remotes) { - SetProgressDescription($"Pushing tag to remote {SelectedRemote.Name} ..."); - succ = new Commands.Push(_repo.FullPath, SelectedRemote.Name, Target.Name, false).Exec(); + 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(); + } - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + log.Complete(); + return succ; } private readonly Repository _repo = null; diff --git a/src/ViewModels/Rebase.cs b/src/ViewModels/Rebase.cs index 963640dc4..2213a55af 100644 --- a/src/ViewModels/Rebase.cs +++ b/src/ViewModels/Rebase.cs @@ -1,7 +1,18 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; +using Avalonia.Threading; namespace SourceGit.ViewModels { + public enum RebaseTestingState + { + Disabled = 0, + Testing, + WillCauseConflicts, + UnknownError, + NoConflicts, + } + public class Rebase : Popup { public Models.Branch Current @@ -22,6 +33,18 @@ public bool AutoStash set; } + 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; @@ -29,7 +52,8 @@ public Rebase(Repository repo, Models.Branch current, Models.Branch on) Current = current; On = on; AutoStash = true; - View = new Views.Rebase() { DataContext = this }; + + Test(); } public Rebase(Repository repo, Models.Branch current, Models.Commit on) @@ -39,23 +63,66 @@ public Rebase(Repository repo, Models.Branch current, Models.Commit on) Current = current; On = on; AutoStash = true; - View = new Views.Rebase() { DataContext = this }; + + Test(); } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); + _repo.ClearCommitMessage(); ProgressDescription = "Rebasing ..."; - return Task.Run(() => + 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 () => { - new Commands.Rebase(_repo.FullPath, _revision, AutoStash).Exec(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return true; + 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 readonly Repository _repo; private readonly string _revision; + private RebaseTestingState _testingState = RebaseTestingState.Disabled; } } diff --git a/src/ViewModels/RemoveWorktree.cs b/src/ViewModels/RemoveWorktree.cs index 0a2e620bc..40c3b87b3 100644 --- a/src/ViewModels/RemoveWorktree.cs +++ b/src/ViewModels/RemoveWorktree.cs @@ -4,11 +4,10 @@ namespace SourceGit.ViewModels { public class RemoveWorktree : Popup { - public Models.Worktree Target + public Worktree Target { get; - private set; - } = null; + } public bool Force { @@ -16,26 +15,28 @@ public bool Force set; } = false; - public RemoveWorktree(Repository repo, Models.Worktree target) + public RemoveWorktree(Repository repo, Worktree target) { _repo = repo; Target = target; - View = new Views.RemoveWorktree() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - ProgressDescription = "Remove worktrees ..."; - - return Task.Run(() => - { - var succ = new Commands.Worktree(_repo.FullPath).Remove(Target.FullPath, Force, SetProgressDescription); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); + 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 Repository _repo = null; + private readonly Repository _repo = null; } } diff --git a/src/ViewModels/RenameBranch.cs b/src/ViewModels/RenameBranch.cs index bd0b4664a..dbca651e7 100644 --- a/src/ViewModels/RenameBranch.cs +++ b/src/ViewModels/RenameBranch.cs @@ -12,7 +12,7 @@ public Models.Branch Target } [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 { @@ -25,7 +25,6 @@ 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) @@ -34,48 +33,37 @@ public static ValidationResult ValidateBranchName(string name, ValidationContext { foreach (var b in rename._repo.Branches) { - if (b.IsLocal && b != rename.Target && b.Name == name) - { + 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() + public override async Task Sure() { - if (_name == Target.Name) - return null; + 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 oldName = Target.FullName; - var succ = Commands.Branch.Rename(_repo.FullPath, Target.Name, _name); - CallUIThread(() => - { - if (succ) - { - foreach (var filter in _repo.Settings.HistoriesFilters) - { - if (filter.Type == Models.FilterType.LocalBranch && - filter.Pattern.Equals(oldName, StringComparison.Ordinal)) - { - filter.Pattern = $"refs/heads/{_name}"; - break; - } - } - } + 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); - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - }); - return succ; - }); + log.Complete(); + return succ; } private readonly Repository _repo; diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 1847c79a9..99a3a47e2 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -2,14 +2,10 @@ using System.Collections.Generic; using System.IO; using System.Text; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Avalonia.Collections; -using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -18,27 +14,19 @@ namespace SourceGit.ViewModels { public class Repository : ObservableObject, Models.IRepository { + public bool IsBare + { + get; + } + public string FullPath { - get => _fullpath; - set - { - if (value != null) - { - var normalized = value.Replace('\\', '/'); - SetProperty(ref _fullpath, normalized); - } - else - { - SetProperty(ref _fullpath, null); - } - } + get; } public string GitDir { - get => _gitDir; - set => SetProperty(ref _gitDir, value); + get; } public Models.RepositorySettings Settings @@ -46,10 +34,34 @@ public Models.RepositorySettings Settings get => _settings; } - public Models.FilterMode HistoriesFilterMode + public Models.RepositoryUIStates UIStates + { + get => _uiStates; + } + + public Models.GitFlow GitFlow + { + get; + set; + } = new(); + + public Models.FilterMode HistoryFilterMode + { + get => _historyFilterMode; + private set => SetProperty(ref _historyFilterMode, value); + } + + public bool IsHistoryFiltersCollapsed { - get => _historiesFilterMode; - private set => SetProperty(ref _historiesFilterMode, value); + get => _uiStates.IsHistoryFiltersCollapsed; + set + { + if (value != _uiStates.IsHistoryFiltersCollapsed) + { + _uiStates.IsHistoryFiltersCollapsed = value; + OnPropertyChanged(); + } + } } public bool HasAllowedSignersFile @@ -64,45 +76,66 @@ public int SelectedViewIndex { if (SetProperty(ref _selectedViewIndex, value)) { - switch (value) - { - case 1: - SelectedView = _workingCopy; - break; - case 2: - SelectedView = _stashesPage; - break; - default: - SelectedView = _histories; - break; - } + OnPropertyChanged(nameof(IsHistoriesVisible)); + OnPropertyChanged(nameof(IsWorkingCopyVisible)); + OnPropertyChanged(nameof(IsStashesVisible)); } } } - public object SelectedView + 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 => _selectedView; - set => SetProperty(ref _selectedView, value); + get => SelectedViewIndex == 2; } - public bool EnableReflog + public bool EnableTopoOrderInHistory { - get => _enableReflog; + get => _uiStates.EnableTopoOrderInHistory; set { - if (SetProperty(ref _enableReflog, value)) - Task.Run(RefreshCommits); + if (value != _uiStates.EnableTopoOrderInHistory) + { + _uiStates.EnableTopoOrderInHistory = value; + RefreshCommits(); + } } } - public bool EnableFirstParentInHistories + public Models.HistoryShowFlags HistoryShowFlags { - get => _enableFirstParentInHistories; - set + get => _uiStates.HistoryShowFlags; + private set { - if (SetProperty(ref _enableFirstParentInHistories, value)) - Task.Run(RefreshCommits); + if (value != _uiStates.HistoryShowFlags) + { + _uiStates.HistoryShowFlags = value; + RefreshCommits(); + } } } @@ -137,7 +170,16 @@ public List Branches public Models.Branch CurrentBranch { get => _currentBranch; - private set => SetProperty(ref _currentBranch, value); + 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 @@ -152,7 +194,7 @@ public List RemoteBranchTrees private set => SetProperty(ref _remoteBranchTrees, value); } - public List Worktrees + public List Worktrees { get => _worktrees; private set => SetProperty(ref _worktrees, value); @@ -164,7 +206,21 @@ public List Tags private set => SetProperty(ref _tags, value); } - public List VisibleTags + 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); @@ -176,7 +232,21 @@ public List Submodules private set => SetProperty(ref _submodules, value); } - public List VisibleSubmodules + 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); @@ -194,391 +264,499 @@ public int StashesCount private set => SetProperty(ref _stashesCount, value); } + public int LocalBranchesCount + { + get => _localBranchesCount; + private set => SetProperty(ref _localBranchesCount, value); + } + public bool IncludeUntracked { - get => _includeUntracked; + get => _uiStates.IncludeUntrackedInLocalChanges; set { - if (SetProperty(ref _includeUntracked, value)) - Task.Run(RefreshWorkingCopyChanges); + if (value != _uiStates.IncludeUntrackedInLocalChanges) + { + _uiStates.IncludeUntrackedInLocalChanges = value; + OnPropertyChanged(); + RefreshWorkingCopyChanges(); + } } } - public bool IsSearching + public bool IsSearchingCommits { - get => _isSearching; + get => _isSearchingCommits; set { - if (SetProperty(ref _isSearching, value)) + if (SetProperty(ref _isSearchingCommits, value)) { - SearchedCommits = new List(); - SearchCommitFilter = string.Empty; - SearchCommitFilterSuggestion.Clear(); - IsSearchCommitSuggestionOpen = false; - _revisionFiles.Clear(); - if (value) - { SelectedViewIndex = 0; - UpdateCurrentRevisionFilesForSearchSuggestion(); - } + else + _searchCommitContext.EndSearch(); } } } - public bool IsSearchLoadingVisible + public SearchCommitContext SearchCommitContext { - get => _isSearchLoadingVisible; - private set => SetProperty(ref _isSearchLoadingVisible, value); + get => _searchCommitContext; } - public bool OnlySearchCommitsInCurrentBranch + public bool IsLocalBranchGroupExpanded { - get => _onlySearchCommitsInCurrentBranch; + get => _uiStates.IsLocalBranchesExpandedInSideBar; set { - if (SetProperty(ref _onlySearchCommitsInCurrentBranch, value) && - !string.IsNullOrEmpty(_searchCommitFilter)) - StartSearchCommits(); + if (value != _uiStates.IsLocalBranchesExpandedInSideBar) + { + _uiStates.IsLocalBranchesExpandedInSideBar = value; + OnPropertyChanged(); + } } } - public int SearchCommitFilterType + public bool IsRemoteGroupExpanded { - get => _searchCommitFilterType; + get => _uiStates.IsRemotesExpandedInSideBar; set { - if (SetProperty(ref _searchCommitFilterType, value)) + if (value != _uiStates.IsRemotesExpandedInSideBar) { - UpdateCurrentRevisionFilesForSearchSuggestion(); - - if (!string.IsNullOrEmpty(_searchCommitFilter)) - StartSearchCommits(); + _uiStates.IsRemotesExpandedInSideBar = value; + OnPropertyChanged(); } } } - public string SearchCommitFilter + public bool IsTagGroupExpanded { - get => _searchCommitFilter; + get => _uiStates.IsTagsExpandedInSideBar; set { - if (SetProperty(ref _searchCommitFilter, value) && - _searchCommitFilterType == 3 && - !string.IsNullOrEmpty(value) && - value.Length >= 2 && - _revisionFiles.Count > 0) + if (value != _uiStates.IsTagsExpandedInSideBar) { - var suggestion = new List(); - foreach (var file in _revisionFiles) - { - if (file.Contains(value, StringComparison.OrdinalIgnoreCase) && file.Length != value.Length) - { - suggestion.Add(file); - if (suggestion.Count > 100) - break; - } - } - - SearchCommitFilterSuggestion.Clear(); - SearchCommitFilterSuggestion.AddRange(suggestion); - IsSearchCommitSuggestionOpen = SearchCommitFilterSuggestion.Count > 0; + _uiStates.IsTagsExpandedInSideBar = value; + OnPropertyChanged(); } - else if (SearchCommitFilterSuggestion.Count > 0) + } + } + + public bool IsSubmoduleGroupExpanded + { + get => _uiStates.IsSubmodulesExpandedInSideBar; + set + { + if (value != _uiStates.IsSubmodulesExpandedInSideBar) { - SearchCommitFilterSuggestion.Clear(); - IsSearchCommitSuggestionOpen = false; + _uiStates.IsSubmodulesExpandedInSideBar = value; + OnPropertyChanged(); } } } - public bool IsSearchCommitSuggestionOpen + public bool IsWorktreeGroupExpanded { - get => _isSearchCommitSuggestionOpen; - set => SetProperty(ref _isSearchCommitSuggestionOpen, value); + get => _uiStates.IsWorktreeExpandedInSideBar; + set + { + if (value != _uiStates.IsWorktreeExpandedInSideBar) + { + _uiStates.IsWorktreeExpandedInSideBar = value; + OnPropertyChanged(); + } + } } - public AvaloniaList SearchCommitFilterSuggestion + public bool IsSortingLocalBranchByName { - get; - private set; - } = new AvaloniaList(); + get => _uiStates.LocalBranchSortMode == Models.BranchSortMode.Name; + set + { + _uiStates.LocalBranchSortMode = value ? Models.BranchSortMode.Name : Models.BranchSortMode.CommitterDate; + OnPropertyChanged(); - public List SearchedCommits - { - get => _searchedCommits; - set => SetProperty(ref _searchedCommits, value); + var builder = BuildBranchTree(_branches, _remotes); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; + } } - public bool IsLocalBranchGroupExpanded + public bool IsSortingRemoteBranchByName { - get => _isLocalBranchGroupExpanded; - set => SetProperty(ref _isLocalBranchGroupExpanded, value); + 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 IsRemoteGroupExpanded + public bool IsSortingTagsByName { - get => _isRemoteGroupExpanded; - set => SetProperty(ref _isRemoteGroupExpanded, value); + get => _uiStates.TagSortMode == Models.TagSortMode.Name; + set + { + _uiStates.TagSortMode = value ? Models.TagSortMode.Name : Models.TagSortMode.CreatorDate; + OnPropertyChanged(); + VisibleTags = BuildVisibleTags(); + } } - public bool IsTagGroupExpanded + public InProgressContext InProgressContext { - get => _isTagGroupExpanded; - set => SetProperty(ref _isTagGroupExpanded, value); + get => _workingCopy?.InProgressContext; } - public bool IsSubmoduleGroupExpanded + public Models.BisectState BisectState { - get => _isSubmoduleGroupExpanded; - set => SetProperty(ref _isSubmoduleGroupExpanded, value); + get => _bisectState; + private set => SetProperty(ref _bisectState, value); } - public bool IsWorktreeGroupExpanded + public bool IsBisectCommandRunning { - get => _isWorktreeGroupExpanded; - set => SetProperty(ref _isWorktreeGroupExpanded, value); + get => _isBisectCommandRunning; + private set => SetProperty(ref _isBisectCommandRunning, value); } - public InProgressContext InProgressContext + public bool IsAutoFetching { - get => _workingCopy?.InProgressContext; + get => _isAutoFetching; + private set => SetProperty(ref _isAutoFetching, value); } - public Models.Commit SearchResultSelectedCommit + public AvaloniaList IssueTrackers { - get => _searchResultSelectedCommit; - set - { - if (SetProperty(ref _searchResultSelectedCommit, value) && value != null) - NavigateToCommit(value.SHA); - } - } + get; + } = []; - public bool IsAutoFetching + public AvaloniaList Logs { get; - private set; - } + } = []; - public void Open() + public Repository(bool isBare, string path, string gitDir) { - var settingsFile = Path.Combine(_gitDir, "sourcegit.settings"); - if (File.Exists(settingsFile)) + 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) { - try - { - _settings = JsonSerializer.Deserialize(File.ReadAllText(settingsFile), JsonCodeGen.Default.RepositorySettings); - } - catch - { - _settings = new Models.RepositorySettings(); - } + 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 { - _settings = new Models.RepositorySettings(); + _gitCommonDir = GitDir; } + _settings = Models.RepositorySettings.Get(_gitCommonDir); + _uiStates = Models.RepositoryUIStates.Load(GitDir); + } + + public void Open() + { try { - _watcher = new Models.Watcher(this); + _watcher = new Models.Watcher(this, FullPath, _gitCommonDir); } catch (Exception ex) { - App.RaiseException(string.Empty, $"Failed to start watcher for repository: '{_fullpath}'. You may need to press 'F5' to refresh repository manually!\n\nReason: {ex.Message}"); + SendNotification($"Failed to start watcher for repository: '{FullPath}'. You may need to press 'F5' to refresh repository manually!\n\nReason: {ex.Message}", true); } - if (_settings.HistoriesFilters.Count > 0) - _historiesFilterMode = _settings.HistoriesFilters[0].Mode; - else - _historiesFilterMode = Models.FilterMode.None; - + _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; - - _autoFetchTimer = new Timer(AutoFetchImpl, null, 5000, 5000); + _searchCommitContext = new SearchCommitContext(this); + _selectedViewIndex = Preferences.Instance.ShowLocalChangesByDefault ? 1 : 0; + _lastFetchTime = DateTime.Now; + _autoFetchTimer = new Timer(AutoFetchByTimer, null, 5000, 5000); RefreshAll(); } public void Close() { - SelectedView = null; // Do NOT modify. Used to remove exists widgets for GC.Collect + var commitMessage = _workingCopy.CommitMessage; + if (!string.IsNullOrEmpty(commitMessage) && _workingCopy.InProgressContext != null) + File.WriteAllText(Path.Combine(GitDir, "MERGE_MSG"), commitMessage); - var settingsSerialized = JsonSerializer.Serialize(_settings, JsonCodeGen.Default.RepositorySettings); - try - { - File.WriteAllText(Path.Combine(_gitDir, "sourcegit.settings"), settingsSerialized); - } - catch (DirectoryNotFoundException) - { - // Ignore - } - _settings = null; - _historiesFilterMode = Models.FilterMode.None; + _uiStates.LastCommitMessage = commitMessage; + _uiStates.Save(); - _autoFetchTimer.Dispose(); - _autoFetchTimer = null; + 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(); - _histories.Cleanup(); - _workingCopy.Cleanup(); - _stashesPage.Cleanup(); + _autoFetchTimer.Dispose(); + } - _watcher = null; - _histories = null; - _workingCopy = null; - _stashesPage = null; + public void SendNotification(string message, bool isError = false) + { + Models.Notification.Send(FullPath, message, isError); + } - _localChangesCount = 0; - _stashesCount = 0; + public bool CanCreatePopup() + { + var page = GetOwnerPage(); + if (page == null) + return false; - _remotes.Clear(); - _branches.Clear(); - _localBranchTrees.Clear(); - _remoteBranchTrees.Clear(); - _tags.Clear(); - _visibleTags.Clear(); - _submodules.Clear(); - _visibleSubmodules.Clear(); - _searchedCommits.Clear(); + return !_isAutoFetching && page.CanCreatePopup(); + } - _revisionFiles.Clear(); - SearchCommitFilterSuggestion.Clear(); + public void ShowPopup(Popup popup) + { + var page = GetOwnerPage(); + if (page != null) + page.Popup = popup; } - public void RefreshAll() + public async Task ShowAndStartPopupAsync(Popup popup) { - Task.Run(() => - { - var allowedSignersFile = new Commands.Config(_fullpath).Get("gpg.ssh.allowedSignersFile"); - _hasAllowedSignersFile = !string.IsNullOrEmpty(allowedSignersFile); - }); + var page = GetOwnerPage(); + page.Popup = popup; - Task.Run(RefreshBranches); - Task.Run(RefreshTags); - Task.Run(RefreshCommits); - Task.Run(RefreshSubmodules); - Task.Run(RefreshWorktrees); - Task.Run(RefreshWorkingCopyChanges); - Task.Run(RefreshStashes); + if (popup.CanStartDirectly()) + await page.ProcessPopupAsync(); } - public void OpenInFileManager() + public bool IsGitFlowEnabled() { - Native.OS.OpenInFileManager(_fullpath); + 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 void OpenInTerminal() + public Models.GitFlowBranchType GetGitFlowType(Models.Branch b) { - Native.OS.OpenTerminal(_fullpath); + if (!IsGitFlowEnabled()) + return Models.GitFlowBranchType.None; + + var name = b.Name; + if (name.StartsWith(GitFlow.FeaturePrefix, StringComparison.Ordinal)) + return Models.GitFlowBranchType.Feature; + if (name.StartsWith(GitFlow.ReleasePrefix, StringComparison.Ordinal)) + return Models.GitFlowBranchType.Release; + if (name.StartsWith(GitFlow.HotfixPrefix, StringComparison.Ordinal)) + return Models.GitFlowBranchType.Hotfix; + return Models.GitFlowBranchType.None; } - public ContextMenu CreateContextMenuForExternalTools() + public bool IsLFSEnabled() { - var tools = Native.OS.ExternalTools; - if (tools.Count == 0) + var path = Path.Combine(FullPath, ".git", "hooks", "pre-push"); + if (!File.Exists(path)) + return false; + + try { - App.RaiseException(_fullpath, "No available external editors found!"); - return null; + var content = File.ReadAllText(path); + return content.Contains("git lfs pre-push"); } + catch + { + return false; + } + } - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - RenderOptions.SetBitmapInterpolationMode(menu, BitmapInterpolationMode.HighQuality); + 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!"); - foreach (var tool in tools) - { - var dupTool = tool; + log.Complete(); + } - var item = new MenuItem(); - item.Header = App.Text("Repository.OpenIn", dupTool.Name); - item.Icon = new Image { Width = 16, Height = 16, Source = dupTool.IconImage }; - item.Click += (_, e) => - { - dupTool.Open(_fullpath); - e.Handled = true; - }; + 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); - menu.Items.Add(item); - } + if (succ) + SendNotification($"Tracking successfully! Pattern: {pattern}"); + + log.Complete(); + return succ; + } + + 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; + } - return menu; + 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 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(bool autoStart) + public async Task FetchAsync(bool autoStart) { - if (!PopupHost.CanCreatePopup()) + if (!CanCreatePopup()) return; if (_remotes.Count == 0) { - App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + SendNotification("No remotes added to this repository!!!", true); return; } if (autoStart) - PopupHost.ShowAndStartPopup(new Fetch(this)); + await ShowAndStartPopupAsync(new Fetch(this)); else - PopupHost.ShowPopup(new Fetch(this)); + ShowPopup(new Fetch(this)); } - public void Pull(bool autoStart) + public async Task PullAsync(bool autoStart) { - if (!PopupHost.CanCreatePopup()) + if (IsBare || !CanCreatePopup()) return; if (_remotes.Count == 0) { - App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + SendNotification("No remotes added to this repository!!!", true); + return; + } + + if (_currentBranch == null) + { + SendNotification("Can NOT find current branch!!!", true); return; } var pull = new Pull(this, null); if (autoStart && pull.SelectedBranch != null) - PopupHost.ShowAndStartPopup(pull); + await ShowAndStartPopupAsync(pull); else - PopupHost.ShowPopup(pull); + ShowPopup(pull); } - public void Push(bool autoStart) + public async Task PushAsync(bool autoStart) { - if (!PopupHost.CanCreatePopup()) + if (!CanCreatePopup()) return; if (_remotes.Count == 0) { - App.RaiseException(_fullpath, "No remotes added to this repository!!!"); + SendNotification("No remotes added to this repository!!!", true); return; } if (_currentBranch == null) { - App.RaiseException(_fullpath, "Can NOT found current branch!!!"); + SendNotification("Can NOT find current branch!!!", true); return; } if (autoStart) - PopupHost.ShowAndStartPopup(new Push(this, null)); + await ShowAndStartPopupAsync(new Push(this, null)); else - PopupHost.ShowPopup(new Push(this, null)); + ShowPopup(new Push(this, null)); } public void ApplyPatch() { - if (!PopupHost.CanCreatePopup()) - return; - PopupHost.ShowPopup(new Apply(this)); + if (CanCreatePopup()) + ShowPopup(new Apply(this)); } - public void Cleanup() + public async Task ExecCustomActionAsync(Models.CustomAction action, object scopeTarget) { - if (!PopupHost.CanCreatePopup()) + if (!CanCreatePopup()) return; - PopupHost.ShowAndStartPopup(new Cleanup(this)); + + var popup = new ExecuteCustomAction(this, action, scopeTarget); + if (action.Controls.Count == 0) + await ShowAndStartPopupAsync(popup); + else + ShowPopup(popup); + } + + public async Task CleanupAsync() + { + if (CanCreatePopup()) + await ShowAndStartPopupAsync(new Cleanup(this)); } public void ClearFilter() @@ -586,165 +764,293 @@ public void ClearFilter() Filter = string.Empty; } - public void ClearSearchCommitFilter() + public IDisposable LockWatcher() { - SearchCommitFilter = string.Empty; + return _watcher?.Lock(); } - public void StartSearchCommits() + public void RefreshAfterCreateBranch(Models.Branch created, bool checkout) { - if (_histories == null) - return; + _watcher?.MarkBranchUpdated(); + _watcher?.MarkWorkingCopyUpdated(); - IsSearchLoadingVisible = true; - SearchResultSelectedCommit = null; - IsSearchCommitSuggestionOpen = false; - SearchCommitFilterSuggestion.Clear(); + _branches.RemoveAll(b => b.IsLocal && b.Name.Equals(created.Name, StringComparison.Ordinal)); + _branches.Add(created); - Task.Run(() => + if (checkout) { - var visible = new List(); - - switch (_searchCommitFilterType) + if (_currentBranch.IsDetachedHead) { - case 0: - var commit = new Commands.QuerySingleCommit(_fullpath, _searchCommitFilter).Result(); - if (commit != null) - visible.Add(commit); - break; - case 1: - visible = new Commands.QueryCommits(_fullpath, _searchCommitFilter, Models.CommitSearchMethod.ByUser, _onlySearchCommitsInCurrentBranch).Result(); - break; - case 2: - visible = new Commands.QueryCommits(_fullpath, _searchCommitFilter, Models.CommitSearchMethod.ByMessage, _onlySearchCommitsInCurrentBranch).Result(); - break; - case 3: - visible = new Commands.QueryCommits(_fullpath, _searchCommitFilter, Models.CommitSearchMethod.ByFile, _onlySearchCommitsInCurrentBranch).Result(); - break; + _branches.Remove(_currentBranch); + } + else + { + _currentBranch.IsCurrent = false; + _currentBranch.WorktreePath = null; } - Dispatcher.UIThread.Invoke(() => + 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; + } + + var locals = new List(); + var count = 0; + foreach (var b in _branches) + { + if (b.IsLocal) { - SearchedCommits = visible; - IsSearchLoadingVisible = false; - }); - }); - } + locals.Add(b); + if (!b.IsDetachedHead) + count++; + } + } - public void SetWatcherEnabled(bool enabled) - { - _watcher?.SetEnabled(enabled); + var builder = BuildBranchTree(locals, [], false); + LocalBranchTrees = builder.Locals; + LocalBranchesCount = count; + + RefreshCommits(); + RefreshWorkingCopyChanges(); + RefreshWorktrees(); } - public void MarkBranchesDirtyManually() + public void RefreshAfterCheckoutBranch(Models.Branch checkouted) { - if (_watcher == null) + _watcher?.MarkBranchUpdated(); + _watcher?.MarkWorkingCopyUpdated(); + + if (_currentBranch.IsDetachedHead) { - Task.Run(RefreshBranches); - Task.Run(RefreshCommits); - Task.Run(RefreshWorkingCopyChanges); - Task.Run(RefreshWorktrees); + _branches.Remove(_currentBranch); } else { - _watcher.MarkBranchDirtyManually(); + _currentBranch.IsCurrent = false; + _currentBranch.WorktreePath = null; } - } - public void MarkWorkingCopyDirtyManually() - { - if (_watcher == null) - Task.Run(RefreshWorkingCopyChanges); - else - _watcher.MarkWorkingCopyDirtyManually(); + 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 MarkFetched() + 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); + } + + var builder = BuildBranchTree(locals, [], false); + LocalBranchTrees = builder.Locals; + + RefreshCommits(); + RefreshWorktrees(); + } + + 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) + public void NavigateToCommit(string sha, bool isDelayMode = false) { - if (_histories != null) + if (isDelayMode) + { + _navigateToCommitDelayed = sha; + } + else { SelectedViewIndex = 0; - _histories.NavigateTo(sha); + _histories?.NavigateTo(sha); } } - public void NavigateToCurrentHead() + public void SetCommitMessage(string message) + { + if (_workingCopy is not null) + _workingCopy.CommitMessage = message; + } + + public void ClearCommitMessage() + { + if (_workingCopy is not null) + _workingCopy.CommitMessage = string.Empty; + } + + public Models.Commit GetSelectedCommitInHistory() { - if (_currentBranch != null) - NavigateToCommit(_currentBranch.Head); + return (_histories?.DetailContext as CommitDetail)?.Commit; } - public void ClearHistoriesFilter() + public void ClearHistoryFilters() { - _settings.HistoriesFilters.Clear(); - HistoriesFilterMode = Models.FilterMode.None; + _uiStates.HistoryFilters.Clear(); + HistoryFilterMode = Models.FilterMode.None; ResetBranchTreeFilterMode(LocalBranchTrees); ResetBranchTreeFilterMode(RemoteBranchTrees); ResetTagFilterMode(); - Task.Run(RefreshCommits); + RefreshCommits(); } - public void SetTagFilterMode(Models.Tag tag, Models.FilterMode mode) + public void RemoveHistoryFilter(Models.HistoryFilter filter) { - var changed = _settings.UpdateHistoriesFilter(tag.Name, Models.FilterType.Tag, mode); - if (changed) + if (_uiStates.HistoryFilters.Remove(filter)) { - if (mode != Models.FilterMode.None || _settings.HistoriesFilters.Count == 0) - HistoriesFilterMode = mode; + HistoryFilterMode = _uiStates.GetHistoryFilterMode(); + RefreshHistoryFilters(true); + } + } - RefreshHistoriesFilters(); + 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); } - public void SetBranchFilterMode(BranchTreeNode node, Models.FilterMode mode) + public void SetBranchFilterMode(Models.Branch branch, Models.FilterMode mode, bool clearExists, bool refresh) + { + var node = FindBranchNode(branch.IsLocal ? _localBranchTrees : _remoteBranchTrees, branch.FullName); + if (node != null) + SetBranchFilterMode(node, mode, clearExists, refresh); + } + + public void SetBranchFilterMode(BranchTreeNode node, Models.FilterMode mode, bool clearExists, bool refresh) { var isLocal = node.Path.StartsWith("refs/heads/", StringComparison.Ordinal); var tree = isLocal ? _localBranchTrees : _remoteBranchTrees; + if (clearExists) + { + _uiStates.HistoryFilters.Clear(); + HistoryFilterMode = Models.FilterMode.None; + } + if (node.Backend is Models.Branch branch) { var type = isLocal ? Models.FilterType.LocalBranch : Models.FilterType.RemoteBranch; - var changed = _settings.UpdateHistoriesFilter(node.Path, type, mode); + var changed = _uiStates.UpdateHistoryFilters(node.Path, type, mode); if (!changed) return; - if (isLocal && !string.IsNullOrEmpty(branch.Upstream) && mode != Models.FilterMode.Excluded) - { - var upstream = branch.Upstream; - var canUpdateUpstream = true; - foreach (var filter in _settings.HistoriesFilters) - { - bool matched = false; - if (filter.Type == Models.FilterType.RemoteBranch) - matched = filter.Pattern.Equals(upstream, StringComparison.Ordinal); - else if (filter.Type == Models.FilterType.RemoteBranchFolder) - matched = upstream.StartsWith(filter.Pattern, StringComparison.Ordinal); - - if (matched && filter.Mode == Models.FilterMode.Excluded) - { - canUpdateUpstream = false; - break; - } - } - - if (canUpdateUpstream) - _settings.UpdateHistoriesFilter(upstream, Models.FilterType.RemoteBranch, mode); - } + 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 = _settings.UpdateHistoriesFilter(node.Path, type, mode); + var changed = _uiStates.UpdateHistoryFilters(node.Path, type, mode); if (!changed) return; - _settings.RemoveChildrenBranchFilters(node.Path); + _uiStates.RemoveBranchFiltersByPrefix(node.Path); } var parentType = isLocal ? Models.FilterType.LocalBranchFolder : Models.FilterType.RemoteBranchFolder; @@ -760,1265 +1066,631 @@ public void SetBranchFilterMode(BranchTreeNode node, Models.FilterMode mode) if (parent == null) break; - _settings.UpdateHistoriesFilter(parent.Path, parentType, Models.FilterMode.None); + _uiStates.UpdateHistoryFilters(parent.Path, parentType, Models.FilterMode.None); cur = parent; } while (true); - if (mode != Models.FilterMode.None || _settings.HistoriesFilters.Count == 0) - HistoriesFilterMode = mode; - - RefreshHistoriesFilters(); + RefreshHistoryFilters(refresh); } - public void StashAll(bool autoStart) + public async Task StashAllAsync(bool autoStart) { - _workingCopy?.StashAll(autoStart); - } + if (!CanCreatePopup()) + return; - public void GotoResolve() - { - if (_workingCopy != null) - SelectedViewIndex = 1; + var popup = new StashChanges(this, null); + if (autoStart) + await ShowAndStartPopupAsync(popup); + else + ShowPopup(popup); } - public void AbortMerge() + public async Task SkipMergeAsync() { - _workingCopy?.AbortMerge(); + if (_workingCopy != null) + await _workingCopy.SkipMergeAsync(); } - public void RefreshBranches() + public async Task AbortMergeAsync() { - var branches = new Commands.QueryBranches(_fullpath).Result(); - var remotes = new Commands.QueryRemotes(_fullpath).Result(); - var builder = BuildBranchTree(branches, remotes); - - Dispatcher.UIThread.Invoke(() => - { - Remotes = remotes; - Branches = branches; - CurrentBranch = branches.Find(x => x.IsCurrent); - LocalBranchTrees = builder.Locals; - RemoteBranchTrees = builder.Remotes; - - if (_workingCopy != null) - _workingCopy.CanCommitWithPush = _currentBranch != null && !string.IsNullOrEmpty(_currentBranch.Upstream); - }); + if (_workingCopy != null) + await _workingCopy.AbortMergeAsync(); } - public void RefreshWorktrees() + public List<(Models.CustomAction, CustomActionContextMenuLabel)> GetCustomActions(Models.CustomActionScope scope) { - var worktrees = new Commands.Worktree(_fullpath).List(); - var cleaned = new List(); + var actions = new List<(Models.CustomAction, CustomActionContextMenuLabel)>(); - foreach (var worktree in worktrees) + foreach (var act in Preferences.Instance.CustomActions) { - if (worktree.IsBare || worktree.FullPath.Equals(_fullpath)) - continue; - - cleaned.Add(worktree); + if (act.Scope == scope) + actions.Add((act, new CustomActionContextMenuLabel(act.Name, true))); } - Dispatcher.UIThread.Invoke(() => + foreach (var act in _settings.CustomActions) { - Worktrees = cleaned; - }); - } + if (act.Scope == scope) + actions.Add((act, new CustomActionContextMenuLabel(act.Name, false))); + } - public void RefreshTags() - { - var tags = new Commands.QueryTags(_fullpath).Result(); - Dispatcher.UIThread.Invoke(() => - { - Tags = tags; - VisibleTags = BuildVisibleTags(); - }); + return actions; } - public void RefreshCommits() + public async Task ExecBisectCommandAsync(string subcmd) { - Dispatcher.UIThread.Invoke(() => _histories.IsLoading = true); - - var builder = new StringBuilder(); - builder.Append($"-{Preference.Instance.MaxHistoryCommits} "); - if (_enableReflog) - builder.Append("--reflog "); - if (_enableFirstParentInHistories) - builder.Append("--first-parent "); - - var invalidFilters = new List(); - var filters = _settings.BuildHistoriesFilter(); - if (string.IsNullOrEmpty(filters)) - builder.Append("--branches --remotes --tags"); - else - builder.Append(filters); + using var lockWatcher = _watcher?.Lock(); + IsBisectCommandRunning = true; - var commits = new Commands.QueryCommits(_fullpath, builder.ToString()).Result(); - var graph = Models.CommitGraph.Parse(commits, _enableFirstParentInHistories); + var log = CreateLog($"Bisect({subcmd})"); - Dispatcher.UIThread.Invoke(() => - { - if (_histories != null) - { - _histories.IsLoading = false; - _histories.Commits = commits; - _histories.Graph = graph; - } - }); - } + var succ = await new Commands.Bisect(FullPath, subcmd).Use(log).ExecAsync(); + log.Complete(); - public void RefreshSubmodules() - { - var submodules = new Commands.QuerySubmodules(_fullpath).Result(); - _watcher?.SetSubmodules(submodules); + 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()); - Dispatcher.UIThread.Invoke(() => - { - Submodules = submodules; - VisibleSubmodules = BuildVisibleSubmodules(); - }); + MarkBranchesDirtyManually(); + NavigateToCommit(head, true); + IsBisectCommandRunning = false; } - public void RefreshWorkingCopyChanges() + public bool MayHaveSubmodules() { - var changes = new Commands.QueryLocalChanges(_fullpath, _includeUntracked).Result(); - if (_workingCopy == null) - return; - - _workingCopy.SetData(changes); - - Dispatcher.UIThread.Invoke(() => - { - LocalChangesCount = changes.Count; - OnPropertyChanged(nameof(InProgressContext)); - }); + var modulesFile = Path.Combine(FullPath, ".gitmodules"); + var info = new FileInfo(modulesFile); + return info.Exists && info.Length > 20; } - public void RefreshStashes() + public void RefreshBranches() { - var stashes = new Commands.QueryStashes(_fullpath).Result(); - Dispatcher.UIThread.Invoke(() => - { - if (_stashesPage != null) - _stashesPage.Stashes = stashes; + if (_cancellationRefreshBranches is { IsCancellationRequested: false }) + _cancellationRefreshBranches.Cancel(); - StashesCount = stashes.Count; - }); - } + _cancellationRefreshBranches = new CancellationTokenSource(); + var token = _cancellationRefreshBranches.Token; - public void CreateNewBranch() - { - if (_currentBranch == null) + Task.Run(async () => { - App.RaiseException(_fullpath, "Git do not hold any branch until you do first commit."); - return; - } + 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); - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateBranch(this, _currentBranch)); - } - - public void CheckoutBranch(Models.Branch branch) - { - if (branch.IsLocal) - { - var worktree = _worktrees.Find(x => x.Branch == branch.FullName); - if (worktree != null) + Dispatcher.UIThread.Invoke(() => { - OpenWorktree(worktree); - return; - } - } + if (token.IsCancellationRequested) + return; - if (!PopupHost.CanCreatePopup()) - return; + Remotes = remotes; + Branches = branches; + CurrentBranch = branches.Find(x => x.IsCurrent); + LocalBranchTrees = builder.Locals; + RemoteBranchTrees = builder.Remotes; - if (branch.IsLocal) - { - if (_localChangesCount > 0) - PopupHost.ShowPopup(new Checkout(this, branch.Name)); - else - PopupHost.ShowAndStartPopup(new Checkout(this, branch.Name)); - } - else - { - foreach (var b in _branches) - { - if (b.IsLocal && b.Upstream == branch.FullName) + var localBranchesCount = 0; + foreach (var b in branches) { - if (!b.IsCurrent) - CheckoutBranch(b); - - return; + if (b.IsLocal && !b.IsDetachedHead) + localBranchesCount++; } - } + LocalBranchesCount = localBranchesCount; - PopupHost.ShowPopup(new CreateBranch(this, branch)); - } - } + if (_workingCopy != null) + _workingCopy.HasRemotes = remotes.Count > 0; - public void DeleteMultipleBranches(List branches, bool isLocal) - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteMultipleBranches(this, branches, isLocal)); + var hasPendingPullOrPush = CurrentBranch?.IsTrackStatusVisible ?? false; + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasPendingPullOrPush, !hasPendingPullOrPush); + }); + }, token); } - public void CreateNewTag() + public void RefreshWorktrees() { - if (_currentBranch == null) + Task.Run(async () => { - App.RaiseException(_fullpath, "Git do not hold any branch until you do first commit."); - return; - } - - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateTag(this, _currentBranch)); - } - - public void AddRemote() - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new AddRemote(this)); - } - - public void AddSubmodule() - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new AddSubmodule(this)); + var worktrees = await new Commands.Worktree(FullPath).ReadAllAsync().ConfigureAwait(false); + var cleaned = Worktree.Build(FullPath, worktrees); + Dispatcher.UIThread.Invoke(() => Worktrees = cleaned); + }); } - public void UpdateSubmodules() + public void RefreshTags() { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new UpdateSubmodules(this)); - } + if (_cancellationRefreshTags is { IsCancellationRequested: false }) + _cancellationRefreshTags.Cancel(); - public void OpenSubmodule(string submodule) - { - var root = Path.GetFullPath(Path.Combine(_fullpath, submodule)); - var normalizedPath = root.Replace("\\", "/"); + _cancellationRefreshTags = new CancellationTokenSource(); + var token = _cancellationRefreshTags.Token; - var node = Preference.Instance.FindNode(normalizedPath); - if (node == null) + Task.Run(async () => { - node = new RepositoryNode() + var tags = await new Commands.QueryTags(FullPath).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Invoke(() => { - Id = normalizedPath, - Name = Path.GetFileName(normalizedPath), - Bookmark = 0, - IsRepository = true, - }; - } - - App.GetLauncer()?.OpenRepositoryInTab(node, null); - } - - public void AddWorktree() - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new AddWorktree(this)); - } + if (token.IsCancellationRequested) + return; - public void PruneWorktrees() - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new PruneWorktrees(this)); + Tags = tags; + VisibleTags = BuildVisibleTags(); + }); + }, token); } - public void OpenWorktree(Models.Worktree worktree) + public void RefreshCommits() { - var node = Preference.Instance.FindNode(worktree.FullPath); - if (node == null) - { - node = new RepositoryNode() - { - Id = worktree.FullPath, - Name = Path.GetFileName(worktree.FullPath), - Bookmark = 0, - IsRepository = true, - }; - } + if (_cancellationRefreshCommits is { IsCancellationRequested: false }) + _cancellationRefreshCommits.Cancel(); - App.GetLauncer()?.OpenRepositoryInTab(node, null); - } + _cancellationRefreshCommits = new CancellationTokenSource(); + var token = _cancellationRefreshCommits.Token; - public ContextMenu CreateContextMenuForGitFlow() - { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - - var isGitFlowEnabled = Commands.GitFlow.IsEnabled(_fullpath, _branches); - if (isGitFlowEnabled) + Task.Run(async () => { - var startFeature = new MenuItem(); - startFeature.Header = App.Text("GitFlow.StartFeature"); - startFeature.Icon = App.CreateMenuIcon("Icons.GitFlow.Feature"); - startFeature.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new GitFlowStart(this, "feature")); - e.Handled = true; - }; - - var startRelease = new MenuItem(); - startRelease.Header = App.Text("GitFlow.StartRelease"); - startRelease.Icon = App.CreateMenuIcon("Icons.GitFlow.Release"); - startRelease.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new GitFlowStart(this, "release")); - e.Handled = true; - }; - - var startHotfix = new MenuItem(); - startHotfix.Header = App.Text("GitFlow.StartHotfix"); - startHotfix.Icon = App.CreateMenuIcon("Icons.GitFlow.Hotfix"); - startHotfix.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new GitFlowStart(this, "hotfix")); - e.Handled = true; - }; + await Dispatcher.UIThread.InvokeAsync(() => _histories.IsLoading = true); - menu.Items.Add(startFeature); - menu.Items.Add(startRelease); - menu.Items.Add(startHotfix); - } - else - { - var init = new MenuItem(); - init.Header = App.Text("GitFlow.Init"); - init.Icon = App.CreateMenuIcon("Icons.Init"); - init.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new InitGitFlow(this)); - e.Handled = true; - }; - menu.Items.Add(init); - } - return menu; - } + var builder = new StringBuilder(); + builder + .Append('-').Append(Preferences.Instance.MaxHistoryCommits).Append(' ') + .Append(_uiStates.BuildHistoryParams(GitDir)); - public ContextMenu CreateContextMenuForGitLFS() - { - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; + var commits = await new Commands.QueryCommits(FullPath, builder.ToString()) + .GetResultAsync() + .ConfigureAwait(false); - var lfs = new Commands.LFS(_fullpath); - if (lfs.IsEnabled()) - { - var addPattern = new MenuItem(); - addPattern.Header = App.Text("GitLFS.AddTrackPattern"); - addPattern.Icon = App.CreateMenuIcon("Icons.File.Add"); - addPattern.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new LFSTrackCustomPattern(this)); - - e.Handled = true; - }; - menu.Items.Add(addPattern); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var fetch = new MenuItem(); - fetch.Header = App.Text("GitLFS.Fetch"); - fetch.Icon = App.CreateMenuIcon("Icons.Fetch"); - fetch.IsEnabled = _remotes.Count > 0; - fetch.Click += (_, e) => + Dispatcher.UIThread.Invoke(() => { - if (PopupHost.CanCreatePopup()) - { - if (_remotes.Count == 1) - PopupHost.ShowAndStartPopup(new LFSFetch(this)); - else - PopupHost.ShowPopup(new LFSFetch(this)); - } - - e.Handled = true; - }; - menu.Items.Add(fetch); + if (token.IsCancellationRequested) + return; - var pull = new MenuItem(); - pull.Header = App.Text("GitLFS.Pull"); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.IsEnabled = _remotes.Count > 0; - pull.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) + if (_histories != null) { - if (_remotes.Count == 1) - PopupHost.ShowAndStartPopup(new LFSPull(this)); - else - PopupHost.ShowPopup(new LFSPull(this)); - } + _histories.IsLoading = false; + _histories.Commits = commits; + BisectState = _histories.UpdateBisectInfo(); - e.Handled = true; - }; - menu.Items.Add(pull); - - var push = new MenuItem(); - push.Header = App.Text("GitLFS.Push"); - push.Icon = App.CreateMenuIcon("Icons.Push"); - push.IsEnabled = _remotes.Count > 0; - push.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - { - if (_remotes.Count == 1) - PopupHost.ShowAndStartPopup(new LFSPush(this)); - else - PopupHost.ShowPopup(new LFSPush(this)); + if (!string.IsNullOrEmpty(_navigateToCommitDelayed)) + NavigateToCommit(_navigateToCommitDelayed); } - e.Handled = true; - }; - menu.Items.Add(push); + _navigateToCommitDelayed = string.Empty; + }); + }, token); + } - var prune = new MenuItem(); - prune.Header = App.Text("GitLFS.Prune"); - prune.Icon = App.CreateMenuIcon("Icons.Clean"); - prune.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new LFSPrune(this)); - - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(prune); - - var locks = new MenuItem(); - locks.Header = App.Text("GitLFS.Locks"); - locks.Icon = App.CreateMenuIcon("Icons.Lock"); - locks.IsEnabled = _remotes.Count > 0; - if (_remotes.Count == 1) - { - locks.Click += (_, e) => - { - var dialog = new Views.LFSLocks() { DataContext = new LFSLocks(_fullpath, _remotes[0].Name) }; - App.OpenDialog(dialog); - e.Handled = true; - }; - } - else + public void RefreshSubmodules() + { + if (!MayHaveSubmodules()) + { + if (_submodules.Count > 0) { - foreach (var remote in _remotes) + Dispatcher.UIThread.Invoke(() => { - var remoteName = remote.Name; - var lockRemote = new MenuItem(); - lockRemote.Header = remoteName; - lockRemote.Click += (_, e) => - { - var dialog = new Views.LFSLocks() { DataContext = new LFSLocks(_fullpath, remoteName) }; - App.OpenDialog(dialog); - e.Handled = true; - }; - locks.Items.Add(lockRemote); - } + Submodules = []; + VisibleSubmodules = BuildVisibleSubmodules(); + }); } - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(locks); - } - else - { - var install = new MenuItem(); - install.Header = App.Text("GitLFS.Install"); - install.Icon = App.CreateMenuIcon("Icons.Init"); - install.Click += (_, e) => - { - var succ = new Commands.LFS(_fullpath).Install(); - if (succ) - App.SendNotification(_fullpath, $"LFS enabled successfully!"); - - e.Handled = true; - }; - menu.Items.Add(install); + return; } - return menu; - } - - public ContextMenu CreateContextMenuForCustomAction() - { - var actions = new List(); - foreach (var action in _settings.CustomActions) + Task.Run(async () => { - if (action.Scope == Models.CustomActionScope.Repository) - actions.Add(action); - } + var submodules = await new Commands.QuerySubmodules(FullPath).GetResultAsync().ConfigureAwait(false); - var menu = new ContextMenu(); - menu.Placement = PlacementMode.BottomEdgeAlignedLeft; - - if (actions.Count > 0) - { - foreach (var action in actions) + Dispatcher.UIThread.Invoke(() => { - var dup = action; - var item = new MenuItem(); - item.Icon = App.CreateMenuIcon("Icons.Action"); - item.Header = dup.Name; - item.Click += (_, e) => + bool hasChanged = _submodules.Count != submodules.Count; + if (!hasChanged) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new ExecuteCustomAction(this, dup, null)); + var old = new Dictionary(); + foreach (var module in _submodules) + old.Add(module.Path, module); - e.Handled = true; - }; + foreach (var module in submodules) + { + if (!old.TryGetValue(module.Path, out var exist)) + { + hasChanged = true; + break; + } - menu.Items.Add(item); - } - } - else - { - menu.Items.Add(new MenuItem() { Header = App.Text("Repository.CustomActions.Empty") }); - } + 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; - return menu; + if (hasChanged) + break; + } + } + + if (hasChanged) + { + Submodules = submodules; + VisibleSubmodules = BuildVisibleSubmodules(); + } + }); + }); } - public ContextMenu CreateContextMenuForLocalBranch(Models.Branch branch) + public void RefreshWorkingCopyChanges() { - var menu = new ContextMenu(); + if (IsBare) + return; - 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; - }; + if (_cancellationRefreshWorkingCopyChanges is { IsCancellationRequested: false }) + _cancellationRefreshWorkingCopyChanges.Cancel(); + + _cancellationRefreshWorkingCopyChanges = new CancellationTokenSource(); + var token = _cancellationRefreshWorkingCopyChanges.Token; + var noOptionalLocks = Interlocked.Add(ref _queryLocalChangesTimes, 1) > 1; - if (branch.IsCurrent) + Task.Run(async () => { - var discard = new MenuItem(); - discard.Header = App.Text("BranchCM.DiscardAll"); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Discard(this)); - e.Handled = true; - }; + var changes = await new Commands.QueryLocalChanges(FullPath, _uiStates.IncludeUntrackedInLocalChanges, noOptionalLocks) + .GetResultAsync() + .ConfigureAwait(false); - menu.Items.Add(discard); - menu.Items.Add(new MenuItem() { Header = "-" }); + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); - if (!string.IsNullOrEmpty(branch.Upstream)) + Dispatcher.UIThread.Invoke(() => { - 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 = branch.TrackStatus.Ahead.Count == 0; - fastForward.Click += (_, 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 += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Pull(this, null)); - e.Handled = true; - }; - - menu.Items.Add(fastForward); - menu.Items.Add(pull); - } - - menu.Items.Add(push); + if (token.IsCancellationRequested) + return; - var compareWithBranch = CreateMenuItemToCompareBranches(branch); - if (compareWithBranch != null) - { - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(compareWithBranch); - } - } - else - { - var checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", branch.Name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - CheckoutBranch(branch); - e.Handled = true; - }; - menu.Items.Add(checkout); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var worktree = _worktrees.Find(x => x.Branch == branch.FullName); - var upstream = _branches.Find(x => x.FullName == branch.Upstream); - if (upstream != null && worktree == null) - { - var fastForward = new MenuItem(); - fastForward.Header = new Views.NameHighlightedTextBlock("BranchCM.FastForward", upstream.FriendlyName); - fastForward.Icon = App.CreateMenuIcon("Icons.FastForward"); - fastForward.IsEnabled = branch.TrackStatus.Ahead.Count == 0; - fastForward.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new FastForwardWithoutCheckout(this, branch, upstream)); - e.Handled = true; - }; - - var fetchInto = new MenuItem(); - fetchInto.Header = new Views.NameHighlightedTextBlock("BranchCM.FetchInto", upstream.FriendlyName, branch.Name); - fetchInto.Icon = App.CreateMenuIcon("Icons.Fetch"); - fetchInto.IsEnabled = branch.TrackStatus.Ahead.Count == 0; - fetchInto.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new FetchInto(this, branch, upstream)); - e.Handled = true; - }; - - menu.Items.Add(fastForward); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(fetchInto); - } + _workingCopy.SetData(changes); + LocalChangesCount = changes.Count; + OnPropertyChanged(nameof(InProgressContext)); + GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasLocalChanges, changes.Count == 0); + }); + }, token); + } - menu.Items.Add(push); + public void RefreshStashes() + { + if (IsBare) + return; - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", branch.Name, _currentBranch.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Merge(this, branch.Name, _currentBranch.Name)); - e.Handled = true; - }; - - var rebase = new MenuItem(); - rebase.Header = new Views.NameHighlightedTextBlock("BranchCM.Rebase", _currentBranch.Name, branch.Name); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Rebase(this, _currentBranch, branch)); - e.Handled = true; - }; + if (_cancellationRefreshStashes is { IsCancellationRequested: false }) + _cancellationRefreshStashes.Cancel(); - menu.Items.Add(merge); - menu.Items.Add(rebase); + _cancellationRefreshStashes = new CancellationTokenSource(); + var token = _cancellationRefreshStashes.Token; - if (_localChangesCount > 0) + Task.Run(async () => + { + var stashes = await new Commands.QueryStashes(FullPath).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Invoke(() => { - var compareWithWorktree = new MenuItem(); - compareWithWorktree.Header = App.Text("BranchCM.CompareWithWorktree"); - compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithWorktree.Click += (_, _) => - { - SearchResultSelectedCommit = null; + if (token.IsCancellationRequested) + return; - if (_histories != null) - { - var target = new Commands.QuerySingleCommit(_fullpath, branch.Head).Result(); - _histories.AutoSelectedCommit = null; - _histories.DetailContext = new RevisionCompare(_fullpath, target, null); - } - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(compareWithWorktree); - } + if (_stashesPage != null) + _stashesPage.Stashes = stashes; - var compareWithBranch = CreateMenuItemToCompareBranches(branch); - if (compareWithBranch != null) - { - if (_localChangesCount == 0) - menu.Items.Add(new MenuItem() { Header = "-" }); + StashesCount = stashes.Count; + }); + }, token); + } - menu.Items.Add(compareWithBranch); - } - } + public void ToggleHistoryShowFlag(Models.HistoryShowFlags flag) + { + if (_uiStates.HistoryShowFlags.HasFlag(flag)) + HistoryShowFlags -= flag; + else + HistoryShowFlags |= flag; + } - var detect = Commands.GitFlow.DetectType(_fullpath, _branches, branch.Name); - if (detect.IsGitFlowBranch) + public void CreateNewBranch() + { + if (_currentBranch == null) { - var finish = new MenuItem(); - finish.Header = new Views.NameHighlightedTextBlock("BranchCM.Finish", branch.Name); - finish.Icon = App.CreateMenuIcon("Icons.GitFlow"); - finish.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new GitFlowFinish(this, branch, detect.Type, detect.Prefix)); - 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 += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new RenameBranch(this, branch)); - e.Handled = true; - }; + SendNotification("Git cannot create a branch before your first commit.", true); + return; + } - 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 += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteBranch(this, branch)); - e.Handled = true; - }; + if (CanCreatePopup()) + ShowPopup(new CreateBranch(this, _currentBranch)); + } - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (_, e) => + public async Task CheckoutBranchAsync(Models.Branch branch) + { + if (branch.IsLocal) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateBranch(this, branch)); - e.Handled = true; - }; + var worktree = _worktrees.Find(x => x.IsAttachedTo(branch)); + if (worktree != null) + { + OpenWorktree(worktree); + return; + } + } - var createTag = new MenuItem(); - createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); - createTag.Header = App.Text("CreateTag"); - createTag.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateTag(this, branch)); - e.Handled = true; - }; + if (IsBare) + return; - 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 = "-" }); + if (!CanCreatePopup()) + return; - var remoteBranches = new List(); - foreach (var b in _branches) + if (branch.IsLocal) { - if (!b.IsLocal) - remoteBranches.Add(b); + if (_workingCopy is { CanSwitchBranchDirectly: true }) + await ShowAndStartPopupAsync(new Checkout(this, branch)); + else + ShowPopup(new Checkout(this, branch)); } - - if (remoteBranches.Count > 0) + else { - var tracking = new MenuItem(); - tracking.Header = App.Text("BranchCM.Tracking"); - tracking.Icon = App.CreateMenuIcon("Icons.Track"); - - foreach (var b in remoteBranches) + foreach (var b in _branches) { - 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 += (_, e) => + if (b.IsLocal && + b.Upstream.Equals(branch.FullName, StringComparison.Ordinal) && + b.Ahead.Count == 0) { - if (Commands.Branch.SetUpstream(_fullpath, branch.Name, upstream)) - Task.Run(RefreshBranches); - - e.Handled = true; - }; + if (b.Behind.Count > 0) + ShowPopup(new CheckoutAndFastForward(this, b, branch)); + else if (!b.IsCurrent) + await CheckoutBranchAsync(b); - tracking.Items.Add(target); + return; + } } - 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); - - menu.Items.Add(tracking); + ShowPopup(new CreateBranch(this, branch)); } + } - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Archive(this, branch)); - e.Handled = true; - }; - menu.Items.Add(archive); - menu.Items.Add(new MenuItem() { Header = "-" }); + 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); + } - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (_, e) => - { - App.CopyText(branch.Name); - e.Handled = true; - }; - menu.Items.Add(copy); + public void DeleteBranch(Models.Branch branch) + { + if (CanCreatePopup()) + ShowPopup(new DeleteBranch(this, branch)); + } - return menu; + public void DeleteMultipleBranches(List branches, bool isLocal) + { + if (CanCreatePopup()) + ShowPopup(new DeleteMultipleBranches(this, branches, isLocal)); } - public ContextMenu CreateContextMenuForRemote(Models.Remote remote) + public void MergeMultipleBranches(List branches) { - var menu = new ContextMenu(); + if (CanCreatePopup()) + ShowPopup(new MergeMultiple(this, branches)); + } - if (remote.TryGetVisitURL(out string visitURL)) + public void CreateNewTag() + { + if (_currentBranch == null) { - var visit = new MenuItem(); - visit.Header = App.Text("RemoteCM.OpenInBrowser"); - visit.Icon = App.CreateMenuIcon("Icons.OpenWith"); - visit.Click += (_, e) => - { - Native.OS.OpenBrowser(visitURL); - e.Handled = true; - }; - - menu.Items.Add(visit); - menu.Items.Add(new MenuItem() { Header = "-" }); + SendNotification("Git cannot create a tag before your first commit.", true); + return; } - var fetch = new MenuItem(); - fetch.Header = App.Text("RemoteCM.Fetch"); - fetch.Icon = App.CreateMenuIcon("Icons.Fetch"); - fetch.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new Fetch(this, remote)); - e.Handled = true; - }; - - var prune = new MenuItem(); - prune.Header = App.Text("RemoteCM.Prune"); - prune.Icon = App.CreateMenuIcon("Icons.Clean"); - prune.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new PruneRemote(this, remote)); - e.Handled = true; - }; - - var edit = new MenuItem(); - edit.Header = App.Text("RemoteCM.Edit"); - edit.Icon = App.CreateMenuIcon("Icons.Edit"); - edit.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new EditRemote(this, remote)); - e.Handled = true; - }; + if (CanCreatePopup()) + ShowPopup(new CreateTag(this, _currentBranch)); + } - var delete = new MenuItem(); - delete.Header = App.Text("RemoteCM.Delete"); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteRemote(this, remote)); - e.Handled = true; - }; + public void DeleteTag(Models.Tag tag) + { + if (CanCreatePopup()) + ShowPopup(new DeleteTag(this, tag)); + } - var copy = new MenuItem(); - copy.Header = App.Text("RemoteCM.CopyURL"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (_, e) => - { - App.CopyText(remote.URL); - e.Handled = true; - }; + public void AddRemote() + { + if (CanCreatePopup()) + ShowPopup(new AddRemote(this)); + } - 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 void DeleteRemote(Models.Remote remote) + { + if (CanCreatePopup()) + ShowPopup(new DeleteRemote(this, remote)); } - public ContextMenu CreateContextMenuForRemoteBranch(Models.Branch branch) + public async Task ToggleAutoFetchOnRemoteAsync(Models.Remote remote) { - var menu = new ContextMenu(); - var name = branch.FriendlyName; + 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; + } - var checkout = new MenuItem(); - checkout.Header = new Views.NameHighlightedTextBlock("BranchCM.Checkout", name); - checkout.Icon = App.CreateMenuIcon("Icons.Check"); - checkout.Click += (_, e) => - { - CheckoutBranch(branch); - e.Handled = true; - }; - menu.Items.Add(checkout); - menu.Items.Add(new MenuItem() { Header = "-" }); + public void AddSubmodule() + { + if (CanCreatePopup()) + ShowPopup(new AddSubmodule(this)); + } - if (_currentBranch != null) - { - var pull = new MenuItem(); - pull.Header = new Views.NameHighlightedTextBlock("BranchCM.PullInto", name, _currentBranch.Name); - pull.Icon = App.CreateMenuIcon("Icons.Pull"); - pull.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Pull(this, branch)); - e.Handled = true; - }; - - var merge = new MenuItem(); - merge.Header = new Views.NameHighlightedTextBlock("BranchCM.Merge", name, _currentBranch.Name); - merge.Icon = App.CreateMenuIcon("Icons.Merge"); - merge.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Merge(this, name, _currentBranch.Name)); - e.Handled = true; - }; - - var rebase = new MenuItem(); - rebase.Header = new Views.NameHighlightedTextBlock("BranchCM.Rebase", _currentBranch.Name, name); - rebase.Icon = App.CreateMenuIcon("Icons.Rebase"); - rebase.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Rebase(this, _currentBranch, branch)); - e.Handled = true; - }; + public void UpdateSubmodules() + { + if (CanCreatePopup()) + ShowPopup(new UpdateSubmodules(this, null)); + } - menu.Items.Add(pull); - menu.Items.Add(merge); - menu.Items.Add(rebase); - menu.Items.Add(new MenuItem() { Header = "-" }); - } + public async Task AutoUpdateSubmodulesAsync(Models.ICommandLog log) + { + var submodules = await new Commands.QueryUpdatableSubmodules(FullPath, false).GetResultAsync(); + if (submodules.Count == 0) + return; - var hasCompare = false; - if (_localChangesCount > 0) + do { - var compareWithWorktree = new MenuItem(); - compareWithWorktree.Header = App.Text("BranchCM.CompareWithWorktree"); - compareWithWorktree.Icon = App.CreateMenuIcon("Icons.Compare"); - compareWithWorktree.Click += (_, _) => + if (_settings.AskBeforeAutoUpdatingSubmodules) { - SearchResultSelectedCommit = null; - - if (_histories != null) - { - var target = new Commands.QuerySingleCommit(_fullpath, branch.Head).Result(); - _histories.AutoSelectedCommit = null; - _histories.DetailContext = new RevisionCompare(_fullpath, target, null); - } - }; - menu.Items.Add(compareWithWorktree); - hasCompare = true; - } - - var compareWithBranch = CreateMenuItemToCompareBranches(branch); - if (compareWithBranch != null) - { - menu.Items.Add(compareWithBranch); - hasCompare = true; - } + 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; + } - if (hasCompare) - menu.Items.Add(new MenuItem() { Header = "-" }); + await new Commands.Submodule(FullPath) + .Use(log) + .UpdateAsync(submodules, false, _settings.EnableRecursiveWhenAutoUpdatingSubmodules, false); + } while (false); + } - var delete = new MenuItem(); - delete.Header = new Views.NameHighlightedTextBlock("BranchCM.Delete", name); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteBranch(this, branch)); - e.Handled = true; - }; + public void OpenSubmodule(string submodule) + { + var selfPage = GetOwnerPage(); + if (selfPage == null) + return; - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateBranch(this, branch)); - e.Handled = true; - }; + var root = Path.GetFullPath(Path.Combine(FullPath, submodule)); + var normalizedPath = root.Replace('\\', '/').TrimEnd('/'); + App.GetLauncher().OpenRepositoryInTab(normalizedPath, null); + } - var createTag = new MenuItem(); - createTag.Icon = App.CreateMenuIcon("Icons.Tag.Add"); - createTag.Header = App.Text("CreateTag"); - createTag.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateTag(this, branch)); - e.Handled = true; - }; + public void AddWorktree() + { + if (CanCreatePopup()) + ShowPopup(new AddWorktree(this)); + } - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Archive(this, branch)); - e.Handled = true; - }; + public async Task PruneWorktreesAsync() + { + if (CanCreatePopup()) + await ShowAndStartPopupAsync(new PruneWorktrees(this)); + } - var copy = new MenuItem(); - copy.Header = App.Text("BranchCM.CopyName"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (_, e) => - { - App.CopyText(name); - e.Handled = true; - }; + public void OpenWorktree(Worktree worktree) + { + if (worktree.IsCurrent) + return; - 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; + var normalizedPath = worktree.FullPath.Replace('\\', '/').TrimEnd('/'); + App.GetLauncher().OpenRepositoryInTab(normalizedPath, null); } - public ContextMenu CreateContextMenuForTag(Models.Tag tag) + public async Task LockWorktreeAsync(Worktree worktree) { - var createBranch = new MenuItem(); - createBranch.Icon = App.CreateMenuIcon("Icons.Branch.Add"); - createBranch.Header = App.Text("CreateBranch"); - createBranch.Click += (_, ev) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateBranch(this, tag)); - ev.Handled = true; - }; + 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(); + } - 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 += (_, ev) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new PushTag(this, tag)); - ev.Handled = true; - }; + 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(); + } - var deleteTag = new MenuItem(); - deleteTag.Header = new Views.NameHighlightedTextBlock("TagCM.Delete", tag.Name); - deleteTag.Icon = App.CreateMenuIcon("Icons.Clear"); - deleteTag.Click += (_, ev) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteTag(this, tag)); - ev.Handled = true; - }; + public List GetPreferredOpenAIServices() + { + var services = Preferences.Instance.OpenAIServices; + if (services == null || services.Count == 0) + return []; - var archive = new MenuItem(); - archive.Icon = App.CreateMenuIcon("Icons.Archive"); - archive.Header = App.Text("Archive"); - archive.Click += (_, ev) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Archive(this, tag)); - ev.Handled = true; - }; + if (services.Count == 1) + return [services[0]]; - var copy = new MenuItem(); - copy.Header = App.Text("TagCM.Copy"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (_, ev) => + var preferred = _settings.PreferredOpenAIService; + var all = new List(); + foreach (var service in services) { - App.CopyText(tag.Name); - ev.Handled = true; - }; + if (service.Name.Equals(preferred, StringComparison.Ordinal)) + return [service]; - var copyMessage = new MenuItem(); - copyMessage.Header = App.Text("TagCM.CopyMessage"); - copyMessage.Icon = App.CreateMenuIcon("Icons.Copy"); - copyMessage.IsEnabled = !string.IsNullOrEmpty(tag.Message); - copyMessage.Click += (_, ev) => - { - App.CopyText(tag.Message); - ev.Handled = true; - }; + all.Add(service); + } - var menu = new ContextMenu(); - menu.Items.Add(createBranch); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(pushTag); - menu.Items.Add(deleteTag); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(archive); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copy); - menu.Items.Add(copyMessage); - return menu; + return all; } - public ContextMenu CreateContextMenuForSubmodule(string submodule) + public void DiscardAllChanges() { - var open = new MenuItem(); - open.Header = App.Text("Submodule.Open"); - open.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - open.Click += (_, ev) => - { - OpenSubmodule(submodule); - ev.Handled = true; - }; - - var copy = new MenuItem(); - copy.Header = App.Text("Submodule.CopyPath"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (_, ev) => - { - App.CopyText(submodule); - ev.Handled = true; - }; - - var rm = new MenuItem(); - rm.Header = App.Text("Submodule.Remove"); - rm.Icon = App.CreateMenuIcon("Icons.Clear"); - rm.Click += (_, ev) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteSubmodule(this, submodule)); - ev.Handled = true; - }; + if (CanCreatePopup()) + ShowPopup(new Discard(this)); + } - var menu = new ContextMenu(); - menu.Items.Add(open); - menu.Items.Add(copy); - menu.Items.Add(rm); - return menu; + public void ClearStashes() + { + if (CanCreatePopup()) + ShowPopup(new ClearStashes(this)); } - public ContextMenu CreateContextMenuForWorktree(Models.Worktree worktree) + public async Task SaveCommitAsPatchAsync(Models.Commit commit, string folder, int index = 0) { - var menu = new ContextMenu(); + var ignoredChars = new HashSet { '/', '\\', ':', ',', '*', '?', '\"', '<', '>', '|', '`', '$', '^', '%', '[', ']', '+', '-' }; + var builder = new StringBuilder(); + builder.Append(index.ToString("D4")); + builder.Append('-'); - if (worktree.IsLocked) - { - var unlock = new MenuItem(); - unlock.Header = App.Text("Worktree.Unlock"); - unlock.Icon = App.CreateMenuIcon("Icons.Unlock"); - unlock.Click += (_, ev) => - { - SetWatcherEnabled(false); - var succ = new Commands.Worktree(_fullpath).Unlock(worktree.FullPath); - if (succ) - worktree.IsLocked = false; - SetWatcherEnabled(true); - ev.Handled = true; - }; - menu.Items.Add(unlock); - } - else + var chars = commit.Subject.ToCharArray(); + var len = 0; + foreach (var c in chars) { - var loc = new MenuItem(); - loc.Header = App.Text("Worktree.Lock"); - loc.Icon = App.CreateMenuIcon("Icons.Lock"); - loc.Click += (_, ev) => + if (!ignoredChars.Contains(c)) { - SetWatcherEnabled(false); - var succ = new Commands.Worktree(_fullpath).Lock(worktree.FullPath); - if (succ) - worktree.IsLocked = true; - SetWatcherEnabled(true); - ev.Handled = true; - }; - menu.Items.Add(loc); - } - - var remove = new MenuItem(); - remove.Header = App.Text("Worktree.Remove"); - remove.Icon = App.CreateMenuIcon("Icons.Clear"); - remove.Click += (_, ev) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new RemoveWorktree(this, worktree)); - ev.Handled = true; - }; - menu.Items.Add(remove); + if (c == ' ' || c == '\t') + builder.Append('-'); + else + builder.Append(c); - var copy = new MenuItem(); - copy.Header = App.Text("Worktree.CopyPath"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (_, e) => - { - App.CopyText(worktree.FullPath); - e.Handled = true; - }; - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(copy); + len++; + + if (len >= 48) + break; + } + } + builder.Append(".patch"); - return menu; + 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; } - private MenuItem CreateMenuItemToCompareBranches(Models.Branch branch) + private LauncherPage GetOwnerPage() { - if (_branches.Count == 1) + var launcher = App.GetLauncher(); + if (launcher == null) return null; - var compare = new MenuItem(); - compare.Header = App.Text("BranchCM.CompareWithBranch"); - compare.Icon = App.CreateMenuIcon("Icons.Compare"); - - foreach (var b in _branches) + foreach (var page in launcher.Pages) { - if (b.FullName != branch.FullName) - { - var dup = b; - var target = new MenuItem(); - target.Header = b.FriendlyName; - target.Icon = App.CreateMenuIcon(b.IsCurrent ? "Icons.Check" : "Icons.Branch"); - target.Click += (_, e) => - { - App.OpenDialog(new Views.BranchCompare() - { - DataContext = new BranchCompare(_fullpath, branch, dup) - }); - e.Handled = true; - }; - - compare.Items.Add(target); - } + if (page.Node.Id.Equals(FullPath)) + return page; } - return compare; + return null; } - private BranchTreeNode.Builder BuildBranchTree(List branches, List remotes) + private BranchTreeNode.Builder BuildBranchTree(List branches, List remotes, bool validateExpandedNodes = true) { - var builder = new BranchTreeNode.Builder(); + var builder = new BranchTreeNode.Builder(_uiStates.LocalBranchSortMode, _uiStates.RemoteBranchSortMode); if (string.IsNullOrEmpty(_filter)) { - builder.CollectExpandedNodes(_localBranchTrees); - builder.CollectExpandedNodes(_remoteBranchTrees); + builder.SetExpandedNodes(_uiStates.ExpandedBranchNodesInSideBar); builder.Run(branches, remotes, false); + + if (validateExpandedNodes) + { + foreach (var invalid in builder.InvalidExpandedNodes) + _uiStates.ExpandedBranchNodesInSideBar.Remove(invalid); + } } else { @@ -2032,14 +1704,24 @@ private BranchTreeNode.Builder BuildBranchTree(List branches, Lis builder.Run(visibles, remotes, true); } - var historiesFilters = _settings.CollectHistoriesFilters(); - UpdateBranchTreeFilterMode(builder.Locals, historiesFilters); - UpdateBranchTreeFilterMode(builder.Remotes, historiesFilters); + var filterMap = _uiStates.GetHistoryFiltersMap(); + UpdateBranchTreeFilterMode(builder.Locals, filterMap); + UpdateBranchTreeFilterMode(builder.Remotes, filterMap); return builder; } - private List BuildVisibleTags() + 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; + } + var visible = new List(); if (string.IsNullOrEmpty(_filter)) { @@ -2054,12 +1736,26 @@ private BranchTreeNode.Builder BuildBranchTree(List branches, Lis } } - var historiesFilters = _settings.CollectHistoriesFilters(); - UpdateTagFilterMode(historiesFilters); - return visible; + var filterMap = _uiStates.GetHistoryFiltersMap(); + UpdateTagFilterMode(filterMap); + + 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; + } } - private List BuildVisibleSubmodules() + private object BuildVisibleSubmodules() { var visible = new List(); if (string.IsNullOrEmpty(_filter)) @@ -2074,40 +1770,48 @@ private BranchTreeNode.Builder BuildBranchTree(List branches, Lis visible.Add(s); } } - return visible; + + if (_uiStates.ShowSubmodulesAsTree) + return SubmoduleCollectionAsTree.Build(visible, _visibleSubmodules as SubmoduleCollectionAsTree); + else + return new SubmoduleCollectionAsList() { Submodules = visible }; } - private void RefreshHistoriesFilters() + private void RefreshHistoryFilters(bool refresh) { - var filters = _settings.CollectHistoriesFilters(); - UpdateBranchTreeFilterMode(LocalBranchTrees, filters); - UpdateBranchTreeFilterMode(RemoteBranchTrees, filters); - UpdateTagFilterMode(filters); - Task.Run(RefreshCommits); + HistoryFilterMode = _uiStates.GetHistoryFilterMode(); + if (!refresh) + return; + + var map = _uiStates.GetHistoryFiltersMap(); + UpdateBranchTreeFilterMode(LocalBranchTrees, map); + UpdateBranchTreeFilterMode(RemoteBranchTrees, map); + UpdateTagFilterMode(map); + RefreshCommits(); } - private void UpdateBranchTreeFilterMode(List nodes, Dictionary filters) + private void UpdateBranchTreeFilterMode(List nodes, Dictionary map) { foreach (var node in nodes) { - if (filters.TryGetValue(node.Path, out var value)) - node.FilterMode = value; - else - node.FilterMode = Models.FilterMode.None; + node.FilterMode = map.GetValueOrDefault(node.Path, Models.FilterMode.None); if (!node.IsBranch) - UpdateBranchTreeFilterMode(node.Children, filters); + UpdateBranchTreeFilterMode(node.Children, map); } } - private void UpdateTagFilterMode(Dictionary filters) + private void UpdateTagFilterMode(Dictionary map) { - foreach (var tag in _tags) + if (VisibleTags is TagCollectionAsTree tree) { - if (filters.TryGetValue(tag.Name, out var value)) - tag.FilterMode = value; - else - tag.FilterMode = Models.FilterMode.None; + 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); } } @@ -2123,12 +1827,24 @@ private void ResetBranchTreeFilterMode(List nodes) private void ResetTagFilterMode() { - foreach (var tag in _tags) - tag.FilterMode = Models.FilterMode.None; + 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; + } } 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)) @@ -2145,114 +1861,113 @@ private BranchTreeNode FindBranchNode(List nodes, string path) return null; } - private void UpdateCurrentRevisionFilesForSearchSuggestion() + private void AutoFetchByTimer(object sender) + { + try + { + Dispatcher.UIThread.Invoke(AutoFetchOnUIThread); + } + catch + { + // Ignore exception. + } + } + + private async Task AutoFetchOnUIThread() { - _revisionFiles.Clear(); + if (IsAutoFetching) + return; - if (_searchCommitFilterType == 3) + CommandLog log = null; + + try { - Task.Run(() => + if (!Preferences.Instance.EnableAutoFetch || !CanCreatePopup()) { - var files = new Commands.QueryCurrentRevisionFiles(_fullpath).Result(); - Dispatcher.UIThread.Invoke(() => - { - if (_searchCommitFilterType != 3) - return; + _lastFetchTime = DateTime.Now; + return; + } - _revisionFiles.AddRange(files); + var lockFile = Path.Combine(GitDir, "index.lock"); + if (File.Exists(lockFile)) + return; - if (!string.IsNullOrEmpty(_searchCommitFilter) && _searchCommitFilter.Length > 2 && _revisionFiles.Count > 0) - { - var suggestion = new List(); - foreach (var file in _revisionFiles) - { - if (file.Contains(_searchCommitFilter, StringComparison.OrdinalIgnoreCase) && file.Length != _searchCommitFilter.Length) - { - suggestion.Add(file); - if (suggestion.Count > 100) - break; - } - } + var now = DateTime.Now; + var desire = _lastFetchTime.AddMinutes(Preferences.Instance.AutoFetchInterval); + if (desire > now) + return; - SearchCommitFilterSuggestion.Clear(); - SearchCommitFilterSuggestion.AddRange(suggestion); - IsSearchCommitSuggestionOpen = SearchCommitFilterSuggestion.Count > 0; - } - }); - }); - } - } + var remotes = new List(); + foreach (var r in _remotes) + { + if (!r.DisableAutoFetch) + remotes.Add(r.Name); + } - private void AutoFetchImpl(object sender) - { - if (!_settings.EnableAutoFetch || IsAutoFetching) - return; + if (remotes.Count == 0) + return; - var lockFile = Path.Combine(_gitDir, "index.lock"); - if (File.Exists(lockFile)) - return; + IsAutoFetching = true; + log = CreateLog("Auto-Fetch"); - var now = DateTime.Now; - var desire = _lastFetchTime.AddMinutes(_settings.AutoFetchInterval); - if (desire > now) - return; + foreach (var remote in remotes) + await new Commands.Fetch(FullPath, remote).Use(log).RunAsync(); + + _lastFetchTime = DateTime.Now; + } + catch + { + // Ignore all exceptions. + } - IsAutoFetching = true; - Dispatcher.UIThread.Invoke(() => OnPropertyChanged(nameof(IsAutoFetching))); - new Commands.Fetch(_fullpath, "--all", false, _settings.EnablePruneOnFetch, null) { RaiseError = false }.Exec(); - _lastFetchTime = DateTime.Now; IsAutoFetching = false; - Dispatcher.UIThread.Invoke(() => OnPropertyChanged(nameof(IsAutoFetching))); + log?.Complete(); } - private string _fullpath = string.Empty; - private string _gitDir = string.Empty; + private readonly string _gitCommonDir = null; private Models.RepositorySettings _settings = null; - private Models.FilterMode _historiesFilterMode = Models.FilterMode.None; + 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 int _localBranchesCount = 0; private int _localChangesCount = 0; private int _stashesCount = 0; - private bool _isSearching = false; - private bool _isSearchLoadingVisible = false; - private bool _isSearchCommitSuggestionOpen = false; - private int _searchCommitFilterType = 2; - private bool _onlySearchCommitsInCurrentBranch = false; - private bool _enableReflog = false; - private bool _enableFirstParentInHistories = false; - private string _searchCommitFilter = string.Empty; - private List _searchedCommits = new List(); - private List _revisionFiles = new List(); - - private bool _isLocalBranchGroupExpanded = true; - private bool _isRemoteGroupExpanded = false; - private bool _isTagGroupExpanded = false; - private bool _isSubmoduleGroupExpanded = false; - private bool _isWorktreeGroupExpanded = false; + private bool _isSearchingCommits = false; + private SearchCommitContext _searchCommitContext = null; private string _filter = string.Empty; - private List _remotes = new List(); - private List _branches = new List(); + private List _remotes = []; + private List _branches = []; private Models.Branch _currentBranch = null; - private List _localBranchTrees = new List(); - private List _remoteBranchTrees = new List(); - private List _worktrees = new List(); - private List _tags = new List(); - private List _visibleTags = new List(); - private List _submodules = new List(); - private List _visibleSubmodules = new List(); - - private bool _includeUntracked = true; - private Models.Commit _searchResultSelectedCommit = 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 620db0740..a76194716 100644 --- a/src/ViewModels/RepositoryConfigure.cs +++ b/src/ViewModels/RepositoryConfigure.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; + using Avalonia.Collections; using CommunityToolkit.Mvvm.ComponentModel; @@ -37,6 +39,19 @@ public string DefaultRemote } } + public int PreferredMergeMode + { + get => _repo.Settings.PreferredMergeMode; + set + { + if (_repo.Settings.PreferredMergeMode != value) + { + _repo.Settings.PreferredMergeMode = value; + OnPropertyChanged(); + } + } + } + public bool GPGCommitSigningEnabled { get; @@ -61,36 +76,35 @@ public string HttpProxy set => SetProperty(ref _httpProxy, value); } - public bool EnableSignOffForCommit + public string ConventionalTypesOverride { - get => _repo.Settings.EnableSignOffForCommit; - set => _repo.Settings.EnableSignOffForCommit = value; + get => _repo.Settings.ConventionalTypesOverride; + set + { + if (_repo.Settings.ConventionalTypesOverride != value) + { + _repo.Settings.ConventionalTypesOverride = value; + OnPropertyChanged(); + } + } } public bool EnablePruneOnFetch { - get => _repo.Settings.EnablePruneOnFetch; - set => _repo.Settings.EnablePruneOnFetch = value; + get; + set; } - public bool EnableAutoFetch + public bool AskBeforeAutoUpdatingSubmodules { - get => _repo.Settings.EnableAutoFetch; - set => _repo.Settings.EnableAutoFetch = value; + get => _repo.Settings.AskBeforeAutoUpdatingSubmodules; + set => _repo.Settings.AskBeforeAutoUpdatingSubmodules = value; } - public int? AutoFetchInterval + public bool EnableRecursiveWhenAutoUpdatingSubmodules { - get => _repo.Settings.AutoFetchInterval; - set - { - if (value is null || value < 1) - return; - - var interval = (int)value; - if (_repo.Settings.AutoFetchInterval != interval) - _repo.Settings.AutoFetchInterval = interval; - } + get => _repo.Settings.EnableRecursiveWhenAutoUpdatingSubmodules; + set => _repo.Settings.EnableRecursiveWhenAutoUpdatingSubmodules = value; } public AvaloniaList CommitTemplates @@ -104,15 +118,15 @@ public Models.CommitTemplate SelectedCommitTemplate set => SetProperty(ref _selectedCommitTemplate, value); } - public AvaloniaList IssueTrackerRules + public AvaloniaList IssueTrackers { - get => _repo.Settings.IssueTrackerRules; - } + get; + } = []; - public Models.IssueTrackerRule SelectedIssueTrackerRule + public Models.IssueTracker SelectedIssueTracker { - get => _selectedIssueTrackerRule; - set => SetProperty(ref _selectedIssueTrackerRule, value); + get => _selectedIssueTracker; + set => SetProperty(ref _selectedIssueTracker, value); } public List AvailableOpenAIServices @@ -121,10 +135,10 @@ public List AvailableOpenAIServices private set; } - public string PreferedOpenAIService + public string PreferredOpenAIService { - get => _repo.Settings.PreferedOpenAIService; - set => _repo.Settings.PreferedOpenAIService = value; + get => _repo.Settings.PreferredOpenAIService; + set => _repo.Settings.PreferredOpenAIService = value; } public AvaloniaList CustomActions @@ -147,13 +161,13 @@ public RepositoryConfigure(Repository repo) Remotes.Add(remote.Name); AvailableOpenAIServices = new List() { "---" }; - foreach (var service in Preference.Instance.OpenAIServices) + foreach (var service in Preferences.Instance.OpenAIServices) AvailableOpenAIServices.Add(service.Name); - if (AvailableOpenAIServices.IndexOf(PreferedOpenAIService) == -1) - PreferedOpenAIService = "---"; + if (!AvailableOpenAIServices.Contains(PreferredOpenAIService)) + PreferredOpenAIService = "---"; - _cached = new Commands.Config(repo.FullPath).ListAll(); + _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)) @@ -166,6 +180,19 @@ public RepositoryConfigure(Repository repo) 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() @@ -187,111 +214,144 @@ public void RemoveSelectedCommitTemplate() SelectedCommitTemplate = null; } - public void AddSampleGithubIssueTracker() + public List GetRemoteVisitUrls() { + var outs = new List(); foreach (var remote in _repo.Remotes) { - if (remote.URL.Contains("github.com", System.StringComparison.Ordinal)) - { - if (remote.TryGetVisitURL(out string url)) - { - SelectedIssueTrackerRule = _repo.Settings.AddGithubIssueTracker(url); - return; - } - } + if (remote.TryGetVisitURL(out var url)) + outs.Add(url); } - - SelectedIssueTrackerRule = _repo.Settings.AddGithubIssueTracker(null); + return outs; } - public void AddSampleJiraIssueTracker() + public void AddIssueTracker(string name, string regex, string url) { - SelectedIssueTrackerRule = _repo.Settings.AddJiraIssueTracker(); + var rule = new Models.IssueTracker() + { + IsShared = false, + Name = name, + RegexString = regex, + URLTemplate = url, + }; + + IssueTrackers.Add(rule); + SelectedIssueTracker = rule; } - public void AddSampleGitLabIssueTracker() + public void RemoveIssueTracker() { - foreach (var remote in _repo.Remotes) - { - if (remote.TryGetVisitURL(out string url)) - { - SelectedIssueTrackerRule = _repo.Settings.AddGitLabIssueTracker(url); - return; - } - } + if (_selectedIssueTracker is { } rule) + IssueTrackers.Remove(rule); - SelectedIssueTrackerRule = _repo.Settings.AddGitLabIssueTracker(null); + SelectedIssueTracker = null; } - public void AddSampleGitLabMergeRequestTracker() + public void AddNewCustomAction() { - foreach (var remote in _repo.Remotes) - { - if (remote.TryGetVisitURL(out string url)) - { - SelectedIssueTrackerRule = _repo.Settings.AddGitLabMergeRequestTracker(url); - return; - } - } - - SelectedIssueTrackerRule = _repo.Settings.AddGitLabMergeRequestTracker(null); + SelectedCustomAction = _repo.Settings.AddNewCustomAction(); } - public void NewIssueTracker() + public void RemoveSelectedCustomAction() { - SelectedIssueTrackerRule = _repo.Settings.AddNewIssueTracker(); + _repo.Settings.RemoveCustomAction(_selectedCustomAction); + SelectedCustomAction = null; } - public void RemoveSelectedIssueTracker() + public void MoveSelectedCustomActionUp() { - _repo.Settings.RemoveIssueTracker(_selectedIssueTrackerRule); - SelectedIssueTrackerRule = null; + if (_selectedCustomAction != null) + _repo.Settings.MoveCustomActionUp(_selectedCustomAction); } - public void AddNewCustomAction() + public void MoveSelectedCustomActionDown() { - SelectedCustomAction = _repo.Settings.AddNewCustomAction(); + if (_selectedCustomAction != null) + _repo.Settings.MoveCustomActionDown(_selectedCustomAction); } - public void RemoveSelectedCustomAction() + public async Task SaveAsync() { - _repo.Settings.RemoveCustomAction(_selectedCustomAction); - SelectedCustomAction = null; + _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(); } - public void Save() + private async Task SetIfChangedAsync(string key, string value, string defValue) { - SetIfChanged("user.name", UserName, ""); - SetIfChanged("user.email", UserEmail, ""); - SetIfChanged("commit.gpgsign", GPGCommitSigningEnabled ? "true" : "false", "false"); - SetIfChanged("tag.gpgsign", GPGTagSigningEnabled ? "true" : "false", "false"); - SetIfChanged("user.signingkey", GPGUserSigningKey, ""); - SetIfChanged("http.proxy", HttpProxy, ""); + if (value != _cached.GetValueOrDefault(key, defValue)) + await new Commands.Config(_repo.FullPath).SetAsync(key, value); } - private void SetIfChanged(string key, string value, string defValue) + private async Task ApplyIssueTrackerChangesAsync() { - bool changed = false; - if (_cached.TryGetValue(key, out var old)) + var changed = false; + var oldRules = new Dictionary(); + foreach (var rule in _repo.IssueTrackers) + oldRules.Add(rule.Name, rule); + + foreach (var rule in IssueTrackers) { - changed = old != value; + 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); + } } - else if (!string.IsNullOrEmpty(value) && value != defValue) + + 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); + _repo.IssueTrackers.Clear(); + _repo.IssueTrackers.AddRange(IssueTrackers); } } - private readonly Repository _repo = null; - private readonly Dictionary _cached = null; + private readonly Repository _repo; + private readonly Dictionary _cached; private string _httpProxy; private Models.CommitTemplate _selectedCommitTemplate = null; - private Models.IssueTrackerRule _selectedIssueTrackerRule = null; + private Models.IssueTracker _selectedIssueTracker = null; private Models.CustomAction _selectedCustomAction = null; } } diff --git a/src/ViewModels/RepositoryNode.cs b/src/ViewModels/RepositoryNode.cs index 7e121b363..9d7ab9deb 100644 --- a/src/ViewModels/RepositoryNode.cs +++ b/src/ViewModels/RepositoryNode.cs @@ -1,11 +1,20 @@ -using System.Collections.Generic; +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; + } + public class RepositoryNode : ObservableObject { public string Id @@ -13,7 +22,7 @@ public string Id get => _id; set { - var normalized = value.Replace('\\', '/'); + var normalized = value.Replace('\\', '/').TrimEnd('/'); SetProperty(ref _id, normalized); } } @@ -55,6 +64,13 @@ public bool IsInvalid get => _isRepository && !Directory.Exists(_id); } + [JsonIgnore] + public bool IsUnmanaged + { + get; + set; + } = false; + [JsonIgnore] public int Depth { @@ -62,42 +78,150 @@ public int Depth 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() { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new EditRepositoryNode(this)); + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new EditRepositoryNode(this); } public void AddSubFolder() { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateGroup(this)); + 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 OpenInFileManager() { - if (!IsRepository) - return; - Native.OS.OpenInFileManager(_id); + if (_isRepository && Directory.Exists(_id)) + Native.OS.OpenInFileManager(_id); } public void OpenTerminal() { - if (!IsRepository) - return; - Native.OS.OpenTerminal(_id); + if (_isRepository && Directory.Exists(_id)) + Native.OS.OpenTerminal(_id); } public void Delete() { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DeleteRepositoryNode(this)); + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new DeleteRepositoryNode(this); + } + + 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 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; @@ -106,5 +230,7 @@ public void Delete() private int _bookmark = 0; private bool _isExpanded = false; private bool _isVisible = true; + private Models.RepositoryStatus _status = null; + private DateTime _lastUpdateStatus = DateTime.UnixEpoch.ToLocalTime(); } } diff --git a/src/ViewModels/Reset.cs b/src/ViewModels/Reset.cs index 9500f40a4..11adf1ea7 100644 --- a/src/ViewModels/Reset.cs +++ b/src/ViewModels/Reset.cs @@ -7,13 +7,11 @@ public class Reset : Popup public Models.Branch Current { get; - private set; } public Models.Commit To { get; - private set; } public Models.ResetMode SelectedMode @@ -28,20 +26,24 @@ public Reset(Repository repo, Models.Branch current, Models.Commit to) Current = current; To = to; SelectedMode = Models.ResetMode.Supported[1]; - View = new Views.Reset() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + 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 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 bbe1d9e98..ea6f86d09 100644 --- a/src/ViewModels/Revert.cs +++ b/src/ViewModels/Revert.cs @@ -7,7 +7,6 @@ public class Revert : Popup public Models.Commit Target { get; - private set; } public bool AutoCommit @@ -21,20 +20,23 @@ public Revert(Repository repo, Models.Commit target) _repo = repo; Target = target; AutoCommit = true; - View = new Views.Revert() { DataContext = this }; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); + 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 readonly Repository _repo = null; diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs index ffc05643a..ef38668cc 100644 --- a/src/ViewModels/RevisionCompare.cs +++ b/src/ViewModels/RevisionCompare.cs @@ -3,15 +3,19 @@ using System.IO; using System.Threading.Tasks; -using Avalonia.Controls; 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 object StartPoint { get => _startPoint; @@ -24,9 +28,35 @@ public object EndPoint private set => SetProperty(ref _endPoint, value); } + public string LeftSideDesc + { + get => GetDesc(StartPoint); + } + + 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 => _canSaveAsPatch; + get => _startPoint != null && _endPoint != null; + } + + public int TotalChanges + { + get => _totalChanges; + private set => SetProperty(ref _totalChanges, value); } public List VisibleChanges @@ -42,10 +72,10 @@ public List SelectedChanges { if (SetProperty(ref _selectedChanges, value)) { - if (value != null && value.Count == 1) + if (value is { Count: 1 }) { var option = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), value[0]); - DiffContext = new DiffContext(_repo, option, _diffContext); + DiffContext = new DiffContext(_repo.FullPath, option, _diffContext); } else { @@ -61,9 +91,7 @@ public string SearchFilter set { if (SetProperty(ref _searchFilter, value)) - { RefreshVisible(); - } } } @@ -73,117 +101,227 @@ public DiffContext 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 = (object)startPoint ?? new Models.Null(); _endPoint = (object)endPoint ?? new Models.Null(); - _canSaveAsPatch = startPoint != null && endPoint != null; + Refresh(); + } + + public RevisionCompare Clone() + { + return new RevisionCompare(_repo, _startPoint as Models.Commit, _endPoint as Models.Commit); + } - Task.Run(Refresh); + public void SetTargets(Models.Commit l, Models.Commit r) + { + var hashes = new HashSet(); + hashes.Add(l.SHA); + hashes.Add(r.SHA); + + 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() + public void OpenChangeWithExternalDiffTool(Models.Change change) { - _repo = null; - _startPoint = null; - _endPoint = null; - if (_changes != null) - _changes.Clear(); - if (_visibleChanges != null) - _visibleChanges.Clear(); - if (_selectedChanges != null) - _selectedChanges.Clear(); - _searchFilter = null; - _diffContext = null; + var opt = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), change); + new Commands.DiffTool(_repo.FullPath, opt).Open(); } public void NavigateTo(string commitSHA) { - var repo = App.FindOpenedRepository(_repo); - repo?.NavigateToCommit(commitSHA); + _repo?.NavigateToCommit(commitSHA); } public void Swap() { (StartPoint, EndPoint) = (_endPoint, _startPoint); + VisibleChanges = []; SelectedChanges = []; - Task.Run(Refresh); + IsLoading = true; + Refresh(); } - public void SaveAsPatch(string saveTo) + public string GetAbsPath(string path) { - Task.Run(() => - { - var succ = Commands.SaveChangesAsPatch.ProcessRevisionCompareChanges(_repo, _changes, GetSHA(_startPoint), GetSHA(_endPoint), saveTo); - if (succ) - Dispatcher.UIThread.Invoke(() => App.SendNotification(_repo, App.Text("SaveAsPatchSuccess"))); - }); + return Native.OS.GetAbsPath(_repo.FullPath, path); } - public void ClearSearchFilter() + public async Task ResetToLeftAsync(Models.Change change) { - SearchFilter = string.Empty; + 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 ContextMenu CreateChangeContextMenu() + public async Task ResetToRightAsync(Models.Change change) { - if (_selectedChanges == null || _selectedChanges.Count != 1) - return null; + var sha = GetSHA(_endPoint); + var log = _repo.CreateLog($"Reset File to '{GetDesc(_endPoint)}'"); - var change = _selectedChanges[0]; - var menu = new ContextMenu(); - - var diffWithMerger = new MenuItem(); - diffWithMerger.Header = App.Text("DiffWithMerger"); - diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - diffWithMerger.Click += (_, ev) => + 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 opt = new Models.DiffOption(GetSHA(_startPoint), GetSHA(_endPoint), change); - var toolType = Preference.Instance.ExternalMergeToolType; - var toolPath = Preference.Instance.ExternalMergeToolPath; + 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); + } - Task.Run(() => Commands.MergeTool.OpenForDiff(_repo, toolType, toolPath, opt)); - ev.Handled = true; - }; - menu.Items.Add(diffWithMerger); + log.Complete(); + } - if (change.Index != Models.ChangeState.Deleted) + public async Task ResetMultipleToLeftAsync(List changes) + { + var sha = GetSHA(_startPoint); + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) { - var full = Path.GetFullPath(Path.Combine(_repo, change.Path)); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(full); - explore.Click += (_, ev) => + 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) { - Native.OS.OpenInFileManager(full, true); - ev.Handled = true; - }; - menu.Items.Add(explore); + 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 copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Click += (_, ev) => - { - App.CopyText(change.Path); - ev.Handled = true; - }; - menu.Items.Add(copyPath); - - var copyFileName = new MenuItem(); - copyFileName.Header = App.Text("CopyFileName"); - copyFileName.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFileName.Click += (_, e) => + 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 async Task ResetMultipleToRightAsync(List changes) + { + var sha = GetSHA(_endPoint); + var checkouts = new List(); + var removes = new List(); + + foreach (var c in changes) { - App.CopyText(Path.GetFileName(change.Path)); - e.Handled = true; - }; - menu.Items.Add(copyFileName); + 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 log = _repo.CreateLog($"Reset Files to '{GetDesc(_endPoint)}'"); + + if (removes.Count > 0) + await new Commands.Remove(_repo.FullPath, removes) + .Use(log) + .ExecAsync(); - return menu; + 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() @@ -210,20 +348,35 @@ private void RefreshVisible() private void Refresh() { - _changes = new Commands.CompareRevisions(_repo, GetSHA(_startPoint), GetSHA(_endPoint)).Result(); - - var visible = _changes; - if (!string.IsNullOrWhiteSpace(_searchFilter)) + Task.Run(async () => { - visible = []; - foreach (var c in _changes) + _changes = await new Commands.CompareRevisions(_repo.FullPath, GetSHA(_startPoint), GetSHA(_endPoint)) + .ReadAsync() + .ConfigureAwait(false); + + var visible = _changes; + if (!string.IsNullOrWhiteSpace(_searchFilter)) { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); + visible = []; + foreach (var c in _changes) + { + if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); + } } - } - Dispatcher.UIThread.Invoke(() => VisibleChanges = visible); + Dispatcher.UIThread.Post(() => + { + TotalChanges = _changes.Count; + VisibleChanges = visible; + IsLoading = false; + + if (VisibleChanges.Count > 0) + SelectedChanges = [VisibleChanges[0]]; + else + SelectedChanges = []; + }); + }); } private string GetSHA(object obj) @@ -231,10 +384,16 @@ private string GetSHA(object obj) return obj is Models.Commit commit ? commit.SHA : string.Empty; } - private string _repo; + private string GetDesc(object obj) + { + return obj is Models.Commit commit ? commit.GetFriendlyName() : App.Text("Worktree"); + } + + private Repository _repo; + private bool _isLoading = true; private object _startPoint = null; private object _endPoint = null; - private bool _canSaveAsPatch = false; + private int _totalChanges = 0; private List _changes = null; private List _visibleChanges = null; private List _selectedChanges = null; diff --git a/src/ViewModels/RevisionFileTreeNode.cs b/src/ViewModels/RevisionFileTreeNode.cs index 083e9d332..2f94d40d9 100644 --- a/src/ViewModels/RevisionFileTreeNode.cs +++ b/src/ViewModels/RevisionFileTreeNode.cs @@ -18,7 +18,7 @@ public string Name public bool IsFolder { - get => Backend != null && Backend.Type == Models.ObjectType.Tree; + get => Backend?.Type == Models.ObjectType.Tree; } public bool IsExpanded 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 955a0d387..000000000 --- a/src/ViewModels/Reword.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -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; - _oldMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, head.SHA).Result(); - _message = _oldMessage; - - Head = head; - View = new Views.Reword() { DataContext = this }; - } - - public override Task Sure() - { - if (string.Compare(_message, _oldMessage, StringComparison.Ordinal) == 0) - return null; - - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Editing head commit message ..."; - - return Task.Run(() => - { - var succ = new Commands.Commit(_repo.FullPath, _message, true, _repo.Settings.EnableSignOffForCommit).Run(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); - } - - private readonly Repository _repo; - private string _message; - private string _oldMessage; - } -} diff --git a/src/ViewModels/ScanRepositories.cs b/src/ViewModels/ScanRepositories.cs index 115edf2dd..f9ac3e41b 100644 --- a/src/ViewModels/ScanRepositories.cs +++ b/src/ViewModels/ScanRepositories.cs @@ -1,69 +1,121 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Threading.Tasks; -using Avalonia.Threading; namespace SourceGit.ViewModels { public class ScanRepositories : Popup { - public string RootDir + public bool UseCustomDir { - get; - private set; + get => _useCustomDir; + set => SetProperty(ref _useCustomDir, value); } - public ScanRepositories(string rootDir) + public string CustomDir { - GetManagedRepositories(Preference.Instance.RepositoryNodes, _managed); + get => _customDir; + set => SetProperty(ref _customDir, value); + } - RootDir = rootDir; - View = new Views.ScanRepositories() { DataContext = this }; + public List ScanDirs + { + get; } - public override Task Sure() + public Models.ScanDir Selected { - ProgressDescription = $"Scan repositories under '{RootDir}' ..."; + get => _selected; + set => SetProperty(ref _selected, value, true); + } - return Task.Run(() => - { - // If it is too fast, the panel will disappear very quickly, then we'll have a bad experience. - Task.Delay(500).Wait(); + public ScanRepositories() + { + ScanDirs = new List(); - var rootDir = new DirectoryInfo(RootDir); - var founded = new List(); - GetUnmanagedRepositories(rootDir, founded, new EnumerationOptions() - { - AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, - IgnoreInaccessible = true, - }); + 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")); - Dispatcher.UIThread.Invoke(() => + 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)) { - var normalizedRoot = rootDir.FullName.Replace("\\", "/"); + Models.Notification.Send(null, "Missing root directory to scan!", true); + return false; + } - foreach (var f in founded) - { - var parent = new DirectoryInfo(f).Parent!.FullName.Replace("\\", "/"); - if (parent.Equals(normalizedRoot, StringComparison.Ordinal)) - { - Preference.Instance.FindOrAddNodeByRepositoryPath(f, null, false); - } - else if (parent.StartsWith(normalizedRoot, StringComparison.Ordinal)) - { - var relative = parent.Substring(normalizedRoot.Length).TrimStart('/'); - var group = FindOrCreateGroupRecursive(Preference.Instance.RepositoryNodes, relative); - Preference.Instance.FindOrAddNodeByRepositoryPath(f, group, false); - } - } + selectedDir = _customDir; + } + else + { + if (_selected == null || string.IsNullOrEmpty(_selected.Path)) + { + Models.Notification.Send(null, "Missing root directory to scan!", true); + return false; + } - Preference.Instance.AutoRemoveInvalidNode(); - Welcome.Instance.Refresh(); - }); + 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) @@ -71,52 +123,63 @@ private void GetManagedRepositories(List group, HashSet foreach (var node in group) { if (node.IsRepository) - repos.Add(node.Id); + repos.Add(OperatingSystem.IsLinux() ? node.Id : node.Id.ToLower(CultureInfo.CurrentCulture)); else GetManagedRepositories(node.SubNodes, repos); } } - private void GetUnmanagedRepositories(DirectoryInfo dir, List outs, EnumerationOptions opts, int depth = 0) + private async Task GetUnmanagedRepositoriesAsync(DirectoryInfo dir, List outs, EnumerationOptions opts, int depth = 0) { var subdirs = dir.GetDirectories("*", opts); foreach (var subdir in subdirs) { - SetProgressDescription($"Scanning {subdir.FullName}..."); + if (subdir.Name.StartsWith(".", StringComparison.Ordinal) || + subdir.Name.Equals("node_modules", StringComparison.Ordinal)) + continue; - var normalizedSelf = subdir.FullName.Replace("\\", "/"); - if (_managed.Contains(normalizedSelf)) + 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 = new Commands.QueryRepositoryRootPath(subdir.FullName).ReadToEnd(); + var test = await new Commands.QueryRepositoryRootPath(subdir.FullName).GetResultAsync(); if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) { - var normalized = test.StdOut.Trim().Replace("\\", "/"); - if (!_managed.Contains(normalizedSelf)) + var normalized = test.StdOut.Trim().Replace('\\', '/').TrimEnd('/'); + if (!IsManaged(normalized)) outs.Add(normalized); - - continue; } + + continue; } - if (depth < 8) - GetUnmanagedRepositories(subdir, outs, opts, depth + 1); + 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) { - var idx = path.IndexOf('/'); - if (idx < 0) - return FindOrCreateGroup(collection, path); - - var name = path.Substring(0, idx); - var tail = path.Substring(idx + 1); - var parent = FindOrCreateGroup(collection, name); - return FindOrCreateGroupRecursive(parent.SubNodes, tail); + 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) @@ -135,17 +198,22 @@ private RepositoryNode FindOrCreateGroup(List collection, string IsExpanded = true, }; collection.Add(added); - collection.Sort((l, r) => - { - if (l.IsRepository != r.IsRepository) - return l.IsRepository ? 1 : -1; - - return string.Compare(l.Name, r.Name, StringComparison.Ordinal); - }); + Preferences.Instance.SortNodes(collection); return added; } - private HashSet _managed = new HashSet(); + 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/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 198dbe9ae..000000000 --- a/src/ViewModels/Squash.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; - -namespace SourceGit.ViewModels -{ - public class Squash : Popup - { - public Models.Commit Target - { - get => _target; - } - - [Required(ErrorMessage = "Commit message is required!!!")] - public string Message - { - get => _message; - set => SetProperty(ref _message, value, true); - } - - public Squash(Repository repo, Models.Commit target, string shaToGetPreferMessage) - { - _repo = repo; - _target = target; - _message = new Commands.QueryCommitFullMessage(_repo.FullPath, shaToGetPreferMessage).Result(); - - 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, Target.SHA, "--soft").Exec(); - if (succ) - succ = new Commands.Commit(_repo.FullPath, _message, true, _repo.Settings.EnableSignOffForCommit).Run(); - CallUIThread(() => _repo.SetWatcherEnabled(true)); - return succ; - }); - } - - private readonly Repository _repo; - private Models.Commit _target; - private string _message; - } -} diff --git a/src/ViewModels/StashChanges.cs b/src/ViewModels/StashChanges.cs index 1ad3a0da2..bde579b8a 100644 --- a/src/ViewModels/StashChanges.cs +++ b/src/ViewModels/StashChanges.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; namespace SourceGit.ViewModels @@ -13,67 +15,144 @@ public string Message public bool HasSelectedFiles { - get; + get => _changes != null; } public bool IncludeUntracked { - get => _repo.Settings.IncludeUntrackedWhenStash; - set => _repo.Settings.IncludeUntrackedWhenStash = value; + get => _repo.UIStates.IncludeUntrackedWhenStash; + set => _repo.UIStates.IncludeUntrackedWhenStash = value; } public bool OnlyStaged { - get => _repo.Settings.OnlyStagedWhenStash; - set => _repo.Settings.OnlyStagedWhenStash = value; + get => _repo.UIStates.OnlyStagedWhenStash; + set + { + if (_repo.UIStates.OnlyStagedWhenStash != value) + { + _repo.UIStates.OnlyStagedWhenStash = value; + OnPropertyChanged(); + } + } } - public bool KeepIndex + public int ChangesAfterStashing { - get => _repo.Settings.KeepIndexWhenStash; - set => _repo.Settings.KeepIndexWhenStash = value; + get => _repo.UIStates.ChangesAfterStashing; + set => _repo.UIStates.ChangesAfterStashing = value; } - public StashChanges(Repository repo, List changes, bool hasSelectedFiles) + public StashChanges(Repository repo, List selectedChanges) { _repo = repo; - _changes = changes; - HasSelectedFiles = hasSelectedFiles; - - View = new Views.StashChanges() { DataContext = this }; + _changes = selectedChanges; } - public override Task Sure() + public override async Task Sure() { - var jobs = _changes; - if (!HasSelectedFiles && !IncludeUntracked) + 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) { - jobs = new List(); - foreach (var job in _changes) + if (OnlyStaged) { - if (job.WorkTree != Models.ChangeState.Untracked && job.WorkTree != Models.ChangeState.Added) + if (Native.OS.GitVersion >= Models.GitVersions.STASH_PUSH_ONLY_STAGED) { - jobs.Add(job); + 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); + + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + _repo.MarkStashesDirtyManually(); + return succ; + } - _repo.SetWatcherEnabled(false); - ProgressDescription = $"Stash changes ..."; + 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); - return Task.Run(() => + 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 { - var succ = new Commands.Stash(_repo.FullPath).Push(jobs, Message, !HasSelectedFiles && OnlyStaged, KeepIndex); - CallUIThread(() => + for (int i = 0; i < changes.Count; i += 32) { - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); - }); - return succ; - }); + 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 readonly Repository _repo = null; diff --git a/src/ViewModels/StashesPage.cs b/src/ViewModels/StashesPage.cs index 1e35d6474..9f4874dba 100644 --- a/src/ViewModels/StashesPage.cs +++ b/src/ViewModels/StashesPage.cs @@ -3,9 +3,7 @@ using System.IO; using System.Threading.Tasks; -using Avalonia.Controls; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -52,13 +50,37 @@ public Models.Stash SelectedStash if (value == null) { Changes = null; + _untracked.Clear(); } else { - Task.Run(() => + Task.Run(async () => { - var changes = new Commands.QueryStashChanges(_repo.FullPath, value.SHA).Result(); - Dispatcher.UIThread.Invoke(() => Changes = changes); + 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; + } + }); }); } } @@ -71,21 +93,23 @@ public List Changes private set { if (SetProperty(ref _changes, value)) - SelectedChange = null; + SelectedChanges = value is { Count: > 0 } ? [value[0]] : []; } } - public Models.Change SelectedChange + public List SelectedChanges { - get => _selectedChange; + get => _selectedChanges; set { - if (SetProperty(ref _selectedChange, value)) + if (SetProperty(ref _selectedChanges, value)) { - if (value == null) + if (value is not { Count: 1 }) DiffContext = null; + 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.SHA}^", _selectedStash.SHA, value), _diffContext); + DiffContext = new DiffContext(_repo.FullPath, new Models.DiffOption(_selectedStash.Parents[0], _selectedStash.SHA, value[0]), _diffContext); } } } @@ -101,133 +125,134 @@ public StashesPage(Repository repo) _repo = repo; } - public void Cleanup() + public void ClearSearchFilter() { - _repo = null; - if (_stashes != null) - _stashes.Clear(); - _selectedStash = null; - if (_changes != null) - _changes.Clear(); - _selectedChange = null; - _diffContext = null; + SearchFilter = string.Empty; } - public ContextMenu MakeContextMenu(Models.Stash stash) + public string GetAbsPath(string path) { - if (stash == null) - return null; + return Native.OS.GetAbsPath(_repo.FullPath, path); + } - var apply = new MenuItem(); - apply.Header = App.Text("StashCM.Apply"); - apply.Click += (_, ev) => - { - Task.Run(() => new Commands.Stash(_repo.FullPath).Apply(stash.Name)); - ev.Handled = true; - }; + public void Apply(Models.Stash stash) + { + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new ApplyStash(_repo, stash)); + } - var pop = new MenuItem(); - pop.Header = App.Text("StashCM.Pop"); - pop.Click += (_, ev) => - { - Task.Run(() => new Commands.Stash(_repo.FullPath).Pop(stash.Name)); - ev.Handled = true; - }; + 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)); + } - var drop = new MenuItem(); - drop.Header = App.Text("StashCM.Drop"); - drop.Click += (_, ev) => + 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) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new DropStash(_repo.FullPath, stash)); + var untracked = await new Commands.CompareRevisions(_repo.FullPath, stash.UntrackedParent, stash.Parents[2]) + .ReadAsync() + .ConfigureAwait(false); - ev.Handled = true; - }; + foreach (var c in untracked) + opts.Add(new Models.DiffOption(stash.UntrackedParent, stash.Parents[2], c)); + + changes.AddRange(untracked); + } - var menu = new ContextMenu(); - menu.Items.Add(apply); - menu.Items.Add(pop); - menu.Items.Add(drop); - return menu; + var succ = await Commands.SaveChangesAsPatch.ProcessStashChangesAsync(_repo.FullPath, opts, saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); } - public ContextMenu MakeContextMenuForChange(Models.Change change) + public void OpenChangeWithExternalDiffTool(Models.Change change) { - if (change == null) - return null; + 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); - var diffWithMerger = new MenuItem(); - diffWithMerger.Header = App.Text("DiffWithMerger"); - diffWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - diffWithMerger.Click += (_, ev) => - { - var toolType = Preference.Instance.ExternalMergeToolType; - var toolPath = Preference.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption($"{_selectedStash.SHA}^", _selectedStash.SHA, change); - - Task.Run(() => Commands.MergeTool.OpenForDiff(_repo.FullPath, toolType, toolPath, opt)); - ev.Handled = true; - }; - - var fullPath = Path.Combine(_repo.FullPath, change.Path); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(fullPath); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(fullPath, true); - ev.Handled = true; - }; - - var resetToThisRevision = new MenuItem(); - resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); - resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout"); - resetToThisRevision.Click += (_, ev) => - { - new Commands.Checkout(_repo.FullPath).FileWithRevision(change.Path, $"{_selectedStash.SHA}"); - ev.Handled = true; - }; - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Click += (_, ev) => - { - App.CopyText(change.Path); - ev.Handled = true; - }; - - var copyFileName = new MenuItem(); - copyFileName.Header = App.Text("CopyFileName"); - copyFileName.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFileName.Click += (_, e) => - { - App.CopyText(Path.GetFileName(change.Path)); - e.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Items.Add(diffWithMerger); - menu.Items.Add(explore); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(resetToThisRevision); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(copyPath); - menu.Items.Add(copyFileName); - - return menu; + new Commands.DiffTool(_repo.FullPath, opt).Open(); } - public void Clear() + public async Task CheckoutFilesAsync(List changes) { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new ClearStashes(_repo)); + 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 ClearSearchFilter() + public async Task ApplySelectedChanges(List changes) { - SearchFilter = string.Empty; + 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); } private void RefreshVisible() @@ -250,12 +275,13 @@ private void RefreshVisible() } private Repository _repo = null; - private List _stashes = new List(); - private List _visibleStashes = new List(); + 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 6caa2d958..add2423e2 100644 --- a/src/ViewModels/Statistics.cs +++ b/src/ViewModels/Statistics.cs @@ -1,8 +1,6 @@ -using System.Threading.Tasks; - -using Avalonia.Media; +using System.Collections.Generic; +using System.Threading.Tasks; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -15,12 +13,28 @@ public bool IsLoading private set => SetProperty(ref _isLoading, value); } - public int SelectedIndex + public List Branches + { + get => _branches; + private set => SetProperty(ref _branches, value); + } + + public Models.Branch SelectedBranch { - get => _selectedIndex; + get => _selectedBranch; set { - if (SetProperty(ref _selectedIndex, value)) + if (SetProperty(ref _selectedBranch, value)) + LoadStatistics(); + } + } + + public Models.StatisticsMode ViewMode + { + get => _viewMode; + set + { + if (SetProperty(ref _viewMode, value)) RefreshReport(); } } @@ -31,31 +45,51 @@ public Models.StatisticsReport SelectedReport private set => SetProperty(ref _selectedReport, value); } - public uint SampleColor + public Models.StatisticsSamples Samples { - get => Preference.Instance.StatisticsSampleColor; - set - { - if (value != Preference.Instance.StatisticsSampleColor) - { - Preference.Instance.StatisticsSampleColor = value; - OnPropertyChanged(nameof(SampleBrush)); - _selectedReport?.ChangeColor(value); - } - } + get => _samples; + private set => SetProperty(ref _samples, value); } - public IBrush SampleBrush + public Statistics(string repo) { - get => new SolidColorBrush(SampleColor); + _repo = repo; + LoadBranches(); + LoadStatistics(); } - public Statistics(string repo) + public void ChangeAuthor(Models.StatisticsAuthor author) + { + if (SelectedReport == null) + return; + + Samples = SelectedReport.GetSamples(author); + } + + private void LoadBranches() { - Task.Run(() => + Task.Run(async () => { - var result = new Commands.Statistics(repo, Preference.Instance.MaxHistoryCommits).Result(); - Dispatcher.UIThread.Invoke(() => + 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(); @@ -69,20 +103,23 @@ private void RefreshReport() if (_data == null) return; - var report = _selectedIndex switch + SelectedReport = _viewMode switch { - 0 => _data.All, - 1 => _data.Month, + Models.StatisticsMode.All => _data.All, + Models.StatisticsMode.ThisMonth => _data.Month, _ => _data.Week, }; - report.ChangeColor(SampleColor); - SelectedReport = report; + Samples = SelectedReport.GetSamples(null); } + 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 index cecb41d52..05b04f5b1 100644 --- a/src/ViewModels/TagCollection.cs +++ b/src/ViewModels/TagCollection.cs @@ -1,25 +1,61 @@ -using System; +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; set; } + 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 object ToolTip + public bool IsSelected { - get => Tag?.Message; + get; + set; } - public bool IsFolder + public Models.FilterMode FilterMode { - get => Tag == null; + get => _filterMode; + set => SetProperty(ref _filterMode, value); + } + + public CornerRadius CornerRadius + { + get => _cornerRadius; + set => SetProperty(ref _cornerRadius, value); } public bool IsExpanded @@ -28,11 +64,17 @@ public bool 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; } @@ -41,16 +83,30 @@ public TagTreeNode(string path, bool isExpanded, int depth) FullPath = path; Depth = depth; IsExpanded = isExpanded; + Counter = 1; } - public static List Build(IList tags, HashSet expaneded) + 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('/', StringComparison.Ordinal); + var sepIdx = tag.Name.IndexOf('/'); if (sepIdx == -1) { nodes.Add(new TagTreeNode(tag, 0)); @@ -66,16 +122,17 @@ public static List Build(IList tags, HashSet ex if (folders.TryGetValue(folder, out var value)) { lastFolder = value; + lastFolder.Counter++; } else if (lastFolder == null) { - lastFolder = new TagTreeNode(folder, expaneded.Contains(folder), depth); + lastFolder = new TagTreeNode(folder, expanded.Contains(folder), depth); folders.Add(folder, lastFolder); InsertFolder(nodes, lastFolder); } else { - var cur = new TagTreeNode(folder, expaneded.Contains(folder), depth); + var cur = new TagTreeNode(folder, expanded.Contains(folder), depth); folders.Add(folder, cur); InsertFolder(lastFolder.Children, cur); lastFolder = cur; @@ -107,16 +164,103 @@ private static void InsertFolder(List collection, TagTreeNode subFo 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 AvaloniaList Tags + 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 @@ -132,5 +276,123 @@ 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 3fb1e63b3..000000000 --- a/src/ViewModels/TwoSideTextDiff.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Collections.Generic; - -using Avalonia; - -using CommunityToolkit.Mvvm.ComponentModel; - -namespace SourceGit.ViewModels -{ - public class TwoSideTextDiff : ObservableObject - { - public string File { get; set; } - public List Old { get; set; } = new List(); - public List New { get; set; } = new List(); - public int MaxLineNumber = 0; - - public Vector SyncScrollOffset - { - get => _syncScrollOffset; - set => SetProperty(ref _syncScrollOffset, value); - } - - public TwoSideTextDiff(Models.TextDiff diff, TwoSideTextDiff previous = null) - { - File = diff.File; - MaxLineNumber = diff.MaxLineNumber; - - foreach (var line in diff.Lines) - { - switch (line.Type) - { - case Models.TextDiffLineType.Added: - New.Add(line); - break; - case Models.TextDiffLineType.Deleted: - Old.Add(line); - break; - default: - FillEmptyLines(); - Old.Add(line); - New.Add(line); - break; - } - } - - FillEmptyLines(); - - if (previous != null && previous.File == File) - _syncScrollOffset = previous._syncScrollOffset; - } - - public void ConvertsToCombinedRange(Models.TextDiff combined, ref int startLine, ref int endLine, bool isOldSide) - { - endLine = Math.Min(endLine, combined.Lines.Count - 1); - - var oneSide = isOldSide ? Old : New; - var firstContentLine = -1; - for (int i = startLine; i <= endLine; i++) - { - var line = oneSide[i]; - if (line.Type != Models.TextDiffLineType.None) - { - firstContentLine = i; - break; - } - } - - if (firstContentLine < 0) - return; - - var endContentLine = -1; - for (int i = Math.Min(endLine, oneSide.Count - 1); i >= startLine; i--) - { - var line = oneSide[i]; - if (line.Type != Models.TextDiffLineType.None) - { - endContentLine = i; - break; - } - } - - if (endContentLine < 0) - return; - - var firstContent = oneSide[firstContentLine]; - var endContent = oneSide[endContentLine]; - startLine = combined.Lines.IndexOf(firstContent); - endLine = combined.Lines.IndexOf(endContent); - } - - private void FillEmptyLines() - { - if (Old.Count < New.Count) - { - int diff = New.Count - Old.Count; - for (int i = 0; i < diff; i++) - Old.Add(new Models.TextDiffLine()); - } - else if (Old.Count > New.Count) - { - int diff = Old.Count - New.Count; - for (int i = 0; i < diff; i++) - New.Add(new Models.TextDiffLine()); - } - } - - private Vector _syncScrollOffset = Vector.Zero; - } -} diff --git a/src/ViewModels/UpdateSubmodules.cs b/src/ViewModels/UpdateSubmodules.cs index d1c433e22..323a60b2d 100644 --- a/src/ViewModels/UpdateSubmodules.cs +++ b/src/ViewModels/UpdateSubmodules.cs @@ -5,13 +5,17 @@ namespace SourceGit.ViewModels { public class UpdateSubmodules : Popup { - public List Submodules + public bool HasPreSelectedSubmodule { get; - private set; - } = new List(); + } - public string SelectedSubmodule + public List Submodules + { + get => _repo.Submodules; + } + + public Models.Submodule SelectedSubmodule { get; set; @@ -23,6 +27,12 @@ public bool UpdateAll set => SetProperty(ref _updateAll, value); } + public bool IsEnableInitVisible + { + get; + set; + } = true; + public bool EnableInit { get; @@ -41,44 +51,55 @@ public bool EnableRemote set; } = false; - public UpdateSubmodules(Repository repo) + public UpdateSubmodules(Repository repo, Models.Submodule selected) { _repo = repo; - foreach (var submodule in _repo.Submodules) - Submodules.Add(submodule.Path); + 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; + } - SelectedSubmodule = Submodules.Count > 0 ? Submodules[0] : string.Empty; - View = new Views.UpdateSubmodules() { DataContext = this }; + EnableRecursive = _repo.Settings.EnableRecursiveWhenAutoUpdatingSubmodules; } - public override Task Sure() + public override async Task Sure() { - _repo.SetWatcherEnabled(false); - - string target = string.Empty; + var targets = new List(); if (_updateAll) { - ProgressDescription = "Updating submodules ..."; + foreach (var submodule in Submodules) + targets.Add(submodule.Path); } - else + else if (SelectedSubmodule != null) { - target = SelectedSubmodule; - ProgressDescription = $"Updating submodule {target} ..."; + targets.Add(SelectedSubmodule.Path); } - return Task.Run(() => - { - new Commands.Submodule(_repo.FullPath).Update( - target, - EnableInit, - EnableRecursive, - EnableRemote, - SetProgressDescription); - - CallUIThread(() => _repo.SetWatcherEnabled(true)); + 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; 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 04ffef875..49539292d 100644 --- a/src/ViewModels/Welcome.cs +++ b/src/ViewModels/Welcome.cs @@ -1,17 +1,17 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; +using System.Threading.Tasks; using Avalonia.Collections; -using Avalonia.Controls; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { public class Welcome : ObservableObject { - public static Welcome Instance => _instance; + public static Welcome Instance { get; } = new(); public AvaloniaList Rows { @@ -38,21 +38,38 @@ public void Refresh() { if (string.IsNullOrWhiteSpace(_searchFilter)) { - foreach (var node in Preference.Instance.RepositoryNodes) + foreach (var node in Preferences.Instance.RepositoryNodes) ResetVisibility(node); } else { - foreach (var node in Preference.Instance.RepositoryNodes) + foreach (var node in Preferences.Instance.RepositoryNodes) SetVisibilityBySearch(node); } var rows = new List(); - MakeTreeRows(rows, Preference.Instance.RepositoryNodes); + MakeTreeRows(rows, Preferences.Instance.RepositoryNodes); Rows.Clear(); Rows.AddRange(rows); } + 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; @@ -83,47 +100,88 @@ public void ToggleNodeIsExpanded(RepositoryNode node) } } - public void InitRepository(string path, RepositoryNode parent, string reason) + public async Task GetRepositoryRootAsync(string path) { - if (!Preference.Instance.IsGitConfigured()) + if (!Preferences.Instance.IsGitConfigured()) { - App.RaiseException(PopupHost.Active.GetId(), App.Text("NotConfigured")); - return; + 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; } - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Init(path, parent, reason)); + 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 (open) + node.Open(); } public void Clone() { - if (!Preference.Instance.IsGitConfigured()) + 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 Clone(activePage.Node.Id); + } + + public void OpenLocalRepository() + { + if (!Preferences.Instance.IsGitConfigured()) { - App.RaiseException(string.Empty, App.Text("NotConfigured")); + Models.Notification.Send(null, App.Text("NotConfigured"), true); return; } - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new Clone()); + 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")); + if (!Preferences.Instance.IsGitConfigured()) + Models.Notification.Send(null, App.Text("NotConfigured"), true); else Native.OS.OpenTerminal(null); } public void ScanDefaultCloneDir() { - var defaultCloneDir = Preference.Instance.GitDefaultCloneDir; - if (string.IsNullOrEmpty(defaultCloneDir)) - App.RaiseException(PopupHost.Active.GetId(), "The default clone dir haven't been configured!"); - else if (!Directory.Exists(defaultCloneDir)) - App.RaiseException(PopupHost.Active.GetId(), $"The default clone dir '{defaultCloneDir}' is not exists!"); - else if (PopupHost.CanCreatePopup()) - PopupHost.ShowAndStartPopup(new ScanRepositories(defaultCloneDir)); + 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() @@ -133,118 +191,50 @@ public void ClearSearchFilter() public void AddRootNode() { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new CreateGroup(null)); - } - - public void MoveNode(RepositoryNode from, RepositoryNode to) - { - Preference.Instance.MoveNode(from, to, true); - Refresh(); + var activePage = App.GetLauncher().ActivePage; + if (activePage != null && activePage.CanCreatePopup()) + activePage.Popup = new CreateGroup(null); } - public ContextMenu CreateContextMenu(RepositoryNode node) + public RepositoryNode FindNodeById(string id, RepositoryNode root = null) { - var menu = new ContextMenu(); - - if (!node.IsRepository && node.SubNodes.Count > 0) + var collection = (root == null) ? Preferences.Instance.RepositoryNodes : root.SubNodes; + foreach (var node in collection) { - var openAll = new MenuItem(); - openAll.Header = App.Text("Welcome.OpenAllInNode"); - openAll.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - openAll.Click += (_, e) => - { - OpenAllInNode(App.GetLauncer(), node); - e.Handled = true; - }; + if (node.Id.Equals(id, StringComparison.Ordinal)) + return node; - menu.Items.Add(openAll); - menu.Items.Add(new MenuItem() { Header = "-" }); + var sub = FindNodeById(id, node); + if (sub != null) + return sub; } - if (node.IsRepository) - { - var open = new MenuItem(); - open.Header = App.Text("Welcome.OpenOrInit"); - open.Icon = App.CreateMenuIcon("Icons.Folder.Open"); - open.Click += (_, e) => - { - App.GetLauncer()?.OpenRepositoryInTab(node, null); - e.Handled = true; - }; - - var explore = new MenuItem(); - explore.Header = App.Text("Repository.Explore"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.Click += (_, e) => - { - node.OpenInFileManager(); - e.Handled = true; - }; - - var terminal = new MenuItem(); - terminal.Header = App.Text("Repository.Terminal"); - terminal.Icon = App.CreateMenuIcon("Icons.Terminal"); - terminal.Click += (_, e) => - { - node.OpenTerminal(); - e.Handled = true; - }; - - menu.Items.Add(open); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(explore); - menu.Items.Add(terminal); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - else - { - var addSubFolder = new MenuItem(); - addSubFolder.Header = App.Text("Welcome.AddSubFolder"); - addSubFolder.Icon = App.CreateMenuIcon("Icons.Folder.Add"); - addSubFolder.Click += (_, e) => - { - node.AddSubFolder(); - e.Handled = true; - }; - menu.Items.Add(addSubFolder); - } - - var edit = new MenuItem(); - edit.Header = App.Text("Welcome.Edit"); - edit.Icon = App.CreateMenuIcon("Icons.Edit"); - edit.Click += (_, e) => - { - node.Edit(); - e.Handled = true; - }; - - var move = new MenuItem(); - move.Header = App.Text("Welcome.Move"); - move.Icon = App.CreateMenuIcon("Icons.MoveToAnotherGroup"); - move.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new MoveRepositoryNode(node)); + return null; + } - e.Handled = true; - }; + public RepositoryNode FindParentGroup(RepositoryNode node, RepositoryNode group = null) + { + var collection = (group == null) ? Preferences.Instance.RepositoryNodes : group.SubNodes; + if (collection.Contains(node)) + return group; - var delete = new MenuItem(); - delete.Header = App.Text("Welcome.Delete"); - delete.Icon = App.CreateMenuIcon("Icons.Clear"); - delete.Click += (_, e) => + foreach (var item in collection) { - node.Delete(); - e.Handled = true; - }; + if (!item.IsRepository) + { + var parent = FindParentGroup(node, item); + if (parent != null) + return parent; + } + } - menu.Items.Add(edit); - menu.Items.Add(move); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(delete); + return null; + } - return menu; + public void MoveNode(RepositoryNode from, RepositoryNode to) + { + Preferences.Instance.MoveNode(from, to, true); + Refresh(); } private void ResetVisibility(RepositoryNode node) @@ -299,18 +289,7 @@ private void MakeTreeRows(List rows, List nodes, } } - private void OpenAllInNode(Launcher launcher, RepositoryNode node) - { - foreach (var subNode in node.SubNodes) - { - if (subNode.IsRepository) - launcher.OpenRepositoryInTab(subNode, null); - else if (subNode.SubNodes.Count > 0) - OpenAllInNode(launcher, subNode); - } - } - - private static Welcome _instance = new Welcome(); private string _searchFilter = string.Empty; + private bool _isUpdatingStatus = false; } } diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs index 4a6e5a014..c0f273529 100644 --- a/src/ViewModels/WorkingCopy.cs +++ b/src/ViewModels/WorkingCopy.cs @@ -2,37 +2,17 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; - -using Avalonia.Controls; -using Avalonia.Platform.Storage; -using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { - public class ConflictContext : ObservableObject + public class WorkingCopy : ObservableObject { - public bool IsResolved + public Repository Repository { - get => _isResolved; - set => SetProperty(ref _isResolved, value); - } - - public ConflictContext(string repo, Models.Change change) - { - Task.Run(() => - { - var result = new Commands.IsConflictResolved(repo, change).ReadToEnd().IsSuccess; - Dispatcher.UIThread.Post(() => IsResolved = result); - }); + get => _repo; } - private bool _isResolved = false; - } - - public class WorkingCopy : ObservableObject - { public bool IncludeUntracked { get => _repo.IncludeUntracked; @@ -46,14 +26,10 @@ public bool IncludeUntracked } } - public bool CanCommitWithPush + public bool HasRemotes { - get => _canCommitWithPush; - set - { - if (SetProperty(ref _canCommitWithPush, value)) - OnPropertyChanged(nameof(IsCommitWithPushVisible)); - } + get => _hasRemotes; + set => SetProperty(ref _hasRemotes, value); } public bool HasUnsolvedConflicts @@ -62,6 +38,12 @@ public bool HasUnsolvedConflicts set => SetProperty(ref _hasUnsolvedConflicts, value); } + public bool CanSwitchBranchDirectly + { + get; + set; + } = true; + public InProgressContext InProgressContext { get => _inProgressContext; @@ -86,6 +68,18 @@ public bool IsCommitting private set => SetProperty(ref _isCommitting, value); } + public bool EnableSignOff + { + get => _repo.UIStates.EnableSignOffForCommit; + set => _repo.UIStates.EnableSignOffForCommit = value; + } + + public bool NoVerifyOnCommit + { + get => _repo.UIStates.NoVerifyOnCommit; + set => _repo.UIStates.NoVerifyOnCommit = value; + } + public bool UseAmend { get => _useAmend; @@ -98,25 +92,48 @@ public bool UseAmend var currentBranch = _repo.CurrentBranch; if (currentBranch == null) { - App.RaiseException(_repo.FullPath, "No commits to amend!!!"); + _repo.SendNotification("No commits to amend!!!", true); _useAmend = false; OnPropertyChanged(); return; } - CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, currentBranch.Head).Result(); + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, currentBranch.Head).GetResult(); + } + else + { + CommitMessage = string.Empty; + ResetAuthor = false; } - Staged = GetStagedChanges(); + Staged = GetStagedChanges(_cached); + VisibleStaged = GetVisibleChanges(_staged); SelectedStaged = []; - OnPropertyChanged(nameof(IsCommitWithPushVisible)); } } } - public bool IsCommitWithPushVisible + public bool ResetAuthor + { + get => _resetAuthor; + set => SetProperty(ref _resetAuthor, value); + } + + public string Filter { - get => !UseAmend && CanCommitWithPush; + get => _filter; + set + { + if (SetProperty(ref _filter, value)) + { + if (_isLoadingData) + return; + + VisibleUnstaged = GetVisibleChanges(_unstaged); + VisibleStaged = GetVisibleChanges(_staged); + SelectedUnstaged = []; + } + } } public List Unstaged @@ -125,12 +142,24 @@ public List Unstaged private set => SetProperty(ref _unstaged, value); } + public List VisibleUnstaged + { + get => _visibleUnstaged; + private set => SetProperty(ref _visibleUnstaged, value); + } + public List Staged { get => _staged; private set => SetProperty(ref _staged, value); } + public List VisibleStaged + { + get => _visibleStaged; + private set => SetProperty(ref _visibleStaged, value); + } + public List SelectedUnstaged { get => _selectedUnstaged; @@ -145,7 +174,7 @@ public List SelectedUnstaged } else { - if (_selectedStaged != null && _selectedStaged.Count > 0) + if (_selectedStaged is { Count: > 0 }) SelectedStaged = []; if (value.Count == 1) @@ -171,7 +200,7 @@ public List SelectedStaged } else { - if (_selectedUnstaged != null && _selectedUnstaged.Count > 0) + if (_selectedUnstaged is { Count: > 0 }) SelectedUnstaged = []; if (value.Count == 1) @@ -200,1310 +229,566 @@ public WorkingCopy(Repository repo) _repo = repo; } - public void Cleanup() - { - _repo = null; - _inProgressContext = null; - - _selectedUnstaged.Clear(); - OnPropertyChanged(nameof(SelectedUnstaged)); - - _selectedStaged.Clear(); - OnPropertyChanged(nameof(SelectedStaged)); - - _unstaged.Clear(); - OnPropertyChanged(nameof(Unstaged)); - - _staged.Clear(); - OnPropertyChanged(nameof(Staged)); - - _detailContext = null; - _commitMessage = string.Empty; - } - public void SetData(List changes) { if (!IsChanged(_cached, changes)) { - // Just force refresh selected changes. - Dispatcher.UIThread.Invoke(() => - { - if (_selectedUnstaged.Count == 1) - SetDetail(_selectedUnstaged[0], true); - else if (_selectedStaged.Count == 1) - SetDetail(_selectedStaged[0], false); - else - SetDetail(null, false); - - var inProgress = null as InProgressContext; - if (File.Exists(Path.Combine(_repo.GitDir, "CHERRY_PICK_HEAD"))) - inProgress = new CherryPickInProgress(_repo.FullPath); - else if (File.Exists(Path.Combine(_repo.GitDir, "REBASE_HEAD")) && Directory.Exists(Path.Combine(_repo.GitDir, "rebase-merge"))) - inProgress = new RebaseInProgress(_repo); - else if (File.Exists(Path.Combine(_repo.GitDir, "REVERT_HEAD"))) - inProgress = new RevertInProgress(_repo.FullPath); - else if (File.Exists(Path.Combine(_repo.GitDir, "MERGE_HEAD"))) - inProgress = new MergeInProgress(_repo.FullPath); - - HasUnsolvedConflicts = _cached.Find(x => x.IsConflit) != null; - InProgressContext = inProgress; - }); - + HasUnsolvedConflicts = _cached.Find(x => x.IsConflicted) != null; + UpdateInProgressState(); + UpdateDetail(); return; } - _cached = changes; - _count = _cached.Count; - var lastSelectedUnstaged = new HashSet(); - var lastSelectedStaged = new HashSet(); - if (_selectedUnstaged != null && _selectedUnstaged.Count > 0) + if (_selectedUnstaged is { Count: > 0 }) { foreach (var c in _selectedUnstaged) lastSelectedUnstaged.Add(c.Path); } - else if (_selectedStaged != null && _selectedStaged.Count > 0) - { - foreach (var c in _selectedStaged) - lastSelectedStaged.Add(c.Path); - } var unstaged = new List(); + var visibleUnstaged = new List(); var selectedUnstaged = new List(); + var noFilter = string.IsNullOrEmpty(_filter); var hasConflict = false; + var canSwitchDirectly = true; foreach (var c in changes) { if (c.WorkTree != Models.ChangeState.None) { unstaged.Add(c); - hasConflict |= c.IsConflit; + hasConflict |= c.IsConflicted; - if (lastSelectedUnstaged.Contains(c.Path)) - selectedUnstaged.Add(c); + if (noFilter || c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + { + visibleUnstaged.Add(c); + if (lastSelectedUnstaged.Contains(c.Path)) + selectedUnstaged.Add(c); + } } + + if (!canSwitchDirectly) + continue; + + if (c.WorkTree == Models.ChangeState.Untracked || c.Index == Models.ChangeState.Added) + continue; + + canSwitchDirectly = false; } - var staged = GetStagedChanges(); + var staged = GetStagedChanges(changes); + var visibleStaged = GetVisibleChanges(staged); var selectedStaged = new List(); - foreach (var c in staged) + if (_selectedStaged is { Count: > 0 }) { - if (lastSelectedStaged.Contains(c.Path)) - selectedStaged.Add(c); - } + var set = new HashSet(); + foreach (var c in _selectedStaged) + set.Add(c.Path); - Dispatcher.UIThread.Invoke(() => - { - _isLoadingData = true; - HasUnsolvedConflicts = hasConflict; - Unstaged = unstaged; - Staged = staged; - SelectedUnstaged = selectedUnstaged; - SelectedStaged = selectedStaged; - _isLoadingData = false; - - if (selectedUnstaged.Count == 1) - SetDetail(selectedUnstaged[0], true); - else if (selectedStaged.Count == 1) - SetDetail(selectedStaged[0], false); - else - SetDetail(null, false); - - var inProgress = null as InProgressContext; - if (File.Exists(Path.Combine(_repo.GitDir, "CHERRY_PICK_HEAD"))) - inProgress = new CherryPickInProgress(_repo.FullPath); - else if (File.Exists(Path.Combine(_repo.GitDir, "REBASE_HEAD")) && Directory.Exists(Path.Combine(_repo.GitDir, "rebase-merge"))) - inProgress = new RebaseInProgress(_repo); - else if (File.Exists(Path.Combine(_repo.GitDir, "REVERT_HEAD"))) - inProgress = new RevertInProgress(_repo.FullPath); - else if (File.Exists(Path.Combine(_repo.GitDir, "MERGE_HEAD"))) - inProgress = new MergeInProgress(_repo.FullPath); - - InProgressContext = inProgress; - - // Try to load merge message from MERGE_MSG - if (string.IsNullOrEmpty(_commitMessage)) + foreach (var c in visibleStaged) { - var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); - if (File.Exists(mergeMsgFile)) - CommitMessage = File.ReadAllText(mergeMsgFile); + if (set.Contains(c.Path)) + selectedStaged.Add(c); } - }); - } + } - public void OpenAssumeUnchanged() - { - App.OpenDialog(new Views.AssumeUnchangedManager() + if (selectedUnstaged.Count == 0 && selectedStaged.Count == 0 && hasConflict) { - DataContext = new AssumeUnchangedManager(_repo.FullPath) - }); - } - - public void StashAll(bool autoStart) - { - if (!PopupHost.CanCreatePopup()) - return; - - if (autoStart) - PopupHost.ShowAndStartPopup(new StashChanges(_repo, _cached, false)); - else - PopupHost.ShowPopup(new StashChanges(_repo, _cached, false)); - } + var firstConflict = visibleUnstaged.Find(x => x.IsConflicted); + selectedUnstaged.Add(firstConflict); + } - public void StageSelected(Models.Change next) - { - StageChanges(_selectedUnstaged, next); - } + _isLoadingData = true; + _cached = changes; + HasUnsolvedConflicts = hasConflict; + CanSwitchBranchDirectly = canSwitchDirectly; + VisibleUnstaged = visibleUnstaged; + VisibleStaged = visibleStaged; + Unstaged = unstaged; + Staged = staged; + SelectedUnstaged = selectedUnstaged; + SelectedStaged = selectedStaged; + _isLoadingData = false; - public void StageAll() - { - StageChanges(_unstaged, null); + UpdateInProgressState(); + UpdateDetail(); } - public async void StageChanges(List changes, Models.Change next) + public async Task StageChangesAsync(List changes, Models.Change next) { - if (_unstaged.Count == 0 || changes.Count == 0) + var canStaged = await GetCanStageChangesAsync(changes); + var count = canStaged.Count; + if (count == 0) return; - // Use `_selectedUnstaged` instead of `SelectedUnstaged` to avoid UI refresh. + IsStaging = true; _selectedUnstaged = next != null ? [next] : []; - IsStaging = true; - _repo.SetWatcherEnabled(false); - if (changes.Count == _unstaged.Count) + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog("Stage"); + var pathSpecFile = Path.GetTempFileName(); + await using (var writer = new StreamWriter(pathSpecFile)) { - await Task.Run(() => new Commands.Add(_repo.FullPath, _repo.IncludeUntracked).Exec()); + foreach (var c in canStaged) + await writer.WriteLineAsync(c.Path); } - 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()); - } - } - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); - IsStaging = false; - } - public void UnstageSelected(Models.Change next) - { - UnstageChanges(_selectedStaged, next); - } + await new Commands.Add(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); + log.Complete(); - public void UnstageAll() - { - UnstageChanges(_staged, null); + _repo.MarkWorkingCopyDirtyManually(); + IsStaging = false; } - public async void UnstageChanges(List changes, Models.Change next) + public async Task UnstageChangesAsync(List changes, Models.Change next) { - if (_staged.Count == 0 || changes.Count == 0) + var count = changes.Count; + if (count == 0) return; - // Use `_selectedStaged` instead of `SelectedStaged` to avoid UI refresh. + IsUnstaging = true; _selectedStaged = next != null ? [next] : []; - IsUnstaging = true; - _repo.SetWatcherEnabled(false); + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog("Unstage"); if (_useAmend) { - await Task.Run(() => new Commands.UnstageChangesForAmend(_repo.FullPath, changes).Exec()); - } - else if (changes.Count == _staged.Count) - { - await Task.Run(() => new Commands.Reset(_repo.FullPath).Exec()); + log.AppendLine("$ git update-index --index-info "); + await new Commands.UpdateIndexInfo(_repo.FullPath, changes).ExecAsync(); } else { - for (int i = 0; i < changes.Count; i += 10) + var pathSpecFile = Path.GetTempFileName(); + await using (var writer = new StreamWriter(pathSpecFile)) { - var count = Math.Min(10, changes.Count - i); - var step = changes.GetRange(i, count); - await Task.Run(() => new Commands.Reset(_repo.FullPath, step).Exec()); - } - } - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); - IsUnstaging = false; - } - - public void Discard(List changes) - { - if (PopupHost.CanCreatePopup()) - { - if (changes.Count == _unstaged.Count && _staged.Count == 0) - PopupHost.ShowPopup(new Discard(_repo)); - else - PopupHost.ShowPopup(new Discard(_repo, changes)); - } - } - - public void ContinueMerge() - { - if (_inProgressContext != null) - { - _repo.SetWatcherEnabled(false); - Task.Run(() => - { - var succ = _inProgressContext.Continue(); - Dispatcher.UIThread.Invoke(() => + foreach (var c in changes) { - if (succ) - CommitMessage = string.Empty; + await writer.WriteLineAsync(c.Path); + if (c.Index == Models.ChangeState.Renamed) + await writer.WriteLineAsync(c.OriginalPath); + } + } - _repo.SetWatcherEnabled(true); - }); - }); - } - else - { - _repo.MarkWorkingCopyDirtyManually(); + await new Commands.Reset(_repo.FullPath, pathSpecFile).Use(log).ExecAsync(); + File.Delete(pathSpecFile); } - } - - public void AbortMerge() - { - if (_inProgressContext != null) - { - _repo.SetWatcherEnabled(false); - Task.Run(() => - { - var succ = _inProgressContext.Abort(); - Dispatcher.UIThread.Invoke(() => - { - if (succ) - CommitMessage = string.Empty; + log.Complete(); - _repo.SetWatcherEnabled(true); - }); - }); - } - else - { - _repo.MarkWorkingCopyDirtyManually(); - } + _repo.MarkWorkingCopyDirtyManually(); + IsUnstaging = false; } - public void Commit() + public async Task SaveChangesToPatchAsync(List changes, bool isUnstaged, string saveTo) { - DoCommit(false, false, false); + var succ = await Commands.SaveChangesAsPatch.ProcessLocalChangesAsync(_repo.FullPath, changes, isUnstaged, saveTo); + if (succ) + _repo.SendNotification(App.Text("SaveAsPatchSuccess")); } - public void CommitWithAutoStage() + public void Discard(List changes) { - DoCommit(true, false, false); + if (_repo.CanCreatePopup()) + _repo.ShowPopup(new Discard(_repo, changes)); } - public void CommitWithPush() + public void ClearFilter() { - DoCommit(false, true, false); + Filter = string.Empty; } - public void CommitWithoutFiles(bool autoPush) + public async Task UseTheirsAsync(List changes) { - DoCommit(false, autoPush, true); - } + using var lockWatcher = _repo.LockWatcher(); - public ContextMenu CreateContextMenuForUnstagedChanges() - { - if (_selectedUnstaged == null || _selectedUnstaged.Count == 0) - return null; + var files = new List(); + var needStage = new List(); + var log = _repo.CreateLog("Use Theirs"); - var menu = new ContextMenu(); - if (_selectedUnstaged.Count == 1) + foreach (var change in changes) { - var change = _selectedUnstaged[0]; - var path = Path.GetFullPath(Path.Combine(_repo.FullPath, change.Path)); - - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(path) || Directory.Exists(path); - explore.Click += (_, e) => - { - Native.OS.OpenInFileManager(path, true); - e.Handled = true; - }; - menu.Items.Add(explore); - - var openWith = new MenuItem(); - openWith.Header = App.Text("OpenWith"); - openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWith.IsEnabled = File.Exists(path); - openWith.Click += (_, e) => - { - Native.OS.OpenWithDefaultEditor(path); - e.Handled = true; - }; - menu.Items.Add(openWith); - menu.Items.Add(new MenuItem() { Header = "-" }); + if (!change.IsConflicted) + continue; - if (change.IsConflit) + if (change.ConflictReason is Models.ConflictReason.BothDeleted or Models.ConflictReason.DeletedByThem or Models.ConflictReason.AddedByUs) { - var useTheirs = new MenuItem(); - useTheirs.Icon = App.CreateMenuIcon("Icons.Incoming"); - useTheirs.Header = App.Text("FileCM.UseTheirs"); - useTheirs.Click += (_, e) => - { - UseTheirs(_selectedUnstaged); - e.Handled = true; - }; - - var useMine = new MenuItem(); - useMine.Icon = App.CreateMenuIcon("Icons.Local"); - useMine.Header = App.Text("FileCM.UseMine"); - useMine.Click += (_, e) => - { - UseMine(_selectedUnstaged); - e.Handled = true; - }; - - var openMerger = new MenuItem(); - openMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openMerger.Header = App.Text("FileCM.OpenWithExternalMerger"); - openMerger.Click += (_, e) => - { - UseExternalMergeTool(change); - e.Handled = true; - }; - - menu.Items.Add(useTheirs); - menu.Items.Add(useMine); - menu.Items.Add(openMerger); - menu.Items.Add(new MenuItem() { Header = "-" }); + var fullpath = Path.Combine(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + File.Delete(fullpath); + + needStage.Add(change.Path); } else { - var stage = new MenuItem(); - stage.Header = App.Text("FileCM.Stage"); - stage.Icon = App.CreateMenuIcon("Icons.File.Add"); - stage.Click += (_, e) => - { - StageChanges(_selectedUnstaged, null); - e.Handled = true; - }; - - var discard = new MenuItem(); - discard.Header = App.Text("FileCM.Discard"); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.Click += (_, e) => - { - Discard(_selectedUnstaged); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.Stash"); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new StashChanges(_repo, _selectedUnstaged, true)); - - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedUnstaged, true, storageFile.Path.LocalPath)); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; - - var assumeUnchanged = new MenuItem(); - assumeUnchanged.Header = App.Text("FileCM.AssumeUnchanged"); - assumeUnchanged.Icon = App.CreateMenuIcon("Icons.File.Ignore"); - assumeUnchanged.IsVisible = change.WorkTree != Models.ChangeState.Untracked; - assumeUnchanged.Click += (_, e) => - { - new Commands.AssumeUnchanged(_repo.FullPath).Add(change.Path); - 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, change.Path) }; - window.Show(); - e.Handled = true; - }; - - menu.Items.Add(stage); - menu.Items.Add(discard); - menu.Items.Add(stash); - menu.Items.Add(patch); - menu.Items.Add(assumeUnchanged); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var extension = Path.GetExtension(change.Path); - var hasExtra = false; - if (change.WorkTree == Models.ChangeState.Untracked) - { - var isRooted = change.Path.IndexOf('/', StringComparison.Ordinal) <= 0; - var addToIgnore = new MenuItem(); - addToIgnore.Header = App.Text("WorkingCopy.AddToGitIgnore"); - addToIgnore.Icon = App.CreateMenuIcon("Icons.GitIgnore"); - - var singleFile = new MenuItem(); - singleFile.Header = App.Text("WorkingCopy.AddToGitIgnore.SingleFile"); - singleFile.Click += (_, e) => - { - Commands.GitIgnore.Add(_repo.FullPath, change.Path); - e.Handled = true; - }; - addToIgnore.Items.Add(singleFile); - - var byParentFolder = new MenuItem(); - byParentFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.InSameFolder"); - byParentFolder.IsVisible = !isRooted; - byParentFolder.Click += (_, e) => - { - var path = Path.GetDirectoryName(change.Path).Replace("\\", "/"); - Commands.GitIgnore.Add(_repo.FullPath, path + "/"); - e.Handled = true; - }; - addToIgnore.Items.Add(byParentFolder); - - if (!string.IsNullOrEmpty(extension)) - { - var byExtension = new MenuItem(); - byExtension.Header = App.Text("WorkingCopy.AddToGitIgnore.Extension", extension); - byExtension.Click += (_, e) => - { - Commands.GitIgnore.Add(_repo.FullPath, "*" + extension); - e.Handled = true; - }; - addToIgnore.Items.Add(byExtension); - - var byExtensionInSameFolder = new MenuItem(); - byExtensionInSameFolder.Header = App.Text("WorkingCopy.AddToGitIgnore.ExtensionInSameFolder", extension); - byExtensionInSameFolder.IsVisible = !isRooted; - byExtensionInSameFolder.Click += (_, e) => - { - var path = Path.GetDirectoryName(change.Path).Replace("\\", "/"); - Commands.GitIgnore.Add(_repo.FullPath, path + "/*" + extension); - e.Handled = true; - }; - addToIgnore.Items.Add(byExtensionInSameFolder); - } - - menu.Items.Add(addToIgnore); - hasExtra = true; - } - - var lfsEnabled = new Commands.LFS(_repo.FullPath).IsEnabled(); - if (lfsEnabled) - { - var lfs = new MenuItem(); - lfs.Header = App.Text("GitLFS"); - lfs.Icon = App.CreateMenuIcon("Icons.LFS"); - - var isLFSFiltered = new Commands.IsLFSFiltered(_repo.FullPath, change.Path).Result(); - if (!isLFSFiltered) - { - var filename = Path.GetFileName(change.Path); - var lfsTrackThisFile = new MenuItem(); - lfsTrackThisFile.Header = App.Text("GitLFS.Track", filename); - lfsTrackThisFile.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Track(filename, true)); - if (succ) - App.SendNotification(_repo.FullPath, $"Tracking file named {filename} successfully!"); - - e.Handled = true; - }; - lfs.Items.Add(lfsTrackThisFile); - - if (!string.IsNullOrEmpty(extension)) - { - var lfsTrackByExtension = new MenuItem(); - lfsTrackByExtension.Header = App.Text("GitLFS.TrackByExtension", extension); - lfsTrackByExtension.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Track("*" + extension)); - if (succ) - App.SendNotification(_repo.FullPath, $"Tracking all *{extension} files successfully!"); - - e.Handled = true; - }; - lfs.Items.Add(lfsTrackByExtension); - } - - lfs.Items.Add(new MenuItem() { Header = "-" }); - } - - var lfsLock = new MenuItem(); - lfsLock.Header = App.Text("GitLFS.Locks.Lock"); - lfsLock.Icon = App.CreateMenuIcon("Icons.Lock"); - lfsLock.IsEnabled = _repo.Remotes.Count > 0; - if (_repo.Remotes.Count == 1) - { - lfsLock.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(_repo.Remotes[0].Name, change.Path)); - if (succ) - App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); - - e.Handled = true; - }; - } - else - { - foreach (var remote in _repo.Remotes) - { - var remoteName = remote.Name; - var lockRemote = new MenuItem(); - lockRemote.Header = remoteName; - lockRemote.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(remoteName, change.Path)); - if (succ) - App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); - - e.Handled = true; - }; - lfsLock.Items.Add(lockRemote); - } - } - lfs.Items.Add(lfsLock); - - var lfsUnlock = new MenuItem(); - lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); - lfsUnlock.Icon = App.CreateMenuIcon("Icons.Unlock"); - lfsUnlock.IsEnabled = _repo.Remotes.Count > 0; - if (_repo.Remotes.Count == 1) - { - lfsUnlock.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(_repo.Remotes[0].Name, change.Path, false)); - if (succ) - App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); - - e.Handled = true; - }; - } - else - { - foreach (var remote in _repo.Remotes) - { - var remoteName = remote.Name; - var unlockRemote = new MenuItem(); - unlockRemote.Header = remoteName; - unlockRemote.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(remoteName, change.Path, false)); - if (succ) - App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); - - e.Handled = true; - }; - lfsUnlock.Items.Add(unlockRemote); - } - } - lfs.Items.Add(lfsUnlock); - - menu.Items.Add(lfs); - hasExtra = true; - } - - if (hasExtra) - menu.Items.Add(new MenuItem() { Header = "-" }); + files.Add(change.Path); } - - var copy = new MenuItem(); - copy.Header = App.Text("CopyPath"); - copy.Icon = App.CreateMenuIcon("Icons.Copy"); - copy.Click += (_, e) => - { - App.CopyText(change.Path); - e.Handled = true; - }; - menu.Items.Add(copy); - - var copyFileName = new MenuItem(); - copyFileName.Header = App.Text("CopyFileName"); - copyFileName.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFileName.Click += (_, e) => - { - App.CopyText(Path.GetFileName(change.Path)); - e.Handled = true; - }; - menu.Items.Add(copyFileName); } - else - { - var hasConflicts = false; - var hasNoneConflicts = false; - foreach (var change in _selectedUnstaged) - { - if (change.IsConflit) - hasConflicts = true; - else - hasNoneConflicts = true; - } - - if (hasConflicts) - { - if (hasNoneConflicts) - { - App.RaiseException(_repo.FullPath, "You have selected both non-conflict changes with conflicts!"); - return null; - } - - var useTheirs = new MenuItem(); - useTheirs.Icon = App.CreateMenuIcon("Icons.Incoming"); - useTheirs.Header = App.Text("FileCM.UseTheirs"); - useTheirs.Click += (_, e) => - { - UseTheirs(_selectedUnstaged); - e.Handled = true; - }; - - var useMine = new MenuItem(); - useMine.Icon = App.CreateMenuIcon("Icons.Local"); - useMine.Header = App.Text("FileCM.UseMine"); - useMine.Click += (_, e) => - { - UseMine(_selectedUnstaged); - e.Handled = true; - }; - - menu.Items.Add(useTheirs); - menu.Items.Add(useMine); - return menu; - } - - var stage = new MenuItem(); - stage.Header = App.Text("FileCM.StageMulti", _selectedUnstaged.Count); - stage.Icon = App.CreateMenuIcon("Icons.File.Add"); - stage.Click += (_, e) => - { - StageChanges(_selectedUnstaged, null); - e.Handled = true; - }; - - var discard = new MenuItem(); - discard.Header = App.Text("FileCM.DiscardMulti", _selectedUnstaged.Count); - discard.Icon = App.CreateMenuIcon("Icons.Undo"); - discard.Click += (_, e) => - { - Discard(_selectedUnstaged); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.StashMulti", _selectedUnstaged.Count); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new StashChanges(_repo, _selectedUnstaged, true)); - - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedUnstaged, true, storageFile.Path.LocalPath)); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; + if (files.Count > 0) + { + var succ = await new Commands.Checkout(_repo.FullPath).Use(log).UseTheirsAsync(files); + if (succ) + needStage.AddRange(files); + } - menu.Items.Add(stage); - menu.Items.Add(discard); - menu.Items.Add(stash); - menu.Items.Add(patch); + 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); } - return menu; + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); } - public ContextMenu CreateContextMenuForStagedChanges() + public async Task UseMineAsync(List changes) { - if (_selectedStaged == null || _selectedStaged.Count == 0) - return null; + using var lockWatcher = _repo.LockWatcher(); - var menu = new ContextMenu(); + var files = new List(); + var needStage = new List(); + var log = _repo.CreateLog("Use Mine"); - var ai = null as MenuItem; - var services = GetPreferedOpenAIServices(); - if (services.Count > 0) + foreach (var change in changes) { - ai = new MenuItem(); - ai.Icon = App.CreateMenuIcon("Icons.AIAssist"); - ai.Header = App.Text("ChangeCM.GenerateCommitMessage"); + if (!change.IsConflicted) + continue; - if (services.Count == 1) + if (change.ConflictReason is Models.ConflictReason.BothDeleted or Models.ConflictReason.DeletedByUs or Models.ConflictReason.AddedByThem) { - ai.Click += (_, e) => - { - var dialog = new Views.AIAssistant(services[0], _repo.FullPath, _selectedStaged, generated => CommitMessage = generated); - App.OpenDialog(dialog); - e.Handled = true; - }; + var fullpath = Path.Combine(_repo.FullPath, change.Path); + if (File.Exists(fullpath)) + File.Delete(fullpath); + + needStage.Add(change.Path); } else { - foreach (var service in services) - { - var dup = service; - - var item = new MenuItem(); - item.Header = service.Name; - item.Click += (_, e) => - { - var dialog = new Views.AIAssistant(dup, _repo.FullPath, _selectedStaged, generated => CommitMessage = generated); - App.OpenDialog(dialog); - e.Handled = true; - }; - - ai.Items.Add(item); - } + files.Add(change.Path); } } - if (_selectedStaged.Count == 1) + if (files.Count > 0) { - var change = _selectedStaged[0]; - var path = Path.GetFullPath(Path.Combine(_repo.FullPath, change.Path)); - - var explore = new MenuItem(); - explore.IsEnabled = File.Exists(path) || Directory.Exists(path); - explore.Header = App.Text("RevealFile"); - explore.Icon = App.CreateMenuIcon("Icons.Explore"); - explore.Click += (_, e) => - { - Native.OS.OpenInFileManager(path, true); - e.Handled = true; - }; - - var openWith = new MenuItem(); - openWith.Header = App.Text("OpenWith"); - openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWith.IsEnabled = File.Exists(path); - openWith.Click += (_, e) => - { - Native.OS.OpenWithDefaultEditor(path); - e.Handled = true; - }; - - var unstage = new MenuItem(); - unstage.Header = App.Text("FileCM.Unstage"); - unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); - unstage.Click += (_, e) => - { - UnstageChanges(_selectedStaged, null); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.Stash"); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new StashChanges(_repo, _selectedStaged, true)); - - e.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedStaged, false, storageFile.Path.LocalPath)); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; + var succ = await new Commands.Checkout(_repo.FullPath).Use(log).UseMineAsync(files); + if (succ) + needStage.AddRange(files); + } - 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, change.Path) }; - window.Show(); - e.Handled = true; - }; - - menu.Items.Add(explore); - menu.Items.Add(openWith); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(unstage); - menu.Items.Add(stash); - menu.Items.Add(patch); - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(new MenuItem() { Header = "-" }); - - var lfsEnabled = new Commands.LFS(_repo.FullPath).IsEnabled(); - if (lfsEnabled) - { - var lfs = new MenuItem(); - lfs.Header = App.Text("GitLFS"); - lfs.Icon = App.CreateMenuIcon("Icons.LFS"); - - var lfsLock = new MenuItem(); - lfsLock.Header = App.Text("GitLFS.Locks.Lock"); - lfsLock.Icon = App.CreateMenuIcon("Icons.Lock"); - lfsLock.IsEnabled = _repo.Remotes.Count > 0; - if (_repo.Remotes.Count == 1) - { - lfsLock.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(_repo.Remotes[0].Name, change.Path)); - if (succ) - App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); + 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); + } - e.Handled = true; - }; - } - else - { - foreach (var remote in _repo.Remotes) - { - var remoteName = remote.Name; - var lockRemote = new MenuItem(); - lockRemote.Header = remoteName; - lockRemote.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Lock(remoteName, change.Path)); - if (succ) - App.SendNotification(_repo.FullPath, $"Lock file \"{change.Path}\" successfully!"); - - e.Handled = true; - }; - lfsLock.Items.Add(lockRemote); - } - } - lfs.Items.Add(lfsLock); + log.Complete(); + _repo.MarkWorkingCopyDirtyManually(); + } - var lfsUnlock = new MenuItem(); - lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); - lfsUnlock.Icon = App.CreateMenuIcon("Icons.Unlock"); - lfsUnlock.IsEnabled = _repo.Remotes.Count > 0; - if (_repo.Remotes.Count == 1) - { - lfsUnlock.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(_repo.Remotes[0].Name, change.Path, false)); - if (succ) - App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); + public async Task UseExternalMergeToolAsync(Models.Change change) + { + return await new Commands.MergeTool(_repo.FullPath, change?.Path).OpenAsync(); + } - e.Handled = true; - }; - } - else - { - foreach (var remote in _repo.Remotes) - { - var remoteName = remote.Name; - var unlockRemote = new MenuItem(); - unlockRemote.Header = remoteName; - unlockRemote.Click += async (_, e) => - { - var succ = await Task.Run(() => new Commands.LFS(_repo.FullPath).Unlock(remoteName, change.Path, false)); - if (succ) - App.SendNotification(_repo.FullPath, $"Unlock file \"{change.Path}\" successfully!"); - - e.Handled = true; - }; - lfsUnlock.Items.Add(unlockRemote); - } - } - lfs.Items.Add(lfsUnlock); + public void UseExternalDiffTool(Models.Change change, bool isUnstaged) + { + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(change, isUnstaged)).Open(); + } - menu.Items.Add(lfs); - menu.Items.Add(new MenuItem() { Header = "-" }); - } + public async Task ContinueMergeAsync() + { + if (_inProgressContext != null) + { + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; - if (ai != null) - { - menu.Items.Add(ai); - menu.Items.Add(new MenuItem() { Header = "-" }); - } + var mergeMsgFile = Path.Combine(_repo.GitDir, "MERGE_MSG"); + if (File.Exists(mergeMsgFile) && !string.IsNullOrWhiteSpace(_commitMessage)) + await File.WriteAllTextAsync(mergeMsgFile, _commitMessage); - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = App.CreateMenuIcon("Icons.Copy"); - copyPath.Click += (_, e) => - { - App.CopyText(change.Path); - e.Handled = true; - }; - - var copyFileName = new MenuItem(); - copyFileName.Header = App.Text("CopyFileName"); - copyFileName.Icon = App.CreateMenuIcon("Icons.Copy"); - copyFileName.Click += (_, e) => - { - App.CopyText(Path.GetFileName(change.Path)); - e.Handled = true; - }; + var log = _repo.CreateLog($"Continue {_inProgressContext.Name}"); + await _inProgressContext.ContinueAsync(log); + log.Complete(); - menu.Items.Add(copyPath); - menu.Items.Add(copyFileName); + CommitMessage = string.Empty; + IsCommitting = false; } else { - var unstage = new MenuItem(); - unstage.Header = App.Text("FileCM.UnstageMulti", _selectedStaged.Count); - unstage.Icon = App.CreateMenuIcon("Icons.File.Remove"); - unstage.Click += (_, e) => - { - UnstageChanges(_selectedStaged, null); - e.Handled = true; - }; - - var stash = new MenuItem(); - stash.Header = App.Text("FileCM.StashMulti", _selectedStaged.Count); - stash.Icon = App.CreateMenuIcon("Icons.Stashes.Add"); - stash.Click += (_, e) => - { - if (PopupHost.CanCreatePopup()) - PopupHost.ShowPopup(new StashChanges(_repo, _selectedStaged, true)); - - e.Handled = true; - }; + _repo.MarkWorkingCopyDirtyManually(); + } + } - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; + public async Task SkipMergeAsync() + { + if (_inProgressContext != null) + { + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + var log = _repo.CreateLog($"Skip {_inProgressContext.Name}"); + await _inProgressContext.SkipAsync(log); + log.Complete(); - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var succ = await Task.Run(() => Commands.SaveChangesAsPatch.ProcessLocalChanges(_repo.FullPath, _selectedStaged, false, storageFile.Path.LocalPath)); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } + CommitMessage = string.Empty; + IsCommitting = false; + } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } + } - e.Handled = true; - }; + public async Task AbortMergeAsync() + { + if (_inProgressContext != null) + { + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; - menu.Items.Add(unstage); - menu.Items.Add(stash); - menu.Items.Add(patch); + var log = _repo.CreateLog($"Abort {_inProgressContext.Name}"); + await _inProgressContext.AbortAsync(log); + log.Complete(); - if (ai != null) - { - menu.Items.Add(new MenuItem() { Header = "-" }); - menu.Items.Add(ai); - } + CommitMessage = string.Empty; + IsCommitting = false; } + else + { + _repo.MarkWorkingCopyDirtyManually(); + } + } + + public void ApplyCommitMessageTemplate(Models.CommitTemplate tmpl) + { + CommitMessage = tmpl.Apply(_repo.CurrentBranch, _staged); + } - return menu; + public async Task ClearCommitMessageHistoryAsync() + { + var sure = await App.AskConfirmAsync(App.Text("WorkingCopy.ClearCommitHistories.Confirm")); + if (sure) + _repo.UIStates.RecentCommitMessages.Clear(); } - public ContextMenu CreateContextMenuForCommitMessages() + public async Task CommitAsync(bool autoStage, bool autoPush) { - var menu = new ContextMenu(); + if (string.IsNullOrWhiteSpace(_commitMessage)) + return; - var templateCount = _repo.Settings.CommitTemplates.Count; - if (templateCount == 0) + if (!_repo.CanCreatePopup()) { - menu.Items.Add(new MenuItem() - { - Header = App.Text("WorkingCopy.NoCommitTemplates"), - Icon = App.CreateMenuIcon("Icons.Code"), - IsEnabled = false - }); + _repo.SendNotification("Repository has an unfinished job! Please wait!", true); + return; } - else + + if (autoStage && HasUnsolvedConflicts) { - for (int i = 0; i < templateCount; i++) - { - var template = _repo.Settings.CommitTemplates[i]; - var item = new MenuItem(); - item.Header = new Views.NameHighlightedTextBlock("WorkingCopy.UseCommitTemplate", template.Name); - item.Icon = App.CreateMenuIcon("Icons.Code"); - item.Click += (_, e) => - { - CommitMessage = template.Apply(_repo.CurrentBranch, _staged); - e.Handled = true; - }; - menu.Items.Add(item); - } + _repo.SendNotification("Repository has unsolved conflict(s). Auto-stage and commit is disabled!", true); + return; } - menu.Items.Add(new MenuItem() { Header = "-" }); + if (_repo.CurrentBranch is { IsDetachedHead: true }) + { + var msg = App.Text("WorkingCopy.ConfirmCommitWithDetachedHead"); + var sure = await App.AskConfirmAsync(msg); + if (!sure) + return; + } - var historiesCount = _repo.Settings.CommitMessages.Count; - if (historiesCount == 0) + if (!string.IsNullOrEmpty(_filter) && _staged.Count > _visibleStaged.Count) { - menu.Items.Add(new MenuItem() - { - Header = App.Text("WorkingCopy.NoCommitHistories"), - Icon = App.CreateMenuIcon("Icons.Histories"), - IsEnabled = false - }); + var msg = App.Text("WorkingCopy.ConfirmCommitWithFilter", _staged.Count, _visibleStaged.Count, _staged.Count - _visibleStaged.Count); + var sure = await App.AskConfirmAsync(msg); + if (!sure) + return; } - else + + if (!_useAmend) { - for (int i = 0; i < historiesCount; i++) + if ((!autoStage && _staged.Count == 0) || (autoStage && _cached.Count == 0)) { - var message = _repo.Settings.CommitMessages[i]; - var item = new MenuItem(); - item.Header = message; - item.Icon = App.CreateMenuIcon("Icons.Histories"); - item.Click += (_, e) => - { - CommitMessage = message; - e.Handled = true; - }; + var rs = await App.AskConfirmEmptyCommitAsync(_cached.Count > 0, _selectedUnstaged is { Count: > 0 }); + if (rs == Models.ConfirmEmptyCommitResult.Cancel) + return; - menu.Items.Add(item); + if (rs == Models.ConfirmEmptyCommitResult.StageAllAndCommit) + autoStage = true; + else if (rs == Models.ConfirmEmptyCommitResult.StageSelectedAndCommit) + await StageChangesAsync(_selectedUnstaged, null); } } - return menu; - } + using var lockWatcher = _repo.LockWatcher(); + IsCommitting = true; + _repo.UIStates.AddRecentCommitMessage(_commitMessage); - public ContextMenu CreateContextForOpenAI() - { - if (_staged == null || _staged.Count == 0) - { - App.RaiseException(_repo.FullPath, "No files added to commit!"); - return null; - } + if (autoStage && _unstaged.Count > 0) + await StageChangesAsync(_unstaged, null); - var services = GetPreferedOpenAIServices(); - if (services.Count == 0) - { - App.RaiseException(_repo.FullPath, "Bad configuration for OpenAI"); - return null; - } + var log = _repo.CreateLog("Commit"); + var succ = await new Commands.Commit(_repo.FullPath, _commitMessage, EnableSignOff, NoVerifyOnCommit, _useAmend, _resetAuthor) + .Use(log) + .RunAsync(); - if (services.Count == 1) - { - var dialog = new Views.AIAssistant(services[0], _repo.FullPath, _staged, generated => CommitMessage = generated); - App.OpenDialog(dialog); - return null; - } - else + log.Complete(); + + if (succ) { - var menu = new ContextMenu() { Placement = PlacementMode.TopEdgeAlignedLeft }; + UseAmend = false; + CommitMessage = string.Empty; - foreach (var service in services) + if (autoPush && _repo.Remotes.Count > 0) { - var dup = service; - - var item = new MenuItem(); - item.Header = service.Name; - item.Click += (_, e) => + Models.Branch pushBranch = null; + if (_repo.CurrentBranch == null) { - var dialog = new Views.AIAssistant(dup, _repo.FullPath, _staged, generated => CommitMessage = generated); - App.OpenDialog(dialog); - e.Handled = true; - }; + var currentBranchName = await new Commands.QueryCurrentBranch(_repo.FullPath).GetResultAsync(); + pushBranch = new Models.Branch() { Name = currentBranchName }; + } - menu.Items.Add(item); + if (_repo.CanCreatePopup()) + await _repo.ShowAndStartPopupAsync(new Push(_repo, pushBranch)); } - - return menu; } + + _repo.MarkBranchesDirtyManually(); + _repo.RefreshSubmodules(); // Committing will not change submodule's HEAD (stage already changes it), So we need refresh submodules here manually. + IsCommitting = false; } - private List GetStagedChanges() + private List GetVisibleChanges(List changes) { - if (_useAmend) - return new Commands.QueryStagedChangesWithAmend(_repo.FullPath).Result(); + if (string.IsNullOrEmpty(_filter)) + return changes; - var rs = new List(); - foreach (var c in _cached) + var visible = new List(); + + foreach (var c in changes) { - if (c.Index != Models.ChangeState.None && - c.Index != Models.ChangeState.Untracked) - rs.Add(c); + if (c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + visible.Add(c); } - return rs; - } - - private void SetDetail(Models.Change change, bool isUnstaged) - { - if (_isLoadingData) - return; - if (change == null) - DetailContext = null; - else if (change.IsConflit && isUnstaged) - DetailContext = new ConflictContext(_repo.FullPath, change); - else - DetailContext = new DiffContext(_repo.FullPath, new Models.DiffOption(change, isUnstaged), _detailContext as DiffContext); + return visible; } - private async void UseTheirs(List changes) + private async Task> GetCanStageChangesAsync(List changes) { - var files = new List(); - foreach (var change in changes) - { - if (change.IsConflit) - files.Add(change.Path); - } + if (!HasUnsolvedConflicts) + return changes; - _repo.SetWatcherEnabled(false); - var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).UseTheirs(files)); - if (succ) + var outs = new List(); + foreach (var c in changes) { - await Task.Run(() => new Commands.Add(_repo.FullPath, changes).Exec()); + 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; + } + else + { + continue; + } + } + + outs.Add(c); } - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); + + return outs; } - private async void UseMine(List changes) + private List GetStagedChanges(List cached) { - var files = new List(); - foreach (var change in changes) + if (_useAmend) { - if (change.IsConflit) - files.Add(change.Path); + var changes = new Commands.QueryStagedChangesWithAmend(_repo.FullPath).GetResult(); + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + return changes; } - _repo.SetWatcherEnabled(false); - var succ = await Task.Run(() => new Commands.Checkout(_repo.FullPath).UseMine(files)); - if (succ) + var rs = new List(); + foreach (var c in cached) { - await Task.Run(() => new Commands.Add(_repo.FullPath, changes).Exec()); + if (c.Index != Models.ChangeState.None) + rs.Add(c); } - _repo.MarkWorkingCopyDirtyManually(); - _repo.SetWatcherEnabled(true); + return rs; } - private async void UseExternalMergeTool(Models.Change change) + private void UpdateDetail() { - var toolType = Preference.Instance.ExternalMergeToolType; - var toolPath = Preference.Instance.ExternalMergeToolPath; - - _repo.SetWatcherEnabled(false); - await Task.Run(() => Commands.MergeTool.OpenForMerge(_repo.FullPath, toolType, toolPath, change.Path)); - _repo.SetWatcherEnabled(true); + if (_selectedUnstaged.Count == 1) + SetDetail(_selectedUnstaged[0], true); + else if (_selectedStaged.Count == 1) + SetDetail(_selectedStaged[0], false); + else + SetDetail(null, false); } - private void DoCommit(bool autoStage, bool autoPush, bool allowEmpty) + private void UpdateInProgressState() { - if (!PopupHost.CanCreatePopup()) - { - App.RaiseException(_repo.FullPath, "Repository has unfinished job! Please wait!"); + 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; - } - if (string.IsNullOrWhiteSpace(_commitMessage)) - { - App.RaiseException(_repo.FullPath, "Commit without message is NOT allowed!"); + if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "MERGE_MSG"))) return; - } - if (!_useAmend && !allowEmpty) - { - if ((autoStage && _count == 0) || (!autoStage && _staged.Count == 0)) - { - App.OpenDialog(new Views.ConfirmCommitWithoutFiles() - { - DataContext = new ConfirmCommitWithoutFiles(this, autoPush) - }); + if (_inProgressContext is not RebaseInProgress { } rebasing) + return; - return; - } - } + if (LoadCommitMessageFromFile(Path.Combine(_repo.GitDir, "rebase-merge", "message"))) + return; - IsCommitting = true; - _repo.Settings.PushCommitMessage(_commitMessage); - _repo.SetWatcherEnabled(false); + CommitMessage = new Commands.QueryCommitFullMessage(_repo.FullPath, rebasing.StoppedAt.SHA).GetResult(); + } - Task.Run(() => - { - var succ = true; - if (autoStage && _unstaged.Count > 0) - succ = new Commands.Add(_repo.FullPath, _repo.IncludeUntracked).Exec(); + private bool LoadCommitMessageFromFile(string file) + { + if (!File.Exists(file)) + return false; - if (succ) - succ = new Commands.Commit(_repo.FullPath, _commitMessage, _useAmend, _repo.Settings.EnableSignOffForCommit).Run(); + var msg = File.ReadAllText(file).Trim(); + if (string.IsNullOrEmpty(msg)) + return false; - Dispatcher.UIThread.Post(() => - { - if (succ) - { - CommitMessage = string.Empty; - UseAmend = false; + CommitMessage = msg; + return true; + } - if (autoPush) - PopupHost.ShowAndStartPopup(new Push(_repo, null)); - } + private void SetDetail(Models.Change change, bool isUnstaged) + { + if (_isLoadingData) + return; - _repo.MarkBranchesDirtyManually(); - _repo.SetWatcherEnabled(true); - IsCommitting = false; - }); - }); + 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) @@ -1511,52 +796,34 @@ private bool IsChanged(List old, List cur) if (old.Count != cur.Count) return true; - var oldSet = new HashSet(); - foreach (var c in old) - oldSet.Add($"{c.Path}\n{c.WorkTree}\n{c.Index}"); - - foreach (var c in cur) + for (int idx = 0; idx < old.Count; idx++) { - if (!oldSet.Contains($"{c.Path}\n{c.WorkTree}\n{c.Index}")) + 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; } return false; } - private IList GetPreferedOpenAIServices() - { - var services = Preference.Instance.OpenAIServices; - if (services == null || services.Count == 0) - return []; - - if (services.Count == 1) - return services; - - var prefered = _repo.Settings.PreferedOpenAIService; - foreach (var service in services) - { - if (service.Name.Equals(prefered, StringComparison.Ordinal)) - return [service]; - } - - return services; - } - private Repository _repo = null; private bool _isLoadingData = false; private bool _isStaging = false; private bool _isUnstaging = false; private bool _isCommitting = false; private bool _useAmend = false; - private bool _canCommitWithPush = false; + private bool _resetAuthor = false; + private bool _hasRemotes = false; private List _cached = []; private List _unstaged = []; + private List _visibleUnstaged = []; private List _staged = []; + private List _visibleStaged = []; private List _selectedUnstaged = []; private List _selectedStaged = []; - private int _count = 0; private object _detailContext = null; + private string _filter = string.Empty; private string _commitMessage = string.Empty; private bool _hasUnsolvedConflicts = false; diff --git a/src/ViewModels/Workspace.cs b/src/ViewModels/Workspace.cs index d527ff489..3ae935c76 100644 --- a/src/ViewModels/Workspace.cs +++ b/src/ViewModels/Workspace.cs @@ -46,6 +46,12 @@ public bool RestoreOnStartup set => SetProperty(ref _restoreOnStartup, value); } + public string DefaultCloneDir + { + get => _defaultCloneDir; + set => SetProperty(ref _defaultCloneDir, value); + } + public IBrush Brush { get => new SolidColorBrush(_color); @@ -55,5 +61,6 @@ public IBrush Brush 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 index 528d0e5b3..9c1db462b 100644 --- a/src/Views/AIAssistant.axaml +++ b/src/Views/AIAssistant.axaml @@ -4,16 +4,16 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:v="using:SourceGit.Views" xmlns:vm="using:SourceGit.ViewModels" - xmlns:c="using:SourceGit.Converters" - mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="120" + mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="400" x:Class="SourceGit.Views.AIAssistant" + x:DataType="vm:AIAssistant" x:Name="ThisControl" Icon="/App.ico" Title="{DynamicResource Text.AIAssistant}" - Width="400" SizeToContent="Height" - CanResize="False" + Width="520" Height="400" + CanResize="True" WindowStartupLocation="CenterOwner"> - + - - - - - + + + + + + + + + + + + - - - - - - - - - + + + + - - + + diff --git a/src/Views/About.axaml.cs b/src/Views/About.axaml.cs index b02cd13f3..842022cf7 100644 --- a/src/Views/About.axaml.cs +++ b/src/Views/About.axaml.cs @@ -1,54 +1,69 @@ +using System; using System.Reflection; -using Avalonia.Input; +using System.Text.RegularExpressions; +using Avalonia.Interactivity; namespace SourceGit.Views { public partial class About : ChromelessWindow { - public string Version - { - get; - private set; - } - public About() { - var ver = Assembly.GetExecutingAssembly().GetName().Version; - if (ver != null) - Version = $"{ver.Major}.{ver.Minor}"; - - DataContext = this; + CloseOnESC = true; InitializeComponent(); - } - private void OnVisitAvaloniaUI(object _, PointerPressedEventArgs e) - { - Native.OS.OpenBrowser("https://www.avaloniaui.net/"); - e.Handled = true; - } + 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; + } + } - private void OnVisitAvaloniaEdit(object _, PointerPressedEventArgs e) - { - Native.OS.OpenBrowser("https://github.com/AvaloniaUI/AvaloniaEdit"); - e.Handled = true; + 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 OnVisitJetBrainsMonoFont(object _, PointerPressedEventArgs e) + private void OnVisitReleaseNotes(object _, RoutedEventArgs e) { - Native.OS.OpenBrowser("https://www.jetbrains.com/lp/mono/"); + 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 OnVisitLiveCharts2(object _, PointerPressedEventArgs e) + private void OnVisitWebsite(object _, RoutedEventArgs e) { - Native.OS.OpenBrowser("https://livecharts.dev/"); + Native.OS.OpenBrowser("https://sourcegit-scm.github.io/"); e.Handled = true; } - private void OnVisitSourceCode(object _, PointerPressedEventArgs e) + 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 f42fbf1ff..3903c540a 100644 --- a/src/Views/AddRemote.axaml +++ b/src/Views/AddRemote.axaml @@ -8,11 +8,18 @@ x:Class="SourceGit.Views.AddRemote" x:DataType="vm:AddRemote"> - + + - + + + + + Text="{Binding Name, Mode=TwoWay}"> + + + + + Text="{Binding Url, Mode=TwoWay}"> + + + + + + + + - - - - - + + + + + + + + + + + + diff --git a/src/Views/AddRemote.axaml.cs b/src/Views/AddRemote.axaml.cs index 4c3914f25..3fb283eaa 100644 --- a/src/Views/AddRemote.axaml.cs +++ b/src/Views/AddRemote.axaml.cs @@ -20,7 +20,7 @@ private async void SelectSSHKey(object _, RoutedEventArgs e) var options = new FilePickerOpenOptions() { AllowMultiple = false, - FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] + FileTypeFilter = [new("SSHKey") { Patterns = ["*"] }] }; var selected = await toplevel.StorageProvider.OpenFilePickerAsync(options); diff --git a/src/Views/AddSubmodule.axaml b/src/Views/AddSubmodule.axaml index 2b5061ff3..0d5f442ef 100644 --- a/src/Views/AddSubmodule.axaml +++ b/src/Views/AddSubmodule.axaml @@ -8,9 +8,16 @@ x:Class="SourceGit.Views.AddSubmodule" x:DataType="vm:AddSubmodule"> - + + + + + + + Text="{Binding Url, Mode=TwoWay}"> + + + + + + + + + Text="{Binding RelativePath, Mode=TwoWay}"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Squash.axaml.cs b/src/Views/AddToIgnore.axaml.cs similarity index 60% rename from src/Views/Squash.axaml.cs rename to src/Views/AddToIgnore.axaml.cs index 552faec25..ae1745e6a 100644 --- a/src/Views/Squash.axaml.cs +++ b/src/Views/AddToIgnore.axaml.cs @@ -2,9 +2,9 @@ namespace SourceGit.Views { - public partial class Squash : UserControl + public partial class AddToIgnore : UserControl { - public Squash() + public AddToIgnore() { InitializeComponent(); } diff --git a/src/Views/AddWorktree.axaml b/src/Views/AddWorktree.axaml index d6853aab4..4f047f489 100644 --- a/src/Views/AddWorktree.axaml +++ b/src/Views/AddWorktree.axaml @@ -3,14 +3,23 @@ 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" + xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.AddWorktree" x:DataType="vm:AddWorktree"> - - + + + + + + + - + - - - - - - - - - - - + + - - - - - - - - - - - - + Text="{DynamicResource Text.AddWorktree.Tracking}"/> + + + IsChecked="{Binding SetTrackingBranch, Mode=TwoWay}" + IsVisible="{Binding RemoteBranches, Converter={x:Static c:ListConverters.IsNotNullOrEmpty}}"/> + diff --git a/src/Views/AddWorktree.axaml.cs b/src/Views/AddWorktree.axaml.cs index 303692470..a0ddb12c2 100644 --- a/src/Views/AddWorktree.axaml.cs +++ b/src/Views/AddWorktree.axaml.cs @@ -1,4 +1,5 @@ using System; + using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform.Storage; @@ -23,11 +24,15 @@ private async void SelectLocation(object _, RoutedEventArgs e) { var selected = await toplevel.StorageProvider.OpenFolderPickerAsync(options); if (selected.Count == 1) - TxtLocation.Text = selected[0].Path.LocalPath; + { + var folder = selected[0]; + var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString(); + TxtLocation.Text = folderPath.TrimEnd('\\', '/'); + } } catch (Exception exception) { - App.RaiseException(string.Empty, $"Failed to select location: {exception.Message}"); + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Blame.axaml.cs b/src/Views/Blame.axaml.cs index d32e43702..e1b5fb1ff 100644 --- a/src/Views/Blame.axaml.cs +++ b/src/Views/Blame.axaml.cs @@ -37,6 +37,9 @@ public override void Render(DrawingContext context) { var typeface = view.CreateTypeface(); 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) { @@ -49,8 +52,8 @@ public override void Render(DrawingContext context) 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) + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; + if (!info.IsFirstInGroup && y > lineHeight) continue; var shaLink = new FormattedText( @@ -60,28 +63,32 @@ 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)); } } } @@ -90,7 +97,7 @@ 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(); @@ -102,9 +109,8 @@ protected override Size MeasureOverride(Size availableSize) var info = _editor.BlameData.LineInfos[lineNumber - 1]; - if (calculated.Contains(info.CommitSHA)) + if (!calculated.Add(info.CommitSHA)) continue; - calculated.Add(info.CommitSHA); var x = 0.0; var shaLink = new FormattedText( @@ -116,23 +122,24 @@ 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; @@ -151,6 +158,7 @@ protected override void OnPointerMoved(PointerEventArgs e) { var pos = e.GetPosition(this); var typeface = view.CreateTypeface(); + var lineHeight = view.DefaultLineHeight; foreach (var line in view.VisualLines) { @@ -162,7 +170,7 @@ protected override void OnPointerMoved(PointerEventArgs e) break; var info = _editor.BlameData.LineInfos[lineNumber - 1]; - var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset; + var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; var shaLink = new FormattedText( info.CommitSHA, CultureInfo.CurrentCulture, @@ -171,16 +179,24 @@ protected override void OnPointerMoved(PointerEventArgs e) _editor.FontSize, Brushes.DarkOrange); - var rect = new Rect(0, y, shaLink.Width, shaLink.Height); + 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; + Cursor = Cursor.Default; + ToolTip.SetTip(this, null); + } } protected override void OnPointerPressed(PointerPressedEventArgs e) @@ -216,9 +232,7 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (rect.Contains(pos)) { if (DataContext is ViewModels.Blame blame) - { - blame.NavigateToCommit(info.CommitSHA); - } + blame.NavigateToCommit(info.File, info.CommitSHA); e.Handled = true; break; @@ -230,9 +244,9 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) private readonly BlameTextEditor _editor = null; } - public class VerticalSeperatorMargin : AbstractMargin + public class VerticalSeparatorMargin : AbstractMargin { - public VerticalSeperatorMargin(BlameTextEditor editor) + public VerticalSeparatorMargin(BlameTextEditor editor) { _editor = editor; } @@ -240,7 +254,7 @@ public VerticalSeperatorMargin(BlameTextEditor editor) public override void Render(DrawingContext context) { var pen = new Pen(_editor.BorderBrush); - context.DrawLine(pen, new Point(0, 0), new Point(0, Bounds.Height)); + context.DrawLine(pen, new Point(0.5, 0), new Point(0.5, Bounds.Height)); } protected override Size MeasureOverride(Size availableSize) @@ -251,13 +265,88 @@ protected override Size MeasureOverride(Size availableSize) 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)); + } + } + + 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 => GetValue(BlameDataProperty); - set => SetValue(BlameDataProperty, value); + 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); @@ -268,52 +357,21 @@ public Models.BlameData BlameData ShowLineNumbers = false; WordWrap = false; + Options.IndentationSize = _tabWidth; + Options.EnableHyperlinks = false; + Options.EnableEmailHyperlinks = false; + _textMate = Models.TextMateHelper.CreateForEditor(this); - TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); - TextArea.LeftMargins.Add(new 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.LayoutUpdated += OnTextAreaLayoutUpdated; - TextArea.PointerWheelChanged += OnTextAreaPointerWheelChanged; + TextArea.TextView.BackgroundRenderers.Add(new LineBackgroundRenderer(this)); TextArea.TextView.ContextRequested += OnTextViewContextRequested; TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; TextArea.TextView.Margin = new Thickness(4, 0); - TextArea.TextView.Options.EnableHyperlinks = false; - TextArea.TextView.Options.EnableEmailHyperlinks = false; - } - - public override void Render(DrawingContext context) - { - base.Render(context); - - if (string.IsNullOrEmpty(_highlight)) - return; - - var view = TextArea.TextView; - if (view == null || !view.VisualLinesValid) - return; - - var color = (Color)this.FindResource("SystemAccentColor")!; - var brush = new SolidColorBrush(color, 0.4); - foreach (var line in view.VisualLines) - { - if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) - continue; - - var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > BlameData.LineInfos.Count) - break; - - var info = BlameData.LineInfos[lineNumber - 1]; - if (info.CommitSHA != _highlight) - continue; - - var startY = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; - var endY = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - view.VerticalOffset; - context.FillRectangle(brush, new Rect(0, startY, Bounds.Width, endY - startY)); - } } protected override void OnUnloaded(RoutedEventArgs e) @@ -322,8 +380,6 @@ protected override void OnUnloaded(RoutedEventArgs e) TextArea.LeftMargins.Clear(); TextArea.Caret.PositionChanged -= OnTextAreaCaretPositionChanged; - TextArea.LayoutUpdated -= OnTextAreaLayoutUpdated; - TextArea.PointerWheelChanged -= OnTextAreaPointerWheelChanged; TextArea.TextView.ContextRequested -= OnTextViewContextRequested; TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged; @@ -338,19 +394,23 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { base.OnPropertyChanged(change); + if (change.Property == FileProperty) + { + if (_file is { Length: > 0 }) + Models.TextMateHelper.SetGrammarByFileName(_textMate, _file); + } if (change.Property == BlameDataProperty) { - if (BlameData != null) - { - Models.TextMateHelper.SetGrammarByFileName(_textMate, BlameData.File); - Text = BlameData.Content; - } + if (_blameData is { IsBinary: false } blame) + Text = blame.Content; else - { Text = string.Empty; - } } - else if (change.Property.Name == "ActualThemeVariant" && change.NewValue != null) + else if (change.Property == TabWidthProperty) + { + Options.IndentationSize = _tabWidth; + } + else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) { Models.TextMateHelper.SetThemeByApp(_textMate); } @@ -358,27 +418,14 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang private void OnTextAreaCaretPositionChanged(object sender, EventArgs e) { - if (!TextArea.IsFocused) + if (!TextArea.IsFocused || _blameData == null) return; var caret = TextArea.Caret; - if (caret == null || caret.Line > BlameData.LineInfos.Count) + if (caret == null || caret.Line > _blameData.LineInfos.Count) return; - _highlight = BlameData.LineInfos[caret.Line - 1].CommitSHA; - InvalidateVisual(); - } - - private void OnTextAreaLayoutUpdated(object sender, EventArgs e) - { - if (TextArea.IsFocused) - InvalidateVisual(); - } - - private void OnTextAreaPointerWheelChanged(object sender, PointerWheelEventArgs e) - { - if (!TextArea.IsFocused && !string.IsNullOrEmpty(_highlight)) - Focus(); + _highlight = _blameData.LineInfos[caret.Line - 1].CommitSHA; } private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) @@ -387,24 +434,15 @@ private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs if (string.IsNullOrEmpty(selected)) return; - var copy = new MenuItem() { Header = App.Text("Copy") }; - copy.Click += (_, ev) => + var copy = new MenuItem(); + copy.Header = App.Text("Copy"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, ev) => { - App.CopyText(selected); + await this.CopyTextAsync(selected); ev.Handled = true; }; - if (this.FindResource("Icons.Copy") is StreamGeometry geo) - { - copy.Icon = new Avalonia.Controls.Shapes.Path() - { - Width = 10, - Height = 10, - Stretch = Stretch.Fill, - Data = geo, - }; - } - var menu = new ContextMenu(); menu.Items.Add(copy); menu.Open(TextArea.TextView); @@ -424,6 +462,9 @@ private void OnTextViewVisualLinesChanged(object sender, EventArgs e) } } + private string _file = null; + private Models.BlameData _blameData = null; + private int _tabWidth = 4; private TextMate.Installation _textMate = null; private string _highlight = string.Empty; } @@ -440,5 +481,24 @@ protected override void OnClosed(EventArgs e) base.OnClosed(e); GC.Collect(); } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (!e.Handled && DataContext is ViewModels.Blame blame) + { + if (e.InitialPressMouseButton == MouseButton.XButton1) + { + blame.Back(); + e.Handled = true; + } + else if (e.InitialPressMouseButton == MouseButton.XButton2) + { + blame.Forward(); + e.Handled = true; + } + } + } } } diff --git a/src/Views/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/BranchCompare.axaml.cs b/src/Views/BranchCompare.axaml.cs deleted file mode 100644 index ca90a180f..000000000 --- a/src/Views/BranchCompare.axaml.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Input; - -namespace SourceGit.Views -{ - public partial class BranchCompare : ChromelessWindow - { - public BranchCompare() - { - InitializeComponent(); - } - - private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) - { - if (DataContext is ViewModels.BranchCompare vm && sender is ChangeCollectionView view) - { - var menu = vm.CreateChangeContextMenu(); - menu?.Open(view); - } - - e.Handled = true; - } - - private void OnPressedSHA(object sender, PointerPressedEventArgs e) - { - if (DataContext is ViewModels.BranchCompare vm && sender is TextBlock block) - vm.NavigateTo(block.Text); - - e.Handled = true; - } - } -} diff --git a/src/Views/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 index 5d8386b4b..98ded57a1 100644 --- a/src/Views/BranchTree.axaml +++ b/src/Views/BranchTree.axaml @@ -1,7 +1,8 @@ - @@ -26,59 +29,149 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 92c2b043e..1c3cfdc3c 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -17,15 +17,6 @@ namespace SourceGit.Views { public class BranchTreeNodeIcon : UserControl { - public static readonly StyledProperty NodeProperty = - AvaloniaProperty.Register(nameof(Node)); - - public ViewModels.BranchTreeNode Node - { - get => GetValue(NodeProperty); - set => SetValue(NodeProperty, value); - } - public static readonly StyledProperty IsExpandedProperty = AvaloniaProperty.Register(nameof(IsExpanded)); @@ -35,16 +26,23 @@ public bool IsExpanded set => SetValue(IsExpandedProperty, value); } - static BranchTreeNodeIcon() + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + UpdateContent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { - NodeProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); - IsExpandedProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); + base.OnPropertyChanged(change); + + if (change.Property == IsExpandedProperty) + UpdateContent(); } private void UpdateContent() { - var node = Node; - if (node == null) + if (DataContext is not ViewModels.BranchTreeNode node) { Content = null; return; @@ -57,7 +55,9 @@ private void UpdateContent() else if (node.Backend is Models.Branch branch) { if (branch.IsCurrent) - CreateContent(new Thickness(0, 2, 0, 0), "Icons.Check"); + 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"); } @@ -70,13 +70,12 @@ private void UpdateContent() } } - private void CreateContent(Thickness margin, string iconKey) + private void CreateContent(Thickness margin, string iconKey, IBrush fill = null) { - var geo = this.FindResource(iconKey) as StreamGeometry; - if (geo == null) + if (this.FindResource(iconKey) is not StreamGeometry geo) return; - Content = new Path() + var path = new Path() { Width = 12, Height = 12, @@ -85,6 +84,11 @@ private void CreateContent(Thickness margin, string iconKey) Margin = margin, Data = geo, }; + + if (fill != null) + path.Fill = fill; + + Content = path; } } @@ -143,18 +147,6 @@ public IBrush Background set => SetValue(BackgroundProperty, value); } - static BranchTreeNodeTrackStatusPresenter() - { - AffectsMeasure( - FontSizeProperty, - FontFamilyProperty, - ForegroundProperty); - - AffectsRender( - ForegroundProperty, - BackgroundProperty); - } - public override void Render(DrawingContext context) { base.Render(context); @@ -166,6 +158,18 @@ public override void Render(DrawingContext context) } } + 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); @@ -179,11 +183,11 @@ protected override Size MeasureOverride(Size availableSize) if (DataContext is ViewModels.BranchTreeNode { Backend: Models.Branch branch }) { - var status = branch.TrackStatus.ToString(); - if (!string.IsNullOrEmpty(status)) + var desc = branch.TrackStatusDescription; + if (!string.IsNullOrEmpty(desc)) { _label = new FormattedText( - status, + desc, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(FontFamily), @@ -198,22 +202,90 @@ protected override Size MeasureOverride(Size availableSize) 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 StyledProperty> NodesProperty = - AvaloniaProperty.Register>(nameof(Nodes)); + public static readonly DirectProperty> NodesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(Nodes), + static o => o.Nodes, + static (o, v) => o.Nodes = v); public List Nodes { - get => GetValue(NodesProperty); - set => SetValue(NodesProperty, value); + get => _nodes; + set => SetAndRaise(NodesProperty, ref _nodes, value); } public AvaloniaList Rows { get; - private set; - } = new AvaloniaList(); + } = []; public static readonly RoutedEvent SelectionChangedEvent = RoutedEvent.Register(nameof(SelectionChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); @@ -233,11 +305,55 @@ public event EventHandler RowsChanged 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; @@ -275,6 +391,9 @@ public void ToggleNodeIsExpanded(ViewModels.BranchTreeNode node) rows.RemoveRange(idx + 1, removeCount); } + var repo = DataContext as ViewModels.Repository; + repo?.UpdateBranchNodeIsExpanded(node); + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); _disableSelectionChangingEvent = false; } @@ -295,10 +414,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { Rows.Clear(); - if (Nodes is { Count: > 0 }) + if (_nodes is { Count: > 0 }) { var rows = new List(); - MakeRows(rows, Nodes, 0); + MakeRows(rows, _nodes, 0); Rows.AddRange(rows); } @@ -310,6 +429,28 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } + 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) @@ -335,10 +476,7 @@ private void OnNodesSelectionChanged(object _, SelectionChangedEventArgs e) if (selected == null || selected.Count == 0) return; - if (selected.Count == 1 && selected[0] is ViewModels.BranchTreeNode { Backend: Models.Branch branch }) - repo.NavigateToCommit(branch.Head); - - var prev = null as ViewModels.BranchTreeNode; + ViewModels.BranchTreeNode prev = null; foreach (var row in Rows) { if (row.IsSelected) @@ -373,8 +511,7 @@ private void OnTreeContextRequested(object _1, ContextRequestedEventArgs _2) if (selected.Count == 1 && selected[0] is ViewModels.BranchTreeNode { Backend: Models.Remote remote }) { - var menu = repo.CreateContextMenuForRemote(remote); - menu?.Open(this); + CreateContextMenuForRemote(repo, remote).Open(this); return; } @@ -388,28 +525,119 @@ private void OnTreeContextRequested(object _1, ContextRequestedEventArgs _2) if (branches.Count == 1) { var branch = branches[0]; - var menu = branch.IsLocal ? - repo.CreateContextMenuForLocalBranch(branch) : - repo.CreateContextMenuForRemoteBranch(branch); - menu?.Open(this); + var menu = branch.IsLocal ? CreateContextMenuForLocalBranch(repo, branch) : CreateContextMenuForRemoteBranch(repo, branch); + menu.Open(this); } - else if (branches.Find(x => x.IsCurrent) == null) + else { var menu = new ContextMenu(); - var deleteMulti = new MenuItem(); - deleteMulti.Header = App.Text("BranchCM.DeleteMultiBranches", branches.Count); - deleteMulti.Icon = App.CreateMenuIcon("Icons.Clear"); - deleteMulti.Click += (_, ev) => + + 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); - ev.Handled = true; - }; - menu.Items.Add(deleteMulti); - menu?.Open(this); + + 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 void OnDoubleTappedBranchNode(object sender, TappedEventArgs _) + private async void OnDoubleTappedBranchNode(object sender, TappedEventArgs _) { if (sender is Grid { DataContext: ViewModels.BranchTreeNode node }) { @@ -419,7 +647,7 @@ private void OnDoubleTappedBranchNode(object sender, TappedEventArgs _) return; if (DataContext is ViewModels.Repository { Settings: not null } repo) - repo.CheckoutBranch(branch); + await repo.CheckoutBranchAsync(branch); } else { @@ -455,7 +683,738 @@ private void CollectBranchesInNode(List outs, ViewModels.BranchTr 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 4ac07880a..948d87e9c 100644 --- a/src/Views/CaptionButtons.axaml +++ b/src/Views/CaptionButtons.axaml @@ -6,14 +6,14 @@ x:Class="SourceGit.Views.CaptionButtons" x:Name="ThisControl"> - - - diff --git a/src/Views/CaptionButtons.axaml.cs b/src/Views/CaptionButtons.axaml.cs index 650ccef0f..a22591673 100644 --- a/src/Views/CaptionButtons.axaml.cs +++ b/src/Views/CaptionButtons.axaml.cs @@ -1,7 +1,6 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; -using Avalonia.VisualTree; namespace SourceGit.Views { @@ -23,7 +22,7 @@ public CaptionButtons() private void MinimizeWindow(object _, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); + var window = TopLevel.GetTopLevel(this) as Window; if (window != null) window.WindowState = WindowState.Minimized; @@ -32,7 +31,7 @@ private void MinimizeWindow(object _, RoutedEventArgs e) private void MaximizeOrRestoreWindow(object _, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); + var window = TopLevel.GetTopLevel(this) as Window; if (window != null) window.WindowState = window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; @@ -41,9 +40,8 @@ private void MaximizeOrRestoreWindow(object _, RoutedEventArgs e) private void CloseWindow(object _, RoutedEventArgs e) { - var window = this.FindAncestorOfType(); - if (window != null) - window.Close(); + var window = TopLevel.GetTopLevel(this) as Window; + window?.Close(); e.Handled = true; } diff --git a/src/Views/ChangeCollectionView.axaml b/src/Views/ChangeCollectionView.axaml index 6ce3d0336..be9143d1b 100644 --- a/src/Views/ChangeCollectionView.axaml +++ b/src/Views/ChangeCollectionView.axaml @@ -20,98 +20,127 @@ - + - + - - - + + + + - - - - + + + + + + - - - + - + + + - - + + - - - + - - + + + + diff --git a/src/Views/ChangeCollectionView.axaml.cs b/src/Views/ChangeCollectionView.axaml.cs index a47988f0d..0a271664f 100644 --- a/src/Views/ChangeCollectionView.axaml.cs +++ b/src/Views/ChangeCollectionView.axaml.cs @@ -3,9 +3,11 @@ 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 @@ -19,6 +21,10 @@ 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); } @@ -27,71 +33,125 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) } } - public class ChangeCollectionContainer : ListBox + public class ChangeCollectionContainer : ListBoxEx { protected override Type StyleKeyOverride => typeof(ListBox); protected override void OnKeyDown(KeyEventArgs e) { - if (SelectedItems is [ViewModels.ChangeTreeNode { IsFolder: true } node] && e.KeyModifiers == KeyModifiers.None) + if (SelectedItems is [ViewModels.ChangeTreeNode node] && e.KeyModifiers == KeyModifiers.None) { - if ((node.IsExpanded && e.Key == Key.Left) || (!node.IsExpanded && e.Key == Key.Right)) + 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) { - this.FindAncestorOfType()?.ToggleNodeIsExpanded(node); - e.Handled = true; + 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 && e.Key != Key.Space) + 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 StyledProperty IsUnstagedChangeProperty = - AvaloniaProperty.Register(nameof(IsUnstagedChange)); + public static readonly DirectProperty IsUnstagedChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsUnstagedChange), + static o => o.IsUnstagedChange, + static (o, v) => o.IsUnstagedChange = v); public bool IsUnstagedChange { - get => GetValue(IsUnstagedChangeProperty); - set => SetValue(IsUnstagedChangeProperty, value); + get => _isUnstagedChange; + set => SetAndRaise(IsUnstagedChangeProperty, ref _isUnstagedChange, value); } - public static readonly StyledProperty SelectionModeProperty = - AvaloniaProperty.Register(nameof(SelectionMode)); + public static readonly DirectProperty ViewModeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ViewMode), + static o => o.ViewMode, + static (o, v) => o.ViewMode = v); - public SelectionMode SelectionMode + public Models.ChangeViewMode ViewMode { - get => GetValue(SelectionModeProperty); - set => SetValue(SelectionModeProperty, value); + get => _viewMode; + set => SetAndRaise(ViewModeProperty, ref _viewMode, value); } - public static readonly StyledProperty ViewModeProperty = - AvaloniaProperty.Register(nameof(ViewMode), Models.ChangeViewMode.Tree); + public static readonly DirectProperty EnableCompactFoldersProperty = + AvaloniaProperty.RegisterDirect( + nameof(EnableCompactFolders), + static o => o.EnableCompactFolders, + static (o, v) => o.EnableCompactFolders = v); - public Models.ChangeViewMode ViewMode + public bool EnableCompactFolders { - get => GetValue(ViewModeProperty); - set => SetValue(ViewModeProperty, value); + get => _enableCompactFolders; + set => SetAndRaise(EnableCompactFoldersProperty, ref _enableCompactFolders, value); } - public static readonly StyledProperty> ChangesProperty = - AvaloniaProperty.Register>(nameof(Changes)); + public static readonly DirectProperty> ChangesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(Changes), + static o => o.Changes, + static (o, v) => o.Changes = v); public List Changes { - get => GetValue(ChangesProperty); - set => SetValue(ChangesProperty, value); + get => _changes; + set => SetAndRaise(ChangesProperty, ref _changes, value); } - public static readonly StyledProperty> SelectedChangesProperty = - AvaloniaProperty.Register>(nameof(SelectedChanges)); + public static readonly DirectProperty> SelectedChangesProperty = + AvaloniaProperty.RegisterDirect>( + nameof(SelectedChanges), + static o => o.SelectedChanges, + static (o, v) => o.SelectedChanges = v); public List SelectedChanges { - get => GetValue(SelectedChangesProperty); - set => SetValue(SelectedChangesProperty, value); + get => _selectedChanges; + set => SetAndRaise(SelectedChangesProperty, ref _selectedChanges, value); } public static readonly RoutedEvent ChangeDoubleTappedEvent = @@ -110,7 +170,7 @@ public ChangeCollectionView() public void ToggleNodeIsExpanded(ViewModels.ChangeTreeNode node) { - if (Content is ViewModels.ChangeCollectionAsTree tree) + if (Content is ViewModels.ChangeCollectionAsTree tree && node.IsFolder) { node.IsExpanded = !node.IsExpanded; @@ -136,6 +196,7 @@ public void ToggleNodeIsExpanded(ViewModels.ChangeTreeNode node) removeCount++; } + tree.Rows.RemoveRange(idx + 1, removeCount); } } @@ -143,7 +204,7 @@ public void ToggleNodeIsExpanded(ViewModels.ChangeTreeNode node) public Models.Change GetNextChangeWithoutSelection() { - var selected = SelectedChanges; + var selected = _selectedChanges; var changes = Changes; if (selected == null || selected.Count == 0) return changes.Count > 0 ? changes[0] : null; @@ -152,7 +213,10 @@ public Models.Change GetNextChangeWithoutSelection() var set = new HashSet(); foreach (var c in selected) - set.Add(c.Path); + { + if (!c.IsConflicted) + set.Add(c.Path); + } if (Content is ViewModels.ChangeCollectionAsTree tree) { @@ -200,22 +264,56 @@ public Models.Change GetNextChangeWithoutSelection() 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(false); - else if (change.Property == ChangesProperty) 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) { - var grid = sender as Grid; - if (grid?.DataContext is ViewModels.ChangeTreeNode node) + if (sender is not Control { DataContext: { } ctx }) + return; + + if (ctx is ViewModels.ChangeTreeNode node) { if (node.IsFolder) { @@ -230,7 +328,7 @@ private void OnRowDoubleTapped(object sender, TappedEventArgs e) RaiseEvent(new RoutedEventArgs(ChangeDoubleTappedEvent)); } } - else if (grid?.DataContext is Models.Change) + else if (ctx is Models.Change) { RaiseEvent(new RoutedEventArgs(ChangeDoubleTappedEvent)); } @@ -258,7 +356,7 @@ private void OnRowSelectionChanged(object sender, SelectionChangedEventArgs _) var old = SelectedChanges ?? []; if (old.Count != selected.Count) { - SetCurrentValue(SelectedChangesProperty, selected); + SelectedChanges = selected; } else { @@ -273,7 +371,7 @@ private void OnRowSelectionChanged(object sender, SelectionChangedEventArgs _) } if (!allEquals) - SetCurrentValue(SelectedChangesProperty, selected); + SelectedChanges = selected; } _disableSelectionChangingEvent = false; @@ -292,11 +390,11 @@ private void MakeTreeRows(List rows, List oldFolded = new HashSet(); + var oldFolded = new HashSet(); if (Content is ViewModels.ChangeCollectionAsTree oldTree) { foreach (var row in oldTree.Rows) @@ -318,7 +416,7 @@ private void UpdateDataSource(bool disableEvents) } var tree = new ViewModels.ChangeCollectionAsTree(); - tree.Tree = ViewModels.ChangeTreeNode.Build(changes, oldFolded); + tree.Tree = ViewModels.ChangeTreeNode.Build(changes, oldFolded, EnableCompactFolders); var rows = new List(); MakeTreeRows(rows, tree.Tree); @@ -326,10 +424,7 @@ private void UpdateDataSource(bool disableEvents) if (selected.Count > 0) { - var sets = new HashSet(); - foreach (var c in selected) - sets.Add(c); - + var sets = new HashSet(selected); var nodes = new List(); foreach (var row in tree.Rows) { @@ -348,6 +443,7 @@ private void UpdateDataSource(bool disableEvents) grid.Changes.AddRange(changes); if (selected.Count > 0) grid.SelectedChanges.AddRange(selected); + Content = grid; } else @@ -356,6 +452,7 @@ private void UpdateDataSource(bool disableEvents) list.Changes.AddRange(changes); if (selected.Count > 0) list.SelectedChanges.AddRange(selected); + Content = list; } @@ -369,17 +466,14 @@ private void UpdateSelection() _disableSelectionChangingEvent = true; - var selected = SelectedChanges ?? []; + var selected = _selectedChanges ?? []; if (Content is ViewModels.ChangeCollectionAsTree tree) { tree.SelectedRows.Clear(); if (selected.Count > 0) { - var sets = new HashSet(); - foreach (var c in selected) - sets.Add(c); - + var sets = new HashSet(selected); var nodes = new List(); foreach (var row in tree.Rows) { @@ -419,6 +513,26 @@ private void CollectChangesInNode(List outs, ViewModels.ChangeTre } } + 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 5d34be09c..ee48cbc89 100644 --- a/src/Views/ChangeStatusIcon.cs +++ b/src/Views/ChangeStatusIcon.cs @@ -4,111 +4,70 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; +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(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), - }, + 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 = ["?", "±", "T", "+", "−", "➜", "❏", "U", "★"]; - private static readonly string[] TIPS = ["Unknown", "Modified", "Type Changed", "Added", "Deleted", "Renamed", "Copied", "Unmerged", "Untracked"]; - - public static readonly StyledProperty IsUnstagedChangeProperty = - AvaloniaProperty.Register(nameof(IsUnstagedChange)); + public static readonly DirectProperty IsUnstagedChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsUnstagedChange), + static o => o.IsUnstagedChange, + static (o, v) => o.IsUnstagedChange = v); public bool IsUnstagedChange { - get => GetValue(IsUnstagedChangeProperty); - set => SetValue(IsUnstagedChangeProperty, value); + get => _isUnstagedChange; + set => SetAndRaise(IsUnstagedChangeProperty, ref _isUnstagedChange, value); } - public static readonly StyledProperty ChangeProperty = - AvaloniaProperty.Register(nameof(Change)); + public static readonly DirectProperty ChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(Change), + static o => o.Change, + static (o, v) => o.Change = v); public Models.Change Change { - get => GetValue(ChangeProperty); - set => SetValue(ChangeProperty, value); + get => _change; + set => SetAndRaise(ChangeProperty, ref _change, value); } public override void Render(DrawingContext context) { - if (Change == null || Bounds.Width <= 0) + if (_change == null || Bounds.Width <= 0) return; - var typeface = new Typeface("fonts:SourceGit#JetBrains Mono"); + 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; - string indicator; - if (IsUnstagedChange) + var background = new LinearGradientBrush { - 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]; - } + 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, @@ -129,22 +88,12 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang base.OnPropertyChanged(change); if (change.Property == IsUnstagedChangeProperty || change.Property == ChangeProperty) - { - var isUnstaged = IsUnstagedChange; - var c = Change; - if (c == null) - { - ToolTip.SetTip(this, null); - return; - } - - if (isUnstaged) - ToolTip.SetTip(this, c.IsConflit ? "Conflict" : TIPS[(int)c.WorkTree]); - else - ToolTip.SetTip(this, TIPS[(int)c.Index]); - 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/FastForwardWithoutCheckout.axaml.cs b/src/Views/ChangeSubmoduleUrl.axaml.cs similarity index 52% rename from src/Views/FastForwardWithoutCheckout.axaml.cs rename to src/Views/ChangeSubmoduleUrl.axaml.cs index 0e3ba20d1..287c20d8d 100644 --- a/src/Views/FastForwardWithoutCheckout.axaml.cs +++ b/src/Views/ChangeSubmoduleUrl.axaml.cs @@ -2,9 +2,9 @@ namespace SourceGit.Views { - public partial class FastForwardWithoutCheckout : UserControl + public partial class ChangeSubmoduleUrl : UserControl { - public FastForwardWithoutCheckout() + public ChangeSubmoduleUrl() { InitializeComponent(); } diff --git a/src/Views/ChangeViewModeSwitcher.axaml b/src/Views/ChangeViewModeSwitcher.axaml index 4ded60c7f..b72fcbcb8 100644 --- a/src/Views/ChangeViewModeSwitcher.axaml +++ b/src/Views/ChangeViewModeSwitcher.axaml @@ -3,41 +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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.ChangeViewModeSwitcher" - x:DataType="v:ChangeViewModeSwitcher"> + x:Name="ThisControl"> diff --git a/src/Views/ChangeViewModeSwitcher.axaml.cs b/src/Views/ChangeViewModeSwitcher.axaml.cs index 0cb2c4a9d..a51eccefd 100644 --- a/src/Views/ChangeViewModeSwitcher.axaml.cs +++ b/src/Views/ChangeViewModeSwitcher.axaml.cs @@ -1,28 +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)); + 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); + get => _viewMode; + set => SetAndRaise(ViewModeProperty, ref _viewMode, value); } public ChangeViewModeSwitcher() { - DataContext = this; InitializeComponent(); } - public void SwitchMode(object param) + private void SwitchToList(object sender, RoutedEventArgs e) { - ViewMode = (Models.ChangeViewMode)param; + 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 eaf2e79e0..3d91c7072 100644 --- a/src/Views/Checkout.axaml +++ b/src/Views/Checkout.axaml @@ -2,53 +2,44 @@ 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:ac="using:Avalonia.Controls.Converters" + 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/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/CheckoutCommit.axaml b/src/Views/CheckoutCommit.axaml deleted file mode 100644 index 370215657..000000000 --- a/src/Views/CheckoutCommit.axaml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - 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 1ba62ff75..85cd19514 100644 --- a/src/Views/CherryPick.axaml +++ b/src/Views/CherryPick.axaml @@ -9,10 +9,17 @@ x:Class="SourceGit.Views.CherryPick" x:DataType="vm:CherryPick"> - - + + + + + + + - - - + + + @@ -66,22 +81,29 @@ - - + + - - - + diff --git a/src/Views/ChromelessWindow.cs b/src/Views/ChromelessWindow.cs index 107a7ba3b..a06ab7565 100644 --- a/src/Views/ChromelessWindow.cs +++ b/src/Views/ChromelessWindow.cs @@ -1,9 +1,9 @@ using System; - +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; -using Avalonia.Platform; +using Avalonia.Interactivity; namespace SourceGit.Views { @@ -11,38 +11,21 @@ public class ChromelessWindow : Window { public bool UseSystemWindowFrame { - get => OperatingSystem.IsLinux() && ViewModels.Preference.Instance.UseSystemWindowFrame; + get => Native.OS.UseSystemWindowFrame; } + public bool CloseOnESC + { + get; + set; + } = false; + protected override Type StyleKeyOverride => typeof(Window); public ChromelessWindow() { - if (OperatingSystem.IsLinux()) - { - if (UseSystemWindowFrame) - { - ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.Default; - ExtendClientAreaToDecorationsHint = false; - } - else - { - ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; - ExtendClientAreaToDecorationsHint = true; - Classes.Add("custom_window_frame"); - } - } - else if (OperatingSystem.IsWindows()) - { - ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; - ExtendClientAreaToDecorationsHint = true; - Classes.Add("fix_maximized_padding"); - } - else - { - ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.SystemChrome; - ExtendClientAreaToDecorationsHint = true; - } + Focusable = true; + Native.OS.SetupForWindow(this); } public void BeginMoveWindow(object _, PointerPressedEventArgs e) @@ -92,9 +75,61 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) } } + 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 border && border.Tag is WindowEdge edge && CanResize) + if (sender is Border { Tag: WindowEdge edge } && CanResize) BeginResizeDrag(edge, e); } } diff --git a/src/Views/Cleanup.axaml b/src/Views/Cleanup.axaml index d385712de..18dbb9eea 100644 --- a/src/Views/Cleanup.axaml +++ b/src/Views/Cleanup.axaml @@ -7,9 +7,16 @@ x:Class="SourceGit.Views.Cleanup" x:DataType="vm:Cleanup"> - + + + + + + diff --git a/src/Views/ClearStashes.axaml b/src/Views/ClearStashes.axaml index b986211b5..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"> - + + + + + - + + - - + + + + - + CornerRadius="3" + Text="{Binding Remote, Mode=TwoWay}"> + + + + + + + + + + + + - - - - @@ -86,10 +88,10 @@ - - - - + + + + @@ -101,8 +103,8 @@ - - + + @@ -111,63 +113,106 @@ - - - - + PointerPressed="OnSHAPressed" + ContextRequested="OnSHAContextRequested" + ToolTip.ShowDelay="0"> + + + + + + - - + + - + - + - - - + + + - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CommitBaseInfo.axaml.cs b/src/Views/CommitBaseInfo.axaml.cs index 8f480745e..fb6be7d02 100644 --- a/src/Views/CommitBaseInfo.axaml.cs +++ b/src/Views/CommitBaseInfo.axaml.cs @@ -1,58 +1,72 @@ -using System.Threading.Tasks; +using System; +using System.Collections.Generic; using Avalonia; -using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Threading; namespace SourceGit.Views { public partial class CommitBaseInfo : UserControl { - public static readonly StyledProperty MessageProperty = - AvaloniaProperty.Register(nameof(Message), string.Empty); + public static readonly DirectProperty FullMessageProperty = + AvaloniaProperty.RegisterDirect( + nameof(FullMessage), + static o => o.FullMessage, + static (o, v) => o.FullMessage = v); - public string Message + public Models.CommitFullMessage FullMessage { - get => GetValue(MessageProperty); - set => SetValue(MessageProperty, value); + get => _fullMessage; + set => SetAndRaise(FullMessageProperty, ref _fullMessage, value); } - public static readonly StyledProperty SignInfoProperty = - AvaloniaProperty.Register(nameof(SignInfo)); + public static readonly DirectProperty SignInfoProperty = + AvaloniaProperty.RegisterDirect( + nameof(SignInfo), + static o => o.SignInfo, + static (o, v) => o.SignInfo = v); public Models.CommitSignInfo SignInfo { - get => GetValue(SignInfoProperty); - set => SetValue(SignInfoProperty, value); + get => _signInfo; + set => SetAndRaise(SignInfoProperty, ref _signInfo, value); } - public static readonly StyledProperty SupportsContainsInProperty = - AvaloniaProperty.Register(nameof(SupportsContainsIn)); + public static readonly DirectProperty> WebLinksProperty = + AvaloniaProperty.RegisterDirect>( + nameof(WebLinks), + static o => o.WebLinks, + static (o, v) => o.WebLinks = v); - public bool SupportsContainsIn + public List WebLinks { - get => GetValue(SupportsContainsInProperty); - set => SetValue(SupportsContainsInProperty, value); + get => _webLinks; + set => SetAndRaise(WebLinksProperty, ref _webLinks, value); } - public static readonly StyledProperty> WebLinksProperty = - AvaloniaProperty.Register>(nameof(WebLinks)); + public static readonly DirectProperty IsSHACopiedProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsSHACopied), + static o => o.IsSHACopied); - public AvaloniaList WebLinks + public bool IsSHACopied { - get => GetValue(WebLinksProperty); - set => SetValue(WebLinksProperty, value); + get => _isSHACopied; + private set => SetAndRaise(IsSHACopiedProperty, ref _isSHACopied, value); } - public static readonly StyledProperty> IssueTrackerRulesProperty = - AvaloniaProperty.Register>(nameof(IssueTrackerRules)); + public static readonly DirectProperty SupportsContainsInProperty = + AvaloniaProperty.RegisterDirect( + nameof(SupportsContainsIn), + static o => o.SupportsContainsIn); - public AvaloniaList IssueTrackerRules + public bool SupportsContainsIn { - get => GetValue(IssueTrackerRulesProperty); - set => SetValue(IssueTrackerRulesProperty, value); + get => _supportsContainsIn; + private set => SetAndRaise(SupportsContainsInProperty, ref _supportsContainsIn, value); } public CommitBaseInfo() @@ -60,11 +74,78 @@ public CommitBaseInfo() InitializeComponent(); } - private void OnCopyCommitSHA(object sender, RoutedEventArgs e) + 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 }) - App.CopyText(commit.SHA); + await this.CopyTextAsync(commit.SHA); + IsSHACopied = true; + _iconResetTimer?.Start(); e.Handled = true; } @@ -90,7 +171,7 @@ private void OnOpenWebLink(object sender, RoutedEventArgs e) menu.Items.Add(item); } - menu?.Open(control); + menu.Open(control); } else if (links.Count == 1) { @@ -102,14 +183,16 @@ private void OnOpenWebLink(object sender, RoutedEventArgs e) e.Handled = true; } - private void OnOpenContainsIn(object sender, RoutedEventArgs e) + private async void OnOpenContainsIn(object sender, RoutedEventArgs e) { if (DataContext is ViewModels.CommitDetail detail && sender is Button button) { - var tracking = new CommitRelationTracking(detail); + var tracking = new CommitRelationTracking(); var flyout = new Flyout(); flyout.Content = tracking; flyout.ShowAt(button); + + await tracking.SetDataAsync(detail); } e.Handled = true; @@ -120,19 +203,12 @@ 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 == sha) - { - ToolTip.SetIsOpen(ctl, true); - } - else - { - var c = await Task.Run(() => detail.GetParent(sha)); - if (c != null) - { - ToolTip.SetTip(ctl, c); - ToolTip.SetIsOpen(ctl, ctl.IsPointerOver); - } - } + 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; @@ -140,12 +216,114 @@ private async void OnSHAPointerEntered(object sender, PointerEventArgs e) private void OnSHAPressed(object sender, PointerPressedEventArgs e) { - if (DataContext is ViewModels.CommitDetail detail && sender is Control { DataContext: string sha }) - { + 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 1c7b34bd6..d7d245ccf 100644 --- a/src/Views/CommitChanges.axaml +++ b/src/Views/CommitChanges.axaml @@ -9,17 +9,18 @@ x:DataType="vm:CommitDetail"> - + - + + ViewMode="{Binding Source={x:Static vm:Preferences.Instance}, Path=CommitChangeViewMode, Mode=TwoWay}"/> - + ContextRequested="OnChangeContextRequested" + KeyDown="OnChangeCollectionViewKeyDown"/> + + + + + + + + + Background="Transparent" + Focusable="False"/> diff --git a/src/Views/CommitChanges.axaml.cs b/src/Views/CommitChanges.axaml.cs index c3d300182..0c1dea0df 100644 --- a/src/Views/CommitChanges.axaml.cs +++ b/src/Views/CommitChanges.axaml.cs @@ -1,4 +1,9 @@ +using System; +using System.Text; + using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.VisualTree; namespace SourceGit.Views { @@ -11,15 +16,60 @@ public CommitChanges() private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) { - if (sender is ChangeCollectionView { SelectedChanges: { } selected } view && - selected.Count == 1 && - DataContext is ViewModels.CommitDetail vm) + e.Handled = true; + + if (sender is not ChangeCollectionView { SelectedChanges: { Count: > 0 } changes } view) + return; + + 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 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 menu = vm.CreateChangeContextMenu(selected[0]); - menu?.Open(view); - } + 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 cb99b3d90..b0fbf5393 100644 --- a/src/Views/CommitDetail.axaml +++ b/src/Views/CommitDetail.axaml @@ -8,32 +8,38 @@ xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.CommitDetail" - x:DataType="vm:CommitDetail"> - + x:DataType="vm:CommitDetail" + x:Name="ThisControl"> + - + + + - + - + Margin="0,0,12,0"/> - + - + ItemsSource="{Binding Changes, Converter={x:Static c:ListConverters.Top100Changes}}" + KeyDown="OnCommitListKeyDown"> - + - + - + + + + + + + + - + - @@ -73,16 +87,22 @@ - + + + - + - + + + - + diff --git a/src/Views/CommitDetail.axaml.cs b/src/Views/CommitDetail.axaml.cs index f0599c660..91bada728 100644 --- a/src/Views/CommitDetail.axaml.cs +++ b/src/Views/CommitDetail.axaml.cs @@ -1,21 +1,538 @@ +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); + } + public CommitDetail() { InitializeComponent(); } + 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 = "-" }); + } + + 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; + } + + 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; + + 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 grid && grid.DataContext is Models.Change change) + if (DataContext is ViewModels.CommitDetail detail && sender is Grid { DataContext: Models.Change change }) { - detail.ActivePageIndex = 1; detail.SelectedChanges = new() { change }; + detail.ActiveTabIndex = 1; } e.Handled = true; @@ -23,13 +540,11 @@ private void OnChangeDoubleTapped(object sender, TappedEventArgs e) private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) { - if (DataContext is ViewModels.CommitDetail detail && sender is Grid grid && grid.DataContext is Models.Change change) - { - var menu = detail.CreateChangeContextMenu(change); - menu?.Open(grid); - } - + 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/StandaloneCommitMessageEditor.axaml b/src/Views/CommitMessageEditor.axaml similarity index 72% rename from src/Views/StandaloneCommitMessageEditor.axaml rename to src/Views/CommitMessageEditor.axaml index 8a0dc91aa..ee1aac3c8 100644 --- a/src/Views/StandaloneCommitMessageEditor.axaml +++ b/src/Views/CommitMessageEditor.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:v="using:SourceGit.Views" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="SourceGit.Views.StandaloneCommitMessageEditor" + x:Class="SourceGit.Views.CommitMessageEditor" x:Name="ThisControl" Icon="/App.ico" Title="{DynamicResource Text.CodeEditor}" @@ -36,13 +36,21 @@ - - + + + + + + + + + + + + + + + + + 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 index e8a66da0c..51aae1359 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -8,15 +8,72 @@ 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 Geometry Icon { get; set; } = null; + 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 = @@ -55,6 +112,15 @@ public IBrush Foreground 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)); @@ -64,25 +130,35 @@ public bool UseGraphColor set => SetValue(UseGraphColorProperty, value); } - public static readonly StyledProperty TagBackgroundProperty = - AvaloniaProperty.Register(nameof(TagBackground), Brushes.White); + public static readonly StyledProperty AllowWrapProperty = + AvaloniaProperty.Register(nameof(AllowWrap)); - public IBrush TagBackground + public bool AllowWrap { - get => GetValue(TagBackgroundProperty); - set => SetValue(TagBackgroundProperty, value); + get => GetValue(AllowWrapProperty); + set => SetValue(AllowWrapProperty, value); } - static CommitRefsPresenter() + public static readonly StyledProperty ShowTagsProperty = + AvaloniaProperty.Register(nameof(ShowTags), true); + + public bool ShowTags { - AffectsMeasure( - FontFamilyProperty, - FontSizeProperty, - ForegroundProperty, - TagBackgroundProperty); + 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; + } - AffectsRender( - BackgroundProperty); + return null; } public override void Render(DrawingContext context) @@ -93,11 +169,21 @@ public override void Render(DrawingContext context) var useGraphColor = UseGraphColor; var fg = Foreground; var bg = Background; - var x = 1.0; + var allowWrap = AllowWrap; + var x = 1.5; + var y = 0.5; + + context.FillRectangle(Brushes.Transparent, Bounds); + foreach (var item in _items) { - var entireRect = new RoundedRect(new Rect(x, 0, item.Width, 16), new CornerRadius(2)); + 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) @@ -108,31 +194,58 @@ public override void Render(DrawingContext context) using (context.PushOpacity(.6)) context.DrawRectangle(item.Brush, null, entireRect); } - - context.DrawText(item.Label, new Point(x + 16, 8.0 - item.Label.Height * 0.5)); } else { if (bg != null) context.DrawRectangle(bg, null, entireRect); - var labelRect = new RoundedRect(new Rect(x + 16, 0, item.Label.Width + 8, 16), new CornerRadius(0, 2, 2, 0)); + 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, 0), new Point(x + 16, 16)); - context.DrawText(item.Label, new Point(x + 20, 8.0 - item.Label.Height * 0.5)); + 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); - using (context.PushTransform(Matrix.CreateTranslation(x + 3, 3))) - context.DrawGeometry(fg, null, item.Icon); + 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); @@ -143,83 +256,123 @@ protected override Size MeasureOverride(Size availableSize) { _items.Clear(); - var commit = DataContext as Models.Commit; - if (commit == null) + if (DataContext is not Models.Commit commit) return new Size(0, 0); var refs = commit.Decorators; - if (refs != null && refs.Count > 0) + 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++) { - var typeface = new Typeface(FontFamily); - var typefaceBold = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); - var fg = Foreground; - var normalBG = UseGraphColor ? commit.Brush : Brushes.Gray; - var tagBG = TagBackground; - var labelSize = FontSize; - var requiredWidth = 0.0; - - foreach (var decorator in refs) + if (skippedIdx.Contains(i)) + continue; + + var decorator = refs[i]; + if (!showTags && decorator.Type == Models.DecoratorType.Tag) + continue; + + var item = new RenderItem() { - var isHead = decorator.Type == Models.DecoratorType.CurrentBranchHead || - decorator.Type == Models.DecoratorType.CurrentCommitHead; + 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); - var label = new FormattedText( + 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, - isHead ? typefaceBold : typeface, - isHead ? labelSize + 1 : labelSize, + typeface, + labelSize, fg); + } - var item = new RenderItem() - { - Label = label, - Brush = normalBG, - IsHead = isHead - }; + item.Width = item.Label.Width + 24; - StreamGeometry geo; - switch (decorator.Type) + var findRemotes = useCompactBranchNames && (decorator.Type == Models.DecoratorType.CurrentBranchHead || decorator.Type == Models.DecoratorType.LocalBranchHead); + if (findRemotes) + { + for (var j = i + 1; j < count; j++) { - case Models.DecoratorType.CurrentBranchHead: - case Models.DecoratorType.CurrentCommitHead: - geo = this.FindResource("Icons.Head") as StreamGeometry; - break; - case Models.DecoratorType.RemoteBranchHead: - geo = this.FindResource("Icons.Remote") as StreamGeometry; - break; - case Models.DecoratorType.Tag: - item.Brush = tagBG; - geo = this.FindResource("Icons.Tag") as StreamGeometry; - break; - default: - geo = this.FindResource("Icons.Branch") as StreamGeometry; - break; + 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); + } } + } - var drawGeo = geo!.Clone(); - var iconBounds = drawGeo.Bounds; - var translation = Matrix.CreateTranslation(-(Vector)iconBounds.Position); - var scale = Math.Min(10.0 / iconBounds.Width, 10.0 / iconBounds.Height); - var transform = translation * Matrix.CreateScale(scale, scale); - if (drawGeo.Transform == null || drawGeo.Transform.Value == Matrix.Identity) - drawGeo.Transform = new MatrixTransform(transform); - else - drawGeo.Transform = new MatrixTransform(drawGeo.Transform.Value * transform); - - item.Icon = drawGeo; - item.Width = 16 + (isHead ? 0 : 4) + label.Width + 4; - _items.Add(item); - - requiredWidth += item.Width + 4; + x += item.Width + 4; + if (allowWrap) + { + if (x > availableSize.Width) + { + requiredHeight += 20.0; + x = item.Width; + } } + } - InvalidateVisual(); - return new Size(requiredWidth + 2, 16); + 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(0, 0); + return new Size(requiredWidth, requiredHeight); } private List _items = new List(); diff --git a/src/Views/CommitRelationTracking.axaml b/src/Views/CommitRelationTracking.axaml index 5e3574d85..53906bfe3 100644 --- a/src/Views/CommitRelationTracking.axaml +++ b/src/Views/CommitRelationTracking.axaml @@ -23,14 +23,14 @@ - + - + diff --git a/src/Views/CommitRelationTracking.axaml.cs b/src/Views/CommitRelationTracking.axaml.cs index 1e436552c..ff3e85469 100644 --- a/src/Views/CommitRelationTracking.axaml.cs +++ b/src/Views/CommitRelationTracking.axaml.cs @@ -1,7 +1,5 @@ using System.Threading.Tasks; - using Avalonia.Controls; -using Avalonia.Threading; namespace SourceGit.Views { @@ -12,21 +10,12 @@ public CommitRelationTracking() InitializeComponent(); } - public CommitRelationTracking(ViewModels.CommitDetail detail) + public async Task SetDataAsync(ViewModels.CommitDetail detail) { - InitializeComponent(); - LoadingIcon.IsVisible = true; - - Task.Run(() => - { - var containsIn = detail.GetRefsContainsThisCommit(); - Dispatcher.UIThread.Invoke(() => - { - Container.ItemsSource = containsIn; - LoadingIcon.IsVisible = false; - }); - }); + 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 index 9c18ad040..aef9515f9 100644 --- a/src/Views/ConfigureWorkspace.axaml +++ b/src/Views/ConfigureWorkspace.axaml @@ -66,11 +66,11 @@ - @@ -79,32 +79,64 @@ - - - - - - - - - + + + - - - + + + + + + + + + + + + + + - - diff --git a/src/Views/ConfigureWorkspace.axaml.cs b/src/Views/ConfigureWorkspace.axaml.cs index e2cc1cb26..3ce2b4a8a 100644 --- a/src/Views/ConfigureWorkspace.axaml.cs +++ b/src/Views/ConfigureWorkspace.axaml.cs @@ -1,4 +1,7 @@ +using System; using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; namespace SourceGit.Views { @@ -6,13 +9,41 @@ public partial class ConfigureWorkspace : ChromelessWindow { public ConfigureWorkspace() { + CloseOnESC = true; InitializeComponent(); } protected override void OnClosing(WindowClosingEventArgs e) { - ViewModels.Preference.Instance.Save(); 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/ConfirmCommitWithoutFiles.axaml b/src/Views/Confirm.axaml similarity index 86% rename from src/Views/ConfirmCommitWithoutFiles.axaml rename to src/Views/Confirm.axaml index a056f0167..a9055f71a 100644 --- a/src/Views/ConfirmCommitWithoutFiles.axaml +++ b/src/Views/Confirm.axaml @@ -3,10 +3,8 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:v="using:SourceGit.Views" - xmlns:vm="using:SourceGit.ViewModels" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="SourceGit.Views.ConfirmCommitWithoutFiles" - x:DataType="vm:ConfirmCommitWithoutFiles" + x:Class="SourceGit.Views.Confirm" x:Name="ThisControl" Icon="/App.ico" Title="{DynamicResource Text.Warn}" @@ -38,27 +36,28 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 index 4fe8b8f9e..394901dc9 100644 --- a/src/Views/ConventionalCommitMessageBuilder.axaml +++ b/src/Views/ConventionalCommitMessageBuilder.axaml @@ -47,13 +47,34 @@ + ItemsSource="{Binding Types, Mode=OneWay}" + SelectedItem="{Binding SelectedType, Mode=TwoWay}" + Grid.IsSharedSizeScope="True"> + + + + + + + + + + + - - - - + + + + + + + + + + + + + @@ -66,7 +87,7 @@ @@ -77,7 +98,7 @@ @@ -110,7 +131,7 @@ ScrollViewer.VerticalScrollBarVisibility="Auto" VerticalAlignment="Center" VerticalContentAlignment="Top" - CornerRadius="2" + CornerRadius="3" Watermark="{DynamicResource Text.Optional}" Text="{Binding BreakingChanges, Mode=TwoWay}"/> @@ -121,7 +142,7 @@ @@ -130,9 +151,13 @@ + + - - - + + - - + + ToolTip.Tip="{DynamicResource Text.Diff.VisualLines.All}" + PropertyChanged="OnToggleButtonPropertyChanged"> @@ -95,42 +165,56 @@ - - + + - - - - - + - + ToolTip.Tip="{DynamicResource Text.Diff.SideBySide}" + PropertyChanged="OnToggleButtonPropertyChanged"> + - @@ -149,20 +233,18 @@ Foreground="{DynamicResource Brush.FG2}" HorizontalAlignment="Center"/> - + - + - - + - + - - + @@ -176,20 +258,18 @@ Foreground="{DynamicResource Brush.FG2}" HorizontalAlignment="Center"/> - + - + - - + - + - - + @@ -206,27 +286,65 @@ - + + + + + - - - + + + + + + + + + + - + - - - - + + + + + + + + + + + + + - - + + + + + + + + + + diff --git a/src/Views/EditRemote.axaml.cs b/src/Views/EditRemote.axaml.cs index 7d88704e5..655cbdcd2 100644 --- a/src/Views/EditRemote.axaml.cs +++ b/src/Views/EditRemote.axaml.cs @@ -17,7 +17,12 @@ private async void SelectSSHKey(object _, RoutedEventArgs e) if (toplevel == null) return; - var options = new FilePickerOpenOptions() { AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("SSHKey") { Patterns = ["*.*"] }] }; + 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; diff --git a/src/Views/EditRepositoryNode.axaml b/src/Views/EditRepositoryNode.axaml index ac8f50f3a..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,43 +9,58 @@ x:Class="SourceGit.Views.EditRepositoryNode" x:DataType="vm:EditRepositoryNode"> - + + - + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/Views/ExecuteCustomAction.axaml b/src/Views/ExecuteCustomAction.axaml index 9ee2b55d2..6769f9481 100644 --- a/src/Views/ExecuteCustomAction.axaml +++ b/src/Views/ExecuteCustomAction.axaml @@ -2,18 +2,177 @@ 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:v="using:SourceGit.Views" + xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450" x:Class="SourceGit.Views.ExecuteCustomAction" x:DataType="vm:ExecuteCustomAction"> - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/ExecuteCustomAction.axaml.cs b/src/Views/ExecuteCustomAction.axaml.cs index e4f9cecf9..5589101e0 100644 --- a/src/Views/ExecuteCustomAction.axaml.cs +++ b/src/Views/ExecuteCustomAction.axaml.cs @@ -1,4 +1,8 @@ +using System; + using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; namespace SourceGit.Views { @@ -8,5 +12,50 @@ 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 b/src/Views/FastForwardWithoutCheckout.axaml deleted file mode 100644 index 16b402566..000000000 --- a/src/Views/FastForwardWithoutCheckout.axaml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - diff --git a/src/Views/Fetch.axaml b/src/Views/Fetch.axaml index a9c2fd900..c9de0e609 100644 --- a/src/Views/Fetch.axaml +++ b/src/Views/Fetch.axaml @@ -8,10 +8,17 @@ x:Class="SourceGit.Views.Fetch" x:DataType="vm:Fetch"> - - + + + + + + + + SelectedItem="{Binding SelectedRemote, Mode=TwoWay}"> + + + + + + @@ -33,12 +45,21 @@ + Content="{DynamicResource Text.Fetch.Force}" + IsChecked="{Binding Force, Mode=TwoWay}" + ToolTip.Tip="--force"/> + + + IsChecked="{Binding NoTags, Mode=TwoWay}" + ToolTip.Tip="--no-tags"/> diff --git a/src/Views/FetchInto.axaml b/src/Views/FetchInto.axaml index 4a0c0966a..29f502547 100644 --- a/src/Views/FetchInto.axaml +++ b/src/Views/FetchInto.axaml @@ -7,9 +7,16 @@ x:Class="SourceGit.Views.FetchInto" x:DataType="vm:FetchInto"> - + + + + + + diff --git a/src/Views/FileHistories.axaml b/src/Views/FileHistories.axaml index 703957b84..f499c2caf 100644 --- a/src/Views/FileHistories.axaml +++ b/src/Views/FileHistories.axaml @@ -13,12 +13,7 @@ Icon="/App.ico" Title="{DynamicResource Text.FileHistory}" MinWidth="1280" MinHeight="720"> - - - - - - + @@ -37,13 +32,22 @@ Text="{DynamicResource Text.FileHistory}" HorizontalAlignment="Center" VerticalAlignment="Center" IsHitTestVisible="False"/> - + + + + + + + + + + - + @@ -55,9 +59,11 @@ BorderThickness="1" Margin="8,4,4,8" BorderBrush="{DynamicResource Brush.Border2}" - ItemsSource="{Binding Commits}" - SelectedItem="{Binding SelectedCommit, Mode=TwoWay}" - SelectionMode="Single" + ItemsSource="{Binding Revisions, Mode=OneWay}" + SelectionMode="Multiple" + SelectionChanged="OnRevisionsSelectionChanged" + PropertyChanged="OnRevisionsPropertyChanged" + ContextRequested="OnRevisionsContextRequested" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.VerticalScrollBarVisibility="Auto"> @@ -75,25 +81,33 @@ - + - + - + - + - + + + @@ -105,73 +119,164 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" BorderThickness="1,0,0,0" - BorderBrush="{DynamicResource Brush.Border0}"/> + BorderBrush="{DynamicResource Brush.Border0}" + Focusable="False"/> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - diff --git a/src/Views/FileHistories.axaml.cs b/src/Views/FileHistories.axaml.cs index 9d74892bd..697aee765 100644 --- a/src/Views/FileHistories.axaml.cs +++ b/src/Views/FileHistories.axaml.cs @@ -1,6 +1,11 @@ +using System; +using System.Collections.Generic; + +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Platform.Storage; namespace SourceGit.Views { @@ -11,31 +16,132 @@ public FileHistories() InitializeComponent(); } + 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.Commit commit } && + if (sender is TextBlock { DataContext: Models.FileVersion ver } && DataContext is ViewModels.FileHistories vm) { - vm.NavigateToCommit(commit); + vm.NavigateToCommit(ver); } e.Handled = true; } - private void OnResetToSelectedRevision(object _, RoutedEventArgs e) + private async void OnResetToSelectedRevision(object sender, RoutedEventArgs e) { - if (DataContext is ViewModels.FileHistories vm) + if (sender is Button { DataContext: ViewModels.FileHistoriesSingleRevision single }) { - vm.ResetToSelectedRevision(); - NotifyDonePanel.IsVisible = true; + await single.ResetToSelectedRevisionAsync(); + await new Alert().ShowAsync(this, "Reset to selected revision successfully.", false); } e.Handled = true; } - private void OnCloseNotifyPanel(object _, PointerPressedEventArgs e) + private async void OnSaveAsPatch(object sender, RoutedEventArgs e) { - NotifyDonePanel.IsVisible = false; + 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 index 0fbbbf8e3..7e3b21d73 100644 --- a/src/Views/FilterModeSwitchButton.axaml +++ b/src/Views/FilterModeSwitchButton.axaml @@ -5,17 +5,18 @@ xmlns:m="using:SourceGit.Models" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.FilterModeSwitchButton" - x:Name="ThisControl"> + x:Name="ThisControl"> + + + + + + + + + + + - + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 7edcb295a..777d29e5e 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -1,41 +1,48 @@ -using System; +using System; using System.Collections.Generic; +using System.Reflection; using System.Text; -using System.Text.RegularExpressions; +using System.Threading.Tasks; using Avalonia; using Avalonia.Collections; using Avalonia.Controls; -using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; -using Avalonia.Threading; +using Avalonia.Platform.Storage; using Avalonia.VisualTree; namespace SourceGit.Views { - public class LayoutableGrid : Grid + public class HistoriesLayout : Grid { - public static readonly StyledProperty UseHorizontalProperty = - AvaloniaProperty.Register(nameof(UseHorizontal)); + 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); + get => _useHorizontal; + set => SetAndRaise(UseHorizontalProperty, ref _useHorizontal, value); } protected override Type StyleKeyOverride => typeof(Grid); - static LayoutableGrid() + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { - UseHorizontalProperty.Changed.AddClassHandler((o, _) => o.RefreshLayout()); + base.OnPropertyChanged(change); + + if (change.Property == UseHorizontalProperty && IsLoaded) + RefreshLayout(); } - public override void ApplyTemplate() + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { - base.ApplyTemplate(); + base.OnAttachedToVisualTree(e); RefreshLayout(); } @@ -72,712 +79,1668 @@ private void RefreshLayout() } } } + + private bool _useHorizontal = false; } - public class CommitStatusIndicator : Control + public class HistoriesCommitList : DataGrid { - public static readonly StyledProperty CurrentBranchProperty = - AvaloniaProperty.Register(nameof(CurrentBranch)); + public static readonly DirectProperty TotalCommitsProperty = + AvaloniaProperty.RegisterDirect( + nameof(TotalCommits), + static o => o.TotalCommits, + static (o, v) => o.TotalCommits = v); - public Models.Branch CurrentBranch + public int TotalCommits { - get => GetValue(CurrentBranchProperty); - set => SetValue(CurrentBranchProperty, value); + get => _totalCommits; + set => SetAndRaise(TotalCommitsProperty, ref _totalCommits, value); } - public static readonly StyledProperty AheadBrushProperty = - AvaloniaProperty.Register(nameof(AheadBrush)); + public static readonly DirectProperty> SelectedCommitsProperty = + AvaloniaProperty.RegisterDirect>( + nameof(SelectedCommits), + static o => o.SelectedCommits, + static (o, v) => o.SelectedCommits = v); - public IBrush AheadBrush + public List SelectedCommits { - get => GetValue(AheadBrushProperty); - set => SetValue(AheadBrushProperty, value); + get => _selectedCommits; + set => SetAndRaise(SelectedCommitsProperty, ref _selectedCommits, value); } - public static readonly StyledProperty BehindBrushProperty = - AvaloniaProperty.Register(nameof(BehindBrush)); + protected override Type StyleKeyOverride => typeof(DataGrid); - public IBrush BehindBrush + public HistoriesCommitList() { - get => GetValue(BehindBrushProperty); - set => SetValue(BehindBrushProperty, value); + 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; } - enum Status + protected override void OnLoaded(RoutedEventArgs e) { - Normal, - Ahead, - Behind, + base.OnLoaded(e); + ApplySelection(); } - public override void Render(DrawingContext context) + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { - if (_status == Status.Normal) - return; - - context.DrawEllipse(_status == Status.Ahead ? AheadBrush : BehindBrush, null, new Rect(0, 0, 5, 5)); - } + base.OnPropertyChanged(change); - protected override Size MeasureOverride(Size availableSize) - { - if (DataContext is Models.Commit commit && CurrentBranch is not null) + if (change.Property == SelectedCommitsProperty && IsLoaded && !_ignoreSelectionChanged) { - var sha = commit.SHA; - var track = CurrentBranch.TrackStatus; - - if (track.Ahead.Contains(sha)) - _status = Status.Ahead; - else if (track.Behind.Contains(sha)) - _status = Status.Behind; + if (change.OldValue is List { Count: 1 } old && + change.NewValue is List { Count: 1 } cur && + old[0] == cur[0]) + ScrollIntoView(old[0], null); else - _status = Status.Normal; + ApplySelection(); } - else - { - _status = Status.Normal; - } - - return _status == Status.Normal ? new Size(0, 0) : new Size(9, 5); - } - - protected override void OnDataContextChanged(EventArgs e) - { - base.OnDataContextChanged(e); - InvalidateMeasure(); - } - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - if (change.Property == CurrentBranchProperty) - InvalidateMeasure(); - } - - private Status _status = Status.Normal; - } - - public partial class CommitSubjectPresenter : TextBlock - { - public static readonly StyledProperty SubjectProperty = - AvaloniaProperty.Register(nameof(Subject)); - - public string Subject - { - get => GetValue(SubjectProperty); - set => SetValue(SubjectProperty, value); } - public static readonly StyledProperty> IssueTrackerRulesProperty = - AvaloniaProperty.Register>(nameof(IssueTrackerRules)); - - public AvaloniaList IssueTrackerRules + protected override void OnSelectionChanged(SelectionChangedEventArgs e) { - get => GetValue(IssueTrackerRulesProperty); - set => SetValue(IssueTrackerRulesProperty, value); - } + base.OnSelectionChanged(e); - protected override Type StyleKeyOverride => typeof(TextBlock); - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); + if (ItemsSource is not IList items) + return; - if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty) + var commits = new List(); + foreach (var o in SelectedItems) { - Inlines!.Clear(); - _matches = null; - ClearHoveredIssueLink(); - - var subject = Subject; - if (string.IsNullOrEmpty(subject)) - return; - - var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject); - if (!keywordMatch.Success) - keywordMatch = REG_KEYWORD_FORMAT2().Match(subject); - - var rules = IssueTrackerRules ?? []; - var matches = new List(); - foreach (var rule in rules) - rule.Matches(matches, subject); + if (o is Models.Commit c) + commits.Add(c); + } - if (matches.Count == 0) + 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) { - if (keywordMatch.Success) - { - Inlines.Add(new Run(subject.Substring(0, keywordMatch.Length)) { FontWeight = FontWeight.Bold }); - Inlines.Add(new Run(subject.Substring(keywordMatch.Length))); - } + var prev = items[firstIndex - 1]; + if (commits.Contains(prev)) + ScrollIntoView(e.AddedItems[^1], null); else - { - Inlines.Add(new Run(subject)); - } - return; + ScrollIntoView(first, null); } + } - matches.Sort((l, r) => l.Start - r.Start); - _matches = matches; + if (!_ignoreSelectionChanged) + { + _ignoreSelectionChanged = true; - var inlines = new List(); - var pos = 0; - foreach (var match in matches) + var old = SelectedCommits; + if (old.Count != commits.Count) + { + SelectedCommits = commits; + } + else if (commits.Count > 0) { - if (match.Start > pos) + var set = new HashSet(); + foreach (var c in old) + set.Add(c.SHA); + + var equals = true; + foreach (var c in commits) { - if (keywordMatch.Success && pos < keywordMatch.Length) - { - if (keywordMatch.Length < match.Start) - { - inlines.Add(new Run(subject.Substring(pos, keywordMatch.Length - pos)) { FontWeight = FontWeight.Bold }); - inlines.Add(new Run(subject.Substring(keywordMatch.Length, match.Start - keywordMatch.Length))); - } - else - { - inlines.Add(new Run(subject.Substring(pos, match.Start - pos)) { FontWeight = FontWeight.Bold }); - } - } - else + if (!set.Contains(c.SHA)) { - inlines.Add(new Run(subject.Substring(pos, match.Start - pos))); + equals = false; + break; } } - var link = new Run(subject.Substring(match.Start, match.Length)); - link.Classes.Add("issue_link"); - inlines.Add(link); - - pos = match.Start + match.Length; - } - - if (pos < subject.Length) - { - if (keywordMatch.Success && pos < keywordMatch.Length) - { - inlines.Add(new Run(subject.Substring(pos, keywordMatch.Length - pos)) { FontWeight = FontWeight.Bold }); - inlines.Add(new Run(subject.Substring(keywordMatch.Length))); - } - else - { - inlines.Add(new Run(subject.Substring(pos))); - } + if (!equals) + SelectedCommits = commits; } - Inlines.AddRange(inlines); + _ignoreSelectionChanged = false; } } - protected override void OnPointerMoved(PointerEventArgs e) + protected override async void OnKeyDown(KeyEventArgs e) { - base.OnPointerMoved(e); - - if (_matches != null) + if (e.KeyModifiers == KeyModifiers.Alt) { - var point = e.GetPosition(this) - new Point(Padding.Left, Padding.Top); - var x = Math.Min(Math.Max(point.X, 0), Math.Max(TextLayout.WidthIncludingTrailingWhitespace, 0)); - var y = Math.Min(Math.Max(point.Y, 0), Math.Max(TextLayout.Height, 0)); - point = new Point(x, y); - - var textPosition = TextLayout.HitTestPoint(point).TextPosition; - foreach (var match in _matches) + if (e.Key == Key.Up) { - if (!match.Intersect(textPosition, 1)) - continue; - - if (match == _lastHover) - return; - - _lastHover = match; - SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); - ToolTip.SetTip(this, match.Link); - ToolTip.SetIsOpen(this, true); e.Handled = true; - return; + 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); } - ClearHoveredIssueLink(); + e.Handled = true; + await this.CopyTextAsync(builder.ToString()); } + + if (!e.Handled) + base.OnKeyDown(e); } - protected override void OnPointerPressed(PointerPressedEventArgs e) + private void ApplySelection() { - base.OnPointerPressed(e); + _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(); + } - if (_lastHover != null) - Native.OS.OpenBrowser(_lastHover.Link); + _ignoreSelectionChanged = false; } - protected override void OnPointerExited(PointerEventArgs e) + private void IncrNoSelectionChangeCount() { - base.OnPointerExited(e); - ClearHoveredIssueLink(); + 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 void ClearHoveredIssueLink() + private void DecrNoSelectionChangeCount() { - if (_lastHover != null) + var property = typeof(DataGrid).GetProperty("NoSelectionChangeCount", BindingFlags.Instance | BindingFlags.NonPublic); + if (property != null) { - ToolTip.SetTip(this, null); - SetCurrentValue(CursorProperty, Cursor.Parse("Arrow")); - _lastHover = null; + var old = (int)property.GetValue(this)!; + property.SetValue(this, old - 1); } } - [GeneratedRegex(@"^\[[\w\s]+\]")] - private static partial Regex REG_KEYWORD_FORMAT1(); - - [GeneratedRegex(@"^\S+([\<\(][\w\s_\-\*,]+[\>\)])?\!?\s?:\s")] - private static partial Regex REG_KEYWORD_FORMAT2(); - - private List _matches = null; - private Models.Hyperlink _lastHover = null; + private bool _ignoreSelectionChanged = false; + private int _totalCommits = 0; + private List _selectedCommits = []; } - public class CommitTimeTextBlock : TextBlock + public partial class Histories : UserControl { - public static readonly StyledProperty ShowAsDateTimeProperty = - AvaloniaProperty.Register(nameof(ShowAsDateTime), true); + public static readonly DirectProperty CurrentBranchProperty = + AvaloniaProperty.RegisterDirect( + nameof(CurrentBranch), + static o => o.CurrentBranch, + static (o, v) => o.CurrentBranch = v); - public bool ShowAsDateTime + public Models.Branch CurrentBranch { - get => GetValue(ShowAsDateTimeProperty); - set => SetValue(ShowAsDateTimeProperty, value); + get => _currentBranch; + set => SetAndRaise(CurrentBranchProperty, ref _currentBranch, value); } - public static readonly StyledProperty UseAuthorTimeProperty = - AvaloniaProperty.Register(nameof(UseAuthorTime), true); + public static readonly DirectProperty BisectProperty = + AvaloniaProperty.RegisterDirect( + nameof(Bisect), + static o => o.Bisect, + static (o, v) => o.Bisect = v); - public bool UseAuthorTime + public Models.Bisect Bisect { - get => GetValue(UseAuthorTimeProperty); - set => SetValue(UseAuthorTimeProperty, value); + get => _bisect; + set => SetAndRaise(BisectProperty, ref _bisect, value); } - protected override Type StyleKeyOverride => typeof(TextBlock); + public static readonly DirectProperty> IssueTrackersProperty = + AvaloniaProperty.RegisterDirect>( + nameof(IssueTrackers), + static o => o.IssueTrackers, + static (o, v) => o.IssueTrackers = v); - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + public AvaloniaList IssueTrackers { - base.OnPropertyChanged(change); + get => _issueTrackers; + set => SetAndRaise(IssueTrackersProperty, ref _issueTrackers, value); + } - if (change.Property == UseAuthorTimeProperty) + 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) { - SetCurrentValue(TextProperty, GetDisplayText()); + vm.NavigateTo(parents[0].SHA); } - else if (change.Property == ShowAsDateTimeProperty) + else if (parents.Count > 1 && TopLevel.GetTopLevel(this) is Window owner) { - SetCurrentValue(TextProperty, GetDisplayText()); + var dialog = new GotoRevisionSelector(); + dialog.RevisionList.ItemsSource = parents; - if (ShowAsDateTime) - StopTimer(); - else - StartTimer(); + var c = await dialog.ShowDialog(owner); + if (c != null) + vm.NavigateTo(c.SHA); } } - protected override void OnLoaded(RoutedEventArgs e) + public async Task GotoChild() { - base.OnLoaded(e); + if (DataContext is not ViewModels.Histories vm) + return; - if (!ShowAsDateTime) - StartTimer(); - } + if (!CommitListContainer.IsKeyboardFocusWithin) + return; - protected override void OnUnloaded(RoutedEventArgs e) - { - base.OnUnloaded(e); - StopTimer(); + 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); - SetCurrentValue(TextProperty, GetDisplayText()); + + if (DataContext is ViewModels.Histories vm) + CommitListContainer.Columns[1].Width = new(vm.AuthorColumnWidth, DataGridLengthUnitType.Pixel); } - private void StartTimer() + private void OnCommitListHeaderPointerMoved(object sender, PointerEventArgs e) { - if (_refreshTimer != null) + if (sender is not Border border) + return; + + if (DataContext is not ViewModels.Histories { IsAuthorColumnVisible: true } vm) return; - _refreshTimer = DispatcherTimer.Run(() => + 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 { - Dispatcher.UIThread.Invoke(() => + var dis = CommitListContainer.Columns[0].ActualWidth - 4 - pos.X; + if (dis < 4 && dis > -4) { - var text = GetDisplayText(); - if (!text.Equals(Text, StringComparison.Ordinal)) - Text = text; - }); - - return true; - }, TimeSpan.FromSeconds(10)); + if (border.Cursor != _resizingCursor) + border.Cursor = _resizingCursor; + } + else if (border.Cursor != Cursor.Default) + { + border.Cursor = Cursor.Default; + } + } } - private void StopTimer() + private void OnCommitListHeaderPointerPressed(object sender, PointerPressedEventArgs e) { - if (_refreshTimer != null) + 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) { - _refreshTimer.Dispose(); - _refreshTimer = null; + _resizingAuthorColumn = true; + e.Handled = true; } } - private string GetDisplayText() + private void OnCommitListHeaderPointerReleased(object sender, PointerReleasedEventArgs e) + { + _resizingAuthorColumn = false; + } + + private void OnCommitListHeaderContextRequested(object sender, ContextRequestedEventArgs e) { - var commit = DataContext as Models.Commit; - if (commit == null) - return string.Empty; + if (DataContext is not ViewModels.Histories vm) + return; - var timestamp = UseAuthorTime ? commit.AuthorTime : commit.CommitterTime; - if (ShowAsDateTime) - return DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss"); + if (sender is not Border border) + return; - var today = DateTime.Today; - var localTime = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + var columnsHeader = new MenuItem(); + columnsHeader.Header = new TextBlock() { Text = App.Text("Histories.ShowColumns"), FontWeight = FontWeight.Bold }; + columnsHeader.IsEnabled = false; - if (localTime >= today) + var authorColumn = new MenuItem(); + authorColumn.Header = App.Text("Histories.Header.Author"); + if (vm.IsAuthorColumnVisible) + authorColumn.Icon = this.CreateMenuIcon("Icons.Check"); + authorColumn.Click += (_, ev) => { - var now = DateTime.Now; - var timespan = now - localTime; - if (timespan.TotalHours > 1) - return App.Text("Period.HoursAgo", (int)timespan.TotalHours); + 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; + } - return timespan.TotalMinutes < 1 ? App.Text("Period.JustNow") : App.Text("Period.MinutesAgo", (int)timespan.TotalMinutes); - } + private void OnCommitListLayoutUpdated(object _1, EventArgs _2) + { + if (!IsLoaded) + return; + + var dataGrid = CommitListContainer; + var rowsPresenter = dataGrid.FindDescendantOfType(); + if (rowsPresenter == null) + return; - var diffYear = today.Year - localTime.Year; - if (diffYear == 0) + double rowHeight = dataGrid.RowHeight; + double startY = 0; + foreach (var child in rowsPresenter.Children) { - var diffMonth = today.Month - localTime.Month; - if (diffMonth > 0) - return diffMonth == 1 ? App.Text("Period.LastMonth") : App.Text("Period.MonthsAgo", diffMonth); + if (child is DataGridRow { IsVisible: true } row) + { + rowHeight = row.Bounds.Height; - var diffDay = today.Day - localTime.Day; - return diffDay == 1 ? App.Text("Period.Yesterday") : App.Text("Period.DaysAgo", diffDay); + if (row.Bounds.Top <= 0 && row.Bounds.Top > -rowHeight) + { + var test = rowHeight * row.Index - row.Bounds.Top; + if (startY < test) + startY = test; + } + } } - return diffYear == 1 ? App.Text("Period.LastYear") : App.Text("Period.YearsAgo", diffYear); - } - - private IDisposable _refreshTimer = null; - } + IsScrollToTopVisible = startY >= rowHeight; - public class CommitGraph : Control - { - public static readonly StyledProperty GraphProperty = - AvaloniaProperty.Register(nameof(Graph)); + 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); + } - public Models.CommitGraph Graph + private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) { - get => GetValue(GraphProperty); - set => SetValue(GraphProperty, value); + if (DataContext is ViewModels.Histories histories) + CommitListContainer.ScrollIntoView(histories.Commits[0], null); } - public static readonly StyledProperty DotBrushProperty = - AvaloniaProperty.Register(nameof(DotBrush), Brushes.Transparent); - - public IBrush DotBrush + private void OnCommitListContextRequested(object sender, ContextRequestedEventArgs e) { - get => GetValue(DotBrushProperty); - set => SetValue(DotBrushProperty, value); + var repoView = this.FindAncestorOfType(); + if (repoView is not { DataContext: ViewModels.Repository repo }) + return; + + var selected = CommitListContainer.SelectedItems; + if (selected is not { Count: > 0 }) + return; + + var commits = new List(); + for (var i = selected.Count - 1; i >= 0; i--) + { + if (selected[i] is Models.Commit c) + commits.Add(c); + } + + 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); + } + + e.Handled = true; } - static CommitGraph() + private async void OnCommitListDoubleTapped(object sender, TappedEventArgs e) { - AffectsRender(GraphProperty, DotBrushProperty); + 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); + } } - public override void Render(DrawingContext context) + private void OnCommitGraphLoaded(object sender, RoutedEventArgs e) { - base.Render(context); - - var graph = Graph; - if (graph == null) - return; + // Force-update the graph layout to ensure the graph is correctly rendered when it's loaded. + OnCommitListLayoutUpdated(sender, e); + } - var histories = this.FindAncestorOfType(); - if (histories == null) + private void OnTabHeaderPointerPressed(object sender, PointerPressedEventArgs e) + { + if (ViewModels.Preferences.Instance.UseTwoColumnsLayoutInHistories) return; - var list = histories.CommitListContainer; - if (list == null) + if (DataContext is not ViewModels.Histories vm) return; - // Calculate drawing area. - double width = Bounds.Width - 273 - histories.AuthorNameColumnWidth.Value; - double height = Bounds.Height; - double startY = list.Scroll?.Offset.Y ?? 0; - double endY = startY + height + 28; + if (vm.IsCollapseDetails) + vm.IsCollapseDetails = false; + } - // Apply scroll offset and clip. - using (context.PushClip(new Rect(0, 0, width, height))) - using (context.PushTransform(Matrix.CreateTranslation(0, -startY))) + private void OnOpenDetailsAsStandalone(object sender, RoutedEventArgs e) + { + if (DataContext is ViewModels.Histories vm) { - // Draw contents - DrawCurves(context, graph, startY, endY); - DrawAnchors(context, graph, startY, endY); + 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 void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom) + private ContextMenu CreateContextMenuForMultipleCommits(ViewModels.Repository repo, List selected) { - foreach (var line in graph.Paths) + var canCherryPick = true; + var canMerge = true; + + foreach (var c in selected) { - var last = line.Points[0]; - var size = line.Points.Count; + if (c.IsMerged) + { + canMerge = false; + canCherryPick = false; + } + else if (c.Parents.Count > 1) + { + canCherryPick = false; + } + } - if (line.Points[size - 1].Y < top) - continue; - if (last.Y > bottom) - break; + var menu = new ContextMenu(); - var geo = new StreamGeometry(); - var pen = Models.CommitGraph.Pens[line.Color]; + 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); + } - using (var ctx = geo.Open()) + if (canMerge) { - var started = false; - var ended = false; - for (int i = 1; i < size; i++) + var merge = new MenuItem(); + merge.Header = App.Text("CommitCM.MergeMultiple"); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => { - var cur = line.Points[i]; - if (cur.Y < top) - { - last = cur; - continue; - } + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.MergeMultiple(repo, selected)); + e.Handled = true; + }; + menu.Items.Add(merge); + } - if (!started) - { - ctx.BeginFigure(last, false); - started = true; - } + if (canCherryPick || canMerge) + menu.Items.Add(new MenuItem() { Header = "-" }); + } - if (cur.Y > bottom) - { - cur = new Point(cur.X, bottom); - ended = true; - } + 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; - if (cur.X > last.X) + 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++) { - ctx.QuadraticBezierTo(new Point(cur.X, last.Y), cur); + succ = await repo.SaveCommitAsPatchAsync(selected[i], folderPath, i); + if (!succ) + break; } - else if (cur.X < last.X) + + 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()) { - 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 + var found = false; + foreach (var d in commit.Decorators) { - ctx.QuadraticBezierTo(new Point(last.X, cur.Y), cur); + 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; + } + } } + + if (!found) + repo.ShowPopup(new ViewModels.Merge(repo, commit, current.Name)); } - else + + 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) => { - ctx.LineTo(cur); - } + await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, commit)); + e.Handled = true; + }; - if (ended) - break; - last = cur; + 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; + }; + + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(interactiveRebase); } - context.DrawGeometry(null, pen, geo); + menu.Items.Add(new MenuItem() { Header = "-" }); } - foreach (var link in graph.Links) + if (!isHead) { - if (link.End.Y < top) - continue; - if (link.Start.Y > bottom) - break; + 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 geo = new StreamGeometry(); - using (var ctx = geo.Open()) + var compareWithHead = new MenuItem(); + compareWithHead.Header = App.Text("CommitCM.CompareWithHead"); + compareWithHead.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithHead.Click += async (_, e) => { - ctx.BeginFigure(link.Start, false); - ctx.QuadraticBezierTo(link.Control, link.End); + var head = await vm.CompareWithHeadAsync(commit); + if (head != null) + CommitListContainer.SelectedItems.Add(head); + + e.Handled = true; + }; + menu.Items.Add(compareWithHead); + + if (repo.LocalChangesCount > 0) + { + var compareWithWorktree = new MenuItem(); + compareWithWorktree.Header = App.Text("CommitCM.CompareWithWorktree"); + compareWithWorktree.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithWorktree.Click += (_, e) => + { + vm.CompareWithWorktree(commit); + e.Handled = true; + }; + menu.Items.Add(compareWithWorktree); } - context.DrawGeometry(null, Models.CommitGraph.Pens[link.Color], geo); + menu.Items.Add(new MenuItem() { Header = "-" }); } - } - private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom) - { - IBrush dotFill = DotBrush; - Pen dotFillPen = new Pen(dotFill, 2); + 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 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); + } - foreach (var dot in graph.Dots) + 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 (dot.Center.Y < top) - continue; - if (dot.Center.Y > bottom) - break; + 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"); - var pen = Models.CommitGraph.Pens[dot.Color]; - switch (dot.Type) + foreach (var action in actions) { - case Models.CommitGraph.DotType.Head: - context.DrawEllipse(dotFill, pen, dot.Center, 6, 6); - context.DrawEllipse(pen.Brush, null, dot.Center, 3, 3); - break; - case Models.CommitGraph.DotType.Merge: - context.DrawEllipse(pen.Brush, null, dot.Center, 6, 6); - context.DrawLine(dotFillPen, new Point(dot.Center.X, dot.Center.Y - 3), new Point(dot.Center.X, dot.Center.Y + 3)); - context.DrawLine(dotFillPen, new Point(dot.Center.X - 3, dot.Center.Y), new Point(dot.Center.X + 3, dot.Center.Y)); - break; - default: - context.DrawEllipse(dotFill, pen, dot.Center, 3, 3); - break; + 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 = "-" }); } - } - } - public partial class Histories : UserControl - { - public static readonly StyledProperty AuthorNameColumnWidthProperty = - AvaloniaProperty.Register(nameof(AuthorNameColumnWidth), new GridLength(120)); + 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; + } - public GridLength AuthorNameColumnWidth + private void FillCurrentBranchMenu(ContextMenu menu, ViewModels.Repository repo, Models.Branch current) { - get => GetValue(AuthorNameColumnWidthProperty); - set => SetValue(AuthorNameColumnWidthProperty, value); - } + var submenu = new MenuItem(); + submenu.Icon = this.CreateMenuIcon("Icons.Branch"); + submenu.Header = current.Name; - public static readonly StyledProperty CurrentBranchProperty = - AvaloniaProperty.Register(nameof(CurrentBranch)); + 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 = "-" }); - public Models.Branch CurrentBranch - { - get => GetValue(CurrentBranchProperty); - set => SetValue(CurrentBranchProperty, value); - } + if (!string.IsNullOrEmpty(current.Upstream)) + { + var upstream = current.Upstream.Substring(13); - public static readonly StyledProperty> IssueTrackerRulesProperty = - AvaloniaProperty.Register>(nameof(IssueTrackerRules)); + 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; - public AvaloniaList IssueTrackerRules - { - get => GetValue(IssueTrackerRulesProperty); - set => SetValue(IssueTrackerRulesProperty, value); - } + if (repo.CanCreatePopup()) + await repo.ShowAndStartPopupAsync(new ViewModels.Merge(repo, b, current.Name, true)); - public static readonly StyledProperty NavigationIdProperty = - AvaloniaProperty.Register(nameof(NavigationId)); + e.Handled = true; + }; + submenu.Items.Add(fastForward); - public long NavigationId - { - get => GetValue(NavigationIdProperty); - set => SetValue(NavigationIdProperty, value); - } + 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); + } - static Histories() - { - NavigationIdProperty.Changed.AddClassHandler((h, _) => + 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 (h.DataContext == null) - return; - - // Force scroll selected item (current head) into view. see issue #58 - var list = h.CommitListContainer; - if (list != null && list.SelectedItems.Count == 1) - list.ScrollIntoView(list.SelectedIndex); - }); + 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 (!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 = "-" }); + } + } - AuthorNameColumnWidthProperty.Changed.AddClassHandler((h, _) => + var copy = new MenuItem(); + copy.Header = App.Text("BranchCM.CopyName"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => { - h.CommitGraph.InvalidateVisual(); - }); - } + await this.CopyTextAsync(current.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); - public Histories() - { - InitializeComponent(); + menu.Items.Add(submenu); } - private void OnCommitListLayoutUpdated(object _1, EventArgs _2) + private void FillOtherLocalBranchMenu(ContextMenu menu, ViewModels.Repository repo, Models.Branch branch, Models.Branch current, bool merged) { - var y = CommitListContainer.Scroll?.Offset.Y ?? 0; - if (y != _lastScrollY) + 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) { - _lastScrollY = y; - CommitGraph.InvalidateVisual(); + 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); } - } - private void OnCommitListSelectionChanged(object _, SelectionChangedEventArgs e) - { - if (DataContext is ViewModels.Histories histories) + 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) => { - histories.Select(CommitListContainer.SelectedItems); + 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 = "-" }); + } } - e.Handled = true; - } - private void OnCommitListContextRequested(object sender, ContextRequestedEventArgs e) - { - if (DataContext is ViewModels.Histories histories && sender is ListBox { SelectedItems: { Count: > 0 } } list) + var compare = new MenuItem(); + compare.Header = App.Text("BranchCM.CompareWithSpecial", current.Name); + compare.Icon = this.CreateMenuIcon("Icons.Compare"); + compare.Click += (_, e) => { - var menu = histories.MakeContextMenu(list); - menu?.Open(list); - } - e.Handled = true; + 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 OnCommitListDoubleTapped(object sender, TappedEventArgs e) + private void FillRemoteBranchMenu(ContextMenu menu, ViewModels.Repository repo, Models.Branch branch, Models.Branch current, bool merged) { - if (DataContext is ViewModels.Histories histories && sender is ListBox { SelectedItems: { Count: 1 } }) + 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) => { - var source = e.Source as Control; - var item = source.FindAncestorOfType(); - if (item is { DataContext: Models.Commit commit }) - histories.DoubleTapped(commit); - } - e.Handled = true; + 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 OnCommitListKeyDown(object sender, KeyEventArgs e) + private void FillTagMenu(ContextMenu menu, ViewModels.Repository repo, Models.Tag tag, Models.Branch current) { - if (!e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) - return; + 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 = "-" }); - // These shortcuts are not mentioned in the Shortcut Reference window. Is this expected? - if (sender is ListBox { SelectedItems: { Count: > 0 } selected }) + var copy = new MenuItem(); + copy.Header = App.Text("TagCM.CopyName"); + copy.Icon = this.CreateMenuIcon("Icons.Copy"); + copy.Click += async (_, e) => { - // CTRL/COMMAND + C -> Copy selected commit SHA and subject. - if (e.Key == Key.C) - { - var builder = new StringBuilder(); - foreach (var item in selected) - { - if (item is Models.Commit commit) - builder.AppendLine($"{commit.SHA.Substring(0, 10)} - {commit.Subject}"); - } + await this.CopyTextAsync(tag.Name); + e.Handled = true; + }; + submenu.Items.Add(copy); - App.CopyText(builder.ToString()); - e.Handled = true; - return; - } + menu.Items.Add(submenu); + } - // CTRL/COMMAND + B -> shows Create Branch pop-up at selected commit. - if (e.Key == Key.B) - { - if (selected.Count == 1 && - selected[0] is Models.Commit commit && - DataContext is ViewModels.Histories histories && - ViewModels.PopupHost.CanCreatePopup()) - { - ViewModels.PopupHost.ShowPopup(new ViewModels.CreateBranch(histories.Repo, commit)); - e.Handled = true; - } - } - } + 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 double _lastScrollY = 0; + + 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 index 4fe770668..d1045cad9 100644 --- a/src/Views/Hotkeys.axaml +++ b/src/Views/Hotkeys.axaml @@ -19,7 +19,7 @@ - + - - - - - + + + + + - + - + - + - - + + + + + + + + + + + + + + + + + + + + - - + + - - + + - + - + - + - - - - - + + - - - - - + + - - + + - - + + - - + + - - + + - - + + - - - + + - + + - - - + + - - + + - - + + - - + + - + diff --git a/src/Views/Hotkeys.axaml.cs b/src/Views/Hotkeys.axaml.cs index d8b5e1a87..b1669252e 100644 --- a/src/Views/Hotkeys.axaml.cs +++ b/src/Views/Hotkeys.axaml.cs @@ -4,6 +4,7 @@ public partial class Hotkeys : ChromelessWindow { public Hotkeys() { + CloseOnESC = true; InitializeComponent(); } } diff --git a/src/Views/ImageContainer.cs b/src/Views/ImageContainer.cs index aecea0b20..8e9926d54 100644 --- a/src/Views/ImageContainer.cs +++ b/src/Views/ImageContainer.cs @@ -42,7 +42,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { base.OnPropertyChanged(change); - if (change.Property.Name == "ActualThemeVariant") + if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) { _bgBrush = null; InvalidateVisual(); @@ -54,21 +54,24 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang public class ImageView : ImageContainer { - public static readonly StyledProperty ImageProperty = - AvaloniaProperty.Register(nameof(Image)); + public static readonly DirectProperty ImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(Image), + static o => o.Image, + static (o, v) => o.Image = v); public Bitmap Image { - get => GetValue(ImageProperty); - set => SetValue(ImageProperty, value); + get => _image; + set => SetAndRaise(ImageProperty, ref _image, value); } public override void Render(DrawingContext context) { base.Render(context); - if (Image is { } image) - context.DrawImage(image, new Rect(0, 0, Bounds.Width, Bounds.Height)); + if (_image != null) + context.DrawImage(_image, new Rect(0, 0, Bounds.Width, Bounds.Height)); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -81,81 +84,87 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang protected override Size MeasureOverride(Size availableSize) { - if (Image is { } image) + if (_image != null) { - var imageSize = image.Size; + var imageSize = _image.Size; var scaleW = availableSize.Width / imageSize.Width; var scaleH = availableSize.Height / imageSize.Height; - var scale = Math.Min(scaleW, scaleH); + var scale = Math.Min(1, Math.Min(scaleW, scaleH)); return new Size(scale * imageSize.Width, scale * imageSize.Height); } - return availableSize; + return new Size(0, 0); } + + private Bitmap _image = null; } public class ImageSwipeControl : ImageContainer { - public static readonly StyledProperty AlphaProperty = - AvaloniaProperty.Register(nameof(Alpha), 0.5); + public static readonly DirectProperty AlphaProperty = + AvaloniaProperty.RegisterDirect( + nameof(Alpha), + static o => o.Alpha, + static (o, v) => o.Alpha = v); public double Alpha { - get => GetValue(AlphaProperty); - set => SetValue(AlphaProperty, value); + get => _alpha; + set => SetAndRaise(AlphaProperty, ref _alpha, value); } - public static readonly StyledProperty OldImageProperty = - AvaloniaProperty.Register(nameof(OldImage)); + public static readonly DirectProperty OldImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(OldImage), + static o => o.OldImage, + static (o, v) => o.OldImage = v); public Bitmap OldImage { - get => GetValue(OldImageProperty); - set => SetValue(OldImageProperty, value); + get => _oldImage; + set => SetAndRaise(OldImageProperty, ref _oldImage, value); } - public static readonly StyledProperty NewImageProperty = - AvaloniaProperty.Register(nameof(NewImage)); + public static readonly DirectProperty NewImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(NewImage), + static o => o.NewImage, + static (o, v) => o.NewImage = v); public Bitmap NewImage { - get => GetValue(NewImageProperty); - set => SetValue(NewImageProperty, value); - } - - static ImageSwipeControl() - { - AffectsMeasure(OldImageProperty, NewImageProperty); - AffectsRender(AlphaProperty); + get => _newImage; + set => SetAndRaise(NewImageProperty, ref _newImage, value); } public override void Render(DrawingContext context) { base.Render(context); - var alpha = Alpha; var w = Bounds.Width; var h = Bounds.Height; - var x = w * alpha; - var left = OldImage; - if (left != null && alpha > 0) - { - var src = new Rect(0, 0, left.Size.Width * alpha, left.Size.Height); - var dst = new Rect(0, 0, x, h); - context.DrawImage(left, src, dst); - } + var x = w * _alpha; - var right = NewImage; - if (right != null && alpha < 1) - { - var src = new Rect(right.Size.Width * alpha, 0, right.Size.Width * (1 - alpha), right.Size.Height); - var dst = new Rect(x, 0, w - x, h); - context.DrawImage(right, src, dst); - } + 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); @@ -185,7 +194,7 @@ protected override void OnPointerMoved(PointerEventArgs e) if (_pressedOnSlider) { - SetCurrentValue(AlphaProperty, Math.Clamp(p.X, 0, w) / w); + Alpha = Math.Clamp(p.X, 0, w) / w; } else { @@ -209,13 +218,145 @@ protected override void OnPointerMoved(PointerEventArgs e) } } + 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 ? availableSize : GetDesiredSize(right.Size, availableSize); + return right == null ? new Size(0, 0) : GetDesiredSize(right.Size, availableSize); if (right == null) return GetDesiredSize(left.Size, availableSize); @@ -229,98 +370,125 @@ private Size GetDesiredSize(Size img, Size available) { var sw = available.Width / img.Width; var sh = available.Height / img.Height; - var scale = Math.Min(sw, sh); + var scale = Math.Min(1, Math.Min(sw, sh)); return new Size(scale * img.Width, scale * img.Height); } - private bool _pressedOnSlider = false; - private bool _lastInSlider = false; + 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 ImageBlendControl : ImageContainer + public class ImageDifferenceControl : ImageContainer { - public static readonly StyledProperty AlphaProperty = - AvaloniaProperty.Register(nameof(Alpha), 1.0); + public static readonly DirectProperty AlphaProperty = + AvaloniaProperty.RegisterDirect( + nameof(Alpha), + static o => o.Alpha, + static (o, v) => o.Alpha = v); public double Alpha { - get => GetValue(AlphaProperty); - set => SetValue(AlphaProperty, value); + get => _alpha; + set => SetAndRaise(AlphaProperty, ref _alpha, value); } - public static readonly StyledProperty OldImageProperty = - AvaloniaProperty.Register(nameof(OldImage)); + public static readonly DirectProperty OldImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(OldImage), + static o => o.OldImage, + static (o, v) => o.OldImage = v); public Bitmap OldImage { - get => GetValue(OldImageProperty); - set => SetValue(OldImageProperty, value); + get => _oldImage; + set => SetAndRaise(OldImageProperty, ref _oldImage, value); } - public static readonly StyledProperty NewImageProperty = - AvaloniaProperty.Register(nameof(NewImage)); + public static readonly DirectProperty NewImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(NewImage), + static o => o.NewImage, + static (o, v) => o.NewImage = v); public Bitmap NewImage { - get => GetValue(NewImageProperty); - set => SetValue(NewImageProperty, value); - } - - static ImageBlendControl() - { - AffectsMeasure(OldImageProperty, NewImageProperty); - AffectsRender(AlphaProperty); + get => _newImage; + set => SetAndRaise(NewImageProperty, ref _newImage, value); } public override void Render(DrawingContext context) { base.Render(context); - var rect = new Rect(0, 0, Bounds.Width, Bounds.Height); var alpha = Alpha; var left = OldImage; var right = NewImage; var drawLeft = left != null && alpha < 1.0; - var drawRight = right != null && alpha > 0; + var drawRight = right != null && alpha > 0.0; if (drawLeft && drawRight) { - using (var rt = new RenderTargetBitmap(right.PixelSize, right.Dpi)) + using (var rt = new RenderTargetBitmap(new PixelSize((int)Bounds.Width, (int)Bounds.Height), right.Dpi)) { - var rtRect = new Rect(rt.Size); using (var dc = rt.CreateDrawingContext()) { using (dc.PushRenderOptions(RO_SRC)) - using (dc.PushOpacity(1 - alpha)) - dc.DrawImage(left, rtRect); + RenderSingleSide(dc, left, rt.Size.Width, rt.Size.Height, Math.Min(1.0, 2.0 - 2.0 * alpha)); using (dc.PushRenderOptions(RO_DST)) - using (dc.PushOpacity(alpha)) - dc.DrawImage(right, rtRect); + RenderSingleSide(dc, right, rt.Size.Width, rt.Size.Height, Math.Min(1.0, 2.0 * alpha)); } - context.DrawImage(rt, rtRect, rect); + context.DrawImage(rt, new Rect(0, 0, Bounds.Width, Bounds.Height)); } } else if (drawLeft) { - using (context.PushOpacity(1 - alpha)) - context.DrawImage(left, rect); + RenderSingleSide(context, left, Bounds.Width, Bounds.Height, 1 - alpha); } else if (drawRight) { - using (context.PushOpacity(alpha)) - context.DrawImage(right, rect); + 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 ? availableSize : GetDesiredSize(right.Size, availableSize); + return right == null ? new Size(0, 0) : GetDesiredSize(right.Size, availableSize); if (right == null) return GetDesiredSize(left.Size, availableSize); @@ -334,11 +502,30 @@ private Size GetDesiredSize(Size img, Size available) { var sw = available.Width / img.Width; var sh = available.Height / img.Height; - var scale = Math.Min(sw, sh); + var scale = Math.Min(1, Math.Min(sw, sh)); return new Size(scale * img.Width, scale * img.Height); } - private static readonly RenderOptions RO_SRC = new RenderOptions() { BitmapBlendingMode = BitmapBlendingMode.Source, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; - private static readonly RenderOptions RO_DST = new RenderOptions() { BitmapBlendingMode = BitmapBlendingMode.Plus, BitmapInterpolationMode = BitmapInterpolationMode.HighQuality }; + 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 index 18d8cef9d..ca954a7e1 100644 --- a/src/Views/ImageDiffView.axaml +++ b/src/Views/ImageDiffView.axaml @@ -3,12 +3,13 @@ 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:v="using:SourceGit.Views" xmlns:c="using:SourceGit.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.ImageDiffView" x:DataType="m:ImageDiff"> - + - + - + - + - + - - - - - + + + + + + + + + + + - - + + + - - - - - - + + + + - - - + + + - + - + + + - - - - - - - + + + - + @@ -226,38 +217,81 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Background="Transparent" BorderThickness="0,1,0,0" - BorderBrush="{DynamicResource Brush.Border2}"/> - - - - - - - - - - - - + BorderBrush="{DynamicResource Brush.Border2}" + Focusable="False"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + - -